Ты накатил Postgres в кластер как StatefulSet. Один под, один PVC, все красиво. А потом начинается реальная эксплуатация. Нужна реплика для отказоустойчивости - руками поднимаешь второй под, настраиваешь стриминговую репликацию, прописываешь кто primary. Падает мастер - кто-то ночью должен зайти, понять что primary мертв, промоутить реплику, переключить трафик, не словив split-brain. Раз в сутки бэкап в S3, раз в неделю проверка что бэкап восстанавливается. Обновление минорной версии Postgres - rolling по репликам, потом switchover, потом мастер. Каждая из этих операций - это знания в голове конкретного DBA, записанные в лучшем случае в Confluence, который никто не читает.
Kubernetes из коробки умеет держать заданное количество подов и перезапускать упавшие. Но он не знает, что такое "primary Postgres" и что failover - это не просто перезапустить под, а сложная процедура с проверкой lag реплики. Деплоймент-контроллер тупой в хорошем смысле: ему все равно, что внутри контейнера. А база данных - это stateful-зверь с доменной логикой эксплуатации.
Operator pattern - это способ зашить эту доменную логику прямо в кластер. Оператор - это программа, которая делает то же, что делал бы опытный человек-оператор у пульта: следит за состоянием системы и приводит ее к желаемому. Отсюда и название. Ты описываешь "хочу HA-кластер Postgres на 3 узла с бэкапом в S3" одним YAML, а дальше робот-DBA крутит ручки сам - круглосуточно, без ошибок усталости.

Два кита: CRD и контроллер
Оператор стоит на двух вещах: новый тип ресурса (CRD) и программа, которая этим типом управляет (контроллер). Разберем по отдельности, потому что путать их - классическая ошибка новичка.
CustomResourceDefinition (CRD) - это способ научить Kubernetes API новому существительному. Из коробки API знает Pod, Service, Deployment, ConfigMap. CRD добавляет, например, PostgresCluster или Certificate. После того как CRD зарегистрирован, ты можешь делать
Код: Выделить всё
kubectl get postgresclustersКод: Выделить всё
kubectl get podsВот минимальный CRD - определение типа (не путать с экземпляром):
Код: Выделить всё
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: postgresclusters.acme.example.com
spec:
group: acme.example.com
names:
kind: PostgresCluster
plural: postgresclusters
singular: postgrescluster
shortNames: ["pgc"]
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
instances:
type: integer
minimum: 1
version:
type: string
required: ["instances"]
status:
type: object
properties:
readyInstances:
type: integer
Обрати внимание на разделение spec и status. spec - это желаемое состояние, его пишет человек. status - фактическое, его пишет контроллер. Это фундамент декларативной модели, к которому мы вернемся в конце.
Теперь экземпляр (custom resource) - вот это и пишет инженер каждый день:
Код: Выделить всё
apiVersion: acme.example.com/v1
kind: PostgresCluster
metadata:
name: billing-db
spec:
instances: 3
version: "16"
Как работает reconcile под капотом
Тут важно понять механику, иначе оператор будет казаться магией. Контроллер не опрашивает API в цикле for. Он использует informer - это локальный кэш плюс подписка на события API-сервера через watch. Когда кто-то создал, изменил или удалил PostgresCluster, API-сервер шлет событие, informer кладет ключ объекта (namespace/name) в рабочую очередь (workqueue). Воркер достает ключ и вызывает твою Reconcile(ctx, req), где req - это просто имя объекта, а не его содержимое.
Из этого вытекают три железных правила, на которых горят все новички.
Reconcile идемпотентна. Один и тот же объект может прийти в reconcile десять раз подряд без изменений - из-за ресинка кэша, рестарта оператора, дребезга. Каждый прогон должен давать один результат. Нельзя писать "создай под" - надо писать "убедись что под существует, если нет - создай". Это и есть выравнивание, а не последовательность шагов.
Reconcile работает по уровню (level-based), а не по событию (edge-based). Тебе не говорят "instances изменился с 2 на 3". Тебе говорят "глянь на billing-db". Ты сам читаешь текущий spec и текущую реальность. Поэтому пропущенное событие не ломает систему: следующий ресинк все равно подтянет актуальное состояние. Это то, чем Kubernetes принципиально надежнее систем на очередях команд.
При ошибке - requeue с backoff. Если Reconcile вернула ошибку или попросила
Код: Выделить всё
return ctrl.Result{RequeueAfter: time.Second*30}, nilГотовые операторы: бери, не пиши
90% задач уже закрыты зрелыми операторами. Писать свой ради базы - почти всегда ошибка. Что брать в проде на 2026:
- CloudNativePG (apiVersion postgresql.cnpg.io/v1, kind Cluster) - сейчас де-факто стандарт для Postgres в Kubernetes. Сам рулит репликацией, failover, бэкапом в S3 через barman, point-in-time recovery. Не требует внешних агентов вроде Patroni - логика в самом операторе.
- CrunchyData PGO (postgres-operator.crunchydata.com/v1beta1, kind PostgresCluster) - второй сильный игрок для Postgres, более "энтерпрайзный".
- Prometheus Operator - вводит CRD ServiceMonitor, PodMonitor, PrometheusRule. Вместо ручной правки prometheus.yml ты декларативно говоришь "собирай метрики с подов с такими лейблами", и оператор пересобирает конфиг.
- cert-manager - CRD Certificate, Issuer, ClusterIssuer. Заказываешь TLS-сертификат как объект, оператор сам ходит в Let's Encrypt, проходит ACME-челлендж, кладет сертификат в Secret и продлевает его до истечения. Без него HTTPS в кластере - это боль вручную.
- etcd-operator, Strimzi (Kafka), Redis Operator - тот же подход для других stateful-систем.
Практика: смотрим живой оператор
Возьмем CloudNativePG. Поставили оператор, применили манифест Cluster на 3 инстанса. Смотрим, что появилось:
Код: Выделить всё
kubectl get clusters.postgresql.cnpg.io
NAME AGE INSTANCES READY STATUS PRIMARY
billing-db 4m 3 3 Cluster in healthy state billing-db-1
Код: Выделить всё
kubectl getТеперь самое интересное - failover. Прибиваем primary и смотрим events объекта:
Код: Выделить всё
kubectl describe cluster billing-db
...
Events:
Type Reason Age From Message
Normal PrimaryFound 30s cloudnative-pg Found primary billing-db-1
Warning ConnectionFailed 12s cloudnative-pg Primary billing-db-1 unreachable
Normal PromotingReplica 10s cloudnative-pg Promoting billing-db-2 to primary
Normal SwitchoverDone 3s cloudnative-pg New primary is billing-db-2
Как написать свой оператор: Kubebuilder и reconcile
Иногда готового нет - у тебя своя доменная сущность (например, "TenantNamespace", который должен создавать неймспейс, квоты, сетевые политики и сервис-аккаунт по одному объекту). Тогда пишут свой на Go через Kubebuilder или Operator SDK (внутри оба используют библиотеку controller-runtime - ту самую, что дает informer, workqueue, кэш).
Скелет создается так:
Код: Выделить всё
kubebuilder init --domain example.com --repo example.com/tenant-op
kubebuilder create api --group platform --version v1 --kind TenantNamespace
Код: Выделить всё
func (r *TenantNamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var tn platformv1.TenantNamespace
if err := r.Get(ctx, req.NamespacedName, &tn); err != nil {
// объект удален - игнорируем NotFound, дочерние ресурсы уберет GC по ownerRef
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// desired: создаем/обновляем неймспейс, ResourceQuota, NetworkPolicy
if err := r.ensureNamespace(ctx, &tn); err != nil {
return ctrl.Result{}, err // ошибка -> requeue с backoff
}
// пишем фактическое состояние в status
tn.Status.Ready = true
if err := r.Status().Update(ctx, &tn); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
Грабли и антипаттерны из практики
Оператор как черный ящик. Главная опасность. Ты ставишь сторонний оператор и перестаешь понимать, что происходит с базой - он "сам разберется". До первого инцидента, когда failover не сработал, а ты не знаешь даже, какие поды он создает и где логи. Лечение: читай документацию CRD, делай
Код: Выделить всё
kubectl explain cluster.specПисать оператор там, где хватит Helm. Если задача - просто разложить набор манифестов с параметрами, тебе нужен Helm или Kustomize, а не оператор. Оператор оправдан, когда есть день-2 операции: автоматический failover, бэкап по расписанию, безопасное обновление stateful-системы, реакция на состояние. Нет день-2 логики - нет смысла в контроллере.
Неидемпотентный reconcile. Код в стиле "создать ресурс" вместо "обеспечить наличие" приводит к дублям и ошибкам AlreadyExists при повторном прогоне. Всегда Get-then-CreateOrUpdate.
Тяжелые блокирующие операции в reconcile. Reconcile должна быть быстрой. Долгие операции (ждать готовности базы 5 минут) делаются через requeue, а не через sleep в коде - иначе воркер залипает и очередь встает.
CRD без версионирования. Поменял схему в проде без conversion webhook - и старые объекты невалидны. Версии CRD (v1alpha1 -> v1) существуют именно для эволюции схемы.
Связь с декларативной моделью
Operator pattern - не отдельная фича, а логичное продолжение того, как устроен весь Kubernetes. Встроенные контроллеры (ReplicaSet, Deployment) работают ровно так же: читают spec, смотрят реальность, выравнивают. CRD плюс контроллер - это просто способ добавить свои существительные и свою логику выравнивания в тот же механизм. Ты не обходишь Kubernetes, ты расширяешь его теми же кирпичами. Поэтому custom resource ведет себя как родной: те же kubectl, RBAC, лейблы, events, тот же GitOps. Положил PostgresCluster в Git, Argo CD синхронизировал - и оператор довел базу до нужного состояния. Декларативность сверху донизу.
Мини-лаба
- Поставь cert-manager в kind или k3s по официальному манифесту. Посмотри, какие CRD он завел: .
Код: Выделить всё
kubectl get crd | grep cert-manager - Сделай и найди поля dnsNames, issuerRef, secretName - это и есть схема из openAPIV3Schema.
Код: Выделить всё
kubectl explain certificate.spec - Создай ClusterIssuer типа selfSigned и объект Certificate. Через проследи events: как оператор выпустил сертификат и положил его в Secret.
Код: Выделить всё
kubectl describe certificate - Удали Secret с сертификатом. Не создавай заново - просто подожди и посмотри events: reconcile сам обнаружит расхождение и переиздаст. Это level-based выравнивание вживую.
- Глянь под самого оператора и его логи: . Найди строки про reconcile конкретного объекта.
Код: Выделить всё
kubectl -n cert-manager logs deploy/cert-manager
- В чем разница между CRD и custom resource, и почему один CRD без контроллера бесполезен?
- Почему reconcile должна быть идемпотентной и level-based, а не реагировать на конкретное событие изменения?
- Зачем нужны ownerReference и finalizer, и в каком случае ты не можешь обойтись одним ownerReference?
- По каким признакам решаешь: брать готовый оператор, писать свой или вообще обойтись Helm/Kustomize?
Operator pattern зашивает знания человека-оператора в кластер: CRD дает новый тип ресурса (свое существительное вроде PostgresCluster), а контроллер в цикле reconcile выравнивает реальность под желаемое - идемпотентно и по уровню, ровно как встроенные контроллеры Kubernetes. Для типовых stateful-систем бери зрелые операторы (CloudNativePG, Prometheus Operator, cert-manager) с OperatorHub, а свой пиши на Kubebuilder только под реальные день-2 операции. И помни главное правило эксплуатации: оператор - не черный ящик, а компонент, который ты обязан понимать так же глубоко, как систему, которой он управляет.