
Привет. В прошлой статье мы в основном говорили про чтение — кэш в 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-strategy — patchStrategy:"merge", patchMergeKey:"name", patchStrategy:"replace" и т. п. По этим тегам apiserver понимает:
что списки
containersнужно мержить поэлементно по ключуname: можно прислать только тот контейнер, который вы меняете, — остальные останутся как есть;что
volumeMountsмержатся аналогично — поname;что, например,
argsилиcommandмержить вообще нельзя, и при упоминании в patch’е они заменяются целиком;что у некоторых полей-словарей мерж тоже работает по своим правилам.
Это и есть тот формат, который под капотом использует обычный kubectl apply (без --server-side). В controller-runtime — client.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.replicas ↔ Scale.spec.replicas», «Deployment.status.replicas ↔ Scale.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/v1
kind: FooBar
metadata:
name: my-foo
namespace: default
spec:
replicas: 5
Клиент как бы говорит: «я отвечаю за spec.replicas, хочу, чтобы там было 5». apiserver, получив такой запрос:
Проставляет значения из apply-конфигурации (если нет конфликтов).
Регистрирует ownership — записывает, что «менеджер X теперь владеет
spec.replicas».Убирает поля, которыми этот менеджер раньше владел, если в новой 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.FooBar
if 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) и как они под капотом реализованы через системные финализаторы.
Это будет финал цикла. Подписывайтесь, чтобы не пропустить.





















