Kubernetes Operators на Go: автоматизация управления кастомными ресурсами в 2026 году
Введение: что такое 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, а не в
specCRD.
# Пример сгенерированного 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. Подробнее обо мне →