Операторы и CRD: расширяем Kubernetes под себя

Оператор — это код, который делает за вас рутину эксплуатации: следит за CRD и сводит фактическое состояние к желаемому через reconcile-loop. Разбираем, как устроены CRD и контроллеры, когда хватит готового оператора, а когда стоит писать свой, и чем это грозит в проде.

Kubernetes сам по себе — это машина сведения фактического состояния к желаемому. Оператор берёт ту же идею и распространяет её на вашу предметную область: описываете в CRD, «чего хотите» (кластер Postgres, сертификат, очередь), а контроллер делает так, чтобы это стало реальностью и оставалось таковым. Разберём, как устроен reconcile-loop, где взять готовый оператор, и в какой момент разумно писать собственный — с честным перечнем рисков. Причём разберём не на псевдокоде: почти всё интересное в операторе видно только по меткам времени, и именно они показывают, что «оператор всё чинит сам» — утверждение с оговорками.

Это восьмая статья серии «Kubernetes на практике». Reconcile-модель здесь — та же, что лежит в основе GitOps из девятой статьиготовится, с 29 октября: и оператор, и ArgoCD непрерывно сравнивают desired и actual. Все наблюдения ниже сняты на живом стенде kubernetes/operators в публичном репозитории digital-cookbook: Kubernetes 1.36.2 на кластере k8s-volga, namespace cookbook-k8s, готовый оператор cert-manager v1.17.2 и собственный крошечный оператор на образе golang:1.26. Две половины стенда дополняют друг друга: cert-manager показывает зрелый оператор снаружи, как его видит эксплуатация, а свой greeter-operator — ту же механику изнутри, построчно, без слоя фреймворка.

Ретрофутуристская схема оператора Kubernetes: в центре латунная кольцевая петля-конвейер с двумя загрузочными желобами, в верхний с телеграфной ленты падает карточка-событие watch, в нижний по храповому колесу с делением на тридцать зубцов сползает карточка планового обхода; внутри петли двое весов-коромысло, на левой чаше перфокарта «желаемое», на правой — «фактическое», при перевесе срабатывает штемпель, допечатывающий на правую карточку недостающие строки; справа от петли стеллаж с новыми ящиками API, к которым приклеены пустые самодельные таблички, а внизу под стеллажом тяжёлая цепь-финалайзер, удерживающая уже помеченный к списанию ящик от падения в люк

В статье

CRD: расширяем API кластера

Оператор начинается не с кода, а с типа. CustomResourceDefinition — это объект, которым вы говорите apiserver’у: «теперь в кластере есть ещё один вид ресурса». После его применения новый тип живёт в API на общих правах: у него есть group, version и kind, он лежит в etcd, на него распространяется RBAC, по нему работает kubectl get и открывается watch.

Проще всего это увидеть на чужом операторе. cert-manager в кластере не просто крутит цикл снаружи — он расширил API шестью собственными типами, и их даты создания старше стенда:

$ kubectl get crd --no-headers \
    -o custom-columns=NAME:.metadata.name,CREATED:.metadata.creationTimestamp \
    | grep cert-manager.io
certificaterequests.cert-manager.io                     2026-07-19T08:31:35Z
certificates.cert-manager.io                            2026-07-19T08:31:35Z
challenges.acme.cert-manager.io                         2026-07-19T08:31:35Z
clusterissuers.cert-manager.io                          2026-07-19T08:31:35Z
issuers.cert-manager.io                                 2026-07-19T08:31:35Z
orders.acme.cert-manager.io                             2026-07-19T08:31:35Z

Это и есть главное отличие оператора от скрипта по расписанию: словарь кластера пополнился. «Сертификат» стал таким же объектом API, как Pod или Service, — со схемой, валидацией и правами доступа.

Свой тип для стенда — намеренно минимальный. Greeting из группы cookbook.khorost.tech, версия v1alpha1, namespaced. В определении есть три вещи, ради которых его стоит рассматривать целиком:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: greetings.cookbook.khorost.tech
spec:
  group: cookbook.khorost.tech
  scope: Namespaced
  names:
    plural: greetings
    singular: greeting
    kind: Greeting
    listKind: GreetingList
  versions:
    - name: v1alpha1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: WHO
          type: string
          jsonPath: .spec.who
        - name: MESSAGE
          type: string
          jsonPath: .status.message
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [who]
              properties:
                who:
                  type: string
            status:
              type: object
              properties:
                message:
                  type: string
                observedGeneration:
                  type: integer

Первое — схема. openAPIV3Schema описывает поля и типы, и apiserver проверяет объект по ней до того, как тот попадёт в etcd. required: [who] стоит внутри properties.spec, а не на верхнем уровне схемы, поэтому объект вообще без секции spec эту проверку пройдёт — а вот объект, у которого spec есть, но в нём нет who, принят не будет. Это валидация на стороне API, а не в коде оператора, и она работает даже если оператор в этот момент лежит.

Второе — подресурс status. Строка subresources: {status: {}} разделяет объект на две зоны ответственности. spec пишет человек (или GitOps), status пишет оператор, и пишет он его отдельным API-вызовом на /status. Это не косметика: запись в status не трогает spec и не увеличивает metadata.generation, а права на неё выдаются отдельной строкой RBAC. Разделение прямо отражено в коде оператора:

// updateGreetingStatus пишет status через подресурс /status — так CRD с
// subresources.status и предполагается использовать: изменение status не
// трогает spec и не увеличивает metadata.generation.
func (c *k8sClient) updateGreetingStatus(ctx context.Context, g *greeting) error {
	body, err := json.Marshal(g)
	if err != nil {
		return err
	}
	return c.doJSON(ctx, http.MethodPut, c.greetingsPath()+"/"+g.Metadata.Name+"/status", body, nil)
}

Третье — printer-колонки. additionalPrinterColumns определяют, что покажет kubectl get без -o yaml. Это чистая эргономика, но именно она превращает свой тип из «объекта, который надо разглядывать в JSON» в такой же обозримый ресурс, как всё остальное. Применяем CR:

apiVersion: cookbook.khorost.tech/v1alpha1
kind: Greeting
metadata:
  name: demo
  namespace: cookbook-k8s
spec:
  who: "мир"

и получаем ровно то, что описали колонками — слева наше spec.who, справа то, что вычислил и записал оператор:

$ kubectl -n cookbook-k8s get greeting -l stand=cookbook-operators --show-kind
NAME                                  WHO   MESSAGE
greeting.cookbook.khorost.tech/demo   мир   Привет, мир!

Ключевой момент, который стоит осознать до перехода к контроллеру: CRD сам по себе не делает ничего. Он даёт хранилище и валидацию для объекта, и на этом всё. Применив только CRD и CR, вы получите объект, который лежит в etcd и никого не волнует. MESSAGE в выводе выше появился не от CRD, а от второй половины конструкции.

Контроллер и reconcile-loop

Вторая половина — контроллер: процесс, который читает желаемое состояние из spec, смотрит на фактическое состояние кластера и устраняет разницу. Ничего больше в операторе нет; вся его сложность в проде — это варианты одного этого действия.

Оператор стенда делает демонстративно простую вещь: из Greeting.spec.who он собирает строку Привет, <who>!, кладёт её в ConfigMap greeting-<имя> и то же самое пишет в Greeting.status. Сердцевина reconcile выглядит так, как выглядела бы на любом языке:

	desired := fmt.Sprintf("Привет, %s!", g.Spec.Who)
	cmName := "greeting-" + name

	cm, err := c.getConfigMap(ctx, cmName)
	switch {
	case errors.Is(err, errNotFound):
		log.Printf("reconcile %s: ConfigMap %s не найден — создаю с message=%q", name, cmName, desired)

Три ветки: объекта нет — создать; объект есть, но содержимое расходится — обновить; всё совпадает — не делать ничего. Третья ветка и есть идемпотентность, и в операторе она важнее первых двух, потому что срабатывает чаще всех:

	default:
		log.Printf("reconcile %s: ConfigMap %s уже в желаемом состоянии (message=%q) — изменений не требуется", name, cmName, desired)
	}

	if g.Status.Message == desired && g.Status.ObservedGeneration == g.Metadata.Generation {
		log.Printf("reconcile %s: status уже актуален (message=%q, observedGeneration=%d) — не трогаю", name, desired, g.Metadata.Generation)
		return
	}

Почему чаще всех — видно на живом логе. После того как оператор сам записал status, apiserver честно уведомляет его об этом изменении: объект-то поменялся. Оператор получает событие о собственной записи и запускает reconcile повторно:

$ kubectl -n cookbook-k8s logs deploy/greeter-operator --timestamps \
    | grep 'T05:57:29' | tail -n 5 | cut -d' ' -f1,4-
2026-07-24T05:57:29.665250436Z reconcile demo: ConfigMap greeting-demo расходится с желаемым (было "Привет, мир!", нужно "Привет, Kubernetes!") — обновляю
2026-07-24T05:57:29.671306283Z reconcile demo: status обновлён (message="Привет, Kubernetes!", observedGeneration=2)
2026-07-24T05:57:29.671321456Z watch: событие MODIFIED greeting=demo (spec.who="Kubernetes") — запускаю reconcile
2026-07-24T05:57:29.674254683Z reconcile demo: ConfigMap greeting-demo уже в желаемом состоянии (message="Привет, Kubernetes!") — изменений не требуется
2026-07-24T05:57:29.674263566Z reconcile demo: status уже актуален (message="Привет, Kubernetes!", observedGeneration=2) — не трогаю

Между третьей и четвёртой строкой — около 3 миллисекунд. Оператор отреагировал на собственное изменение, ещё раз сверил состояния и остановился. Не будь третья ветка настоящей, а не «на всякий случай», этот цикл ушёл бы в самоподдерживающийся шторм записей: каждая запись рождает событие, каждое событие рождает запись.

Отсюда практическое правило, которое переносится на любой оператор: reconcile всё равно выполняет побочные эффекты (создаёт и меняет объекты, ходит во внешние системы) — чистой функцией он не бывает, но обязан быть идемпотентным (повторный вызов при том же состоянии не даёт нового эффекта) и сходящимся (повторные проходы приводят факт к желаемому), а не последовательностью команд. Его вызовут неизвестное число раз с неизвестными интервалами, в том числе на объекте, который никто не менял, и в том числе сразу после его собственной записи. Правильная реализация от этого не портится и не шумит.

Второй элемент той же дисциплины — observedGeneration. metadata.generation увеличивает apiserver при каждом изменении spec; оператор, отработав, записывает эту цифру в status.observedGeneration. Сравнение двух чисел отвечает на вопрос, который иначе не имеет ответа: «этот status относится к текущему spec или к предыдущему?». В стенде после первого применения — {"message":"Привет, мир!","observedGeneration":1}, после правки spec.who — {"message":"Привет, Kubernetes!","observedGeneration":2}. Совпадение с generation означает, что оператор уже видел актуальную версию желаемого; расхождение — что он ещё не дошёл. В проде именно по этой паре чисел строят проверки готовности и ждут в CI, а не по факту существования объекта.

Два входа в петлю: событие и resync

Теперь самое интересное — откуда reconcile вообще вызывается. В этом минимальном демо входов ровно два, и они принципиально разные по скорости и по покрытию. Оговорюсь сразу: «ровно два» — свойство именно этого демо. В общем случае источников пробуждения reconcile больше, и они явно описаны в controller-runtime (sigs.k8s.io/controller-runtime/pkg/reconcile): вторичные watches на подчинённые (owned) объекты, повтор с backoff при ошибке, RequeueAfter (плановый повтор через заданное время), внешние события. Здесь же, в учебном операторе, их всего два.

Первый — watch. Оператор держит открытым долгоживущий HTTP-запрос к apiserver и читает поток событий по мере их появления. В коде стенда это буквально одна строка и построчное чтение ответа:

	path := c.greetingsPath() + "?watch=true"
		switch ev.Type {
		case "ADDED", "MODIFIED":
			log.Printf("watch: событие %s greeting=%s (spec.who=%q) — запускаю reconcile", ev.Type, g.Metadata.Name, g.Spec.Who)
			reconcile(ctx, c, g.Metadata.Name)

Второй — resync: независимый таймер, по которому оператор сам обходит все объекты своего типа и реконсилит каждый, ничего не спрашивая у событий.

const (
	saDir          = "/var/run/secrets/kubernetes.io/serviceaccount"
	apiGroup       = "cookbook.khorost.tech"
	apiVersion     = "v1alpha1"
	resyncInterval = 30 * time.Second
	watchRetryWait = 5 * time.Second
		case <-ticker.C:
			list, err := c.listGreetings(ctx)
			if err != nil {
				log.Printf("resync: список Greeting не получен: %v", err)
				continue
			}
			log.Printf("resync: полный проход, найдено Greeting: %d", len(list.Items))
			for _, item := range list.Items {
				reconcile(ctx, c, item.Metadata.Name)
			}

Кажется, что resync — страховка на случай обрыва watch, и только. Стенд показывает, что это не так: у двух входов разное покрытие, и разница видна на двух почти одинаковых экспериментах с противоположным результатом.

Эксперимент первый: правим то, за чем оператор следит. Меняем spec.who на Kubernetes. Момент правки зафиксирован командой date — 05:57:28Z. Реакция оператора — из его лога:

$ kubectl -n cookbook-k8s logs deploy/greeter-operator --timestamps \
    | grep 'T05:57:29' | head -n 3 | cut -d' ' -f1,4-
2026-07-24T05:57:29.661638420Z watch: событие MODIFIED greeting=demo (spec.who="Kubernetes") — запускаю reconcile
2026-07-24T05:57:29.665250436Z reconcile demo: ConfigMap greeting-demo расходится с желаемым (было "Привет, мир!", нужно "Привет, Kubernetes!") — обновляю
2026-07-24T05:57:29.671306283Z reconcile demo: status обновлён (message="Привет, Kubernetes!", observedGeneration=2)

Здесь нужна аккуратность с арифметикой, и это тот случай, когда небрежность превращает честный замер в красивое враньё. Метка правки снята с точностью до секунды (05:57:28Z), метка реакции — с точностью до наносекунды (05:57:29.661…Z). Секундная метка не говорит, в какой момент внутри своей секунды произошёл сам patch: если в самом начале (05:57:28.000) — разница около 1,66 с, если в конце (05:57:28.999) — около 0,66 с. Поэтому честная формулировка — реакция до ~1,7 с, а не «меньше секунды»: разрешения исходной метки просто не хватает, чтобы утверждать субсекундную реакцию. Направление вывода это не меняет — сравните со вторым экспериментом.

Эксперимент второй: правим то, что оператор породил. Подменяем data.message в ConfigMap greeting-demo на строку испорчено вручную в 05:56:02Z и опрашиваем содержимое раз в 5 секунд:

$ for i in $(seq 3); do \
    echo "$(date -u +%H:%M:%S) $(kubectl -n cookbook-k8s get configmap greeting-demo -o jsonpath='{.data.message}')"; \
    sleep 5; done
05:56:02 испорчено вручную
05:56:07 испорчено вручную
05:56:13 Привет, мир!

Исправление пришло, но не сразу. Точный момент — снова из лога оператора, и он же называет источник:

$ kubectl -n cookbook-k8s logs deploy/greeter-operator --timestamps \
    | grep 'T05:56:10' | head -n 2 | cut -d' ' -f1,4-
2026-07-24T05:56:10.890948380Z resync: полный проход, найдено Greeting: 1
2026-07-24T05:56:10.893691769Z reconcile demo: ConfigMap greeting-demo расходится с желаемым (было "испорчено вручную", нужно "Привет, мир!") — обновляю

Починку сделал не watch, а плановый обход. Тикер запущен при старте пода в 05:55:10, значит тики ложатся ровно через 30 секунд: 05:55:40, 05:56:10, 05:56:40, 05:57:10, 05:57:40 — все пять видны в логе. Порча в 05:56:02 пришлась уже после тика 05:55:40, поэтому оператор узнал о ней только на следующем, 05:56:10. Фактическая задержка — 8 секунд, и она целиком объясняется временем до ближайшего тика, а не медленной обработкой.

Причина разницы не в скорости и не в нагрузке, а в подписке. Оператор открыл watch только на Greeting — за ConfigMap, который сам же создал, он отдельно не следит. Правка ConfigMap чужой рукой порождает событие в API, но не на том потоке, который читает оператор: он на него не подписан, и события до него не доходит. Единственный механизм, который вообще способен обнаружить такое расхождение, — полный обход по таймеру.

Это ключевой урок раздела, и он не про демо-оператор, а про эксплуатацию любого. Оператор приводит к желаемому состоянию не «всё, за что отвечает», а только то, что он наблюдает. Настоящие операторы на controller-runtime поэтому ставят watch не только на свой CRD, но и на все порождённые типы — в Kubebuilder это делается через Owns() в билдере контроллера, и тогда правка порождённого объекта долетает как событие, а не ждёт обхода. В операторе стенда такого watch нет намеренно — именно чтобы разница между двумя входами стала измеримой, а не осталась теорией.

Практический вывод для того, кто оператор эксплуатирует, а не пишет: если у вашего оператора конфигурация «сама чинится за полминуты» — проверьте, не resync ли это. Разница станет видна ровно в тот день, когда интервал обхода в проде поднимут с 30 секунд до 10 минут ради снижения нагрузки на apiserver.

Петля reconcile: два входа, одно сведение состоянийвход 1 — событие watchправка spec.who: до ~1,7 свход 2 — плановый resyncтик каждые 30 с; в прогоне 8 сreconcile(demo)повтор не меняет результатwatch открыт только на Greetingправка порождённого ConfigMapсобытия не приносит —её находит только resyncжелаемое — spec.who«Привет, Kubernetes!»фактическое — ConfigMapgreeting-demo.data.messageсравнение желаемого и фактическогорасходится — обновляюConfigMap приводится к specсовпадает — ничего не делаюявная запись в лог, без записи в APIstatus: message + observedGenerationоператор сводит к желаемому не всё, за что отвечает, а только то, что наблюдает

Готовые операторы: не изобретать велосипед

Тот же цикл, но у зрелого продукта, выглядит иначе снаружи: вы не видите ни watch, ни resync — вы видите объект, который «просто работает». cert-manager в стенде получает один Certificate и разворачивает из него цепочку:

$ kubectl -n cookbook-k8s get issuer,certificate,certificaterequest,secret \
    -l stand=cookbook-operators
NAME                                         READY   AGE
issuer.cert-manager.io/cookbook-selfsigned   True    79s

NAME                                    READY   SECRET          AGE
certificate.cert-manager.io/demo-cert   True    demo-cert-tls   79s

NAME                                             APPROVED   DENIED   READY   ISSUER                REQUESTER                                         AGE
certificaterequest.cert-manager.io/demo-cert-1   True                True    cookbook-selfsigned   system:serviceaccount:cert-manager:cert-manager   78s

NAME                   TYPE                DATA   AGE
secret/demo-cert-tls   kubernetes.io/tls   3      78s

Из одного объекта, который написал человек, оператор породил два: CertificateRequest demo-cert-1 (APPROVED=True, READY=True) и Secret demo-cert-tls типа kubernetes.io/tls с тремя ключами — ca.crt, tls.crt, tls.key. Заметьте REQUESTER: запрос выставил не человек, а system:serviceaccount:cert-manager:cert-manager — сам оператор. Статус исходного Certificate при этом сформулирован человеческим языком, и это тоже часть зрелости продукта:

$ kubectl -n cookbook-k8s get certificate demo-cert -o jsonpath=\
'{range .status.conditions[*]}type={.type} status={.status} reason={.reason} message={.message}{"\n"}{end}'
type=Ready status=True reason=Ready message=Certificate is up to date and has not expired

Живая идемпотентность. Проверка, ради которой стенд и затевался: удаляем порождённый Secret руками и смотрим, вернётся ли он сам. Момент удаления зафиксирован, дальше опрос раз в 4 секунды:

$ date -u +%Y-%m-%dT%H:%M:%SZ; kubectl -n cookbook-k8s delete secret demo-cert-tls >/dev/null
2026-07-24T05:40:55Z
$ for i in $(seq 4); do \
    echo "$(date -u +%H:%M:%S) $(kubectl -n cookbook-k8s get secret demo-cert-tls --no-headers 2>&1)"; \
    sleep 4; done
05:40:55 Error from server (NotFound): secrets "demo-cert-tls" not found
05:40:59 demo-cert-tls   kubernetes.io/tls   3     4s
05:41:04 demo-cert-tls   kubernetes.io/tls   3     8s
05:41:08 demo-cert-tls   kubernetes.io/tls   3     12s

Никто ничего не применял после kubectl delete — Secret вернул оператор, среагировав на расхождение: Certificate требует существования секрета, секрета нет, значит надо создать. По шагу опроса восстановление укладывается в один интервал: последняя отметка с ошибкой — 05:40:55, первая с объектом — 05:40:59, то есть граница сверху — 4 секунды.

Но у объекта есть собственная метка времени, и она точнее любого полинга:

$ kubectl -n cookbook-k8s get secret demo-cert-tls \
    -o jsonpath='creationTimestamp={.metadata.creationTimestamp}{"\n"}'
creationTimestamp=2026-07-24T05:40:56Z

Пересоздание уложилось примерно в секунду после удаления. Опрос с шагом 4 секунды такую скорость показать не мог в принципе — это отдельный, более точный факт, доступный только потому, что объект хранит время своего рождения сам. Полезный приём в целом: когда меряете реакцию контроллера, ищите метку в самом объекте, а не полагайтесь на разрешение своего цикла опроса. Дальше AGE растёт монотонно (4s → 8s → 12s → … до 59s) — значит это один устойчивый объект, а не серия пересозданий в цикле.

И отдельная деталь, которая ломает ожидания. Кто владеет порождённым Secret? Смотрим ownerReferences:

$ kubectl -n cookbook-k8s get secret demo-cert-tls \
    -o jsonpath='type={.type} ownerRefs={.metadata.ownerReferences}{"\n"}'
type=kubernetes.io/tls ownerRefs=

Пусто. Связь с родителем несёт не ссылка на владельца, а аннотация:

$ kubectl -n cookbook-k8s get secret demo-cert-tls -o jsonpath=\
'cert-manager.io/certificate-name={.metadata.annotations.cert-manager\.io/certificate-name}{"\n"}'
cert-manager.io/certificate-name=demo-cert

Это свойство конкретной версии и её настроек, а не общее правило операторов: в cert-manager v1.17 флаг --enable-certificate-owner-ref по умолчанию выключен. Практическое следствие прямое: порождённый Secret переживёт удаление Certificate. Без ownerReferences у Kubernetes нет механизма каскадного удаления — сборщик мусора не знает, что этот объект чей-то, и оставит его в кластере сиротой. Для сравнения: у Secret, который разворачивает контроллер sealed-secrets из своего CRD (разбор — в «Секреты в Kubernetes: Sealed Secrets и External Secrets»), ownerReferences заполнены, и удаление родителя уносит с собой производный объект. Два оператора, две разные модели владения — и узнать, какая у вашего, можно только посмотрев на реальный объект.

Отсюда общая практика приёмки чужого оператора: не гадайте по документации, что произойдёт при удалении, а создайте объект на тестовом контуре, удалите родителя и посмотрите, что осталось. Разница между «оператор всё уберёт» и «останется мусор, который надо чистить отдельно» — это разница между работающим teardown и медленно заполняющимся кластером.

Когда писать свой оператор, а когда нет

Стенд даёт удобный контраст для этого вопроса, потому что содержит оба полюса.

greeter-operator из стенда — пример оператора, который писать не нужно. Если снять с него демонстрационную функцию, останется вот что: он подставляет значение из одного поля в шаблон и кладёт результат в ConfigMap. Ровно это делают Helm и kustomize — на этапе рендера, без единого работающего процесса в кластере, без CRD, без RBAC, без пода, который надо мониторить. Единственное, что оператор добавляет сверху, — непрерывное возвращение к желаемому состоянию после порчи. И это честный, но узкий выигрыш: тот же эффект в GitOps даёт автосинхронизация ArgoCD, который тоже сравнивает git с кластером по расписанию.

cert-manager — пример, где оператор оправдан полностью, и разница видна по составу работы. Он не разворачивает шаблон, а ведёт жизненный цикл, растянутый во времени: выпускает сертификат, отслеживает срок годности, перевыпускает до истечения, проводит цепочку из нескольких объектов (Certificate → CertificateRequest → Secret), разбирается с ответом внешнего УЦ и восстанавливает порождённое, если его снесли. Ни один apply не выразит «перевыпусти за 12 часов до истечения»: это не состояние манифеста, это поведение во времени.

Отсюда рабочий признак. Оператор нужен, когда у вашей сущности есть состояние, которое меняется само по себе, без изменения манифеста: сертификат истекает, лидер БД падает, реплика отстаёт, внешний ресурс исчезает. Ровно эту разницу — «состояние поменялось, а желаемое нет» — и обслуживает reconcile-loop. Если же состояние меняется только тогда, когда кто-то правит манифест, — вам нужен рендер шаблонов и, возможно, разовая Job после него, а не постоянно работающий контроллер.

Практический порядок для тех, кто раздумывает:

  1. Уже есть зрелый оператор для этой задачи? Берите его. Свой оператор для БД, брокера или сертификатов — почти всегда проигрыш: чужой уже пережил те отказы, до которых вы дойдёте через год. Операторы БД (CloudNativePG, Zalando, Crunchy) сами по себе не отменяют вопроса, стоит ли держать базу в кластере, — он разбирается отдельно в «StatefulSets и хранилище: stateful-нагрузки в Kubernetes».
  2. Задача выражается статическим манифестом? Хватит Helm/kustomize плюс, при необходимости, Job для разовых действий. CRD тут добавит слой, но не возможность.
  3. Есть своя доменная сущность с поведением во времени и закодированная рутина, которую иначе делает человек по инструкции? Вот здесь оператор окупается — вы переносите инструкцию из wiki в код, и она начинает выполняться в три часа ночи без вас.
  4. Готовы ли вы этот оператор эксплуатировать? Это отдельный сервис с версиями CRD, миграциями, правами и своим blast radius — раздел ниже про то, чего это стоит.

Отдельно стоит сказать, что CRD без своего контроллера — тоже законный приём. Собственный тип объекта имеет смысл и просто как валидируемое, версионируемое хранилище конфигурации в API кластера, которое читают чужие компоненты. CiliumNetworkPolicy из «Сеть в Kubernetes: Cilium и когда нужен service mesh» — ровно такой пример расширения API собственным типом, у которого потребитель совсем не похож на классический оператор.

Инструменты: Kubebuilder и Operator SDK

Теперь честная оговорка про код, который вы видели выше. Оператор стенда написан на чистой стандартной библиотеке Go — без controller-runtime и даже без client-go. Доступ к API — обычный net/http с токеном ServiceAccount и CA кластера из /var/run/secrets/kubernetes.io/serviceaccount/, TLS с настоящей проверкой сертификата (не InsecureSkipVerify); watch — построчное чтение потока ответа bufio.Scanner‘ом, потому что apiserver отдаёт newline-delimited JSON и специальный клиент для этого не обязателен. Запускается он go run прямо из ConfigMap на стоковом образе golang:1.26, поэтому стенду не нужен ни registry, ни сборка образа.

Это сделано ради наглядности, а не как рекомендация. Смысл в том, что механика reconcile в таком виде видна целиком: вот открытие watch, вот таймер обхода, вот сравнение, вот запись в подресурс status. За фасадом фреймворка каждая из этих вещей существует ровно так же, но найти её глазами уже труднее. В проде так не делают: компиляция на каждом старте пода — это холодный старт и риск разъезда исходника, поэтому рядом в стенде лежит Dockerfile с обычной multi-stage-сборкой в distroless-образ под непривилегированным пользователем — как обозначение продуктового пути.

Для настоящего оператора берут Kubebuilder или Operator SDK — оба поверх controller-runtime, и оба дают из коробки то, что в стенде написано руками или не написано вовсе:

  • Скаффолдинг проекта: структура, main.go с менеджером, Makefile, манифесты деплоя.
  • Генерация CRD и RBAC из кода: тип на Go с маркерами превращается в CustomResourceDefinition со схемой, а маркеры прав — в Role/ClusterRole. Сами маркеры +kubebuilder:rbac пишет разработчик рядом с кодом, анализа фактических обращений к API за ними нет. Поэтому дрейф прав, который стенд показывает в следующем разделе, генерация не устраняет: лишнее право, записанное в маркер, попадёт в роль так же исправно. Она лишь держит права рядом с кодом, где их проще сверять на ревью.
  • Informer’ы с локальным кешем вместо ручного watch: контроллер читает объекты из кеша, а не дёргает apiserver на каждый reconcile, и переподключение потока — не ваша забота.
  • Рабочая очередь с дедупликацией и экспоненциальным backoff: повторные события по одному объекту схлопываются, а упавший reconcile переставляется в очередь с растущей паузой, а не крутится в цикле.
  • Owns() в билдере контроллера — тот самый watch на порождённые типы, отсутствие которого в стенде дало восьмисекундную задержку из раздела про resync.
  • Leader election — чтобы из нескольких реплик оператора реконсилила ровно одна.
  • envtest — тестирование против настоящего apiserver и etcd, поднятых локально, без кластера. Это принципиально лучше моков: reconcile проверяется на реальном поведении API, включая валидацию по схеме CRD и работу подресурса status.

Практический вывод простой: понимать петлю полезно на уровне сырого HTTP, а писать — на фреймворке. Всё, что перечислено выше, вы всё равно реализуете, только хуже и позже.

Риски и эксплуатация

Оператор — это процесс с правами в кластере, который что-то делает без вашего участия. Обе половины этой фразы и есть источник рисков.

Масштаб прав — это масштаб поражения. Свой оператор в стенде намеренно узок. kubectl auth can-i --list от имени его ServiceAccount возвращает короткий список — ниже содержательная часть, стандартный самослужебный набор (selfsubjectreviews и т. п.) и discovery-эндпоинты (/api, /apis, /healthz…) опущены:

$ kubectl auth can-i --list \
    --as=system:serviceaccount:cookbook-k8s:greeter-operator -n cookbook-k8s
Resources                                       Non-Resource URLs                      Resource Names   Verbs
configmaps                                      []                                     []               [get list create update]
greetings.cookbook.khorost.tech                 []                                     []               [get list watch update]
greetings.cookbook.khorost.tech/status          []                                     []               [get update patch]
…

Секреты в своём namespace — no. В чужом namespace — тоже no, потому что права выданы ролью Role через привязку RoleBinding в cookbook-k8s, и область действия за этот namespace не выходит. Полный захват этого пода не даёт злоумышленнику ничего, кроме ConfigMap и Greeting в одном namespace.

Для контраста — те же права у кластерного cert-manager:

$ kubectl auth can-i --list --as=system:serviceaccount:cert-manager:cert-manager \
    -n cookbook-k8s | grep '^secrets '
secrets                                                 []                                     []                                   [get list watch create update delete patch]

А kubectl auth can-i get secrets --all-namespaces от его имени отвечает yes. Это не дефект и не претензия к продукту: оператор, который обязан выпускать TLS-сертификаты в произвольный namespace, физически не может обойтись без права писать Secret в этом namespace. Права ровно такие, каких требует его работа. Но следствие всё равно надо держать в голове: ошибка или компрометация кластерного оператора масштабируется на всё, чем он управляет, — то есть на весь кластер, а не на один namespace. Отсюда рабочее правило проектирования: если оператору достаточно одного namespace — выдавайте права через RoleBinding, а не ClusterRoleBinding. Область действия задаёт именно привязка: RoleBinding может ссылаться и на ClusterRole, и тогда её правила действуют только внутри namespace привязки — так удобно переиспользовать один набор правил в нескольких namespace. Кластерная область должна быть осознанным решением, а не значением по умолчанию из чужого чарта.

Права дрейфуют шире необходимого — и это видно в том же списке. Строка greetings.cookbook.khorost.tech ... [get list watch update] содержит право update на сам объект. Код оператора им не пользуется ни разу: единственная запись, которую он делает, — PUT на подресурс .../greetings/{name}/status через updateGreetingStatus; прямого PUT или PATCH на greetings в нём нет. Право выдано «на всякий случай» и осталось неиспользованным — миниатюрная версия того, что происходит с RBAC любого оператора за пару лет жизни. Лечится это сверкой прав с тем, что реально дёргает клиент — например, по аудит-логу apiserver, — а не разовым аудитом раз в год. Генерация Role из маркеров Kubebuilder здесь не спасает: маркер пишет тот же человек, что выдал лишнее право.

Finalizer: удаление, которое отложено, а не отменено. Второй классический эксплуатационный сюжет — объект, который не удаляется. Механику видно на полном цикле, проведённом на своём же Greeting. Ставим на объект finalizer, который никто не собирается снимать, и удаляем объект:

$ date -u +%Y-%m-%dT%H:%M:%SZ; kubectl -n cookbook-k8s delete greeting demo --wait=false
2026-07-24T06:06:35Z
greeting.cookbook.khorost.tech "demo" deleted from cookbook-k8s namespace

kubectl отчитался об удалении, но это отчёт о принятом запросе, а не о том, что объект исчез. Смотрим на объект:

$ kubectl -n cookbook-k8s get greeting demo \
    -o jsonpath='deletionTimestamp={.metadata.deletionTimestamp} finalizers={.metadata.finalizers}{"\n"}'
deletionTimestamp=2026-07-24T06:06:36Z finalizers=["cookbook.khorost.tech/demo-finalizer"]
$ kubectl -n cookbook-k8s get greeting demo
NAME   WHO          MESSAGE
demo   Kubernetes   Привет, Kubernetes!

Объект жив и прекрасно отвечает на kubectl get. deletionTimestamp apiserver проставил практически сразу (запрос — 06:06:35Z, метка — 06:06:36Z), но физического удаления не будет, пока список finalizers не опустеет. Снимаем finalizer вручную:

$ date -u +%Y-%m-%dT%H:%M:%SZ; kubectl -n cookbook-k8s patch greeting demo --type merge \
    -p '{"metadata":{"finalizers":[]}}'
2026-07-24T06:06:53Z
greeting.cookbook.khorost.tech/demo patched

Следующий же kubectl get возвращает NotFound:

$ kubectl -n cookbook-k8s get greeting demo
Error from server (NotFound): greetings.cookbook.khorost.tech "demo" not found

Единственный интервал, который здесь измерен, — 18 секунд между delete и ручным снятием finalizer: столько объект провисел в подвешенном состоянии. Отдельной метки времени на саму проверку kubectl get фикстура не даёт, поэтому точнее сказать нечего — и это как раз не важно. Важно другое: без вмешательства он провисел бы неопределённо долго, а отложенное удаление выполнилось, как только finalizers опустели. Оно было отложено, а не отменено.

Отсюда самое практичное следствие всей статьи. Залипший finalizer — самая частая причина «namespace не удаляется»: внутри висит объект с непустым finalizers, чей контроллер мёртв, снят или не имеет прав доделать свою часть. Диагностика — не читать логи оператора наугад, а искать объекты с непустым metadata.deletionTimestamp: такой объект уже в процессе удаления и чего-то ждёт, и список finalizers прямо называет, чего именно. Ручное снятие — крайняя мера: finalizer ставился ради логики (обычно — освобождения внешнего ресурса), и снятие эту логику обходит. Применимо только тогда, когда точно известно, что она уже не выполнится и её отсутствие не оставит внешний хвост в виде оплаченного балансировщика или неудалённого тома.

Порождённое переживает родителя. Тот же стенд показывает это дважды. ConfigMap greeting-demo, созданный оператором, после удаления Greeting demo остался на месте с пустыми ownerReferences — ровно как Secret у cert-manager. В демо-операторе это осознанное упрощение: ветка case "DELETED" в обработчике watch только пишет в лог и намеренно ничего не удаляет, потому что реализация сборки мусора вручную оставлена за рамками примера. В настоящем операторе эту связь оформляют либо ownerReferences (и тогда каскадное удаление делает сам Kubernetes), либо finalizer’ом с явной уборкой в коде. Выбор между ними — проектное решение, но не выбрать ничего значит выбрать первый вариант с накоплением сирот.

И общее, о чём легко забыть за архитектурой. Оператор — это ещё один сервис в проде. Его надо мониторить (жив ли под, не сыплются ли ошибки в reconcile, не растёт ли очередь), обновлять вместе с версиями его CRD, откатывать при неудаче — и всё это на компоненте, у которого есть права менять кластер. Версии CRD эволюционируют отдельно от версий оператора: переход v1alpha1 → v1beta1 требует либо conversion webhook, либо согласованного обновления всех потребителей, и делается это на живых объектах в etcd. Ошибка в reconcile — это не упавший запрос одного пользователя, а систематическое изменение всех объектов, которыми оператор управляет, приведённых к неверному «желаемому» состоянию за один обход.

Дальше по серии оператор смыкается с соседними темами: контроллеры sealed-secrets и External Secrets Operator — это ровно такие же операторы со своими CRD, и их поведение разбирается в «Секреты в Kubernetes: Sealed Secrets и External Secrets»; ArgoCD сравнивает git с кластером тем же циклом сведения состояний — «GitOps с ArgoCD: progressive delivery, canary и blue-green»готовится, с 29 октября; та же петля, но у встроенных контроллеров control plane, — в «Архитектура Kubernetes: control plane и reconciliation loop»; расширение API собственным типом на примере сетевых политик — в «Сеть в Kubernetes: Cilium и когда нужен service mesh»; а вопрос, стоит ли отдавать базу под управление оператора внутри кластера, — в «StatefulSets и хранилище: stateful-нагрузки в Kubernetes». Входящий трафик, который операторы вроде cert-manager обслуживают сертификатами, — тема статьи про Gateway API; кластер, на котором всё это снято и к узлам которого нет SSH, собран в «Четыре отказа при bootstrap на Talos»; а если вопрос стоит шире — нужен ли вообще кластер под вашу задачу, — на него отвечает «Kubernetes для домашней лаборатории».

Источники

Обсуждение в Telegram

Присоединиться →