Kubernetes для самых маленьких. Часть 28: Что такое CRD и операторы?

Все части серии «Kubernetes для самых маленьких»

В части 1 мы определили Kubernetes как набор контроллеров, каждый из которых приводит фактическое состояние к желаемому, а в части 3 — его API как единообразный набор ресурсов с spec и status, которые можно читать, менять и на изменения которых можно подписаться. Обе идеи открыты для расширения: в API можно добавить свой тип ресурса, а рядом запустить свой контроллер, который будет его обслуживать. Вместе это называется оператором, и мы уже пользовались двумя: Tigera operator ставил Calico в части 4, cert-manager выпускал сертификаты в части 19. Сегодня — как это устроено: CustomResourceDefinition, схема и валидация, контроллер и его цикл, а затем два настоящих оператора для баз данных и очередей и то, как оценивать оператор перед установкой. Kustomize из части 26 и Helm из части 27 здесь тоже пригодятся: операторы обычно ставятся манифестами выпуска или чартами.

Всё в этой части проверено на учебном кластере: CRD и контроллер на controller-runtime, CloudNativePG 1.30.1 и RabbitMQ Cluster Operator 2.23.2. Без проверки остались резервное копирование PostgreSQL в S3, плагин kubectl cnpg, конверсия версий CRD и Messaging Topology Operator — о них по документации.


Ресурс без кода в apiserver

CustomResourceDefinition (CRD) — встроенный ресурс группы apiextensions.k8s.io, который описывает новый тип: его группу, имя, версии и схему. Создали CRD — и через секунду kube-apiserver обслуживает новый путь REST, хранит объекты в etcd, проверяет их по схеме, отдаёт watch, поддерживает kubectl get, apply, explain, RBAC и всё остальное, что есть у встроенных ресурсов. Ни строчки кода в apiserver при этом не пишется и ничего не перезапускается.

Чего CRD не делает — не придаёт объекту поведения. Объект Greeting будет лежать в etcd и ничего не произойдёт, пока какой-то контроллер не начнёт за ним следить. В этом его отличие от встроенных ресурсов только количественное: Deployment тоже «просто данные», которые обрабатывает контроллер внутри kube-controller-manager.

Есть и второй, более старый механизм расширения — агрегация API: отдельный сервер API регистрируется объектом APIService, и kube-apiserver проксирует к нему запросы своей группы. Так работает metrics-server (часть 32). Это нужно, когда данные хранятся не в etcd или нужна своя логика хранения; для всего остального используют CRD.

Пример на всю часть — ресурс Greeting: «хочу приложение hello такой-то версии, с таким-то приветствием, в стольких-то репликах, на таком-то хосте». Контроллер будет превращать его в Deployment, Service и Ingress.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: greetings.demo.example.com      # <plural>.<group>, обязательно так
spec:
  group: demo.example.com
  scope: Namespaced                     # или Cluster
  names:
    kind: Greeting
    listKind: GreetingList
    plural: greetings
    singular: greeting
    shortNames: [gr]
    categories: [demo]                  # kubectl get demo покажет все ресурсы категории
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {}
        scale:
          specReplicasPath: .spec.replicas
          statusReplicasPath: .status.readyReplicas
          labelSelectorPath: .status.selector
      additionalPrinterColumns:
        - name: Version
          type: string
          jsonPath: .spec.version
        - name: Replicas
          type: integer
          jsonPath: .spec.replicas
        - name: Ready
          type: integer
          jsonPath: .status.readyReplicas
        - name: URL
          type: string
          jsonPath: .status.url
          priority: 1                    # только в -o wide
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      selectableFields:
        - jsonPath: .spec.version
      schema:
        openAPIV3Schema:
          type: object
          required: [spec]
          properties:
            spec:
              type: object
              required: [version]
              properties:
                version:
                  type: string
                  pattern: '^[0-9]+\.[0-9]+(\.[0-9]+)?$'
                  description: Тег образа ghcr.io/example/hello
                message:
                  type: string
                  maxLength: 200
                  default: hello
                replicas:
                  type: integer
                  minimum: 1
                  maximum: 10
                  default: 2
                host:
                  type: string
                  maxLength: 253
                  x-kubernetes-validations:
                    - rule: self == oldSelf
                      message: host нельзя менять, создайте новый Greeting
              x-kubernetes-validations:
                - rule: "!has(self.host) || self.host.endsWith('.apps.example.com')"
                  message: host должен быть в зоне apps.example.com
                  fieldPath: .host
                - rule: "!has(self.host) || self.replicas >= 2"
                  messageExpression: "'публичному приложению нужно минимум 2 реплики, задано %d'.format([self.replicas])"
                  reason: FieldValueForbidden
            status:
              type: object
              properties:
                readyReplicas:
                  type: integer
                selector:
                  type: string
                url:
                  type: string
                observedGeneration:
                  type: integer
                  format: int64
                conditions:
                  type: array
                  x-kubernetes-list-type: map
                  x-kubernetes-list-map-keys: [type]
                  items:
                    type: object
                    required: [type, status]
                    properties:
                      type: {type: string}
                      status: {type: string, enum: ["True", "False", "Unknown"]}
                      reason: {type: string}
                      message: {type: string}
                      lastTransitionTime: {type: string, format: date-time}
                      observedGeneration: {type: integer, format: int64}

Разберём по частям.


Группа, версии, served и storage

Группа — доменное имя, которым вы владеете (demo.example.com), чтобы не столкнуться с чужими CRD. Группы *.k8s.io и *.kubernetes.io зарезервированы за проектом Kubernetes и требуют аннотации со ссылкой на одобренное предложение.

Версии — у одного ресурса их может быть несколько, как у встроенных (v1alpha1, v1beta1, v1). У каждой два флага:

  • served — отдаётся ли эта версия по API. Выключив served, версию «снимают с обслуживания»: запросы к ней получат 404, но объекты в etcd не трогаются.
  • storage — в какой версии объекты записываются в etcd. Ровно одна версия имеет storage: true.

Если версий несколько и схемы различаются, объекты приходится конвертировать: клиент пишет в v1, а в etcd хранится v2, или наоборот. spec.conversion.strategy: None годится, только когда схемы совпадают (меняется лишь apiVersion). Иначе — Webhook: apiserver отправляет объект на HTTPS-сервис (обычно в том же поде, что контроллер) запросом ConversionReview и получает обратно объект нужной версии. Конверсия должна быть без потерь в обе стороны, иначе чтение и запись через разные версии будут портить данные.

Объекты, записанные в старой версии хранения, так и остаются в ней, пока их не перезапишут. Список версий, реально присутствующих в etcd, показывает status.storedVersions у CRD, и удалить старую версию из CRD можно, только когда её там нет: для этого все объекты переписывают (миграцию хранения выполняет сам оператор при обновлении или утилита вроде kube-storage-version-migrator), а затем правят storedVersions.


Схема: structural schema, значения по умолчанию, валидация

С apiextensions.k8s.io/v1 схема обязательна и должна быть структурной: у корня, у каждого поля-объекта и у каждого элемента массива указан непустой type, а всё, что описано внутри allOf/anyOf/oneOf, описано и снаружи. Это ограничение не прихоть: зная тип каждого узла, apiserver может делать три вещи.

Pruning (обрезка) — поля, которых нет в схеме, при записи молча выбрасываются. Опечатка replica: 3 вместо replicas: 3 не сохранится, а kubectl apply с --validate=strict (поведение по умолчанию) вернёт ошибку: Greeting in version "v1" cannot be handled as a Greeting: strict decoding error: unknown field "spec.replica". Если какому-то поддереву нужен произвольный YAML (например, кусок конфигурации программы), его помечают x-kubernetes-preserve-unknown-fields: true.

Значения по умолчанию — default: 2 у replicas подставляется при чтении из etcd и при записи, так что контроллер всегда видит заполненное поле.

Валидация — по ключевым словам OpenAPI (type, pattern, minimum, maxLength, enum, required) и по правилам на CEL в x-kubernetes-validations. CEL (Common Expression Language) — язык выражений от Google без циклов и побочных эффектов, гарантированно завершающийся за ограниченное время; apiserver вычисляет его у себя, без внешнего вызова. Валидация на CEL стабильна с Kubernetes 1.29.

Поля правила:

Поле Назначение
rule выражение; self — значение поля, где правило стоит, oldSelf — его прежнее значение при обновлении
message текст ошибки
messageExpression текст ошибки, вычисляемый CEL-выражением (важнее message)
reason машинная причина: FieldValueInvalid (по умолчанию), FieldValueForbidden, FieldValueRequired, FieldValueDuplicate
fieldPath на какое поле указать в ошибке
optionalOldSelf вычислять правило с oldSelf и при создании объекта

Выражения CEL apiserver оценивает заранее, при создании CRD: каждое правило получает оценку «стоимости» по худшему случаю (длина строк, число элементов списков), и схема, которая не укладывается в бюджет, отвергается целиком. Первая версия этой CRD собирала сообщение конкатенацией '… задано ' + string(self.replicas), и apiserver её не принял:

The CustomResourceDefinition "greetings.demo.example.com" is invalid:
* spec.validation.openAPIV3Schema.properties[spec].x-kubernetes-validations[1].messageExpression: Forbidden: estimated messageExpression cost exceeds budget by factor of more than 100x (try simplifying the rule, or adding maxItems, maxProperties, and maxLength where arrays, maps, and strings are declared)
…

Длину строки, полученной из числа, оценщик не знает и считает её неограниченной, а склейка с ней — тоже. string(self.replicas) без склейки проходит, и проходит форматирование '… задано %d'.format([self.replicas]) — его и оставили. Лекарство в общем случае подсказывает сама ошибка: maxLength, maxItems, maxProperties у полей, с которыми работают правила.

Правило с oldSelf — правило перехода: self == oldSelf на host делает поле неизменяемым после создания, ровно как у встроенных spec.selector. Ещё одна полезная деталь — ratcheting (стабилен с 1.33): если вы ужесточили схему, а в кластере уже лежат объекты, которые новым правилам не соответствуют, их можно обновлять, не исправляя старые нарушения, — проверяются только изменённые поля. Без этого ужесточение схемы ломало бы все существующие объекты.

selectableFields — поля, по которым работает kubectl get greetings --field-selector spec.version=1.0 (стабильно с 1.32); раньше для CRD селектор полей работал только по metadata.name и metadata.namespace. Фильтрует сам apiserver: --field-selector spec.version=1.1 на стенде вернул No resources found in demo namespace., а с 1.0 — объект hello.

Printer columns — колонки kubectl get: что показывать, вместо одного NAME AGE. Колонки с priority больше нуля видны только в -o wide.


Подресурсы status и scale

subresources.status: {} включает подресурс /status, тот же, что у встроенных типов. Последствия:

  • запись в основной ресурс игнорирует изменения status, а запись в /status — всё, кроме status. Пользователь пишет желаемое, контроллер — наблюдаемое, и они не затирают друг друга;
  • права разделяются: пользователю — update на greetings, контроллеру — ещё и на greetings/status (RBAC и подресурсы — в части 29);
  • metadata.generation увеличивается только при изменении полей вне metadata и status, то есть фактически при изменении желаемого состояния. Контроллер записывает в status.observedGeneration, какую генерацию он уже обработал, и любой клиент сравнением двух чисел понимает, догнал ли контроллер последние изменения. Ровно так kubectl rollout status следит за Deployment.

subresources.scale подключает ресурс к общему механизму масштабирования: kubectl scale greeting/hello --replicas=4 и HorizontalPodAutoscaler (автомасштабирование по метрикам, часть 32) работают с Greeting так же, как с Deployment. Нужно указать, где в объекте лежат желаемое число реплик, фактическое и селектор подов в строковом виде.


Смотрим руками: CRD

$ kubectl apply -f greeting-crd.yaml
customresourcedefinition.apiextensions.k8s.io/greetings.demo.example.com created

$ kubectl get crd greetings.demo.example.com -o jsonpath='{range .status.conditions[*]}{.type}={.status}{"\n"}{end}'
NamesAccepted=True
Established=True

$ kubectl api-resources --api-group=demo.example.com
NAME        SHORTNAMES   APIVERSION            NAMESPACED   KIND
greetings   gr           demo.example.com/v1   true         Greeting

$ kubectl explain greeting.spec.version
GROUP:      demo.example.com
KIND:       Greeting
VERSION:    v1

FIELD: version <string>

DESCRIPTION:
    Тег образа ghcr.io/example/hello

Established=True — apiserver начал обслуживать новый путь. Посмотрим на сам путь и на валидацию:

$ kubectl get --raw /apis/demo.example.com/v1/namespaces/demo/greetings
{"apiVersion":"demo.example.com/v1","items":[],"kind":"GreetingList","metadata":{"continue":"","resourceVersion":"1261791"}}

$ cat <<'EOF' | kubectl apply -f -
apiVersion: demo.example.com/v1
kind: Greeting
metadata:
  name: hello
  namespace: demo
spec:
  version: "1.0"
  replicas: 1
  host: hello.example.org
EOF
The Greeting "hello" is invalid: 
* spec.host: Invalid value: host должен быть в зоне apps.example.com
* spec: Forbidden: публичному приложению нужно минимум 2 реплики, задано 1

Обе ошибки пришли от apiserver, контроллера ещё нет. Исправленный объект:

$ cat <<'EOF' | kubectl apply -f -
apiVersion: demo.example.com/v1
kind: Greeting
metadata:
  name: hello
  namespace: demo
spec:
  version: "1.0"
  message: привет
  host: hello.apps.example.com
EOF
greeting.demo.example.com/hello created

$ kubectl -n demo get gr
NAME    VERSION   REPLICAS   READY   AGE
hello   1.0       2                  1s

REPLICAS 2 — значение по умолчанию из схемы, READY пуст: статус заполнять некому. Правило перехода на host тоже проверяет apiserver:

$ kubectl -n demo patch gr hello --type=merge -p '{"spec":{"host":"hello2.apps.example.com"}}'
The Greeting "hello" is invalid: spec.host: Invalid value: "hello2.apps.example.com": host нельзя менять, создайте новый Greeting

Где объект лежит на самом деле — в etcd, под ключом по группе и ресурсу; в отличие от встроенных ресурсов, которые apiserver хранит в Protobuf, пользовательские хранятся как JSON. Утилита etcdctl есть в образе etcd, поэтому её проще всего запустить внутри контейнера через crictl exec, как в части 12; jq оставляет от объекта главное:

user@k8s-master-1:~$ sudo crictl exec "$(sudo crictl ps --name etcd -q)" etcdctl \
    --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \
    get /registry/demo.example.com/greetings/demo/hello --print-value-only \
    | jq -c '{apiVersion, kind, metadata: {name: .metadata.name, namespace: .metadata.namespace, generation: .metadata.generation, uid: .metadata.uid}, spec}'
{"apiVersion":"demo.example.com/v1","kind":"Greeting","metadata":{"name":"hello","namespace":"demo","generation":1,"uid":"1cccbbb4-fcb6-4617-a2a5-2a91cbc31c37"},"spec":{"host":"hello.apps.example.com","message":"привет","replicas":2,"version":"1.0"}}

replicas: 2 записан в etcd: значение по умолчанию подставлено ещё при записи. Для сравнения — начало значения встроенного объекта, неймспейса demo: вместо JSON там двоичный Protobuf с префиксом k8s\0:

user@k8s-master-1:~$ sudo crictl exec "$(sudo crictl ps --name etcd -q)" etcdctl … \
    get /registry/namespaces/demo --print-value-only | head -c 12 | od -c | head -1
0000000   k   8   s  \0  \n 017  \n 002   v   1 022  \t

Утилита etcdctl и устройство etcd — тема части 34; здесь достаточно увидеть, что CRD ничем не отличается от остальных данных кластера.


Контроллер

Контроллер — программа, которая следит за объектами через API и приводит мир в соответствие с ними. Устроена она всегда одинаково:

                        kube-apiserver
                  ▲            │ list + watch (Greeting, Deployment, Service, Ingress)
     create/update│            ▼
     patch status │     ┌──────────────┐
                  │     │   informer   │  локальный кеш объектов,
                  │     │   (кеш)      │  события add / update / delete
                  │     └──────┬───────┘
                  │            │ ключ "demo/hello"
                  │            ▼
                  │     ┌──────────────┐
                  │     │  work queue  │  очередь ключей: дубликаты схлопываются,
                  │     │              │  ошибки — повтор с нарастающей задержкой
                  │     └──────┬───────┘
                  │            ▼
                  │     ┌──────────────┐
                  └─────┤  Reconcile   │  прочитать желаемое (из кеша), посмотреть,
                        │  (ключ)      │  что есть, создать / поправить / удалить,
                        └──────────────┘  записать status

Informer держит watch на нужные типы и локальную копию объектов в памяти. Контроллер читает только из этого кеша, а не из apiserver напрямую: десятки контроллеров, каждую секунду перечитывающих все объекты, положили бы apiserver. При старте informer делает list, потом получает изменения через watch с resourceVersion (часть 3) и периодически сверяется заново (resync).

Work queue получает не события, а ключи namespace/name. Если объект поменялся пять раз за секунду, в очереди окажется один ключ, и Reconcile выполнится один раз — с последней версией. Если Reconcile вернул ошибку, ключ вернётся в очередь с экспоненциальной задержкой. Один ключ никогда не обрабатывается двумя потоками одновременно.

Reconcile — функция «привести объект с этим ключом к желаемому состоянию». Главное её свойство — она не знает, что изменилось. Ей не передают «поле replicas поменялось с 2 на 3», ей передают имя объекта, и она каждый раз заново сравнивает желаемое с действительным. Такой подход называется level-triggered (по уровню), в противоположность edge-triggered (по фронту, по событию), и он делает контроллер устойчивым: пропущенное событие, перезапуск контроллера, ручная правка Deployment — следующий вызов Reconcile всё исправит. Отсюда требование идемпотентности: вызов Reconcile десять раз подряд должен давать тот же результат, что и один.

Контроллер следит не только за своими объектами, но и за дочерними: если кто-то удалит Deployment, созданный для Greeting, событие удаления Deployment должно поставить в очередь ключ его владельца. Связь хранится в самом Deployment.


ownerReferences и сборка мусора

У каждого объекта, созданного контроллером, в metadata.ownerReferences записывается владелец:

metadata:
  name: hello
  namespace: demo
  ownerReferences:
    - apiVersion: demo.example.com/v1
      kind: Greeting
      name: hello
      uid: 1cccbbb4-fcb6-4617-a2a5-2a91cbc31c37
      controller: true
      blockOwnerDeletion: true

Этим пользуются двое. Контроллер — чтобы по событию дочернего объекта найти владельца (controller: true означает «управляющий» владелец, он может быть только один). И сборщик мусора в kube-controller-manager: когда владелец удалён, он удаляет все объекты, у которых этот владелец записан. Так удаление Deployment удаляет его ReplicaSet-ы, а те — поды; так же удаление Greeting удалит Deployment, Service и Ingress без единой строчки кода удаления в нашем контроллере.

Режим удаления выбирается при удалении владельца (kubectl delete --cascade=…): background (по умолчанию: владелец удаляется сразу, дети — следом), foreground (владелец остаётся с deletionTimestamp, пока не удалены дети с blockOwnerDeletion: true), orphan (дети остаются, ссылки на владельца с них снимаются). Ограничение: владелец и дочерний объект должны быть в одном неймспейсе, либо владелец — кластерный. Объект в неймспейсе не может владеть кластерным.


Finalizers

Сборщик мусора убирает объекты внутри кластера. Но контроллер может создавать и внешние ресурсы: запись в DNS, бакет в S3, учётную запись в базе. О них сборщик не знает. Для этого есть finalizers — список строк в metadata.finalizers.

Пока список не пуст, объект не удаляется: kubectl delete лишь проставляет metadata.deletionTimestamp, объект остаётся видимым, и это изменение — событие для контроллера. Контроллер видит deletionTimestamp, чистит внешние ресурсы и убирает свою строку из finalizers. Когда список опустел, apiserver удаляет объект окончательно.

 kubectl delete greeting hello
        │
        ▼
 apiserver: finalizers не пуст → ставит deletionTimestamp, объект остаётся
        │
        ▼
 контроллер: Reconcile видит deletionTimestamp
        │    → удалить запись DNS (внешний ресурс)
        │    → PATCH: убрать "demo.example.com/cleanup" из finalizers
        ▼
 apiserver: finalizers пуст → объект удалён
        │
        ▼
 сборщик мусора: удалить Deployment, Service, Ingress по ownerReferences

Обратная сторона: если контроллер удалён или сломан, объекты с его финализатором висят в Terminating вечно, и неймспейс с ними тоже не удалится. Типичная картина после удаления оператора раньше его ресурсов. Снять финализатор руками (kubectl patch … --type=merge -p '{"metadata":{"finalizers":null}}') можно, но тогда внешние ресурсы останутся неубранными — это осознанное решение, а не способ «починить». Правильный порядок удаления оператора: сначала его ресурсы, потом сам оператор, потом CRD.


status и conditions

status — то, что контроллер наблюдает: число готовых реплик, адрес, ошибки. Для сообщения о состоянии принят общий формат conditions — список условий, у каждого:

Поле Значение
type имя условия в PascalCase: Ready, Available, Progressing, Degraded
status True, False или Unknown
reason машинная причина в PascalCase: DeploymentNotReady, ImagePullFailed
message текст для человека
lastTransitionTime когда status последний раз менялся (не когда условие перезаписывали)
observedGeneration к какой генерации объекта относится

Условие Ready — то, что проверяют kubectl wait --for=condition=Ready, kstatus в Helm 4 (часть 27) и GitOps-инструменты. Положительная полярность (Ready=True — хорошо) читается легче, чем Failed=False. Status должен целиком восстанавливаться из наблюдения мира: контроллер не хранит в нём ничего, чего не может вычислить заново, — иначе потеря статуса (а при резервном копировании etcd через kubectl get -o yaml и восстановлении он теряется) потеряет и данные.


Минимальный контроллер на controller-runtime

Писать informer-ы и очереди руками не нужно. client-go — официальная библиотека Go для работы с API, в ней есть informer-ы и work queue. controller-runtime — библиотека проекта Kubernetes SIGs поверх client-go: менеджер с общим кешем, регистрация контроллеров, вебхуки, выбор лидера при нескольких репликах. На ней написано большинство операторов. Kubebuilder — генератор проектов на controller-runtime: по Go-структурам с аннотациями-маркерами генерирует CRD со схемой и CEL-правилами, RBAC, заготовку контроллера и тесты. Operator SDK — проект Red Hat поверх Kubebuilder: те же Go-операторы плюс операторы на Ansible и Helm и интеграция с OLM (Operator Lifecycle Manager — менеджером установки и обновления операторов, встроенным в OpenShift, дистрибутив Kubernetes от Red Hat).

Суть контроллера Greeting без генерированного окружения:

func (r *GreetingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var g demov1.Greeting
	if err := r.Get(ctx, req.NamespacedName, &g); err != nil {
		// объект удалён — дочерние уберёт сборщик мусора
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	labels := map[string]string{"app.kubernetes.io/name": "hello", "app.kubernetes.io/instance": g.Name}

	// Желаемый Deployment. CreateOrUpdate читает текущий, вызывает функцию
	// и пишет, только если что-то изменилось.
	dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: g.Name, Namespace: g.Namespace}}
	_, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
		dep.Labels = labels
		dep.Spec.Replicas = g.Spec.Replicas
		dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
		dep.Spec.Template.Labels = labels
		dep.Spec.Template.Spec.Containers = []corev1.Container{{
			Name:  "hello",
			Image: "ghcr.io/example/hello:" + g.Spec.Version,
			Env:   []corev1.EnvVar{{Name: "GREETING", Value: g.Spec.Message}},
			Ports: []corev1.ContainerPort{{Name: "http", ContainerPort: 8080}},
		}}
		// ownerReference: Greeting — управляющий владелец Deployment
		return controllerutil.SetControllerReference(&g, dep, r.Scheme)
	})
	if err != nil {
		return ctrl.Result{}, err // ключ вернётся в очередь с задержкой
	}
	// ... так же Service и Ingress ...

	// Status — отдельным запросом в подресурс /status.
	g.Status.ReadyReplicas = dep.Status.ReadyReplicas
	g.Status.Selector = "app.kubernetes.io/instance=" + g.Name
	g.Status.URL = "https://" + g.Spec.Host
	g.Status.ObservedGeneration = g.Generation
	ready := metav1.ConditionFalse
	if dep.Status.ReadyReplicas == *g.Spec.Replicas {
		ready = metav1.ConditionTrue
	}
	meta.SetStatusCondition(&g.Status.Conditions, metav1.Condition{
		Type: "Ready", Status: ready, Reason: "DeploymentStatus",
		Message: fmt.Sprintf("%d/%d replicas ready", dep.Status.ReadyReplicas, *g.Spec.Replicas),
		ObservedGeneration: g.Generation,
	})
	return ctrl.Result{}, r.Status().Update(ctx, &g)
}

func (r *GreetingReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&demov1.Greeting{}).      // свои объекты
		Owns(&appsv1.Deployment{}).   // события дочерних → ключ владельца
		Owns(&corev1.Service{}).
		Owns(&networkingv1.Ingress{}).
		Complete(r)
}

Этот код собран на стенде с controller-runtime v0.25.2 (библиотеки k8s.io/* v0.37) и Go 1.27.3. Типы Greeting и GreetingList вместо Kubebuilder написаны руками — Go-структуры с полями spec и status и функциями глубокого копирования DeepCopyObject, которые требует интерфейс runtime.Object; функция main создаёт менеджер (ctrl.NewManager), регистрирует в его схеме встроенные типы и Greeting и запускает контроллер. Service и Ingress создаются так же, как Deployment, и с теми же метками.

Здесь видны все идеи предыдущих разделов: Reconcile получает только имя, читает объект из кеша, приводит Deployment к желаемому виду идемпотентно, проставляет владельца, пишет status через подресурс. Owns подписывает контроллер на дочерние Deployment, и их изменения ставят в очередь ключ владельца. Удаление вообще не обрабатывается — его делает сборщик мусора. Финализатор понадобился бы, только если бы контроллер создавал что-то вне кластера.

Контроллер в кластере — это Deployment с ServiceAccount, которому через ClusterRole (или Role, если он следит за одним неймспейсом) разрешены get, list, watch на greetings, update на greetings/status и полный набор на Deployment, Service и Ingress. Kubebuilder генерирует эти права из маркеров // +kubebuilder:rbac:… над функцией Reconcile. Если реплик контроллера больше одной, активна только одна: они соревнуются за объект Lease в coordination.k8s.io, как kube-scheduler и kube-controller-manager.


Смотрим руками: контроллер в работе

Чтобы попробовать контроллер, его не обязательно собирать в образ и ставить Deployment-ом: controller-runtime находит kubeconfig так же, как kubectl, и программу можно запустить прямо на машине администратора (go run . или собранный бинарник) — так контроллеры и отлаживают. На стенде бинарник запускался на мастере. Собирать его лучше не там: компиляция зависимостей client-go заняла оба ядра мастера на несколько минут, kube-apiserver не прошёл проверку живучести и перезапустился, а kube-controller-manager и kube-scheduler успели посидеть в CreateContainerError — рабочая станция для сборки подходит больше. Объект hello из прошлого раздела уже есть, и через несколько секунд после старта:

$ kubectl -n demo get deploy,svc,ingress -l app.kubernetes.io/instance=hello
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello   1/2     2            1           64s

NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/hello   ClusterIP   10.102.187.61   <none>        80/TCP    64s

NAME                              CLASS     HOSTS                    ADDRESS   PORTS   AGE
ingress.networking.k8s.io/hello   traefik   hello.apps.example.com             80      64s

$ kubectl -n demo get gr -o wide
NAME    VERSION   REPLICAS   READY   URL                              AGE
hello   1.0       2          2       https://hello.apps.example.com   9m49s

$ kubectl -n demo get gr hello -o jsonpath='{.status.conditions[0].message}{"\n"}'
2/2 replicas ready

$ kubectl -n demo get deploy hello -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name} controller={.metadata.ownerReferences[0].controller}{"\n"}'
Greeting/hello controller=true

Колонка ADDRESS у Ingress пуста, а PORTS — только 80: на стенде не было Ingress-контроллера, а наш контроллер не создаёт секцию tls; в кластере с Traefik из части 18 адрес появится. В журнале контроллера среди строк reconcile нашлась и ошибка:

{"level":"error","msg":"Reconciler error","controller":"greeting",…,"error":"Operation cannot be fulfilled on deployments.apps \"hello\": the object has been modified; please apply your changes to the latest version and try again"}

Это оптимистическая блокировка из части 3: между чтением Deployment из кеша и записью его успел изменить kube-controller-manager, и запись с устаревшим resourceVersion отвергнута. Reconcile вернул ошибку, ключ вернулся в очередь с задержкой, следующий проход прошёл чисто — ровно так, как задумано. Устойчивость к ручным правкам — level-triggered в действии:

$ kubectl -n demo scale deploy hello --replicas=5
deployment.apps/hello scaled
$ kubectl -n demo get deploy hello -w
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
hello   2/5     2            2           79s
hello   2/2     2            2           80s
hello   2/2     2            2           80s
hello   2/2     2            2           81s
hello   2/2     5            2           82s
hello   2/2     2            2           83s

Контроллер получил событие изменения Deployment, поставил в очередь demo/hello, Reconcile вернул replicas: 2 из Greeting — в пределах секунды, лишние поды даже не успели стать готовыми. Масштабирование через сам ресурс, благодаря подресурсу scale:

$ kubectl -n demo scale greeting hello --replicas=3
greeting.demo.example.com/hello scaled
$ kubectl -n demo wait greeting/hello --for=condition=Ready --timeout=60s
greeting.demo.example.com/hello condition met
$ kubectl -n demo get greeting hello -o jsonpath='{.metadata.generation} {.status.observedGeneration}{"\n"}'
2 2
$ kubectl get --raw /apis/demo.example.com/v1/namespaces/demo/greetings/hello/scale
{"kind":"Scale","apiVersion":"autoscaling/v1","metadata":{"name":"hello","namespace":"demo",…},"spec":{"replicas":3},"status":{"replicas":3,"selector":"app.kubernetes.io/instance=hello"}}

Генерация 2, контроллер обработал генерацию 2 — состояние догнало желаемое. Объект Scale по адресу …/greetings/hello/scale apiserver собирает сам по путям из subresources.scale: с ним и работают kubectl scale и HorizontalPodAutoscaler.

Финализатор можно увидеть в действии и без кода — поставив его руками. Контроллер его не обрабатывает, поэтому снимать придётся тоже руками:

$ kubectl -n demo patch greeting hello --type=merge -p '{"metadata":{"finalizers":["demo.example.com/cleanup"]}}'
greeting.demo.example.com/hello patched
$ kubectl -n demo delete greeting hello --wait=false
greeting.demo.example.com "hello" deleted from demo namespace
$ kubectl -n demo get greeting hello -o jsonpath='{.metadata.deletionTimestamp} {.metadata.finalizers}{"\n"}'
2026-10-31T05:06:33Z ["demo.example.com/cleanup"]
$ kubectl -n demo get deploy -l app.kubernetes.io/instance=hello
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
hello   3/3     3            3           2m4s

kubectl пишет deleted, но объект на месте: у него только появился deletionTimestamp, и дочерний Deployment работает. Снимаем финализатор — и apiserver удаляет объект, а сборщик мусора следом убирает Deployment, Service и Ingress:

$ kubectl -n demo patch greeting hello --type=merge -p '{"metadata":{"finalizers":null}}'
greeting.demo.example.com/hello patched
$ kubectl -n demo get greeting
No resources found in demo namespace.
$ kubectl -n demo get deploy,svc,ingress -l app.kubernetes.io/instance=hello
No resources found in demo namespace.

Оператор

Оператор — контроллер с CRD, в которые вложено знание администратора конкретной системы. Термин придумали в 2016 году в CoreOS — компании, создавшей etcd: «оператор» делает то, что раньше делал человек-оператор, — разворачивает кластер базы, заменяет упавший узел, обновляет версию в правильном порядке, делает бэкапы. Наш Greeting — контроллер, но не оператор: в нём нет знаний сверх «создай Deployment». Разница видна на настоящих примерах.

CloudNativePG

CloudNativePG (CNPG) — оператор PostgreSQL, проект в песочнице CNCF. Ставится официальным манифестом выпуска или Helm-чартом в неймспейс cnpg-system; актуальная версия — 1.30.1:

$ kubectl apply --server-side -f https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.1/cnpg-1.30.1.yaml
…
deployment.apps/cnpg-controller-manager serverside-applied
mutatingwebhookconfiguration.admissionregistration.k8s.io/cnpg-mutating-webhook-configuration serverside-applied
validatingwebhookconfiguration.admissionregistration.k8s.io/cnpg-validating-webhook-configuration serverside-applied

--server-side здесь не прихоть: CRD clusters.postgresql.cnpg.io в манифесте занимает около 460 КиБ, poolers — около 660 КиБ, а клиентский apply записал бы копию каждого объекта в аннотацию last-applied-configuration, тогда как все аннотации объекта вместе ограничены 256 КиБ. Главный ресурс — Cluster:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: pg
  namespace: demo
spec:
  instances: 3
  imageName: ghcr.io/cloudnative-pg/postgresql:18.6-standard-trixie
  storage:
    storageClass: local-path
    size: 20Gi
  bootstrap:
    initdb:
      database: app
      owner: app
  affinity:
    topologyKey: kubernetes.io/hostname
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: s3-store

Образ PostgreSQL — из официального набора проекта: minimal (только PostgreSQL), standard (плюс локали и популярные расширения) и устаревший system со встроенным Barman Cloud для старого способа резервного копирования. Без imageName CNPG 1.30.1 берёт 18.6-system-trixie; с плагином резервного копирования (о нём ниже) достаточно standard. Класс хранилища local-path — из части 25.

Что делает оператор по этому объекту — и что раньше делал бы администратор:

  • Разворачивает кластер PostgreSQL из трёх экземпляров: первичный и две реплики с потоковой репликацией (PostgreSQL передаёт журнал предзаписи, WAL, на реплики по сети). Он не использует StatefulSet: поды pg-1, pg-2, pg-3 и их PVC он создаёт сам, потому что ему нужно управлять каждым экземпляром отдельно — какой первичный, какой пересоздать, к какому тому прикрепить.
  • Создаёт сервисы pg-rw (всегда указывает на первичный), pg-ro (только реплики) и pg-r (любой экземпляр). Приложение подключается к pg-rw.demo.svc, и при переключении ему не нужно знать, кто теперь главный. Пароль пользователя app — в Secret pg-app (тип kubernetes.io/basic-auth, там же готовые строки подключения); рядом оператор создаёт свой удостоверяющий центр pg-ca, сертификаты сервера pg-server и репликации pg-replication и два PodDisruptionBudget — pg для реплик и pg-primary для первичного.
  • Переключает при отказе (failover): если первичный недоступен, оператор выбирает реплику с наиболее свежим WAL, повышает её и перенаправляет pg-rw. Плановое переключение (switchover) — например, перед обслуживанием узла — делается плагином kubectl cnpg promote pg pg-2.
  • Обновляет PostgreSQL в правильном порядке: при смене imageName сначала по одной пересоздаёт реплики, дожидаясь готовности каждой, и только потом берётся за первичный. Как именно — задаёт primaryUpdateMethod: по умолчанию restart — первичный перезапускается на месте с новым образом (на стенде запись была недоступна около 20 секунд), а switchover сначала переключает роль на уже обновлённую реплику, и пауза сокращается до времени переключения. primaryUpdateStrategy: supervised вместо умолчания unsupervised оставляет этот последний шаг администратору.
  • Делает резервные копии: непрерывный архив WAL и базовые копии в объектное хранилище S3 (протокол объектного хранилища Amazon, который реализуют и многие другие системы), что даёт восстановление на любой момент времени (PITR, point-in-time recovery). Начиная с версии 1.26 встроенная настройка barmanObjectStore объявлена устаревшей, а копирование вынесено в плагин Barman Cloud (актуальная версия 0.15.1) со своим ресурсом ObjectStore. Плагин ставится отдельным манифестом в тот же cnpg-system и требует cert-manager из части 19: оператор и плагин общаются по gRPC (протоколу удалённого вызова процедур поверх HTTP/2) со взаимной проверкой TLS-сертификатов, и манифест плагина заказывает их объектами Issuer и Certificate. На стенде схемы обоих ресурсов ниже проверены kubectl apply --dry-run=server с установленными CRD, а сами бэкапы — нет:
apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: s3-store
  namespace: demo
spec:
  retentionPolicy: "30d"
  configuration:
    destinationPath: s3://pg-backups/
    endpointURL: https://s3.example.com
    s3Credentials:
      accessKeyId:
        name: pg-backup-s3
        key: ACCESS_KEY_ID
      secretAccessKey:
        name: pg-backup-s3
        key: ACCESS_SECRET_KEY
    wal:
      compression: gzip
---
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: pg-nightly
  namespace: demo
spec:
  schedule: "0 0 2 * * *"          # формат cron с секундами: каждый день в 02:00:00
  backupOwnerReference: self
  cluster:
    name: pg
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io

Восстановление — новый Cluster с bootstrap.recovery и источником из того же хранилища; так же поднимают копию для проверки бэкапов, и это стоит делать регулярно. Хранилище для данных PostgreSQL — локальные диски узлов или блочные тома, а не NFS: почему, разбиралось в части 23 и части 25. Репликацию на уровне PostgreSQL оператор и так обеспечивает, поэтому локальные тома без собственной репликации здесь — нормальный выбор.

На стенде Cluster создавался без секции plugins (плагин не ставился) и сначала без imageName:

$ kubectl -n demo get cluster pg
NAME   AGE     INSTANCES   READY   STATUS                     PRIMARY
pg     4m13s   3           3       Cluster in healthy state   pg-1

$ kubectl -n demo get pods -l cnpg.io/cluster=pg -L cnpg.io/instanceRole -o wide
NAME   READY   STATUS    RESTARTS   AGE     IP              NODE         NOMINATED NODE   READINESS GATES   INSTANCEROLE
pg-1   1/1     Running   0          2m36s   172.20.172.33   k8s-node-2   <none>           <none>            primary
pg-2   1/1     Running   0          69s     172.20.211.41   k8s-node-1   <none>           <none>            replica
pg-3   1/1     Running   0          28s     172.20.211.42   k8s-node-1   <none>           <none>            replica

$ kubectl -n demo get svc,sts
NAME            TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
service/pg-r    ClusterIP   10.100.227.180   <none>        5432/TCP   4m13s
service/pg-ro   ClusterIP   10.106.229.62    <none>        5432/TCP   4m12s
service/pg-rw   ClusterIP   10.101.30.144    <none>        5432/TCP   4m12s

StatefulSet нет, экземпляры создаются по одному: сначала Job pg-1-initdb инициализирует базу на томе первого, потом реплики клонируют его. pg-2 и pg-3 оказались на одном узле: воркеров на стенде два, а анти-аффинити CNPG по умолчанию preferred — с podAntiAffinityType: required третий экземпляр ждал бы в Pending третьего узла. Удаляем первичный под и смотрим за кластером раз в две секунды:

$ kubectl -n demo delete pod pg-1
pod "pg-1" deleted from demo namespace
05:12:16  pg    4m29s   3     2     Failing over                                 pg-1
05:12:21  pg    4m33s   3     2     Failing over                                 pg-2
05:12:23  pg    4m36s   3     2     Waiting for the instances to become active   pg-2
05:12:45  pg    4m57s   3     3     Cluster in healthy state                     pg-2

$ kubectl -n demo get pods -l cnpg.io/cluster=pg -L cnpg.io/instanceRole
NAME   READY   STATUS    RESTARTS   AGE    INSTANCEROLE
pg-1   1/1     Running   0          24s    replica
pg-2   1/1     Running   0          113s   primary
pg-3   1/1     Running   0          72s    replica

Через пять секунд первичным стал pg-2, а пересозданный pg-1 вернулся репликой; кластер полностью здоров через полминуты. Затем смена образа — imageName: …:18.6-standard-trixie патчем (выборка тех же строк по изменению статуса):

05:13:35  Waiting for the instances to become active           pg-3 пересоздаётся (replica)
05:14:03  Waiting for the instances to become active           pg-3 готов, следом pg-1 (replica)
05:14:33  Primary instance is being restarted without a switchover   pg-2 (primary)
05:14:54  Cluster in healthy state                              первичный — по-прежнему pg-2

Реплики обновлены по одной, первичный перезапущен на месте — умолчание primaryUpdateMethod: restart. Удаление Cluster удаляет и его PVC: через секунды после kubectl delete cluster pg в неймспейсе не осталось ни подов, ни томов.

RabbitMQ Cluster Operator

RabbitMQ Cluster Operator — официальный оператор брокера сообщений RabbitMQ от его разработчиков. Ставится одним манифестом выпуска (актуальный — 2.23.2) и создаёт неймспейс rabbitmq-system, но в кластере уже должен быть cert-manager из части 19: сертификат для своих вебхуков манифест заказывает объектами Issuer и Certificate. Без него kubectl apply создаёт всё остальное и сообщает no matches for kind "Certificate" in version "cert-manager.io/v1", под оператора ждёт несуществующий Secret с сертификатом, а любой RabbitmqCluster отвергается — failed calling webhook "mrabbitmqcluster-v1beta1.kb.io" … connect: connection refused. Ресурс — RabbitmqCluster:

apiVersion: rabbitmq.com/v1beta1
kind: RabbitmqCluster
metadata:
  name: mq
  namespace: demo
spec:
  replicas: 3
  persistence:
    storageClassName: local-path
    storage: 10Gi
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
    limits:
      memory: 1Gi
  rabbitmq:
    additionalConfig: |
      default_queue_type = quorum

По нему оператор создаёт StatefulSet mq-server (здесь стабильные имена подов как раз нужны: узлы RabbitMQ знают друг друга по имени), сервис mq для клиентов и безголовый mq-nodes для связи узлов между собой, Secret mq-default-user с учётными данными администратора, ConfigMap с конфигурацией и Secret mq-erlang-cookie — общий секрет Erlang, без которого узлы не объединяются в кластер:

$ kubectl -n demo get rabbitmqcluster,sts,svc,secret,cm -o wide | grep mq
rabbitmqcluster.rabbitmq.com/mq   True               True               2m27s
statefulset.apps/mq-server   3/3     2m26s   rabbitmq     rabbitmq:4.3.4-management
service/mq         ClusterIP   10.111.195.78   <none>        5672/TCP,15672/TCP,15692/TCP   2m27s   app.kubernetes.io/name=mq
service/mq-nodes   ClusterIP   None            <none>        4369/TCP,25672/TCP             2m27s   app.kubernetes.io/name=mq
secret/mq-default-user    Opaque                           8      2m26s
secret/mq-erlang-cookie   Opaque                           1      2m27s
configmap/mq-plugins-conf    1      2m26s
configmap/mq-server-conf     2      2m26s

Порты mq: 5672 — AMQP (двоичный протокол, по которому клиенты публикуют сообщения в брокер и получают их из очередей), 15672 — веб-интерфейс управления, 15692 — метрики Prometheus; у mq-nodes 4369 — служба имён Erlang (epmd; RabbitMQ написан на Erlang, и его узлы находят друг друга через неё) и 25672 — связь узлов кластера. Дальше — то, что администратор делал бы вручную по инструкции: при обновлении версии или конфигурации поды перезапускаются по одному, и хук preStop каждого пода не даёт ему остановиться, пока у кворумных очередей (реплицируемых очередей RabbitMQ на алгоритме Raft — согласованной репликации с выбором лидера, где запись подтверждается большинством копий) не останется достаточно живых копий. Сам хук — несколько команд rabbitmq-upgrade:

$ kubectl -n demo get sts mq-server -o jsonpath='{.spec.template.spec.containers[0].lifecycle.preStop.exec.command[2]}'
if [ ! -z "$(cat /etc/pod-info/skipPreStopChecks)" ]; then exit 0; fi; rabbitmq-upgrade await_online_quorum_plus_one -t 604800 && (rabbitmq-upgrade await_online_synchronized_mirror -t 604800 || true) && rabbitmq-upgrade drain -t 604800

await_online_quorum_plus_one ждёт, пока у каждой кворумной очереди останется кворум и после остановки этого узла, а drain переводит узел в режим обслуживания — клиенты и лидеры очередей уходят с него до остановки. После изменения кластера оператор выполняет rabbitmq-queues rebalance all — перераспределяет лидеров очередей между узлами (отключается полем skipPostDeploySteps). Память под лимит пода оператор вычисляет сам: в конфигурацию попадает total_memory_available_override_value = 858993460 — 80 % от лимита 1 ГиБ, и уже от этого значения RabbitMQ считает свой порог vm_memory_high_watermark (по умолчанию 0,6), при котором перестаёт принимать сообщения. Оставшиеся 20 % — запас под память, которую RabbitMQ не учитывает, чтобы контейнер не упёрся в лимит и не был убит OOM раньше, чем брокер начнёт сдерживать отправителей.

Очереди, обмены, пользователи и права — отдельным оператором Messaging Topology Operator с ресурсами Queue, Exchange, Binding, User, Permission, Policy: тогда вся топология брокера описывается YAML-ом в Git рядом с приложением.


Как оценивать оператор перед установкой

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

Зрелость. Сколько лет проекту, как часто выходят релизы, есть ли политика поддержки версий, кто стоит за проектом (фонд, вендор, один энтузиаст). Полезная шкала — уровни возможностей операторов из Operator SDK: базовая установка → бесшовные обновления → полный жизненный цикл (бэкапы, восстановление, отказ) → метрики и анализ → автопилот (самонастройка). Оператор, который умеет только установку, — по сути чарт с лишним процессом.

Права. Посмотреть ClusterRole оператора до установки (helm template или манифест) и после — что он реально может:

$ kubectl auth can-i --list --as=system:serviceaccount:cnpg-system:cnpg-manager \
    | grep -E '^(Resources|configmaps|secrets|services|pods|pods/exec|persistentvolumeclaims|jobs.batch|nodes) '
Resources                    Non-Resource URLs   Resource Names   Verbs
configmaps                   []                  []               [create delete get list patch update watch]
secrets                      []                  []               [create delete get list patch update watch]
services                     []                  []               [create delete get list patch update watch]
persistentvolumeclaims       []                  []               [create delete get list patch watch]
pods/exec                    []                  []               [create delete get list patch watch]
pods                         []                  []               [create delete get list patch watch]
jobs.batch                   []                  []               [create delete get list patch watch]
nodes                        []                  []               [get list watch]
$ kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:cnpg-system:cnpg-manager
yes

Оператор базы данных, читающий Secret во всех неймспейсах, — обычное дело, но это значит, что его компрометация — компрометация всех секретов кластера. Многие операторы умеют работать в режиме одного или нескольких неймспейсов с Role вместо ClusterRole — если он подходит, его стоит предпочесть. Как читать эти права и почему secrets, pods и create опасны — в части 29.

Вебхуки. Операторы часто ставят ValidatingWebhookConfiguration и MutatingWebhookConfiguration — регистрацию своего HTTPS-сервиса, который apiserver вызывает при создании объектов. С failurePolicy: Fail и широким objectSelector упавший оператор блокирует создание объектов далеко за пределами своих CRD. Проверьте, на что именно подписан вебхук, — kubectl get validatingwebhookconfigurations -o yaml. У CNPG, например, все девять вебхуков (пять проверяющих и четыре изменяющих) с failurePolicy: Fail, но подписаны только на собственные ресурсы — clusters, backups, scheduledbackups, databases, poolers: упавший оператор не даст создать Cluster, но поды и Deployment остальных приложений не заденет.

Обновление CRD. Как обновляются CRD при новой версии: отдельным манифестом, шаблоном чарта, самим оператором при старте? Есть ли конверсионный вебхук и мигрируют ли storedVersions? Что из этого нужно делать руками — обычно написано в заметках к релизу, и пропуск шага приводит к тому, что новый оператор не понимает старые объекты.

Удаление. Что будет с данными при удалении Cluster или самого оператора, есть ли финализаторы, удаляются ли PVC. У CNPG, например, PVC по умолчанию удаляются вместе с Cluster (на стенде — за секунды), и бэкап — единственное, что останется.

Совместимость. С какими версиями Kubernetes оператор проверен, и не помешает ли он обновлению кластера (часть 35).


Знакомые операторы

Теперь можно по-новому посмотреть на то, что уже стоит на стенде:

  • Tigera operator из части 4 следит за ресурсом Installation (operator.tigera.io/v1) и по нему разворачивает calico-node, calico-typha, calico-kube-controllers и их конфигурацию. Обновление Calico — смена версии оператора, а дальше он сам в правильном порядке обновляет компоненты на узлах.
  • cert-manager из части 19 — набор контроллеров: Certificate → CertificateRequest → Order → Challenge (для ACME) → Secret с сертификатом. В status.conditions у Certificate — Ready, и к нему же применима вся механика owner references: удалите Certificate, и сборщик удалит промежуточные объекты.
  • MetalLB из части 17 настраивается ресурсами IPAddressPool и L2Advertisement — CRD, по которым его контроллер и speaker-ы на узлах раздают адреса.

На стенде в момент, когда стояли Calico, cert-manager, CNPG с CRD плагина, оператор RabbitMQ и наш Greeting, CRD по группам выглядели так:

$ kubectl get crd -o custom-columns=GROUP:.spec.group --no-headers | sort | uniq -c | sort -rn
     22 crd.projectcalico.org
     11 postgresql.cnpg.io
      9 operator.tigera.io
      4 cert-manager.io
      2 acme.cert-manager.io
      1 rabbitmq.com
      1 policy.networking.k8s.io
      1 demo.example.com
      1 barmancloud.cnpg.io

52 типа, из которых свой — один. В кластере, где стоит десяток компонентов, CRD бывает больше сотни, и почти весь «Kubernetes», с которым работает администратор, — это уже не встроенные ресурсы, а чужие расширения API.

Уборка

Удалять — в обратном порядке, как говорилось выше: сначала Cluster и RabbitmqCluster (оператор удалит поды и PVC), потом операторы теми же манифестами (kubectl delete -f …; CRD уходят вместе с ними, а с CRD — все объекты этих типов), потом наш CRD и cert-manager. Объекты Lease выбора лидера cert-manager в kube-system (cert-manager-controller и cert-manager-cainjector-leader-election) манифест не удаляет — их убирают руками.


Источники


Дальше — аутентификация и RBAC: как kube-apiserver узнаёт, кто к нему пришёл, почему в Kubernetes нет объекта «пользователь», как выдать доступ администратору сертификатом и приложению — токеном ServiceAccount, и какие права на самом деле опасны.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.