Запись в Kubernetes: как контроллеры учились не перезаписывать друг друга

от автора

Привет. В прошлой статье мы в основном говорили про чтение — кэш в controller-runtime, informer’ы, Reflector, DeltaFIFO, почему r.Get в реконсайле не ходит в apiserver. Сегодня поговорим больше про запись.

Kubernetes по своей природе спроектирован так, что одним и тем же объектом могут управлять разные контроллеры — и это нормально. На один Deployment смотрят и deployment-controller (правит status), и HPA (правит spec.replicas), и admission-мутаторы (расставляют labels), и cert-manager (дописывает свои аннотации), и пользователь с kubectl apply. Каждый из них отвечает за свои поля и не лезет в чужие. И всё это работает.

Сегодня будем разбираться, какие механизмы в Kubernetes позволяют разным компонентам делить ответственность за части одного и того же объекта, не превращая запись в гонку — и как ими правильно пользоваться, когда оператор пишете вы сами. Добро пожаловать под кат.

resourceVersion и optimistic concurrency

Самое базовое в API: у каждого объекта в Kubernetes есть поле metadata.resourceVersion — непрозрачная строка, которая под капотом — монотонно растущая позиция в etcd. apiserver возвращает её в ответах и ждёт обратно в запросах на изменение.

Когда вы делаете r.Update(ctx, &obj), в теле запроса уходит весь объект целиком, включая resourceVersion. apiserver сверяет:

  • resourceVersion в запросе совпадает с тем, что сейчас в etcd → пишем, версия инкрементируется;

  • в etcd уже что-то новее → 409 Conflict, «кто-то тебя опередил».

Это и есть optimistic concurrency control. Никаких реальных блокировок не берётся — все пишут параллельно, но из конкурирующих Update выигрывает только тот, чей resourceVersion в момент записи был свежим. Остальным прилетит 409, и они должны перечитать объект и попытаться снова.

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

  • При активной конкуренции вы постоянно ловите 409. И тут начинается самое неприятное: даже если вы хотели поменять одно поле, вам всё равно приходится поднимать полный цикл retry — вычитать новую версию объекта (со свежим resourceVersion и со всеми изменениями, которые внёс кто-то ещё), заново применить свои изменения к этой версии в памяти и снова отправить целиком в apiserver. И так до тех пор, пока не повезёт «попасть» между чужими записями. В горячих контроллерах этот retry-цикл начинает занимать ощутимую долю времени.

  • Нет никакого представления «моё поле / чужое поле». Вы всегда пишете весь объект, даже когда не собирались трогать ничего, кроме одного атрибута.

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

Частичные обновления: Patch

Patch — это альтернативный способ записи, при котором вы отправляете не весь объект, а только то, что хотите изменить. В Kubernetes исторически представлено три формата patch-операций; все три поддерживаются и apiserver, и controller-runtime. Разберём по очереди — от самого распространённого.

JSON Merge Patch (RFC 7396)

Самый интуитивный формат. Идея простая: отправляйте JSON того, что хотите изменить, остальное не трогается:

{ "spec": { "replicas": 5 } }

Что не упомянуто — apiserver не трогает. Поля со значением null удаляются. В controller-runtime за этим форматом стоит client.MergeFrom(original), и именно его вы чаще всего встретите в коде контроллеров — например, для записи в status.

У Merge Patch есть одно ощутимое ограничение, которое всплывает ровно в момент попытки поправить элемент списка. Это поведение прописано прямо в RFC 7396: вложенные объекты мерджатся рекурсивно, ключ-к-ключу, а массивы — заменяются целиком. То есть любой список, упомянутый в patch’е, перезаписывает существующий, какие бы элементы там ни были.

Для словарей (labels, annotations) это работает как и ожидается — каждый ключ обновляется независимо. А вот списки оборачиваются неприятным сюрпризом: хотите поменять image у одного контейнера в spec.containers — придётся отправить весь список целиком, со всеми остальными полями каждого. Если в этот момент кто-то рядом добавил в список ещё один контейнер — вы его сотрёте, не подозревая об этом.

JSON Patch (RFC 6902)

Чтобы обойти ограничение со списками, можно использовать более точечный формат. JSON Patch — это явный массив операций над конкретными путями:

[  { "op": "replace", "path": "/spec/containers/0/image", "value": "nginx:1.27" },  { "op": "add",     "path": "/metadata/labels/foo",     "value": "bar" },  { "op": "remove",  "path": "/metadata/annotations/legacy" }]

Здесь вы прямо говорите: «замени значение в позиции 0 списка containers», «добавь ключ в labels», «удали аннотацию». Со списком работаете не на уровне «весь список», а на уровне отдельных элементов.

В controller-runtime это client.RawPatch(types.JSONPatchType, ...):

patchBytes := []byte(`[    {"op":"remove","path":"/metadata/finalizers/0"}]`)if err := r.Patch(ctx, &obj, client.RawPatch(types.JSONPatchType, patchBytes)); err != nil {    return err}

На практике JSON Patch в операторах используется редко. Писать его в коде неудобно — получается массив сырого JSON, который нужно вручную формировать строкой или собирать через сторонние библиотеки; из-за статически типизированной природы Go никакой схема-проверки в момент компиляции вы не получите, любая опечатка в path всплывёт только в рантайме. Поэтому для повседневных задач обычно предпочитают MergeFrom, который формирует diff автоматически из двух версий объекта.

JSON Patch удобен в нишевых сценариях, где важна точечная атомарная операция: снять конкретный финализатор (remove по индексу), удалить конкретный ключ из карты, или сделать compare-and-swap через операцию test. То есть когда вы заведомо знаете и точный путь, и точное действие — и вам не нужен «вычисляемый diff».

Strategic Merge Patch

Strategic Merge Patch (SMP) — k8s-специфичный формат, который пытается совместить простоту Merge Patch с умением работать со списками, как у JSON Patch. Внешне это обычный JSON-документ, как Merge Patch:

{ "spec": { "containers": [ { "name": "app", "image": "nginx:1.27" } ] } }

Но apiserver обрабатывает его умнее. Для каждого нативного типа в Kubernetes в Go-тегах его структуры зашита patch-strategypatchStrategy:"merge", patchMergeKey:"name", patchStrategy:"replace" и т. п. По этим тегам apiserver понимает:

  • что списки containers нужно мержить поэлементно по ключу name: можно прислать только тот контейнер, который вы меняете, — остальные останутся как есть;

  • что volumeMounts мержатся аналогично — по name;

  • что, например, args или command мержить вообще нельзя, и при упоминании в patch’е они заменяются целиком;

  • что у некоторых полей-словарей мерж тоже работает по своим правилам.

Это и есть тот формат, который под капотом использует обычный kubectl apply (без --server-side). В controller-runtimeclient.StrategicMergeFrom(original).

Важный нюанс: SMP работает только для нативных Kubernetes-типов. Для CRD apiserver его не поддерживает в принципе — попытка отправить запрос с Content-Type: application/strategic-merge-patch+json к CRD просто отвергается. Для частичных обновлений CRD остаются JSON Patch и JSON Merge Patch — со всеми их ограничениями по спискам.

Умное слияние списков для CRD появилось только в Server-Side Apply, и там — не само по себе. По умолчанию SSA для CRD тоже считает любые списки атомарными и заменяет их целиком. Чтобы apiserver мержил список поэлементно, в схеме CRD нужно явно прописать маркеры x-kubernetes-list-type: map и x-kubernetes-list-map-keys. Сгенерировать их можно через controller-gen (+listType=map, +listMapKey=name), и kubebuilder это умеет из коробки.

Patch без resourceVersion

Тонкость, которую часто упускают. Посмотрите на типичный код:

original := obj.DeepCopy()obj.Spec.Replicas = ptr.To[int32](5)patch := client.MergeFrom(original)if err := r.Patch(ctx, obj, patch); err != nil {    return err}

Здесь интересно, как client.MergeFrom формирует payload patch-запроса. Логика такая: вы заранее сделали obj.DeepCopy() и сохранили исходную версию в original. Дальше изменили нужное поле в obj. После этого client.MergeFrom берёт две версии — original и obj — и вычисляет между ними diff. Этот diff и становится телом PATCH-запроса.

В нашем примере между original и obj отличается ровно одно поле — spec.replicas. Соответственно, в HTTP-запрос уйдёт только оно. Поле metadata.resourceVersion в diff не попадёт — мы его не меняли, значения в original и obj совпадают.

И вот ключевой момент: apiserver, получив PATCH без resourceVersion в payload, его не проверяет. Он берёт текущее состояние объекта в etcd, применяет ваш патч поверх него и пишет результат. Никаких 409 Conflict, никакого optimistic lock — это штатное поведение, не баг: вы сказали «измени мне вот эти поля», apiserver применил их поверх актуальной версии, какой бы она ни была.

Это удобно, когда вы уверены, что ваше поле никто другой не трогает — типичный пример это status вашего CRD, который пишет только ваш контроллер. Если же нужен optimistic lock и при patch’е — в controller-runtime есть явный способ:

patch := client.MergeFromWithOptions(original, client.MergeFromWithOptimisticLock{})

Тогда resourceVersion попадёт в diff, и apiserver будет его проверять.

Patch при этом не отвечает на вопрос «кому какое поле принадлежит». Любой клиент с правами update на ресурс может запатчить любую часть объекта — apiserver не различает, на чьи поля он посягнул. Чтобы разграничить права хотя бы между крупными частями объекта (например, дать пользователю писать в spec, а контроллеру — в status), используется механизм subresources.

Subresources: разные двери к одному объекту

Если посмотреть на типичный объект в Kubernetes, у него почти всегда есть три секции:

  • metadata — общая информация об объекте (имя, namespace, labels, annotations, owner-ссылки, временные метки). Эта секция одинакова по структуре для всех типов объектов в Kubernetes;

  • spec — желаемое состояние, которое описал пользователь;

  • status — фактическое состояние, которое поддерживает контроллер.

И ответственность за эти секции на практике почти всегда разная. С metadata и spec обычно работает один клиент — пользователь, GitOps-система или внешний оператор. Со status — реконсайлер самого этого ресурса. RBAC в Kubernetes выдаётся на ресурс целиком: разрешение update на единый HTTP-эндпоинт автоматически означает право менять и spec, и status, и метаданные. Разделить права между этими секциями в такой модели невозможно — а на практике это нужно почти всегда.

Эту задачу решает механизм subresources. У одного и того же объекта в API может быть несколько HTTP-эндпоинтов: основной — на ресурс целиком, и дополнительные, привязанные к нему как к родителю. Каждый из них — самостоятельная точка входа со своим набором операций и своими RBAC-правилами. Бывают они двух разных видов.

Императивные — это не «обновление поля», а действие над объектом. Через них в Kubernetes реализованы такие вещи, как kubectl logs (subresource /log), kubectl exec (/exec), kubectl port-forward (/portforward), kubectl attach (/attach). Это отдельные API-эндпоинты с собственной серверной логикой, которые ничего не пишут в etcd, а делают что-то наружное — открывают stream к контейнеру, проксируют поток данных, и так далее.

Декларативные — это subresource, который снаружи выглядит как обычное поле объекта в YAML, но живёт за отдельной «дверью» в API. Самый распространённый пример — /status. Для CRD он включается в CustomResourceDefinition явно:

spec:  versions:  - name: v1    subresources:      status: {}

После этого у вашего CRD появляется отдельный HTTP-эндпоинт /status. На уровне хранения это всё ещё один объект в etcd с единой resourceVersion — в YAML вы видите metadata, spec и status рядом, как обычно. Но на уровне API:

  • PATCH /apis/example.com/v1/.../foobars/my-foo — меняет spec и metadata. Поле status в payload молча игнорируется apiserver’ом.

  • PATCH /apis/example.com/v1/.../foobars/my-foo/status — меняет только status. Поле spec игнорируется.

Зачем такое разделение? Причин несколько, и RBAC — лишь самая видимая из них:

  • Разные права для разных клиентов. На разные эндпоинты вешаются разные RBAC-правила: пользователю отдаётся update на основной ресурс, контроллеру — на subresource /status. У Pod ровно та же история: пользователю обычно недоступен pods/status, у kubelet — наоборот, только он и есть.

  • Независимость записей. Запись в status не затрагивает spec и наоборот. Это снимает целый класс гонок при kubectl apply: пользовательский apply может прилететь параллельно с записью статуса от контроллера, и они физически не перетрут друг друга, потому что меняют непересекающиеся куски через разные эндпоинты.

  • Поведение системных счётчиков. Запись через /status не двигает metadata.generation — на этом построен типовой паттерн observedGeneration (про него ниже).

  • Отдельная схема валидации и admission-цепочка для status — webhook’и можно навешивать раздельно, и проверки на spec не дёргаются на каждый чих от контроллера, обновляющего status.

  • Отдельный watch-канал и метрики — apiserver различает изменения в /status и в основном ресурсе.

В controller-runtime это разделение видно на уровне API клиента — для записи в /status есть отдельный «фасад» r.Status():

// Пишем в /status, spec не трогаемobj.Status.Phase = "Ready"if err := r.Status().Update(ctx, &obj); err != nil {    return err}

Распространённая ошибка у новичков — пытаться записать обновление статуса обычным r.Update. Запрос проходит без ошибки, в коде кажется, что всё ок, но в кластере status остаётся прежним: apiserver молча отбросил это поле, потому что endpoint не тот.

/scale: subresource как обёртка с собственной логикой

Не каждый decl-subresource — это просто «другая дверь к тому же полю». Иногда subresource — это самостоятельная сущность с захардкоженной в apiserver логикой, которая отображается на родительский объект, но не повторяет его семантику. Хороший пример — /scale у Deployment, ReplicaSet, StatefulSet.

С точки зрения API, /scale — это отдельный объект autoscaling/v1.Scale со своими spec.replicas и status.replicas. Универсальный, общий для всех scalable-типов. HPA работает именно с ним: ему достаточно прав на deployments/scale, statefulsets/scale, replicasets/scale, и не нужен полный update на сами Deployment’ы / ReplicaSet’ы / StatefulSet’ы — а это, кстати, и более узкая поверхность атаки с точки зрения безопасности.

Внутри apiserver маппинг /scale на родителя захардкожен: «Deployment.spec.replicasScale.spec.replicas», «Deployment.status.replicasScale.status.replicas». Эта связь не описана в схеме — она прямо в коде apiserver’а. Снаружи это просто отдельный API-эндпоинт со своей семантикой.

Похожим образом устроены и другие subresources: /binding у Pod (через него scheduler записывает spec.nodeName), /eviction у Pod (используется при kubectl drain с учётом PodDisruptionBudget), /finalize у Namespace, /token у ServiceAccount — у каждого собственная серверная логика и собственный круг клиентов.

Кстати. Если хочется реализовать subresource с какой-то нетривиальной логикой для собственного типа, штатный паттерн для этого — Aggregation API Layer. Вместо CRD вы пишете собственный API server, который регистрируется в kube-apiserver через APIService и реализует любые эндпоинты с любой логикой записи и чтения. Используя этот подход, можно заложить практически любую логику чтения и записи для своих объектов, но это уже выходит за рамки нашей статьи — про aggregation я подробно писал в отдельной публикации. Здесь упоминаю, чтобы было понятно, как далеко тянется концепция «отдельных дверей к объекту». Возвращаемся к основной теме.

generation и observedGeneration

Помимо resourceVersion, у каждого объекта в metadata есть ещё один счётчик — generation. Apiserver увеличивает его только при изменении spec: правки в status и в системных полях metadata его не двигают. Где resourceVersion отвечает на вопрос «изменилось ли в объекте хоть что-нибудь», generation отвечает на «изменилось ли то, чем управляет пользователь».

На этом и построен типовой паттерн status.observedGeneration. Контроллер на каждом успешном реконсайле пишет в status.observedGeneration значение текущего metadata.generation. По разнице между ними потом сразу видно, отражает ли status актуальный spec:

  • observedGeneration == generation → контроллер уже отреконсайлил последнюю версию spec, текущему status можно верить;

  • observedGeneration < generation → пользователь поменял spec, контроллер ещё не успел отреконсайлить — status устарел.

У этого паттерна несколько важных применений:

  • Сам контроллер использует это сравнение, чтобы решить, что делать на текущем проходе. Если observedGeneration == generation, можно ограничиться проверкой, что фактическое состояние всё ещё совпадает с желаемым, и спокойно ничего не менять. Если значения расходятся — пользователь только что изменил spec, и нужно прогнать всю реконсайл-логику заново.

  • Условия в status.conditions становятся осмысленнее. Хорошие контроллеры в каждое условие тоже пишут observedGeneration — чтобы потребители (kubectl, дашборды, другие контроллеры) могли понять, относится это условие к актуальной версии spec или к ещё не отработанному изменению. Без этого пользователь видит «Ready: True» и думает, что всё хорошо, тогда как контроллер просто ещё не дошёл до его последнего apply.

  • Отладка. Если observedGeneration отстаёт от generation и не растёт, это первое место, куда стоит смотреть в инциденте «я применил изменение, а ничего не происходит». Контроллер либо упал, либо застрял на ошибке — и metadata.generation против status.observedGeneration показывает это сразу.

Гарантирует всё это поведение именно subresource /status: запись через него не двигает generation. Если бы статус правился через основной эндпоинт, счётчик прыгал бы на каждое обновление и весь паттерн потерял бы смысл.

Где subresources заканчиваются

Заводить subresource на каждый чих было бы накладно. Это и проблема operability (на каждый subresource нужны свои RBAC-права и отдельный код вызова), и проблема инструментария: в controller-runtime, например, под subresource нужен отдельный writer, и из коробки реализован он только для /status (это и есть r.Status()StatusWriter). Никакого MyCustomSubresourceWriter под произвольный subresource там нет — для записи в свой subresource придётся работать на более низком уровне, через client.SubResource("...").Patch/Update. И это сразу делает такой подход неудобным.

А главное — детализация subresources грубая, на уровне секций объекта (spec vs status), а не на уровне отдельных полей.

Представьте сценарий: пользователь развернул Deployment через kubectl apply -f, в манифесте spec.replicas: 3. Через час подключился HPA и установил spec.replicas: 12 под текущей нагрузкой. Дальше пользователь делает ещё один kubectl apply — например, поменял image контейнера. Что должно произойти с replicas? Если kubectl apply отправит весь манифест обычным Update’ом, он перезапишет replicas обратно на 3, и HPA придётся снова масштабировать.

Subresources в этом сценарии не помогают: и replicas, и image лежат внутри одного spec, в одном эндпоинте. Корректное решение этой ситуации требует механизма с более тонкой гранулярностью — на уровне отдельных полей объекта, а не его секций.

kubectl apply и three-way merge на клиенте

Настало время поговорить про three-way merge — механизм, который лежит в основе декларативного kubectl apply. Снаружи всё выглядит просто: вы пишете манифест, делаете kubectl apply -f, и kubectl приводит объект в кластере к описанному состоянию. Но как именно? Если бы он просто выполнял Update объекта целиком, вы перезаписывали бы всё, что туда положили другие — HPA, контроллеры, мутирующие вебхуки. Если бы дёргал Patch — пришлось бы каждый раз руками решать, какие поля менять, а какие оставлять. Нужен подход, который сочетал бы декларативность манифеста с уважением к чужим изменениям.

Решение, которое придумали для kubectl apply — это three-way merge на клиенте. Идея такая: kubectl хранит в специальной аннотации объекта kubectl.kubernetes.io/last-applied-configuration тот манифест, который вы сами в прошлый раз применили. При следующем apply у kubectl оказывается на руках три версии:

  • last-applied — что вы применяли в прошлый раз (читается из аннотации);

  • current — что сейчас в etcd (читается через Get);

  • new — что вы применяете сейчас (читаете с диска).

Дальше kubectl вычисляет diff между last-applied и new — это и есть «то, что вы хотели изменить». Этот diff применяется к current через Strategic Merge Patch. В итоге:

  • Поля, которые вы добавили или поменяли, — обновляются.

  • Поля, которые вы удалили из манифеста, — удаляются (потому что diff между last-applied и new их «знает»).

  • Поля, которые в last-applied не было, но кто-то добавил в current (другой контроллер, мутирующий вебхук, пользователь через kubectl edit), — остаются нетронутыми, потому что вы их «не трогали».

Этот подход на долгое время стал стандартом работы с кластером: за ним стояла простая и интуитивная модель «вы отвечаете только за то, что сами применили». Большинство современных GitOps-инструментов изначально работали именно поверх client-side apply.

Кстати, про Helm. Тот же паттерн three-way merge используется и в helm, но реализован немного по-другому. Сравниваются те же три версии — «прошлая», «то, что в кластере сейчас» и «новая», — но место для хранения «прошлого применённого состояния» Helm выбрал не в аннотации объекта, а в отдельном Secret типа helm.sh/release.v1 в namespace релиза. В этом секрете лежат сжатые и сериализованные манифесты последнего успешно применённого ревизиона. При helm upgrade он распаковывается, сравнивается с новым рендером и текущим состоянием в кластере, и Helm применяет результат к каждому ресурсу. Достоинство по сравнению с аннотацией — состояние не может быть случайно затёрто посторонним kubectl edit. Недостаток — тот же, что и у kubectl apply: о существовании этого хранилища знает только сам Helm, никакой серверной модели «кто чем владеет» это не даёт.

Но у client-side apply есть фундаментальное слабое место: вся логика живёт на клиенте, а ground truth (last-applied) хранится в аннотации объекта. Из этого вытекает несколько неприятных последствий:

  • Достаточно кому-то сделать Update или Patch мимо kubectl apply, и аннотация last-applied-configuration устаревает. После этого следующий kubectl apply будет работать с устаревшим представлением о «вашем» состоянии.

  • Аннотация — обычная строка в metadata. Она не защищена, её можно случайно стереть или подменить.

  • Если объектом управляет несколько разных клиентов (kubectl apply от разработчика, kubectl apply из CI, helm apply от деплой-системы) — они не знают друг о друге и затирают чужие last-applied.

  • На сервере нет никакой модели «кто чем владеет». Нельзя получить ответ на вопрос «какой клиент отвечает за spec.replicas сегодня».

Логичный следующий шаг — перенести тот же three-way merge на сервер и заменить аннотацию на стороне клиента на честную серверную модель «кто чем владеет». Именно эту задачу и решает следующий механизм, к которому мы переходим.

Server-Side Apply: per-field ownership

Идея Server-Side Apply (SSA) — взять three-way merge, перенести его в apiserver и сверху добавить строгую модель «кто чем владеет». Фича находится в GA с Kubernetes 1.22.

Прежде чем разбирать механику, нужно ввести одно понятие — field manager (или просто менеджер). Это стабильный строковый ярлык, по которому apiserver различает разных клиентов, изменяющих объект. Не объект, не пользователь, не контроллер целиком — именно конкретный клиент в смысле «вот этот процесс, который сейчас отправляет запрос на изменение». Для kubectl apply --server-side это kubectl, для контроллера-оператора — обычно имя самого контроллера (например, my-controller), для горизонтального автоскейлера — что-то вроде horizontal-pod-autoscaler.

Теперь суть SSA одной фразой: apiserver сам хранит, какое поле какому менеджеру принадлежит, и не даёт одному менеджеру перезаписать поле, которым владеет другой.

Протокол

SSA — это HTTP PATCH с Content-Type: application/apply-patch+yaml (есть и JSON-вариант, но YAML канонический). В теле — частичное представление объекта, в котором описано только то, чем владеет клиент:

apiVersion: example.com/v1kind: FooBarmetadata:  name: my-foo  namespace: defaultspec:  replicas: 5

Клиент как бы говорит: «я отвечаю за spec.replicas, хочу, чтобы там было 5». apiserver, получив такой запрос:

  1. Проставляет значения из apply-конфигурации (если нет конфликтов).

  2. Регистрирует ownership — записывает, что «менеджер X теперь владеет spec.replicas».

  3. Убирает поля, которыми этот менеджер раньше владел, если в новой apply-конфигурации их нет.

Последний пункт особенно непривычен: в SSA нельзя «допатчить одно поле, не трогая остальные свои». Apply — всегда декларативное «вот всё, чем я владею прямо сейчас». Если в предыдущий apply вы отправляли {spec: {replicas: 5, image: "foo"}}, а в новом — только {spec: {replicas: 5}}, поле spec.image будет удалено.

managedFields: где живёт ownership

Раз apiserver отслеживает владение, ему нужно где-то это хранить. Хранит он в metadata.managedFields:

metadata:  managedFields:  - manager: my-controller    operation: Apply    apiVersion: example.com/v1    time: "2026-04-23T10:00:00Z"    fieldsType: FieldsV1    fieldsV1:      f:spec:        f:replicas: {}  - manager: kubectl    operation: Update    apiVersion: example.com/v1    time: "2026-04-23T10:01:00Z"    fieldsType: FieldsV1    fieldsV1:      f:metadata:        f:labels:          f:environment: {}      f:spec:        f:image: {}

По полям:

  • manager — идентификатор того, кто писал. Для Apply задаётся параметром fieldManager в запросе. Для Update — apiserver вычисляет сам по User-Agent’у.

  • operation — то, как было сделано изменение. Возможны два значения: Apply (запись пришла через Server-Side Apply) или Update (любая другая запись — обычный Update, JSON Merge Patch, JSON Patch). apiserver запоминает не только что владеет полем, но и каким способом владение было заявлено: между двумя Apply-менеджерами на одно поле возникает явный конфликт, а вот запись в режиме Update ведёт себя по-другому — она просто перезапишет значение, и поле «переедет» в managedFields под именем менеджера-Update’а.

  • fieldsV1 — пути к полям. Каждый ключ с префиксом f:, пустой {} означает «владею этим полем».

При SSA-запросе apiserver смотрит: вы хотите записать spec.replicas. Кто им сейчас владеет?

  • Никто или этот же менеджер → пишем без вопросов.

  • Другой менеджер, и значение в payload отличается от текущего409 Conflict с телом «поле X принадлежит менеджеру Y, попробуйте force=true».

  • Другой менеджер, но новое значение совпадает с тем, что уже в etcd → конфликта нет, обa менеджера становятся совладельцами поля (shared ownership). С точки зрения apiserver — все, кто заявил «я хочу здесь это значение», одинаково правы.

  • Другой менеджер, и запрос с force=true → отбираем владение. Старый менеджер теряет это поле из своих managedFields, новый получает.

То есть SSA — это контракт между клиентами. Управление ownership — per-field, а не per-object. На одном объекте может быть полдюжины менеджеров, и у каждого свой кусок (а часть полей и вовсе общая).

Тут стоит сделать важную оговорку: managedFields существуют у любого объекта, не только у тех, что меняются через SSA. Даже при обычном Update или Patch apiserver сам вычисляет, какие поля вы изменили, и записывает их под вашим менеджером — имя в этом случае берётся из заголовка User-Agent HTTP-запроса (отсюда в managedFields встречаются записи manager: kubectl-client-side-apply или manager: kube-controller-manager с operation: Update).

То же касается и старых объектов, созданных задолго до появления SSA: при первом же изменении (Update / Patch / Apply — на простой Get это не срабатывает) apiserver заполняет managedFields, привязывая всё имеющееся содержимое к условному менеджеру before-first-apply. На живом кластере у любого объекта managedFields почти всегда заполнены — причём чаще всего не одной записью, а несколькими.

Как смотреть managedFields на проде

Посмотреть, кто чем владеет на конкретном объекте, можно прямо из терминала. По умолчанию kubectl начиная с 1.21 прячет managedFields в выводе, поэтому надо явно попросить их показать:

kubectl get deployment my-app -o yaml --show-managed-fields

Через jq удобно вытащить только список менеджеров и их операции:

kubectl get deployment my-app -o json --show-managed-fields \  | jq '.metadata.managedFields[] | {manager, operation, time}'

Это первый шаг в любой отладке «почему контроллер X не может перезаписать поле Y» — открыть managedFields, найти, кто сейчас держит интересующее поле, и решить: договариваться или забирать с force. Также бывает полезно при разборе ситуаций «откуда в моём объекте взялось это поле» — managedFields покажут, какой менеджер его записал и в какое время.

managedFields как источник боли

Раз уж managedFields появляются автоматически на каждом объекте, у них есть и неприятная сторона — точнее, две:

Раздувают объекты. Для сложного CRD со вложенными структурами managedFields легко весят десятки килобайт. В etcd это место, в watch-потоке — трафик.

Мешают читать. kubectl get foo -o yaml показывает простыню managedFields в середине YAML’а. Именно поэтому с 1.21 в kubectl появился флаг --show-managed-fields=false, по умолчанию включённый, — иначе работать с выводом становилось очень некомфортно.

Для типов, в которых вы не инспектируете managedFields из кода (то есть просто пишете и читаете объект, не разбирая ownership руками), managedFields можно срезать прямо на входе в кэш через Transform — мы про это говорили в первой статье:

Transform: func(obj any) (any, error) {    m, err := meta.Accessor(obj)    if err != nil {        return obj, nil    }    m.SetManagedFields(nil)    return obj, nil},

Тут важная техническая деталь: на саму запись через SSA это не повлияет. SSA-запрос не отправляет managedFields в payload — apiserver берёт их из etcd сам. Так что Transform на чтении просто скрывает managedFields в локальной копии в кэше, но не ломает корректность apply.

Не подходит этот трюк только в одном случае — если ваш контроллер активно читает managedFields для принятия решений (например, проверяет, кто владеет полем, прежде чем ставить Force). Тогда срезать их в кэше нельзя, иначе вы будете принимать решения «вслепую».

SSA в controller-runtime

В коде SSA реализован через специальный вид патча — client.Apply:

desired := &examplev1.FooBar{    TypeMeta: metav1.TypeMeta{        APIVersion: "example.com/v1",        Kind:       "FooBar",    },    ObjectMeta: metav1.ObjectMeta{        Name:      "my-foo",        Namespace: "default",    },    Spec: examplev1.FooBarSpec{        Replicas: ptr.To[int32](5),    },}if err := r.Patch(ctx, desired,    client.Apply,    client.FieldOwner("my-controller"),    client.ForceOwnership,); err != nil {    return err}

Три параметра:

  • client.Apply — это не обычный patch, а Server-Side Apply. Внутри превращается в PATCH с application/apply-patch+yaml.

  • client.FieldOwner("my-controller") — обязательный параметр. Это и есть fieldManager. Должен быть стабильным: никогда не меняйте его в релизах, иначе все старые записи в managedFields останутся на старом имени, а новый менеджер ничем не владеет.

  • client.ForceOwnership — если поле уже принадлежит кому-то, отобрать. Без него на конфликте — 409. Включайте осознанно: для status, где контроллер — единственный источник истины, ок; для spec, куда пользователь правит руками, лучше обрабатывать конфликты, а не молча забирать.

Apply configurations

Тут стоит сделать отступление про инструмент, который многие авторы операторов незаслуженно обходят стороной — apply configurations. Для SSA он бывает крайне удобен.

Представьте ситуацию: вы хотите через client.Apply обновить только spec.replicas, и собрали желаемое состояние как обычную Go-структуру:

desired := &examplev1.FooBar{    TypeMeta: metav1.TypeMeta{        APIVersion: "example.com/v1",        Kind:       "FooBar",    },    ObjectMeta: metav1.ObjectMeta{Name: "my-foo", Namespace: "default"},    Spec: examplev1.FooBarSpec{        Replicas: ptr.To[int32](5),    },}

Работает, но есть особенность. У Spec могут быть и другие поля — скажем, не-указательное Threshold int32, которое вы не задавали. В Go zero-value int32 — это 0, и при сериализации в JSON оно превратится в "threshold": 0. С точки зрения apiserver это полноценное заявленное значение, а не «не задано»: он запишет, что ваш менеджер теперь владеет полем threshold со значением 0. Если за threshold отвечал другой контроллер — он получит конфликт, а вам придётся либо обрабатывать его, либо ставить Force (что для чужого поля совсем не то, что вам нужно).

Корень проблемы — в самой Go-структуре. У не-указательного поля просто нет способа отличить «значение не задано» от «значение задано нулём». А SSA эту разницу принципиально различает: одно означает «я не претендую на это поле», другое — «я хочу владеть им со значением 0».

Apply configuration — это сгенерированный отдельный пакет с параллельными типами, в которых каждое поле — указатель, и единственный способ его задать — через явный With*-сеттер. Если WithThreshold(...) не вызывали, поля в payload просто не будет — и apiserver не будет за него бороться.

Для нативных Kubernetes-типов apply-configurations лежат в k8s.io/client-go/applyconfigurations/...:

import appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1"import corev1ac "k8s.io/client-go/applyconfigurations/core/v1"dep := appsv1ac.Deployment("my-deploy", "default").    WithSpec(appsv1ac.DeploymentSpec().        WithReplicas(5).        WithTemplate(corev1ac.PodTemplateSpec().            WithSpec(corev1ac.PodSpec().                WithContainers(                    corev1ac.Container().                        WithName("app").                        WithImage("nginx:1.25"),                ),            ),        ),    )

Для собственных CRD apply-configurations не приезжают сами — их нужно сгенерировать. Делается это утилитой applyconfiguration-gen (часть k8s.io/code-generator). Точные флаги зависят от версии генератора (в свежих релизах используются --input-pkgs и --output-pkg, в более старых — --input-dirs и --output-package), общая идея — указать пакет с типами и пакет, куда складывать сгенерированные apply-configurations:

applyconfiguration-gen \  --input-pkgs=github.com/example/api/v1 \  --output-pkg=github.com/example/api/v1/applyconfiguration \  --go-header-file=hack/boilerplate.go.txt

На выходе появляется отдельный пакет рядом с вашими типами. Для каждого CRD-типа FooBar генерируются:

  • FooBarApplyConfiguration — корневой builder с методами WithSpec, WithStatus, WithLabels и т. д.

  • FooBarSpecApplyConfiguration — builder для spec, у которого все поля — указатели; есть метод-сеттер на каждое поле (WithReplicas, WithImage, …).

  • Аналогичные builder’ы для каждой вложенной структуры.

Использование выглядит примерно так:

import examplev1ac "github.com/example/api/v1/applyconfiguration"desired := examplev1ac.FooBar("my-foo", "default").    WithSpec(examplev1ac.FooBarSpec().        WithReplicas(5),    )if err := r.Apply(ctx, desired, client.FieldOwner("my-controller")); err != nil {    return err}

То есть builder-API на каждом шаге явно следует принципу «не вызвал — не претендую» — ровно то, чего и требует SSA.

В свежих версиях controller-runtime есть прямой метод r.Apply(ctx, desired, client.FieldOwner(...)), который умеет работать с такими applyconfigurations напрямую — без оборачивания в client.Patch(..., client.Apply, ...). Если в вашей версии метода ещё нет — используйте client.Patch со вторым аргументом client.Apply, как в примере выше.

Распространённая ошибка: desired из прочитанного объекта

На этой ошибке спотыкаются почти все, кто впервые садится писать SSA-контроллер. Логика кажется естественной: «прочитал текущий объект, поправил одно поле, отправил обратно через Apply». А вот так делать нельзя:

// как НЕ надоvar obj examplev1.FooBarif err := r.Get(ctx, key, &obj); err != nil {    return err}obj.Spec.Replicas = ptr.To(int32(5))if err := r.Patch(ctx, &obj,    client.Apply,    client.FieldOwner("my-controller"),); err != nil {    return err}

Что произошло: вы отправили в SSA весь прочитанный объект целиком, со всеми полями spec, которые apiserver вам вернул. С точки зрения apiserver вы только что заявили: «всеми этими полями владею я». В managedFields у вашего my-controller теперь записаны и spec.image (который пользователь установил руками), и spec.tolerations (которое поставил admission-webhook), и всё остальное. Следующий kubectl edit от пользователя получит конфликт по spec.image, потому что им теперь «владеет» ваш контроллер.

Для контроллера, который должен отвечать только за spec.replicas, правильный путь — собирать желаемое состояние с нуля и явно указывать только свои поля:

// как надоdesired := examplev1ac.FooBar(key.Name, key.Namespace).    WithSpec(examplev1ac.FooBarSpec().        WithReplicas(5),    )if err := r.Apply(ctx, desired, client.FieldOwner("my-controller")); err != nil {    return err}

Здесь в SSA-payload уйдут ровно metadata.name, metadata.namespace и spec.replicas. Никаких побочных эффектов на чужие поля.

И вот ради этого apply-configurations в принципе и существуют. С обычной Go-структурой из API-пакета вы либо случайно поставите zero-value на поле, которым не собирались владеть, либо забудете обнулить «прочитанное» — и в любом случае получите проблему. apply-configuration в виде builder’а с With*-методами просто не даёт вам ошибиться: что не вызвали — то и не уйдёт в payload.

Прочие особенности SSA

Вкратце, что ещё стоит держать в голове:

  • Два контроллера на одно поле, оба с Force → бесконечный пинг-понг ownership. Лечение: один уступает (не Force, обрабатывает конфликт), либо поля разделяются, либо один вообще не должен писать.

  • Смена FieldOwner в релизе → старые записи в managedFields остаются на старом имени, новый менеджер ничем не владеет. FieldOwner — часть API-контракта вашего оператора, его нельзя менять.

  • kubectl apply (client-side) и SSA на одном объекте дерутся. Если часть вашего тулинга уже ушла на --server-side, а часть — нет, на одном объекте начнётся перетягивание ownership: client-side apply не уважает managedFields и спокойно перезатирает поля, помеченные за чужими SSA-менеджерами. Старайтесь не смешивать на одном и том же объекте; если иначе никак — будьте готовы к конфликтам.

  • dryRun для отладки. Если вы не уверены, как ваш SSA-запрос ляжет на текущий ownership, прогоните его в dry-run: r.Patch(ctx, desired, client.Apply, client.FieldOwner("..."), client.DryRunAll). apiserver вернёт ровно ту же ошибку конфликта, которую вы получили бы при настоящей записи, но ничего не запишет в etcd. Очень удобно для разбора «что бы изменилось».

Best practices

  • Решите, кто владеет чем. Перед тем как писать контроллер, явно сформулируйте: «этот контроллер отвечает за такие-то поля». Остальные не трогайте.

  • Для status обычно достаточно client.MergeFrom без optimistic lock — status правит только ваш контроллер, и apiserver разделит его по subresource.

  • Для spec и общих полей — Server-Side Apply. Особенно если рядом живут другие клиенты, которые могут писать в те же поля.

  • Стабильный FieldOwner. Константа в коде, не меняется в релизах.

  • Apply-configurations, а не голые Go-структуры — чтобы не забрать ownership случайно по zero-value полям.

  • ForceOwnership включайте осознанно: для status — обычно ок, для spec — лучше обрабатывать конфликты, чем молча отбирать.

  • Не смешивайте Update/Patch и Apply на одних и тех же полях. Выбирайте один способ записи и держитесь его.

Итого

Запись в Kubernetes — это слои, накопленные с годами, и важно понимать: новые механизмы не вытеснили старые. На практике каждый из них остаётся актуальным и решает свой класс задач: Update с resourceVersion — для редких атомарных операций (включая аккуратную работу с финализаторами), MergeFrom без resourceVersion — для повседневной записи в свои поля и status, Strategic Merge Patch — для встроенных типов с умным слиянием списков, subresources — для разделения ролей по секциям объекта, kubectl apply без --server-side — для CI-пайплайнов и Helm-релизов, Server-Side Apply — там, где несколько контроллеров правят разные поля одного spec и нужно избежать тихих перезаписей.

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

Что дальше

Третья и завершающая статья цикла будет посвящена жизненному циклу самого объекта — от admission chain в момент kubectl apply до полного удаления через Garbage Collector. В неё войдут:

  • Admission chain: mutating и validating webhooks, как они встраиваются в путь записи и что происходит с объектом до того, как он окажется в etcd.

  • Связь между объектами: ownerReferences, controller: true, blockOwnerDeletion.

  • Удаление: deletionTimestamp, финализаторы, три стратегии каскадного удаления (--cascade=orphan|foreground|background) и как они под капотом реализованы через системные финализаторы.

Это будет финал цикла. Подписывайтесь, чтобы не пропустить.

ссылка на оригинал статьи https://habr.com/ru/articles/1039282/