golang

Жизненный цикл объекта в Kubernetes: путь от kubectl apply до полного удаления

  • суббота, 30 мая 2026 г. в 00:00:17
https://habr.com/ru/companies/aenix/articles/1040618/

Привет. В предыдущих статьях этого цикла мы разбирали, как Kubernetes-объекты читаются (первая — informer и кэш в controller-runtime) и записываются (вторая — Server-Side Apply, patch’и, managedFields). Сегодня — про их жизненный цикл.

Между kubectl apply и появлением объекта в etcd проходит целая цепочка: admission chain, мутирующие и валидирующие вебхуки, schema-валидация, встроенные плагины. Между kubectl delete и реальным исчезновением объекта может пройти от миллисекунд до часов — в зависимости от того, какие на нём финализаторы и какая стратегия каскадного удаления выбрана. Механизм при этом универсален для любого ресурса: Pod, Deployment, ваш CRD — жизненный цикл у всех один.

В этой статье я постараюсь ответить, что происходит с объектом от его рождения до смерти. И отдельно поговорим про другое измерение — эволюцию его API-схемы.

Жизненный цикл экземпляра

Создание: путь через apiserver

Когда клиент делает kubectl apply -f или r.Create(ctx, &obj), объект не попадает напрямую в etcd. Между HTTP-запросом и записью в хранилище стоит admission chain — конвейер из плагинов и вебхуков, через который проходит каждый запрос на изменение. Выглядит он примерно так:

Самое интересное в этой цепочке — admission webhooks. Через них вы или сторонний оператор можете встроиться в путь записи: что-то поправить в объекте, что-то проверить, что-то отвергнуть.

Mutating и validating webhooks

Webhook’и регистрируются в кластере через специальные объекты — MutatingWebhookConfiguration и ValidatingWebhookConfiguration. Внутри указаны:

  • какой URL дёргать (или какой Service в кластере);

  • на какие ресурсы и операции навешиваться (pods / CREATE,UPDATE,DELETE);

  • TLS-конфигурация — без неё apiserver к webhook’у обращаться не будет;

  • failurePolicy (Fail / Ignore) — что делать, если webhook недоступен;

  • timeoutSeconds — сколько ждать ответа (по умолчанию 10s, потолок 30s; зависший webhook на 30s тормозит каждую запись соответствующего ресурса);

  • sideEffects — есть ли у webhook’а внешние побочные эффекты (запись во внешние системы, изменение состояния вне обрабатываемого объекта). На основе этого apiserver решает, можно ли вызывать webhook в режиме dry-run. Допустимые значения сейчас — None или NoneOnDryRun; устаревшие Some/Unknown в admissionregistration.k8s.io/v1 уже запрещены.

Операции — это не только CREATE и UPDATE. Webhook можно навесить и на DELETE: типичный кейс — policy-проверка «нельзя удалять PV с annotation X». Удобно, но опасно: сломанный webhook на DELETE способен заблокировать удаление целого класса ресурсов в кластере.

Mutating webhook вызывается до валидации схемы. Apiserver формирует объект AdmissionReview с копией применяемого объекта и POST’ит его на ваш webhook:

apiVersion: admission.k8s.io/v1
kind: AdmissionReview
request:
  uid: 705ab4f5-6393-11e8-b7cc-42010a800002
  operation: CREATE
  kind: { group: "", version: v1, kind: Pod }
  namespace: default
  object:                            # копия применяемого объекта
    apiVersion: v1
    kind: Pod
    metadata:
      name: my-pod
    spec:
      containers:
      - { name: app, image: my-app:1.0 }

В ответ webhook возвращает либо «не трогать», либо JSON Patch с изменениями:

apiVersion: admission.k8s.io/v1
kind: AdmissionReview
response:
  uid: 705ab4f5-6393-11e8-b7cc-42010a800002
  allowed: true
  patchType: JSONPatch
  patch: <base64>                    # например, добавить аннотацию:
  # [{"op":"add","path":"/metadata/annotations",
  #   "value":{"my.example.com/audit":"1"}}]

Apiserver применяет patch к объекту и передаёт результат дальше по цепочке. Типичные кейсы — внедрение sidecar-контейнеров (Istio, Linkerd), проставление аннотаций по политикам, дополнение imagePullSecrets, переписывание nodeSelector.

Mutating webhooks вызываются цепочкой, и тут всплывает важный нюанс — reinvocationPolicy. По умолчанию (Never) каждый webhook вызывается ровно один раз. Если у webhook’а выставлено reinvocationPolicy: IfNeeded, apiserver может вызвать его повторно — если после него другие webhook’и изменили объект. Гарантии «вызовы до устаканивания» нет: если повторный вызов снова что-то поменяет, третьего может уже не быть.

Отсюда практическое правило: если ваш webhook стоит в цепочке с другими mutating’ами или у него IfNeeded — он должен быть идемпотентным. Один-единственный mutating с Never идемпотентным быть не обязан, но реальный кластер редко содержит ровно один mutating webhook: достаточно, чтобы рядом появился любой другой (Istio, Linkerd, OPA, второй ваш собственный) — и идемпотентность сразу становится необходимой. Поэтому идемпотентным разумно писать с самого начала.

Validating webhook вызывается после мутаций и schema-валидации. Он может только разрешить запрос или отказать с осмысленной ошибкой; менять объект не может. Его место — проверки, которые нельзя выразить через OpenAPI-схему: «у этого Pod должен быть label X», «этот Deployment не может скейлиться выше 100 реплик», «имя ресурса должно соответствовать regex».

В последних версиях Kubernetes часть таких правил всё чаще можно задавать через CEL — без webhook’а вообще. CEL (Common Expression Language) встраивается сразу в нескольких местах:

  • x-kubernetes-validations в OpenAPI-схеме CRD (GA с 1.29) — CEL-выражения прямо в схеме вашего CR. Подходит для проверок диапазона значений, зависимостей одного поля от другого, формата имени.

  • ValidatingAdmissionPolicy (GA с 1.30) — отдельный кластерный объект, применяется к произвольным ресурсам, включая встроенные. Замена значительной части validating webhook’ов.

  • MutatingAdmissionPolicy (alpha с 1.32, beta с 1.34) — CEL-аналог mutating webhook’а. Часть простых мутаций (проставить аннотацию, дополнить label) тоже постепенно уезжает в декларативный CEL.

Пример CEL прямо в схеме CRD:

properties:
  replicas:
    type: integer
    x-kubernetes-validations:
    - rule: "self <= 100"
      message: "replicas must not exceed 100"

Webhook остаётся нужен там, где CEL не справляется: походы во внешние системы, сложные cross-resource проверки, динамические правила, которые не выразить чистым выражением.

В controller-runtime свои webhook’и писать не сложно. Под капотом — HTTP-сервер, обрабатывающий AdmissionReview-запросы, и регистрация через builder:

type podMutator struct{}

func (m *podMutator) Default(ctx context.Context, obj runtime.Object) error {
    pod := obj.(*corev1.Pod)
    if pod.Annotations == nil {
        pod.Annotations = map[string]string{}
    }
    pod.Annotations["my.example.com/audit"] = "1"
    return nil
}

// в SetupWithManager:
ctrl.NewWebhookManagedBy(mgr).
    For(&corev1.Pod{}).
    WithDefaulter(&podMutator{}).
    Complete()

Сертификаты для TLS обычно выпускаются cert-manager’ом или генерируются собственным контроллером — apiserver проверяет их на каждом вызове.

Учтите. Mutating webhook, переписывая поля объекта, влияет на metadata.managedFields: у каждого webhook’а появляется собственная запись под своим manager (имя берётся из конфигурации webhook’а). Для контроллеров на Server-Side Apply это означает, что попытка Apply на эти же поля может получить конфликт — если SSA-клиент пытается «отобрать» поле, которым теперь владеет admission-менеджер. Если же SSA-клиент применяет ровно то же значение, что выставил webhook, конфликта не будет (shared ownership). На практике это всё равно частая боль, особенно с sidecar-инжекторами, поэтому mutating webhooks стараются ограничивать минимумом.

Что проставляет сам apiserver

Кроме webhook’ов, в admission-цепочке участвуют встроенные плагины. Несколько важных:

  • ServiceAccount — проставляет spec.serviceAccountName: default, если иное не указано, и оформляет монтирование токена SA. По умолчанию это projected service account token (projected volume); старый механизм с Secret per SA сейчас deprecated.

  • LimitRanger — добавляет default’ы из LimitRange для CPU/memory.

  • DefaultStorageClass — у PersistentVolumeClaim без storageClassName ставит дефолтный.

  • NamespaceLifecycle — заодно с проверкой, что namespace вообще существует (попытка создать объект в несуществующем ns даёт 404 именно от него), запрещает создавать ресурсы в namespace в состоянии Terminating.

Apiserver также сам ставит несколько системных полей в metadata: uid (генерирует UUID), creationTimestamp, resourceVersion, generation (на нём держится паттерн observedGeneration, разобранный во второй статье). Эти поля read-only для клиента: даже если вы их пришлёте в payload, apiserver их перезапишет своими.

Отдельная заметка про defaulting. Писать mutating webhook ради проставления дефолтных значений не всегда имеет смысл — OpenAPI-схема CRD позволяет задать дефолты на уровне самой схемы: достаточно объявить default: прямо в поле, и apiserver проставит значение сам. Это не покрывает вычисляемые мутации (например, дефолт зависит от значения другого поля), но огромную долю кейсов «если не задано — поставить X» закрывает декларативно, без отдельного webhook-сервера.

После прохождения всей цепочки объект попадает в etcd — и только теперь становится «существующим» с точки зрения остальной системы.

Связи между объектами: ownerReferences

Объекты в Kubernetes редко живут поодиночке. Оператор по CR FooBar обычно создаёт целый набор зависимых объектов — Deployment, Service, ConfigMap, RBAC. Между ними нужна связь, чтобы при удалении родителя удалились и дочерние ресурсы.

Для этого в metadata есть поле ownerReferences:

metadata:
  name: my-foo-deploy
  namespace: default
  ownerReferences:
  - apiVersion: example.com/v1
    kind: FooBar
    name: my-foo
    uid: 4f3d2e1c-...
    controller: true
    blockOwnerDeletion: true

Что значит каждое поле:

  • apiVersion, kind, name, uid — ссылка на родителя. Самое важное — uid: Kubernetes идентифицирует родителя именно по нему, не по имени. Пересоздали родителя с тем же именем — для старых дочерних ресурсов это уже другой объект, и они становятся сиротами.

  • controller: true — специальный маркер; такой owner может быть только один на объект. Означает «за жизненный цикл и реконсайл данного объекта отвечает вот этот главный родитель». На этом флаге работает handler.EnqueueRequestForOwner в controller-runtime.

  • blockOwnerDeletion: true — «пока я жив, родителя удалить нельзя» (это пригодится, когда дойдём до Foreground-удаления).

В controller-runtime ownership ставится двумя функциями:

// Главный родитель, controller:true
if err := ctrl.SetControllerReference(parent, child, r.Scheme); err != nil {
    return err
}

// Дополнительный родитель, без controller:true
if err := controllerutil.SetOwnerReference(parent, child, r.Scheme); err != nil {
    return err
}

Дополнительно в SetupWithManager есть метод Owns(...), который автоматически подписывается на изменения дочерних ресурсов и переносит реконсайл на родителя:

return ctrl.NewControllerManagedBy(mgr).
    For(&examplev1.FooBar{}).
    Owns(&appsv1.Deployment{}).
    Complete(r)

Под капотом это регистрирует watch на Deployment и обработчик EnqueueRequestForOwner, который по любому изменению дочернего ресурса кладёт в очередь ключ родителя с учётом controller: true.

Учтите. В ownerReferences нет поля namespace — это сделано осознанно. Для namespace-scoped дочернего объекта родитель всегда подразумевается в том же namespace, что и сам объект. Сослаться на родителя в другом namespace в принципе невозможно. Допустимые комбинации: либо родитель и дочерний объект в одном namespace, либо родитель — cluster-scoped (а дочерний объект может быть любым). Связь «cluster-scoped дочерний объект → namespace-scoped родитель» формально невозможна: для cluster-scoped объекта родитель обязан быть cluster-scoped.

ownerReferences — это статическая декларация связи. Чтобы каскадное удаление действительно сработало, нужен компонент, который разрешает эти ссылки и инициирует удаление зависимых объектовGarbage Collector (далее GC). Финализаторы — отдельный механизм, не связанный напрямую с каскадом: они позволяют контроллерам и самому apiserver’у отложить физическое удаление объекта до того, как будут выполнены завершающие действия.

Удаление: deletionTimestamp, финализаторы, GC

Удаление в Kubernetes — не одномоментная операция, как может показаться по слову DELETE. Это процесс, в котором участвуют четыре сущности: apiserver (ставит маркеры), финализаторы (тормозят удаление до завершающих действий), GC (обходит граф ownerReferences) и стратегии каскада (определяют, что делать с дочерними ресурсами). Разберём по порядку.

deletionTimestamp как маркер начала удаления

Apiserver ведёт два важных timestamp’а в ObjectMeta:

creationTimestamp — момент создания. Apiserver проставляет его при первом Create и в дальнейшем не изменяет. Если вы укажете своё значение — apiserver его проигнорирует.

deletionTimestamp — маркер начала удаления. Объект при этом ещё физически существует в etcd. Появляется не сразу при Delete, а в нескольких ситуациях:

  1. Клиент делает DELETE /api/.../foobars/my-foo.

  2. Apiserver смотрит: есть ли непустой finalizers и какая выбрана стратегия каскадного удаления?

  3. Если finalizers пуст и стратегия — Background (дефолт), без gracePeriodSeconds — объект удаляется из etcd сразу.

  4. Если у объекта есть finalizers — apiserver проставляет deletionTimestamp = now(), и объект остаётся в etcd с проставленным маркером удаления. Удалится физически только когда последний finalizer будет снят.

  5. Если стратегия — Foreground или Orphan, apiserver сам добавит соответствующий системный финализатор (foregroundDeletion или orphan) ещё до записи, даже при пустом finalizers: []. Дальше — как в пункте 4.

  6. Если в DeleteOptions указан gracePeriodSeconds > 0 — apiserver проставит deletionTimestamp и deletionGracePeriodSeconds, и физическое удаление отложится даже без финализаторов.

Особый случай — Pod’ы с graceful termination. У них почти всегда terminationGracePeriodSeconds (по умолчанию 30s): apiserver на DELETE ставит deletionTimestamp + deletionGracePeriodSeconds, объект остаётся в etcd, kubelet штатно гасит контейнеры в пределах окна, после чего сам делает финальный DELETE уже с gracePeriodSeconds=0. Это не финализатор в смысле metadata.finalizers, но «немедленного» физического удаления нет. Обойти можно через kubectl delete --force --grace-period=0, но именно потому, что это обход, а не штатный путь.

Для контроллера deletionTimestamp != nil — главный сигнал: «клиент попросил меня убрать, я должен корректно завершить работу с объектом и снять свой финализатор». Заодно deletionGracePeriodSeconds подсказывает, сколько у вас на это есть времени.

Финализаторы

metadata.finalizers — просто список строк. Пока он не пуст, объект не удаляется физически, даже после DELETE. Это даёт контроллерам шанс выполнить завершающие действия во внешних системах: удалить ресурс в облаке, освободить квоту, отозвать DNS-запись, убрать данные из внешних систем.

Классический паттерн:

const myFinalizer = "foobars.example.com/cleanup"

func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var obj examplev1.FooBar
    if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    if obj.DeletionTimestamp.IsZero() {
        // объект живой — следим, чтобы финализатор был на месте
        if !controllerutil.ContainsFinalizer(&obj, myFinalizer) {
            controllerutil.AddFinalizer(&obj, myFinalizer)
            return ctrl.Result{}, r.Update(ctx, &obj)
        }
        // ... обычная реконсайл-логика
        return ctrl.Result{}, nil
    }

    // объект помечен на удаление — освобождаем внешние ресурсы, потом снимаем финализатор
    if err := r.externalCleanup(ctx, &obj); err != nil {
        return ctrl.Result{}, err
    }
    controllerutil.RemoveFinalizer(&obj, myFinalizer)
    return ctrl.Result{}, r.Update(ctx, &obj)
}

Имя финализатора должно быть стабильным и уникальным. Apiserver валидирует его как DNS-subdomain (<group>/<name>, длиной до 253 символов); типичная форма — <crd-plural>.<group>/<purpose>, например foobars.example.com/cleanup. Если контроллер пропадёт, а финализатор останется, объект зависнет в Terminating навсегда — единственный способ это починить — снять финализаторы вручную:

kubectl patch foobar my-foo -p '{"metadata":{"finalizers":[]}}' --type=merge

Финализаторы и ownerReferences — два независимых механизма, которые решают разные задачи. Финализатор — блокировка физического удаления: пока он на объекте, тот не исчезнет, какие бы DELETE к нему ни приходили. Это даёт зависимым контроллерам возможность увидеть ресурс в очередном реконсайле, корректно завершить связанные действия и только потом снять финализатор. ownerReference — связка в графе владения: объект будет удалён, когда удалят родителя. Они часто работают вместе.

Garbage Collector

Финализаторы дают контроллеру время освободить внешние ресурсы перед физическим удалением. Но кто-то должен ещё пройтись по всему графу ownerReferences и удалить осиротевшие дочерние ресурсы. За это отвечает отдельный контроллер внутри kube-controller-manager — Garbage Collector.

Под капотом GC устроен как граф в памяти. При старте он через Discovery API получает список всех типов в кластере, на каждый поднимает metadata-only watch (через PartialObjectMetadata — иначе на больших кластерах GC съедал бы гигабайты RAM на полные объекты) и из событий строит граф «родитель → дочерние ресурсы». Дальше реактивно реагирует на изменения:

  • удалили родителя → дочерние ресурсы помечаются как сироты → GC удаляет их;

  • у дочернего ресурса все ownerReferences указывают на несуществующих родителей (например, разные uid) → GC удаляет его как сироту;

  • появился новый объект с ownerReferences → GC обновляет граф и связывает его с родителем.

Удаляет GC через тот же admission chain, что и обычные клиенты — финализаторы на дочерних ресурсах работают как ожидается.

Три стратегии каскадного удаления

Когда вы удаляете объект с дочерними ресурсами, Kubernetes предлагает выбрать стратегию через DeletionPropagation. Их три, и под каждой — конкретный механизм с финализаторами.

Background — дефолт для большинства API. Apiserver просто удаляет объект, GC потом асинхронно подчистит дочерние ресурсы. Никаких специальных финализаторов не ставится.

Foreground — apiserver проставляет на родителе системный финализатор foregroundDeletion и deletionTimestamp, объект переходит в Terminating. Дальше:

  • GC удаляет все дочерние ресурсы родителя — независимо от значения blockOwnerDeletion.

  • Сам финализатор foregroundDeletion с родителя снимается только когда исчезнут все дочерние ресурсы, у которых выставлен blockOwnerDeletion: true. В этом и состоит смысл флага: он не «блокирует удаление дочернего ресурса», а «удерживает родителя в Terminating, пока этот дочерний ресурс жив».

  • Дочерние ресурсы без blockOwnerDeletion: true тоже удаляются GC, но родителя в Terminating они не держат — apiserver может физически убрать его из etcd сразу, как только последний blockOwnerDeletion: true-дочерний исчез.

Orphan — отвязать дочерние ресурсы вместо удаления. Apiserver ставит на родителе системный финализатор orphan и deletionTimestamp. GC проходит по всем дочерним ресурсам и снимает у них owner-ссылку на этого родителя. После того как все ссылки убраны, GC снимает с родителя финализатор orphan, и apiserver удаляет родителя. Дочерние ресурсы остаются в кластере как самостоятельные объекты, без родителей.

Стоит обратить внимание: стратегии каскада — это не отдельный механизм, а те же самые финализаторы. Пользовательские финализаторы ставит и снимает ваш контроллер; системные foregroundDeletion и orphan — apiserver и GC. Один и тот же механизм отложенного удаления с двух сторон. На практике вся механика этой темы сводится к двум вопросам: «кто ставит финализатор?» и «кто его снимает?».

В API стратегия передаётся через DeleteOptions.PropagationPolicy. В kubectl:

kubectl delete foobar my-foo --cascade=orphan
kubectl delete foobar my-foo --cascade=foreground
kubectl delete foobar my-foo --cascade=background   # дефолт

В коде на Go:

orphan := metav1.DeletePropagationOrphan
err := r.Delete(ctx, obj, &client.DeleteOptions{
    PropagationPolicy: &orphan,
})

Типичные ошибки

  • Застрявшие финализаторы. Контроллер, который поставил finalizer и пропал (удалили deployment, переименовали controller), оставляет объекты в Terminating навсегда. Лечится только ручным удалением финализаторов (см. выше).

  • Зависший namespace в Terminating. Удаление namespace ждёт ухода всех объектов в нём, поэтому один-единственный CR с зависшим финализатором блокирует весь namespace. Это самая частая «странность Kubernetes» в проде: вы делаете kubectl delete ns my-ns, а оно висит часами — и вся причина в одном CR с финализатором, чей контроллер давно мёртв.

  • uid-зависимость GC. Удалили родителя и тут же создали нового с тем же именем — для старых дочерних ресурсов это уже другой объект (uid новый), и они становятся сиротами и удаляются GC. Часто всплывает при «быстрых пересозданиях» в CI.

Жизненный цикл API-представления

До сих пор мы говорили про жизненный цикл конкретного экземпляра объекта: как он создаётся, связывается с родителем и удаляется. Но у Kubernetes-объекта есть и второе измерение жизненного цикла — не в etcd, а в API-представлении. Сам ресурс может годами жить в кластере, пока его схема проходит путь от v1alpha1 до v1.

Если раньше маршрут объекта был такой:

клиент → admission chain → etcd → GC/finalizers

то для versioned CRD в этот маршрут встраивается ещё один шаг — conversion. Он вызывается до admission chain (на входе) и после чтения из etcd (на выходе):

Один и тот же объект, физически хранящийся в etcd в одной версии, при каждом чтении и записи преобразуется между версиями схемы. С точки зрения клиента обе версии существуют одновременно — это и составляет второе измерение его жизненного цикла.

Зачем нужны версии

served и storage

В CRD у каждой версии есть два важных флага:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: foobars.example.com
spec:
  group: example.com
  versions:
  - name: v1alpha1
    served: true
    storage: false
    schema: { ... }
  - name: v1
    served: true
    storage: true
    schema: { ... }

served: true означает, что эту версию apiserver обслуживает: клиенты могут читать и записывать объекты, явно указывая её в apiVersion. Каждая served-версия фактически становится отдельным эндпоинтом API для одного и того же ресурса.

storage: true может быть только у одной версии — это та, в которой объект физически лежит в etcd. У всех остальных served-версий storage: false. Именно поэтому conversion вообще возникает: читать и писать можно через одну версию, а хранится объект в другой.

Сценарий: пользователь делает kubectl apply -f v1alpha1.yaml, а storage-версия CRD уже v1. Что происходит:

  1. Apiserver принимает запрос в v1alpha1.

  2. Конвертит объект из v1alpha1 в storage-версию v1.

  3. Прогоняет его через admission chain (мутирующие, schema validation, валидирующие — всё, что было выше).

  4. Пишет в etcd как v1.

  5. Если кто-то потом читает тот же объект через v1alpha1 — apiserver достаёт v1 из etcd и конвертит обратно в v1alpha1.

Сам объект остаётся тем же, но по дороге через apiserver может несколько раз «переупаковываться» между версиями API.

Когда новая версия вообще нужна

Общепринятая конвенция:

  • Новые поля обычно добавляют в текущую версию, а не заводят новую только ради этого.

  • Новая версия нужна для breaking changes: переименовали поле, сменили тип, удалили поле, изменили семантику, перестроили вложенную структуру.

  • Storage-версию переключают отдельно от появления новой served-версии: сначала версии живут рядом, потом новая становится storage, и только потом старая снимается с обслуживания.

Отсюда вывод: conversion webhook нужен не «всегда, когда версий больше одной», а тогда, когда между версиями есть реальная логика преобразования.

Когда хватает None, а когда нужен webhook

В CRD у поля conversion.strategy есть два значения: None и Webhook.

None означает, что apiserver не выполняет никаких преобразований данных. На любой запрос он берёт объект из etcd, при необходимости меняет в нём строку apiVersion на ту, в которой клиент запросил объект, и отдаёт результат. Никакой логики между версиями не запускается.

Из этого следует одно жёсткое условие: None корректно работает только тогда, когда между served-версиями нет реальной разницы в данных — те же поля, те же типы, та же вложенность; отличается только сама метка версии. Типичный сценарий — переименование версии (v1beta1v1) при стабилизации API без структурных изменений. Как только между версиями появляется хоть какое-то осмысленное различие — переименовали поле, разделили одно поле на два, поменяли вложенность или семантику, — None уже не подходит и нужен conversion webhook.

При этом None часто путают с другим механизмом — pruning (отбрасывание неизвестных полей). Они работают на разных этапах:

  • Pruning срабатывает при записи: если клиент прислал в payload’е поле, не описанное в structural schema целевой версии, apiserver выкидывает его перед сохранением в etcd. Включён по умолчанию для CRD ≥ 1.25.

  • Conversion strategy определяет, что apiserver делает с уже валидным объектом, когда нужно преобразовать его между served-версиями.

Эти этапы не подменяют друг друга. Если поле существует в одной served-версии и отсутствует в другой, strategy: None его не перенесёт: при попытке записи через версию, в схеме которой поля нет, pruning его выбросит ещё до записи в etcd. Любое преобразование вида «поле в одной версии нужно представить иначе в другой» — это уже территория conversion webhook’а.

И ещё одно понятие, которое стоит сразу определить — structural schema. Это OpenAPI-схема CRD, удовлетворяющая дополнительным требованиям полноты:

  • у каждого поля указан type:;

  • структура объекта полностью описана: каждое вложенное поле объявлено явно;

  • логические конструкции (oneOf, anyOf, allOf, not) разрешены только внутри полей, не на уровне самого объекта.

Зачем это нужно: без structural schema apiserver не может полноценно понимать структуру вашего CR — поэтому многие фичи (defaulting, корректная обработка неизвестных полей при conversion, server-side apply на CR, CEL-валидация) либо не работают, либо ведут себя сюрпризно. На современных версиях apiserver structural schema по факту обязательна — это просто условие игры для современных CRD.

Conversion webhook

Что это и как регистрируется

Conversion webhook — отдельный HTTP-эндпоинт, который apiserver вызывает каждый раз, когда нужно сконвертировать объект между версиями. Регистрируется в самом CRD:

spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions: ["v1"]
      clientConfig:
        service:
          namespace: my-operator
          name: foobars-conversion
          path: /convert
        caBundle: <base64>

Apiserver передаёт webhook’у ConversionReview-запрос с объектом в исходной версии и желаемой целевой; webhook возвращает объект в целевой версии. Реализация в Go обычно сводится к описанию функций ConvertTo / ConvertFrom на типах объекта — controller-runtime и kubebuilder помогают сгенерировать обвязку.

У conversion webhook’а, как и у admission, есть свой потолок по времени ответа (порядка десятков секунд, в зависимости от версии apiserver). Webhook на грани таймаута превратит каждый LIST соответствующего CRD в кошмар — а каждый LIST дёргает webhook на каждый объект из выборки.

Когда webhook вызывается на самом деле

Тонкость, на которой многие спотыкаются: webhook вызывается не только при apply’ях, а буквально при любой операции, где запрашиваемая версия отличается от storage. Это касается LIST, WATCH и admission chain.

Конкретно:

  • Read (Get). Запросили объект через не-storage версию → одна конверсия per object.

  • List. Запросили список через не-storage версию → конверсия на каждый объект в списке. Apiserver группирует несколько объектов в один ConversionReview-запрос (по несколько десятков объектов на запрос), так что речь не про «10 000 round-trip’ов на 10 000 объектов», а про сотни round-trip’ов с сетевой латентностью на каждом. Для in-process конверсии это были бы микросекунды; через webhook — миллисекунды на запрос, что на больших коллекциях суммируется в заметное время.

  • Watch. Подписались на не-storage версию → каждое событие из etcd проходит через конверсию, прежде чем долететь до клиента. Если объект меняется часто, webhook дергается часто.

  • Admission chain. При любой записи через не-storage версию объект сначала конвертируется в storage-версию (на ней работают мутирующие/валидирующие плагины), потом результат записывается в etcd, потом, если кому-то нужен ответ в исходной версии, конвертится обратно. Это уже две конверсии на одну запись.

Отсюда требование номер один к webhook’у — скорость. Apiserver не любит, когда conversion висит дольше нескольких десятков миллисекунд. Из этого вытекают практические следствия:

  • Webhook должен быть детерминированным и без внешних побочных эффектов — никаких записей в БД, никаких изменяющих API-вызовов наружу. Чтение из кэша или быстрого хранилища ради ответа — нормально; то, что вычитанное состояние через секунду изменилось, не должно ломать корректность.

  • В контроллере, который сам же владеет CRD, подписывайтесь на storage-версию. Тогда watch и LIST для него вообще не дёргают conversion. В controller-runtime для этого достаточно использовать в реконсайлере типы той версии, которая помечена как storage.

Hub-and-Spoke и один реконсайлер на все версии

В kubebuilder-проекте каждая версия CRD живёт в отдельном пакете:

api/
├── v1alpha1/
│   ├── foobar_types.go              — определение типа FooBar (v1alpha1)
│   ├── foobar_conversion.go         — методы ConvertTo / ConvertFrom
│   └── zz_generated.deepcopy.go
└── v1/
    ├── foobar_types.go              — определение типа FooBar (v1)
    └── zz_generated.deepcopy.go

Если бы каждая версия умела конвертироваться напрямую в каждую другую, для n версий пришлось бы написать O(n²) пар. Чтобы этого избежать, controller-runtime навязывает паттерн Hub-and-Spoke: одна версия объявляется Hub, все остальные — Spoke. Spoke-версии знают, как конвертироваться в Hub и из него; конверсия между двумя spoke’ами идёт через Hub (A → Hub → B). В сумме — O(n) методов на n версий.

Hub-and-Spoke — конвенция фреймворка, а не системное ограничение Kubernetes. Apiserver про Hub ничего не знает: он просто дёргает webhook на пары (from, to) и ждёт результат. Если пишете conversion-webhook руками, без kubebuilder, можете реализовать любую топологию — на практике это редко имеет смысл, но возможность есть.

Hub-версией обычно делают storage-версию. Объявляется одной строчкой:

// в api/v1/foobar_types.go
func (*FooBar) Hub() {}

Spoke-версия реализует интерфейс conversion.Convertible:

// в api/v1alpha1/foobar_conversion.go
import (
    v1 "github.com/example/api/v1"
    "sigs.k8s.io/controller-runtime/pkg/conversion"
)

// v1alpha1 → Hub (v1)
func (src *FooBar) ConvertTo(dstRaw conversion.Hub) error {
    dst := dstRaw.(*v1.FooBar)
    dst.Spec.Replicas = src.Spec.Replicas
    // переименовали поле:
    dst.Spec.NewField = src.Spec.OldField
    return nil
}

// Hub (v1) → v1alpha1
func (dst *FooBar) ConvertFrom(srcRaw conversion.Hub) error {
    src := srcRaw.(*v1.FooBar)
    dst.Spec.Replicas = src.Spec.Replicas
    dst.Spec.OldField = src.Spec.NewField
    return nil
}

В main.go для каждого типа вызывается SetupWebhookWithManagercontroller-runtime поднимает один HTTP-сервер на менеджере с общим эндпоинтом /convert, регистрирует его в CRD (если задано через kubebuilder-маркеры) и направляет каждый ConversionReview в нужный метод. Никакой ручной маршрутизации между парами версий писать не нужно, и отдельный сервер на каждый CRD тоже не нужен.

Ещё одна вещь, которая часто всплывает в вопросах: реконсайлер пишется только под одну версию — обычно ту же, что Hub/storage. Это не «отдельный контроллер на каждую версию». Клиент пишет в любую served-версию, apiserver через conversion webhook приводит объект к storage-версии, дальше всё происходит на ней — admission chain, валидация, запись в etcd. Контроллер подписан на storage-версию и видит её. То есть controller-runtime обслуживает обе версии одновременно бесплатно — за счёт того, что apiserver сам делает всю конвертацию через webhook. От вас требуются только корректные ConvertTo/ConvertFrom и реконсайлер под одну версию.

Эволюция в проде

Написать webhook — половина дела. Дальше нужно прожить с двумя версиями в реальном кластере: мигрировать данные, предупредить пользователей, не сломать обратную совместимость. Несколько вещей, на которых легко споткнуться.

Конверсии с потерями недопустимы в обе стороны. Kubernetes API conventions требуют так называемый round-trip-инвариант — гарантию, что объект, прошедший по цепочке «версия A → storage → обратно в версию A», вернётся идентичным исходному. Иначе говоря, пользователь, который читает и записывает объект в одной и той же версии, не должен терять данные на этом пути.

Это соглашение, а не runtime-проверка apiserver’а — если webhook вернёт lossy-конверсию, никто не упадёт, но объекты тихо начнут портиться.

Тонкий момент возникает, когда в storage-версии есть поле, которого нет в схеме одной из served-версий. Пусть v1 — storage и в нём есть поле Spec.NewField, а в схеме v1alpha1 этого поля нет. Что произойдёт:

  1. Пользователь читает объект через v1alpha1 → apiserver вызывает webhook v1 → v1alpha1. Поле NewField пропадает из выдачи (его не существует в схеме v1alpha1).

  2. Пользователь редактирует объект и делает apply через v1alpha1. В payload поля NewField нет — ни до записи, ни после. Webhook конвертирует v1alpha1 → v1 и должен вернуть полный объект v1, включая NewField. Если webhook этого не сделает, в storage v1 поле NewField запишется в zero value, и предыдущее значение пропадёт.

Чтобы не терять «чужие» неизвестные поля при конверсии, в старую схему CRD (ту, в которой полей из новой версии не существует) добавляют x-kubernetes-preserve-unknown-fields: true — точечно, на отдельные поля или вложенные структуры. Apiserver тогда не отбрасывает эти поля при pruning’е и сохраняет их в etcd, давая webhook’у возможность пробросить их обратно при конверсии. Это штатный приём для безопасной эволюции схемы.

Storage-версия меняется не просто так. Перевести storage с v1alpha1 на v1 — операция, которая требует, чтобы все объекты в etcd были перезаписаны в новой версии. Сделать это можно либо вручную (kubectl get foobars -o yaml | kubectl apply -f - — читает в произвольной served-версии, пишет в storage), либо через storage version migrator (storage-version-migrator-controller из k8s-sigs). Migrator работает не сам по себе — это отдельный компонент, его нужно поставить, и он управляется объектами StorageVersionMigration. Важная оговорка: если у вас CRD с conversion webhook’ом, который падает на старых данных, миграция через migrator тоже сломается — она просто прочитает объект и попытается записать его обратно, упрётся в тот же webhook.

Что в это время происходит с клиентами: миграция не блокирует ни чтение, ни запись. Объекты по очереди перезаписываются в новую storage-версию, а клиенты продолжают работать через любую served-версию — apiserver на каждом запросе делает нужную конверсию через webhook. В каждый момент часть объектов в etcd может быть в старой storage-версии, часть — в новой; от клиента эта разница скрыта. Главное условие — стабильный и корректный conversion webhook: если он сломается во время миграции, всё, что осталось в «не той» версии, временно станет недоступно, потому что любая попытка прочитать или записать упрётся в нерабочий webhook.

Отказываться от старой served-версии нужно постепенно. Сначала добавляете новую версию рядом со старой (обе served), мигрируете все объекты в новую storage-версию, ждёте пока клиенты обновятся — и только потом удаляете served: true у старой. «Клиенты» здесь — это в первую очередь не люди, а другие контроллеры и операторы, которые работают с вашим API. Их авторам нужно импортировать новое API в свой код, написать обновлённую логику и выкатить новые версии в кластеры — процесс месяцев, а не часов. Поэтому старую served-версию убирают не «когда мы переключили storage», а только после того, как ушли все её потребители. Аналитикой того, кто ещё ходит к старой версии, помогают занять apiserver_requested_deprecated_apis метрики и audit log.

Предупреждайте пользователей об устаревании прямо в CRD. Когда вы вводите новую версию и хотите потихоньку отказаться от старой, удобно сразу сообщать пользователю, что версия устарела:

versions:
- name: v1alpha1
  served: true
  storage: false
  deprecated: true
  deprecationWarning: "example.com/v1alpha1 FooBar is deprecated; use example.com/v1 instead"
- name: v1
  served: true
  storage: true

При любом запросе к устаревшей версии (kubectl apply -f с apiVersion: example.com/v1alpha1, kubectl get foobars.v1alpha1.example.com, watch — что угодно) apiserver добавляет к ответу HTTP-заголовок Warning: 299 - "...". kubectl показывает это сообщение в stderr — выглядит так:

Warning: example.com/v1alpha1 FooBar is deprecated; use example.com/v1 instead
foobar.example.com/my-foo configured

Если deprecationWarning не задан, apiserver выдаст generic-сообщение. Сигнал в любом случае доходит до пользователя автоматически, без необходимости что-то делать в коде контроллера.

Кстати. Тот же механизм HTTP-предупреждений используют validating webhooks: в AdmissionResponse можно вернуть массив warnings, и apiserver покажет их клиенту так же. Это удобно для предупреждений в духе «вы используете устаревшее поле, в следующем релизе оно будет удалено» — без отказа в записи. В controller-runtime метод-валидатор может вернуть admission.Warnings (это просто []string), они автоматически попадут в AdmissionResponse.Warnings.

Полноценный API-уровень из коробки

Если посмотреть на оба измерения вместе, видна простая вещь: CRD + admission webhooks + conversion webhooks дают вам ровно тот же набор примитивов, который apiserver применяет к встроенным типам. Любой kube-нативный паттерн поведения, который вы видите у Pod или Deployment, реализуем для вашего CRD без выхода за пределы CRD-модели:

  • defaulting — через default: в OpenAPI-схеме или mutating webhook;

  • validation — через OpenAPI-схему, CEL (x-kubernetes-validations, ValidatingAdmissionPolicy) или validating webhook;

  • mutation — через CEL (MutatingAdmissionPolicy) или mutating webhook, в том числе на DELETE;

  • multi-version API с обратной совместимостью — через served/storage и conversion webhook;

  • deprecation warnings для пользователей — через deprecationWarning в CRD или admission.Warnings из валидатора;

  • жизненный цикл и каскады — через ownerReferences, финализаторы и стратегии каскадного удаления.

Apiserver не делает для встроенных типов чего-то фундаментально другого — он использует те же самые точки расширения, просто реализованные внутри его процесса как Go-код, а не как HTTP-сервис. Поэтому ваш CRD-объект — не «второго сорта»: вы получаете полноценный API-уровень из коробки, со всем поведением, которое привычно видеть у Pod.

Когда CRD-модели мало: Aggregation API Layer

Conversion webhooks решают задачу «несколько схем для одного типа», но даже они остаются жёстко привязаны к модели CRD: схема, валидация, версии, конверсии. Если вашему API хочется чего-то принципиально не укладывающегося в эту модель — например, своя бэкенд-логика поверх собственной БД, нестандартная семантика операций, специальные subresources, поведение, не выражаемое через OpenAPI-схему, — стоит посмотреть в сторону Aggregation API Layer.

Вы пишете собственный apiserver, регистрируете его в kube-apiserver через APIService, и сами реализуете всю логику чтения, записи, валидации и эволюции версий. Conversion webhooks при этом не нужны вообще — версионирование вы делаете внутри своего сервера так, как вам удобно.

Заодно снимается и проблема производительности conversion на больших коллекциях. При LIST/WATCH каждый объект из выборки проходит через сторонний HTTP-сервис (apiserver батчит их в общий запрос, но сама операция остаётся сетевой), и его latency с доступностью становятся прямой частью пути запроса. На сотнях тысяч объектов и активных watch’ах это превращается в материальное узкое место. В Aggregation API такого слоя просто нет: ваш apiserver хранит и отдаёт объекты сам, конверсия между версиями делается in-process без сетевого взаимодействия.

Подробно про Aggregation API и наш кейс использования я писал в отдельной статье.

Итого

Изначально я задумывал эту серию статей, чтобы рассказать про то, как устроены чтение и запись в Kubernetes API и как это реализуется в controller-runtime. Но рассказ был бы не полным без того, что происходит с объектом до того, как он окажется в etcd, и после того, как клиент сделал kubectl delete.

Если объединить весь цикл одной картинкой:

  • Первая статья — про чтение состояния через informer и кэш.

  • Вторая — про запись: Update, Patch, subresources, kubectl apply, Server-Side Apply и managedFields.

  • Третья (эта) — про жизненный цикл объекта в двух измерениях: жизнь экземпляра (admission chain → ownerReferences → finalizers → GC) и жизнь API-представления (versioned CRD, conversion webhooks).

Это не вся внутрянка Kubernetes, но это базовая карта для всех, кто пишет операторы.

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