DevOps

Kubernetes Operators на Go: автоматизация управления кастомными ресурсами в 2026 году

Ruslan Ismailov Опубликовано 18 мин чтения
K

Введение: что такое Kubernetes Operator и зачем он нужен

Kubernetes предоставляет мощный декларативный API для управления инфраструктурой, однако стандартных абстракций — Deployment, StatefulSet, Service — часто недостаточно для управления сложными stateful-приложениями. Helm-чарты решают проблему шаблонизации манифестов, но не умеют реагировать на события в кластере и принимать решения в реальном времени. Kubernetes Operator закрывает этот пробел: он кодирует операционные знания (runbook) инженера прямо в контроллер, работающий внутри кластера.

Operator — это паттерн расширения Kubernetes, состоящий из двух частей: Custom Resource Definition (CRD), описывающего желаемое состояние, и контроллера, который приводит реальное состояние кластера в соответствие с желаемым. В отличие от Helm, Operator умеет делать backup по расписанию, автоматически восстанавливать кластер PostgreSQL после сбоя, ротировать секреты и выполнять rolling upgrade с учётом бизнес-логики.

В 2026 году Operator-паттерн стал де-факто стандартом для платформенных команд: OperatorHub.io насчитывает более 400 готовых решений, а инструментарий на Go — kubebuilder и controller-runtime — достиг высокой зрелости. В этой статье мы пройдём путь от концепций до рабочего Operator для управления PostgreSQL-инстансами.

Ключевые концепции: CRD, контроллер и reconciliation loop

Custom Resource Definition

CRD — это расширение схемы Kubernetes API. После применения CRD-манифеста кластер начинает принимать объекты нового вида, например PostgreSQLCluster. Пользователи работают с ним через kubectl так же, как с Deployment.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresqlclusters.db.example.com
spec:
  group: db.example.com
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicas:
                  type: integer
                  minimum: 1
                version:
                  type: string
            status:
              type: object
              properties:
                phase:
                  type: string
  scope: Namespaced
  names:
    plural: postgresqlclusters
    singular: postgresqlcluster
    kind: PostgreSQLCluster

Reconciliation Loop

Сердце любого Operator — это reconciliation loop. Контроллер подписывается на события (создание, изменение, удаление объектов) и при каждом событии вызывает функцию Reconcile. Функция получает текущее состояние из кластера и принимает решение, какие действия нужно выполнить, чтобы реальное состояние совпало с желаемым, описанным в spec. Важнейшее свойство reconciler — идемпотентность: повторный вызов с тем же состоянием не должен приводить к побочным эффектам.

«Не думай о событиях — думай о состоянии. Reconciler всегда отвечает на вопрос: что нужно сделать прямо сейчас, чтобы достичь желаемого состояния?»

Инструменты: kubebuilder vs operator-sdk в 2026 году

Два главных инструмента scaffolding для Go-операторов — kubebuilder (поддерживается sig-controller-tools) и operator-sdk (Red Hat). В 2026 году разница между ними минимальна: operator-sdk использует kubebuilder как основу и добавляет интеграцию с OLM (Operator Lifecycle Manager) и плагины для Ansible/Helm операторов.

  • kubebuilder — выбор для команд, которые хотят минимальный overhead и полный контроль над кодом.
  • operator-sdk — выбор для команд, планирующих публикацию на OperatorHub или работающих в экосистеме OpenShift.
  • Оба используют controller-runtime — библиотеку Go, абстрагирующую низкоуровневую работу с Kubernetes API.

В этой статье мы используем kubebuilder v4, актуальный на 2026 год.

Пошаговое создание Operator на Go

Шаг 1: Scaffolding проекта

Установите kubebuilder и инициализируйте проект:

# Установка kubebuilder
curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)
chmod +x kubebuilder && mv kubebuilder /usr/local/bin/

# Инициализация проекта
mkdir postgres-operator && cd postgres-operator
kubebuilder init --domain example.com --repo github.com/example/postgres-operator

# Создание API и контроллера
kubebuilder create api --group db --version v1alpha1 --kind PostgreSQLCluster

После выполнения команд kubebuilder генерирует структуру проекта: api/v1alpha1/ содержит типы Go для CRD, internal/controller/ — заготовку reconciler, config/ — kustomize-манифесты.

Шаг 2: Определение типов CRD на Go

// api/v1alpha1/postgresqlcluster_types.go
package v1alpha1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

// PostgreSQLClusterSpec описывает желаемое состояние кластера
type PostgreSQLClusterSpec struct {
    // Replicas — количество реплик (1 primary + N standbys)
    Replicas int32 `json:"replicas"`
    // Version — версия PostgreSQL, например "16.2"
    Version string `json:"version"`
    // StorageSize — размер PVC для каждого инстанса
    StorageSize string `json:"storageSize"`
    // BackupSchedule — cron-расписание для бэкапов
    BackupSchedule string `json:"backupSchedule,omitempty"`
}

// PostgreSQLClusterStatus отражает реальное состояние
type PostgreSQLClusterStatus struct {
    Phase      string `json:"phase,omitempty"`
    ReadyNodes int32  `json:"readyNodes,omitempty"`
    Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
// +kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyNodes"
type PostgreSQLCluster struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec   PostgreSQLClusterSpec   `json:"spec,omitempty"`
    Status PostgreSQLClusterStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type PostgreSQLClusterList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items []PostgreSQLCluster `json:"items"`
}

func init() {
    SchemeBuilder.Register(&PostgreSQLCluster{}, &PostgreSQLClusterList{})
}

После изменения типов запустите make generate manifests — controller-gen автоматически обновит CRD YAML и сгенерирует DeepCopy-методы.

Шаг 3: Реализация Reconciler

// internal/controller/postgresqlcluster_controller.go
package controller

import (
    "context"
    "fmt"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"

    dbv1alpha1 "github.com/example/postgres-operator/api/v1alpha1"
)

type PostgreSQLClusterReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=db.example.com,resources=postgresqlclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=db.example.com,resources=postgresqlclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete

func (r *PostgreSQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. Получаем объект из кластера
    cluster := &dbv1alpha1.PostgreSQLCluster{}
    if err := r.Get(ctx, req.NamespacedName, cluster); err != nil {
        if errors.IsNotFound(err) {
            // Объект удалён — ничего делать не нужно
            return ctrl.Result{}, nil
        }
        return ctrl.Result{}, fmt.Errorf("failed to get PostgreSQLCluster: %w", err)
    }

    logger.Info("Reconciling", "name", cluster.Name, "phase", cluster.Status.Phase)

    // 2. Убеждаемся, что StatefulSet существует и соответствует spec
    if err := r.reconcileStatefulSet(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    // 3. Убеждаемся, что Service существует
    if err := r.reconcileService(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    // 4. Обновляем статус
    if err := r.updateStatus(ctx, cluster); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

func (r *PostgreSQLClusterReconciler) reconcileStatefulSet(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    desired := r.buildStatefulSet(cluster)

    // Устанавливаем owner reference для garbage collection
    if err := ctrl.SetControllerReference(cluster, desired, r.Scheme); err != nil {
        return err
    }

    existing := &appsv1.StatefulSet{}
    err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
    if errors.IsNotFound(err) {
        return r.Create(ctx, desired)
    }
    if err != nil {
        return err
    }

    // Обновляем только если изменились replicas или image
    existing.Spec.Replicas = desired.Spec.Replicas
    existing.Spec.Template = desired.Spec.Template
    return r.Update(ctx, existing)
}

func (r *PostgreSQLClusterReconciler) buildStatefulSet(
    cluster *dbv1alpha1.PostgreSQLCluster,
) *appsv1.StatefulSet {
    image := fmt.Sprintf("postgres:%s", cluster.Spec.Version)
    replicas := cluster.Spec.Replicas

    return &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cluster.Name,
            Namespace: cluster.Namespace,
        },
        Spec: appsv1.StatefulSetSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{"app": cluster.Name},
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{"app": cluster.Name},
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "postgres",
                            Image: image,
                            Env: []corev1.EnvVar{
                                {
                                    Name:  "POSTGRES_PASSWORD",
                                    Value: "changeme", // в реальности — из Secret
                                },
                            },
                        },
                    },
                },
            },
        },
    }
}

func (r *PostgreSQLClusterReconciler) reconcileService(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    svc := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cluster.Name + "-svc",
            Namespace: cluster.Namespace,
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{"app": cluster.Name},
            Ports: []corev1.ServicePort{
                {Port: 5432},
            },
        },
    }
    if err := ctrl.SetControllerReference(cluster, svc, r.Scheme); err != nil {
        return err
    }
    existing := &corev1.Service{}
    if err := r.Get(ctx, client.ObjectKeyFromObject(svc), existing); errors.IsNotFound(err) {
        return r.Create(ctx, svc)
    } else {
        return err
    }
}

func (r *PostgreSQLClusterReconciler) updateStatus(
    ctx context.Context,
    cluster *dbv1alpha1.PostgreSQLCluster,
) error {
    cluster.Status.Phase = "Running"
    cluster.Status.ReadyNodes = cluster.Spec.Replicas
    return r.Status().Update(ctx, cluster)
}

func (r *PostgreSQLClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&dbv1alpha1.PostgreSQLCluster{}).
        Owns(&appsv1.StatefulSet{}).
        Owns(&corev1.Service{}).
        Complete(r)
}

Практический пример: backup и restore PostgreSQL

Расширим Operator функциональностью резервного копирования. Добавим CRD PostgreSQLBackup и reconciler, который создаёт Kubernetes Job для выполнения pg_dump.

// Фрагмент reconciler для backup
func (r *PostgreSQLBackupReconciler) Reconcile(
    ctx context.Context,
    req ctrl.Request,
) (ctrl.Result, error) {
    backup := &dbv1alpha1.PostgreSQLBackup{}
    if err := r.Get(ctx, req.NamespacedName, backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Идемпотентность: если Job уже существует — пропускаем
    job := &batchv1.Job{}
    jobName := fmt.Sprintf("%s-backup-job", backup.Name)
    err := r.Get(ctx, types.NamespacedName{
        Name:      jobName,
        Namespace: backup.Namespace,
    }, job)

    if errors.IsNotFound(err) {
        newJob := r.buildBackupJob(backup, jobName)
        if err := ctrl.SetControllerReference(backup, newJob, r.Scheme); err != nil {
            return ctrl.Result{}, err
        }
        if err := r.Create(ctx, newJob); err != nil {
            return ctrl.Result{}, fmt.Errorf("failed to create backup job: %w", err)
        }
        backup.Status.Phase = "Running"
        return ctrl.Result{RequeueAfter: 30 * time.Second},
            r.Status().Update(ctx, backup)
    }

    // Проверяем результат Job
    if job.Status.Succeeded > 0 {
        backup.Status.Phase = "Completed"
        backup.Status.CompletedAt = &metav1.Time{Time: time.Now()}
    } else if job.Status.Failed > 3 {
        backup.Status.Phase = "Failed"
    } else {
        // Job ещё выполняется
        return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
    }

    return ctrl.Result{}, r.Status().Update(ctx, backup)
}

func (r *PostgreSQLBackupReconciler) buildBackupJob(
    backup *dbv1alpha1.PostgreSQLBackup,
    name string,
) *batchv1.Job {
    clusterSvc := backup.Spec.ClusterName + "-svc"
    return &batchv1.Job{
        ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: backup.Namespace},
        Spec: batchv1.JobSpec{
            Template: corev1.PodTemplateSpec{
                Spec: corev1.PodSpec{
                    RestartPolicy: corev1.RestartPolicyOnFailure,
                    Containers: []corev1.Container{
                        {
                            Name:  "pg-dump",
                            Image: "postgres:16",
                            Command: []string{
                                "pg_dump",
                                "-h", clusterSvc,
                                "-U", "postgres",
                                "-F", "c",
                                "-f", "/backup/dump.pgc",
                                backup.Spec.Database,
                            },
                        },
                    },
                },
            },
        },
    }
}

Обработка ошибок и идемпотентность

Reconciliation loop должен быть устойчив к сбоям сети, конфликтам версий (optimistic locking) и повторным вызовам. Ключевые правила:

  • Всегда перечитывай объект в начале Reconcile — не полагайся на кэшированное состояние из события.
  • Используй ctrl.Result{RequeueAfter: ...} для отложенных проверок, а не блокирующий sleep.
  • Оборачивай ошибки через fmt.Errorf("...: %w", err) для трассировки.
  • Проверяй конфликты: при errors.IsConflict(err) достаточно вернуть ошибку — контроллер повторит reconcile автоматически.
  • Finalizer-ы используй для cleanup-логики при удалении объекта: добавь финализатор при создании, удали после очистки.
// Пример работы с finalizer
const finalizer = "db.example.com/cleanup"

if cluster.DeletionTimestamp.IsZero() {
    // Объект не удаляется — добавляем finalizer
    if !controllerutil.ContainsFinalizer(cluster, finalizer) {
        controllerutil.AddFinalizer(cluster, finalizer)
        return ctrl.Result{}, r.Update(ctx, cluster)
    }
} else {
    // Объект удаляется — выполняем cleanup
    if controllerutil.ContainsFinalizer(cluster, finalizer) {
        if err := r.cleanupExternalResources(ctx, cluster); err != nil {
            return ctrl.Result{}, err
        }
        controllerutil.RemoveFinalizer(cluster, finalizer)
        return ctrl.Result{}, r.Update(ctx, cluster)
    }
}

Тестирование Operator: envtest и интеграционные тесты

controller-runtime поставляет пакет envtest, который запускает реальный API-сервер Kubernetes и etcd локально — без полноценного кластера. Это позволяет писать интеграционные тесты, проверяющие полный цикл reconciliation.

// internal/controller/suite_test.go
package controller_test

import (
    "testing"
    "path/filepath"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "sigs.k8s.io/controller-runtime/pkg/envtest"
    "sigs.k8s.io/controller-runtime/pkg/client"
)

var (
    testEnv *envtest.Environment
    k8sClient client.Client
)

func TestControllers(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
    testEnv = &envtest.Environment{
        CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
    }
    cfg, err := testEnv.Start()
    Expect(err).NotTo(HaveOccurred())

    k8sClient, err = client.New(cfg, client.Options{})
    Expect(err).NotTo(HaveOccurred())
})

var _ = AfterSuite(func() {
    Expect(testEnv.Stop()).To(Succeed())
})

// internal/controller/postgresqlcluster_controller_test.go
var _ = Describe("PostgreSQLCluster Controller", func() {
    It("should create a StatefulSet for a new cluster", func() {
        ctx := context.Background()
        cluster := &dbv1alpha1.PostgreSQLCluster{
            ObjectMeta: metav1.ObjectMeta{
                Name:      "test-cluster",
                Namespace: "default",
            },
            Spec: dbv1alpha1.PostgreSQLClusterSpec{
                Replicas:    2,
                Version:     "16.2",
                StorageSize: "10Gi",
            },
        }
        Expect(k8sClient.Create(ctx, cluster)).To(Succeed())

        sts := &appsv1.StatefulSet{}
        Eventually(func() error {
            return k8sClient.Get(ctx, types.NamespacedName{
                Name:      "test-cluster",
                Namespace: "default",
            }, sts)
        }, "10s", "1s").Should(Succeed())

        Expect(*sts.Spec.Replicas).To(Equal(int32(2)))
    })
})

Деплой Operator в кластер: kustomize, Helm и OLM

kubebuilder генерирует готовую структуру config/ с kustomize-манифестами. Для деплоя достаточно:

# Собираем и пушим образ
make docker-build docker-push IMG=ghcr.io/example/postgres-operator:v0.1.0

# Деплоим через kustomize
make deploy IMG=ghcr.io/example/postgres-operator:v0.1.0

# Или напрямую
kubectl apply -k config/default

Для продакшн-деплоя рекомендуется упаковать Operator в Helm-чарт с возможностью параметризации через values.yaml. Если планируется публикация на OperatorHub — используйте operator-sdk для генерации OLM-бандла:

operator-sdk generate bundle \
  --package postgres-operator \
  --version 0.1.0 \
  --channels stable

operator-sdk bundle validate ./bundle

Безопасность: RBAC и принцип минимальных привилегий

Operator работает с правами ServiceAccount в кластере. Аннотации // +kubebuilder:rbac: в коде контроллера автоматически генерируют ClusterRole при запуске make manifests. Следуйте принципу минимальных привилегий:

  • Давайте доступ только к тем ресурсам и namespace, которые действительно нужны.
  • По возможности используйте namespace-scoped Role вместо ClusterRole.
  • Никогда не давайте Operator права cluster-admin.
  • Используйте Admission Webhooks (ValidatingWebhookConfiguration) для валидации CRD на уровне API-сервера.
  • Храните секреты (пароли БД, S3-ключи) в Kubernetes Secrets или External Secrets Operator, а не в spec CRD.
# Пример сгенерированного ClusterRole (фрагмент)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: postgres-operator-manager-role
rules:
- apiGroups: ["db.example.com"]
  resources: ["postgresqlclusters"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["db.example.com"]
  resources: ["postgresqlclusters/status"]
  verbs: ["get", "update", "patch"]
- apiGroups: ["apps"]
  resources: ["statefulsets"]
  verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["services", "persistentvolumeclaims"]
  verbs: ["get", "list", "watch", "create", "update", "patch"]

Заключение: когда писать Operator, а когда обойтись стандартными средствами

Kubernetes Operator — мощный инструмент, но не серебряная пуля. Вот критерии принятия решения:

  • Пиши Operator, если нужно автоматизировать операционные процедуры (backup/restore, failover, rolling upgrade с проверкой здоровья), которые требуют реакции на события в кластере в реальном времени.
  • Пиши Operator, если у тебя сложный stateful-сервис (база данных, очередь, брокер) с нетривиальной логикой масштабирования.
  • Используй Helm или kustomize, если задача сводится к шаблонизации манифестов и управлению конфигурацией без сложной бизнес-логики.
  • Используй готовый Operator (CloudNativePG для PostgreSQL, Strimzi для Kafka), если он покрывает ваши требования — не изобретай велосипед.

В 2026 году экосистема Kubernetes Operators на Go достигла зрелости: kubebuilder v4 + controller-runtime обеспечивают надёжную основу, а паттерны идемпотентного reconciliation и тестирования через envtest стали индустриальным стандартом. Для платформенных инженеров и Go-разработчиков владение этим стеком открывает возможность строить по-настоящему самоуправляемую инфраструктуру.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →