Жизненный цикл объекта в Kubernetes: путь от kubectl apply до полного удаления
- суббота, 30 мая 2026 г. в 00:00:17

Привет. В предыдущих статьях этого цикла мы разбирали, как Kubernetes-объекты читаются (первая — informer и кэш в controller-runtime) и записываются (вторая — Server-Side Apply, patch’и, managedFields). Сегодня — про их жизненный цикл.
Между kubectl apply и появлением объекта в etcd проходит целая цепочка: admission chain, мутирующие и валидирующие вебхуки, schema-валидация, встроенные плагины. Между kubectl delete и реальным исчезновением объекта может пройти от миллисекунд до часов — в зависимости от того, какие на нём финализаторы и какая стратегия каскадного удаления выбрана. Механизм при этом универсален для любого ресурса: Pod, Deployment, ваш CRD — жизненный цикл у всех один.
В этой статье я постараюсь ответить, что происходит с объектом от его рождения до смерти. И отдельно поговорим про другое измерение — эволюцию его API-схемы.
Когда клиент делает kubectl apply -f или r.Create(ctx, &obj), объект не попадает напрямую в etcd. Между HTTP-запросом и записью в хранилище стоит admission chain — конвейер из плагинов и вебхуков, через который проходит каждый запрос на изменение. Выглядит он примерно так:

Самое интересное в этой цепочке — admission 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 стараются ограничивать минимумом.
Кроме 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 — и только теперь становится «существующим» с точки зрения остальной системы.
Объекты в 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’у отложить физическое удаление объекта до того, как будут выполнены завершающие действия.
Удаление в Kubernetes — не одномоментная операция, как может показаться по слову DELETE. Это процесс, в котором участвуют четыре сущности: apiserver (ставит маркеры), финализаторы (тормозят удаление до завершающих действий), GC (обходит граф ownerReferences) и стратегии каскада (определяют, что делать с дочерними ресурсами). Разберём по порядку.
Apiserver ведёт два важных timestamp’а в ObjectMeta:
creationTimestamp — момент создания. Apiserver проставляет его при первом Create и в дальнейшем не изменяет. Если вы укажете своё значение — apiserver его проигнорирует.
deletionTimestamp — маркер начала удаления. Объект при этом ещё физически существует в etcd. Появляется не сразу при Delete, а в нескольких ситуациях:
Клиент делает DELETE /api/.../foobars/my-foo.
Apiserver смотрит: есть ли непустой finalizers и какая выбрана стратегия каскадного удаления?
Если finalizers пуст и стратегия — Background (дефолт), без gracePeriodSeconds — объект удаляется из etcd сразу.
Если у объекта есть finalizers — apiserver проставляет deletionTimestamp = now(), и объект остаётся в etcd с проставленным маркером удаления. Удалится физически только когда последний finalizer будет снят.
Если стратегия — Foreground или Orphan, apiserver сам добавит соответствующий системный финализатор (foregroundDeletion или orphan) ещё до записи, даже при пустом finalizers: []. Дальше — как в пункте 4.
Если в 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 — связка в графе владения: объект будет удалён, когда удалят родителя. Они часто работают вместе.
Финализаторы дают контроллеру время освободить внешние ресурсы перед физическим удалением. Но кто-то должен ещё пройтись по всему графу 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.
До сих пор мы говорили про жизненный цикл конкретного экземпляра объекта: как он создаётся, связывается с родителем и удаляется. Но у Kubernetes-объекта есть и второе измерение жизненного цикла — не в etcd, а в API-представлении. Сам ресурс может годами жить в кластере, пока его схема проходит путь от v1alpha1 до v1.
Если раньше маршрут объекта был такой:
клиент → admission chain → etcd → GC/finalizers
то для versioned CRD в этот маршрут встраивается ещё один шаг — conversion. Он вызывается до admission chain (на входе) и после чтения из etcd (на выходе):

Один и тот же объект, физически хранящийся в etcd в одной версии, при каждом чтении и записи преобразуется между версиями схемы. С точки зрения клиента обе версии существуют одновременно — это и составляет второе измерение его жизненного цикла.
В 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. Что происходит:
Apiserver принимает запрос в v1alpha1.
Конвертит объект из v1alpha1 в storage-версию v1.
Прогоняет его через admission chain (мутирующие, schema validation, валидирующие — всё, что было выше).
Пишет в etcd как v1.
Если кто-то потом читает тот же объект через v1alpha1 — apiserver достаёт v1 из etcd и конвертит обратно в v1alpha1.
Сам объект остаётся тем же, но по дороге через apiserver может несколько раз «переупаковываться» между версиями API.
Общепринятая конвенция:
Новые поля обычно добавляют в текущую версию, а не заводят новую только ради этого.
Новая версия нужна для breaking changes: переименовали поле, сменили тип, удалили поле, изменили семантику, перестроили вложенную структуру.
Storage-версию переключают отдельно от появления новой served-версии: сначала версии живут рядом, потом новая становится storage, и только потом старая снимается с обслуживания.
Отсюда вывод: conversion webhook нужен не «всегда, когда версий больше одной», а тогда, когда между версиями есть реальная логика преобразования.
В CRD у поля conversion.strategy есть два значения: None и Webhook.
None означает, что apiserver не выполняет никаких преобразований данных. На любой запрос он берёт объект из etcd, при необходимости меняет в нём строку apiVersion на ту, в которой клиент запросил объект, и отдаёт результат. Никакой логики между версиями не запускается.
Из этого следует одно жёсткое условие: None корректно работает только тогда, когда между served-версиями нет реальной разницы в данных — те же поля, те же типы, та же вложенность; отличается только сама метка версии. Типичный сценарий — переименование версии (v1beta1 → v1) при стабилизации 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 — отдельный 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 вызывается не только при 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.
В 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 для каждого типа вызывается SetupWebhookWithManager — controller-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 этого поля нет. Что произойдёт:
Пользователь читает объект через v1alpha1 → apiserver вызывает webhook v1 → v1alpha1. Поле NewField пропадает из выдачи (его не существует в схеме v1alpha1).
Пользователь редактирует объект и делает 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.
Если посмотреть на оба измерения вместе, видна простая вещь: 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.
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, но это базовая карта для всех, кто пишет операторы.
Спасибо всем, кто читал цикл, ловил неточности и писал в комментариях — без вашего фидбека статьи не получились бы такими, какими получились.