Client-side Apply (Клиентское применение)
Если вы когда-либо использовали kubectl apply, вы использовали client-side apply (CSA).
Это по-прежнему режим по умолчанию, и большинство инженеров никогда особо не задумывается об этом.
Что же происходит, когда вы запускаете kubectl apply -f deployment.yaml?
Начнём с простого манифеста Deployment:
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
labels:
app: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: nginx:1.26
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
Вопреки названию, client-side apply не является полностью клиентской операцией.
Прежде чем выполнять какие-либо вычисления локально, kubectl делает несколько обращений к API-серверу.
Это можно увидеть, запустив kubectl apply с включённым подробным журналированием.
Но сначала взглянем на kubectl create:
kubectl create -f deployment.yaml -v=8 2>&1 | grep -E "PATCH|POST|GET|Content-Type"
GET /openapi/v3
GET /openapi/v3/apis/apps/v1
POST /apis/apps/v1/namespaces/default/deployments?fieldManager=kubectl-create
Content-Type: application/json
kubectl create прост: сначала получает схему OpenAPI, затем отправляет единственный POST-запрос для создания ресурса.
Если ресурс уже существует, команда завершается с ошибкой.
Схема OpenAPI описывает Kubernetes API: типы ресурсов, поля, признак обязательности и правила работы со списками.
Перед каждой операцией apply или create kubectl получает эту схему от API-сервера по пути /openapi/v3.
Именно так kubectl узнаёт структуру Deployment, Pod или любого другого ресурса.
Теперь удалим Deployment и применим его через apply:
kubectl delete deployment my-app
kubectl apply -f deployment.yaml -v=8 2>&1 | grep -E "PATCH|POST|GET|Content-Type"
GET /openapi/v3
GET /openapi/v3/apis/apps/v1
GET /apis/apps/v1/namespaces/default/deployments/my-app
POST /apis/apps/v1/namespaces/default/deployments?fieldManager=kubectl-client-side-apply
Content-Type: application/json
Первые два вызова идентичны вызовам в kubectl create — это обращения к OpenAPI.
В данном случае kubectl apply тоже отправляет POST, но обратите внимание на дополнительный GET перед ним: kubectl проверяет, существует ли уже ресурс, прежде чем решить, что делать дальше.
Изменим версию образа контейнера и запустим ту же команду ещё раз — теперь ресурс уже существует:
sed 's/nginx:1.26/nginx:1.27/' deployment.yaml > deployment-v2.yaml
kubectl apply -f deployment-v2.yaml -v=8 2>&1 | grep -E "PATCH|POST|GET|Content-Type"
GET /openapi/v3
GET /openapi/v3/apis/apps/v1
GET /apis/apps/v1/namespaces/default/deployments/my-app
PATCH /apis/apps/v1/namespaces/default/deployments/my-app?fieldManager=kubectl-client-side-apply
Content-Type: application/strategic-merge-patch+json
Последовательность та же, но последний вызов теперь стал PATCH.
Поскольку ресурс уже существует, заголовок Content-Type указывает применяемую семантику патча: application/strategic-merge-patch+json.
Strategic Merge Patch (Стратегическое слияние патчей)
В традиционных операциях патчинга Kubernetes использует три типа патчей:
-
application/strategic-merge-patch+json— специфичен для Kubernetes и понимает структуру ресурса через схему OpenAPI. Знает, какие списки следует объединять по ключу, а какие заменять атомарно. Используется kubectl apply (client-side) по умолчанию. Не поддерживается для CRD. -
application/merge-patch+json— проще и не понимает специфичную для Kubernetes семантику списков. Списки всегда заменяются целиком. Работает с CRD и используется сkubectl patch --type=merge. -
application/json-patch+json— соответствует RFC 6902 и оперирует конкретными путями с использованием явных операций (add, remove, replace). Наиболее точный, но и наиболее многословный. Используется сkubectl patch --type=json.
Почему kubectl apply использует application/strategic-merge-patch+json, а не один из других типов патчей?
kubectl должен знать, следует ли список объединять или заменять целиком.
Если вы добавляете контейнер в spec.containers, должен ли он быть добавлен к существующему списку или перезаписать имеющиеся контейнеры?
Например, представьте, что в живом Deployment есть один контейнер:
containers:
- name: app
image: nginx:1.26
А в новом манифесте — другой контейнер:
containers:
- name: sidecar
image: busybox
Должен ли Kubernetes в итоге содержать оба контейнера?
containers:
- name: app
image: nginx:1.26
- name: sidecar
image: busybox
Или новый список должен заменить старый?
containers:
- name: sidecar
image: busybox
Оба поведения правомерны для разных полей.
Для spec.containers Kubernetes объединяет элементы по полю name контейнера.
Для других списков, например tolerations, Kubernetes может заменить список целиком.
Strategic merge patch решает это, считывая аннотации из схемы OpenAPI.
Запросим OpenAPI, чтобы увидеть, какую информацию он предоставляет о контейнерах в Pod.
Можно обратиться к /openapi/v3/apis/apps/v1, чтобы получить схему для группы apps/v1.
Внутри неё типы ресурсов идентифицируются по полному имени типа Go.
Смотрим на io.k8s.api.core.v1.PodSpec, поскольку контейнеры определяются на уровне Pod.
kubectl get --raw /openapi/v3/apis/apps/v1 | jq \
'.components.schemas["io.k8s.api.core.v1.PodSpec"].properties.containers'
{
"description": "List of containers belonging to the pod. Containers cannot be added or removed.",
# truncated
"x-kubernetes-list-map-keys": ["name"],
"x-kubernetes-list-type": "map",
"x-kubernetes-patch-merge-key": "name",
"x-kubernetes-patch-strategy": "merge"
}
В описании сказано, что контейнеры нельзя добавлять или удалять.
Звучит противоречиво, но это относится к работающим Pod, а не к Deployment.
Когда вы обновляете контейнеры в Deployment, контроллер создаёт новые Pod вместо того, чтобы изменять существующие.
Strategic merge patch использует x-kubernetes-patch-merge-key и x-kubernetes-patch-strategy, чтобы определить, как работать с этим списком: стратегия — объединять по полю name.
Контейнер с именем my-app в патче будет объединён с существующим контейнером с именем my-app в кластере.
Для таких списков, как tolerations, патч-аннотации отсутствуют:
kubectl get --raw /openapi/v3/apis/apps/v1 | jq \
'.components.schemas["io.k8s.api.core.v1.PodSpec"].properties.tolerations'
{
"description": "If specified, the pod's tolerations.",
"type": "array",
"items": {
"default": {},
"allOf": [
{ "$ref": "#/components/schemas/io.k8s.api.core.v1.Toleration" }]
},
"x-kubernetes-list-type": "atomic"
}
Без x-kubernetes-patch-merge-key и x-kubernetes-patch-strategy у strategic merge patch нет ключа слияния, поэтому он заменяет список целиком.
Трёхстороннее слияние (Three-way Merge)
Как только kubectl понимает структуру ресурса, он выполняет «трёхстороннее слияние», объединяя три источника информации.
-
Первый источник — аннотация, хранящаяся непосредственно в ресурсе. Эта аннотация называется
kubectl.kubernetes.io/last-applied-configurationи содержит последнюю конфигурацию, применённую через kubectl. -
Второй — текущее состояние ресурса в кластере.
-
Третий — новый манифест, который вы применяете.
Сравнивая аннотацию last-applied с новым манифестом, kubectl определяет, какие поля вы изменили намеренно.
Но это не означает, что все внешние изменения сохраняются.
Если ваш манифест по-прежнему декларирует поле, kubectl воспринимает это значение как желаемое состояние и может создать патч, чтобы вернуть живой объект к этому значению.
Внешние изменения сохраняются только в том случае, когда kubectl решает, что они находятся за пределами полей, которыми он сейчас управляет.
Затем вычисляется strategic merge patch и отправляется на API-сервер.
Это работает достаточно хорошо, пока kubectl является единственным инструментом, управляющим ресурсом, — но в реальном кластере так бывает редко.
На практике типична ситуация, когда кто-то вручную применяет изменение в ресурсе через kubectl, Helm обновляет тот же ресурс при обновлении до нового релиза, а Argo CD согласует тот же манифест в рамках процесса GitOps.
Когда kubectl применяет изменения снова, он может перезаписать изменения, сделанные другими инструментами, без вашего ведома.
А с контроллерами ситуация становится ещё хуже!
Horizontal Pod Autoscaler (HPA) может увеличить количество реплик Deployment при росте нагрузки, но Helm способен снова перезаписать это значение.
Молчаливые перезаписи на практике
Рассмотрим пример, чтобы понять, как это работает.
Применим исходный манифест:
kubectl apply -f deployment.yaml
И проверим результат:
kubectl get deployment my-app -o yaml
Здесь вы найдёте аннотацию last-applied-configuration.
Она хранится в виде JSON-блоба прямо в ресурсе:
kubectl.kubernetes.io/last-applied-configuration: |
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"spec": { "replicas": 3 }
# truncated
}
Теперь смоделируем масштабирование Deployment средствами HPA до 5 реплик.
Можно использовать субресурс /scale с JSON merge patch (--type=merge) — тот же механизм, что применяет HPA:
kubectl patch deployment my-app --subresource='scale' --type='merge' -p '{"spec":{"replicas":5}}'
И снова проверим last-applied-configuration:
kubectl get deployment my-app -o yaml
# truncated output
kubectl.kubernetes.io/last-applied-configuration: |
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"spec": { "replicas": 3 }
# truncated
}
Аннотация по-прежнему показывает replicas: 3, хотя spec.replicas теперь равно 5!
spec:
replicas: 5
Аннотация не была обновлена патчем, потому что kubectl patch отправляет прямой PATCH-запрос на API-сервер, минуя аннотацию last-applied-configuration.
Трёхстороннее слияние не задействуется вовсе — поле просто напрямую изменяется в живом состоянии.
Аннотация обновляется только при использовании kubectl apply — единственной команды, которая и записывает, и читает её.
С точки зрения аннотации last-applied-configuration, spec.replicas по-прежнему равно 3, и именно это значение kubectl apply использует в качестве опорной точки при следующем применении.
Теперь смоделируем повторное применение того же манифеста через kubectl во время обновления:
kubectl apply -f deployment.yaml
kubectl get deployment my-app -o yaml
# truncated output
spec:
replicas: 3
Количество реплик молча вернулось к 3 без каких-либо предупреждений или ошибок.
Патч был перезаписан без малейшего намёка на то, что кто-то другой изменил это поле.
А поскольку аннотация всегда показывала replicas: 3, у kubectl не было способа узнать, что он перезаписывает нечто значимое.
Вы можете подумать, что это редкие граничные случаи.
Нет, это не так.
Подобные ситуации очень распространены на зрелых платформах, и трёхстороннее слияние в client-side apply способно доставить немало проблем.
Как работает Server-side Apply
Добавление флага --server-side к команде kubectl apply переключает режим на server-side apply (SSA).
При server-side apply ответственность смещается: kubectl больше не строит патч локально.
Вместо этого новое желаемое состояние отправляется напрямую на API-сервер, и уже он решает, что именно изменить.
Это желаемое состояние, которое официальная документация Kubernetes называет «полностью указанным намерением» (fully specified intent), включает только те поля и значения, которыми клиент хочет управлять.
Разницу можно сразу увидеть, сравнив HTTP-вызовы с вызовами CSA:
kubectl delete deployment my-app
kubectl apply --server-side -f deployment.yaml -v=8 2>&1 | grep -E "PATCH|POST|GET|Content-Type"
GET /openapi/v3
GET /openapi/v3/apis/apps/v1
PATCH /apis/apps/v1/namespaces/default/deployments/my-app?fieldManager=kubectl
Content-Type: application/apply-patch+yaml
Что бросается в глаза:
-
В логах SSA нет GET-вызова для получения ресурса. Client-side apply должен получить живое состояние, чтобы вычислить diff локально. При SSA diff происходит на сервере, поэтому kubectl он не нужен.
-
Хотя мы выполнили kubectl apply для нового ресурса, отправляется PATCH, а не POST. SSA всегда отправляет PATCH вне зависимости от того, существует ли ресурс. API-сервер сам обрабатывает логику создания или обновления. Client-side apply, как мы видели ранее, отправляет POST для новых ресурсов и PATCH для существующих.
-
Content-Typeотличается: он равенapplication/apply-patch+yaml, а неapplication/strategic-merge-patch+json. Этот тип контента эксклюзивен для SSA: он сообщает API-серверу, что нужно применить манифест с семантикой SSA, отслеживать владение полями и обнаруживать конфликты.
Мы упомянули смещение ответственности, но теперь рассмотрим ещё одно важное изменение: отслеживание владения.
При server-side apply API-сервер записывает, какой менеджер владеет каждым применённым полем.
Эта информация используется API-сервером, чтобы определить, какой инструмент изменил то или иное поле и как применить новое желаемое состояние.
Владение идентифицируется по имени менеджера.
Каждый инструмент называет себя по-своему: Helm использует «helm», Argo CD — «argocd», kubectl — «kubectl» и т.д.
Также можно задать произвольное имя менеджера с помощью флага --field-manager.
Эта информация хранится на API-сервере и используется при каждой последующей операции apply.
Теперь, зная, что API-сервер выполняет слияние, разберёмся, как именно он это делает.
Получив SSA-запрос, API-сервер не слепо применяет манифест.
Он использует библиотеку Go с открытым исходным кодом под названием structured-merge-diff (sigs.k8s.io/structured-merge-diff), разработанную специально для Kubernetes, чтобы сравнить входящий манифест с живым состоянием ресурса.
В отличие от plain-text diff или даже JSON diff, библиотека понимает структуру ресурсов Kubernetes через схему OpenAPI.
Она знает, что spec.containers — это список, объединяемый по имени, что spec.tolerations атомарен, а spec.replicas — скалярное значение.
Благодаря этим знаниям библиотека принимает корректные решения о слиянии, которые не под силу универсальному алгоритму diff.
До SSA подобная структурированная разбивка выполнялась на клиентской стороне в kubectl посредством strategic merge patch.
Библиотека structured-merge-diff перенесла эту логику на сервер, сделав её доступной для любого инструмента, отправляющего SSA-запрос, а не только для kubectl.
Ранее вы видели, как strategic merge patch читает x-kubernetes-patch-merge-key и x-kubernetes-patch-strategy из схемы OpenAPI, чтобы понять, как работать со списками.
SSA использует другой, но дополняющий набор аннотаций из той же схемы.
Посмотрим на список аннотаций ещё раз:
"x-kubernetes-list-map-keys": ["name"], // используется SSA
"x-kubernetes-list-type": "map", // используется SSA
"x-kubernetes-patch-merge-key": "name", // используется CSA
"x-kubernetes-patch-strategy": "merge" // используется CSA
SSA читает x-kubernetes-list-type и x-kubernetes-list-map-keys.
Библиотека structured-merge-diff использует их, чтобы принять то же решение: объединять список контейнеров по полю name.
Для tolerations:
"x-kubernetes-list-type": "atomic" // используется SSA
x-kubernetes-list-type: atomic говорит SSA, что нужно рассматривать весь список как единое целое и заменять его атомарно.
Это то же самое решение, к которому strategic merge patch приходит из-за отсутствия патч-аннотаций.
Всё описанное выше применимо к встроенным ресурсам Kubernetes, где схема OpenAPI хорошо определена и содержит все необходимые аннотации.
Server-side Apply и CRD
Современные кластеры полны Custom Resource Definitions (CRD), и поведение SSA зависит от того, есть ли у CRD схема и как она определена.
Без информации о схеме SSA не может знать, как объединять списки, поэтому принимает наиболее безопасное значение по умолчанию: все списки считаются атомарными.
Весь список заменяется при каждом применении.
Это может привести к потере данных, если вы этого не ожидаете.
Авторы CRD могут управлять поведением слияния SSA, добавив аннотации x-kubernetes-list-type и x-kubernetes-list-map-keys в свою схему.
Эти аннотации те же, что используются во встроенных ресурсах.
Их можно непосредственно проверить на любых установленных CRD.
Попробуем с CRD сертификата cert-manager:
kubectl get crd certificates.cert-manager.io -o yaml | grep -A 3 "x-kubernetes-list"
Большинство списков в этом CRD используют x-kubernetes-list-type: atomic.
Вывод очень объёмный, сосредоточимся на dnsNames:
dnsNames:
description: Requested DNS subject alternative names.
items:
type: string
type: array
x-kubernetes-list-type: atomic # весь список доменных имён заменяется
Список status.conditions устроен иначе:
conditions:
type: array
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
SSA объединяет этот список по полю type.
В контексте данного CRD это означает, что разные контроллеры могут управлять разными типами условий, не перезаписывая друг друга.
Если вы разрабатываете операторы Kubernetes или CRD, обязательно добавьте правильные аннотации в схему.
К счастью, современные фреймворки для операторов, например Kubebuilder, автоматически генерируют эти аннотации, так что популярные операторы обычно им соответствуют.
Если вы используете CRD, которым не управляете, перед тем как полностью полагаться на SSA-аннотации, рекомендуется их проверить:
kubectl get crd <crd-name> -o yaml | grep -A 3 "x-kubernetes-list"
Если вывод пуст, ожидайте атомарного поведения для всех списков в этом CRD.
Слияние на основе владения (Ownership-based Merging)
Получив информацию из схемы OpenAPI, API-сервер готов обработать «полностью указанное намерение» и проверяет каждое поле: если вы хотите изменить поле, которым владеете, изменение разрешается; если поле, которым вы не владеете, отсутствует в намерении, оно остаётся неизменным; если вы пытаетесь изменить поле, которым не владеете, возвращается ошибка конфликта.
Явная ошибка.
Это ключевое отличие SSA от CSA.
Strategic merge patch выполняет слияние по значениям: смотрит, что изменилось, и пытается сохранить нетронутое.
SSA выполняет слияние по владению: смотрит, кто что объявил, и принимает решения соответственно.
Результат — более стабильный и предсказуемый жизненный цикл объектов, особенно в средах, где несколько инструментов управляют одними и теми же ресурсами.
В client-side apply конфликт разрешался молча и автоматически.
Server-side apply, напротив, возвращает ошибку и сообщает о поле, вызвавшем конфликт, и его законном владельце.
Поскольку наш Deployment уже применён через SSA, проверим, что изменилось в живом состоянии:
kubectl get deployment my-app -o yaml
#truncated
metadata:
annotations:
deployment.kubernetes.io/revision: '1'
Сразу заметно, что аннотации last-applied-configuration в метаданных больше нет, потому что SSA на неё не полагается!
Теперь создадим копию того же Deployment с 5 репликами вместо 3:
sed 's/replicas: 3/replicas: 5/' deployment.yaml > deployment-helm.yaml
Используем другой менеджер (укажем «helm» вручную) и попробуем изменить поле, которым уже владеет другой менеджер:
kubectl apply --server-side --field-manager=helm -f deployment-helm.yaml
error: Apply failed with 1 conflict: conflict with "kubectl": .spec.replicas
Please review the fields above--they currently have other managers. Here
are the ways you can resolve this warning:
* If you intend to manage all of these fields, please re-run the apply
command with the `--force-conflicts` flag.
* If you do not intend to manage all of the fields, please edit your
manifest to remove references to the fields that should keep their
current managers.
* You may co-own fields by updating your manifest to match the existing
value; in this case, you'll become the manager if the other manager(s)
stop managing the field (remove it from their configuration).
See https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
Конфликт!
Но так ли это плохо?
Ошибка говорит вам точно, что конфликтует и кто этим владеет.
В этом принципиальное отличие от client-side apply: конфликты явные, а не молчаливые.
Вернёмся к трём предложенным способам разрешения после того, как рассмотрим, где хранятся данные о владении.
Управляемые поля (Managed Fields)
Когда вы применяете ресурс через server-side apply, API-сервер должен где-то хранить информацию о владении.
Для этого предназначены управляемые поля (managed fields) в метаданных ресурса.
Возможно, вы встречали эти «managed fields».
По умолчанию kubectl скрывает managedFields, чтобы вывод был более читаемым.
Чтобы увидеть их, нужно добавить флаг --show-managed-fields к команде kubectl get.
Даже если вы их видели, скорее всего воспринимали как шум и прокручивали мимо.
Но managedFields гораздо ценнее, чем кажется на первый взгляд.
Они фиксируют критическую информацию: какой менеджер владеет каким полем, когда и через какую операцию.
Посмотрим на реальный пример:
kubectl get deployment my-app -o yaml --show-managed-fields
managedFields:
- fieldsV1:
f:spec:
f:replicas: {}
f:selector: {}
f:template: {}
# truncated
manager: kubectl
operation: Apply
time: "2026-05-09T11:47:40Z"
- fieldsV1:
f:status:
f:availableReplicas: {}
f:conditions: {}
f:readyReplicas: {}
# truncated
manager: k3s
operation: Update
subresource: status
time: "2026-01-01T00:00:00Z"
В нашем Deployment два менеджера с разными ролями: kubectl владеет spec, который вы объявили; k3s владеет субресурсом status, который контроллер сообщает обратно.
Поле operation показывает, как каждый менеджер взаимодействовал с ресурсом: «Apply» означает, что поля установлены через SSA, «Update» — что через другой механизм.
Разница кроется в структуре запроса.
Операция server-side apply использует HTTP-метод PATCH со специфичным content-type: application/apply-patch+yaml.
Этот тип контента сообщает API-серверу: «это полностью указанное намерение, пожалуйста, отслеживай моё владение».
«Update» используется для всего остального: POST, PUT или PATCH с любым другим content-type (strategic merge patch, JSON merge patch, JSON patch).
Все они рассматриваются как императивные операции без семантики владения.
Вот почему в нашем предыдущем выводе для субресурса «status» указана операция «update».
В этом кластере контроллер, записанный как k3s, не использует SSA для обновления статуса.
Он использует endpoint субресурса /status — это императивная операция PUT или PATCH.
У некоторых ресурсов Kubernetes есть субресурсы (subresources), которые можно обновлять независимо от родительского ресурса.
Наиболее распространённые — /status и /scale.
Когда менеджер обновляет ресурс через endpoint субресурса, API-сервер фиксирует изменение в managedFields, указывая, что этот менеджер имеет полномочия только над конкретным субресурсом, а не над всем ресурсом.
Чтение FieldsV1
FieldsV1 — это карта владения полями: наличие ключа означает владение, а не присвоение значения.
Карта fieldsV1 использует нотацию, следующую простым правилам:
-
f: — префикс, обозначающий имя поля.
-
k: — префикс, обозначающий ключ карты.
-
v: — префикс, обозначающий конкретное значение. Появляется, когда у списка нет естественного ключевого поля и само значение используется как идентификатор.
-
.: — означает сам объект. Обозначает владение родительским объектом, а не только его полями.
f:spec:
f:replicas: {} # этот менеджер владеет полем replicas
f:containers:
k:{"name":"my-app"}: # идентифицирует, о каком контейнере идёт речь
.: {} # этот менеджер владеет контейнером my-app целиком
f:image: {} # этот менеджер владеет полями image и name контейнера
f:name: {}
Префикс v: не используется в нашем Deployment my-app, поскольку он встречается реже, чем f: и k:.
Типичные примеры для v: — imagePullSecrets и finalizers:
f:imagePullSecrets:
v:{"name":"my-registry-secret"}:
.: {}
f:finalizers:
v:"kubernetes.io/pvc-protection":
.: {}
Что меняется в Managed Fields
Мы уже упоминали, что поле operation у client-side apply отличается от server-side (update против apply).
Но есть и другие отличия.
Первое — имя менеджера.
Даже если в обоих случаях используется kubectl, в одном случае имя будет kubectl-client-side-apply, в другом — kubectl.
Так что имя менеджера уже подсказывает способ применения.
Мы также упоминали, что аннотация last-applied-configuration исчезает после операции server-side apply.
После операции client-side apply аннотация не только присутствует, но и указана в managedFields как поле, которым владеет менеджер.
Меняется и сама карта fieldsV1: server-side apply претендует только на те поля, которые вы добавили в манифест; тогда как client-side apply включает множество полей, которые вы никогда явно не задавали.
Этот последний момент наиболее интересен, поскольку хорошо иллюстрирует концепцию SSA «полностью указанного намерения»: SSA заявляет право только на то, что вы фактически объявили, а не на всё, что Kubernetes добавил в качестве значений по умолчанию.
Сравним managedFields из предыдущего примера с server-side apply с managedFields, созданными операцией client-side apply:
kubectl delete deployment my-app
kubectl apply -f deployment.yaml
kubectl get deployment my-app -o yaml --show-managed-fields
Вывод:
managedFields:
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:annotations:
.: {}
f:kubectl.kubernetes.io/last-applied-configuration: {}
f:labels:
.: {}
f:app: {}
f:spec:
f:progressDeadlineSeconds: {}
f:replicas: {}
f:revisionHistoryLimit: {}
f:strategy:
f:rollingUpdate:
.: {}
f:maxSurge: {}
f:maxUnavailable: {}
f:type: {}
f:template:
f:spec:
f:containers:
k:{"name":"my-app"}:
f:imagePullPolicy: {}
f:terminationMessagePath: {}
f:terminationMessagePolicy: {}
f:dnsPolicy: {}
f:restartPolicy: {}
f:schedulerName: {}
manager: kubectl-client-side-apply
operation: Update
# truncated
Выделенные строки показывают разницу: client-side apply владеет аннотацией last-applied-configuration, рядом стандартных полей Kubernetes и записывает операцию как Update.
Отладка неожиданных изменений
ManagedFields — это не просто метаданные; они также могут служить инструментом отладки.
Мы уже видели, что managedFields не статичны: они меняются каждый раз, когда менеджер применяет, изменяет или перестаёт управлять полями.
Если в кластере что-то изменилось неожиданно, managedFields покажет, какой инструмент это сделал и когда.
Например, возвращаясь к нашему более раннему сценарию: после того как kubectl patch изменил количество реплик до 5, в managedFields появилась новая запись менеджера:
deployment.yaml
- fieldsV1:
f:spec:
f:replicas: {}
manager: kubectl-patch
operation: Update
time: '2026-01-01T19:10:00Z'
Это точно говорит о том, что произошло: kubectl patch взял владение spec.replicas в 19:10.
С помощью managedFields текущее состояние владения всегда видно и показывает, какой инструмент владеет каждым полем.
Обнаружение и разрешение конфликтов
Вы уже видели, как выглядит сообщение об ошибке конфликта.
Теперь разберёмся глубже: что именно вызывает конфликт и какие у вас есть варианты при его возникновении?
Конфликт возникает, когда два менеджера пытаются владеть одним и тем же полем с разными значениями.
API-сервер проверяет managedFields ресурса, и если поле, которое вы пытаетесь установить, уже принадлежит другому менеджеру с другим значением, возвращается ошибка конфликта.
Выведем сообщение о конфликте ещё раз:
error: Apply failed with 1 conflict: conflict with "kubectl": .spec.replicas
Please review the fields above--they currently have other managers. Here
are the ways you can resolve this warning:
* If you intend to manage all of these fields, please re-run the apply
command with the `--force-conflicts` flag.
* If you do not intend to manage all of the fields, please edit your
manifest to remove references to the fields that should keep their
current managers.
* You may co-own fields by updating your manifest to match the existing
value; in this case, you'll become the manager if the other manager(s)
stop managing the field (remove it from their configuration).
See https://kubernetes.io/docs/reference/using-api/server-side-apply/#conflicts
Рассмотрим три возможных решения, которые предлагает сообщение об ошибке.
Первый вариант — забрать владение спорным полем с помощью флага --force-conflicts:
kubectl apply --server-side --field-manager=helm --force-conflicts -f deployment-helm.yaml
Этой командой вы принудительно переносите владение spec.replicas от kubectl к helm, перезаписывая запись в managedFields.
Вариант работает, но очень рискован: у другого менеджера могут быть веские причины владеть этим полем.
Второй вариант — удалить поле из манифеста, если вам не нужно им управлять.
В таком случае вы просто позволяете текущему владельцу продолжать управлять им.
Это наиболее безопасное и чистое решение, когда конфликт случаен.
В этом случае SSA спасает вас от нежелательной перезаписи.
Третий вариант — совместное владение (co-ownership): если вы задаёте то же значение, что и текущий владелец, оба менеджера становятся совладельцами поля.
Конфликта не возникает, поскольку оба менеджера согласны со значением.
Однако не упустите важный нюанс.
При совместном владении поле удаляется только тогда, когда все совладельцы перестают им управлять.
Рассмотрим пример для лучшего понимания совместного владения:
kubectl delete deployment my-app
kubectl apply --server-side -f deployment.yaml
kubectl apply --server-side --field-manager=helm -f deployment.yaml
kubectl get deployment my-app -o yaml --show-managed-fields
Вывод:
managedFields:
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:labels:
f:app: {}
f:spec:
f:replicas: {}
f:selector: {}
manager: helm
operation: Apply
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:labels:
f:app: {}
f:spec:
f:replicas: {}
f:selector: {}
manager: kubectl
operation: Apply
# truncated
И kubectl, и helm владеют f:spec.replicas.
Совместное владение подтверждено.
Удалим replicas из манифеста kubectl:
grep -v "replicas: 3" deployment.yaml > deployment-no-replicas.yaml
cat deployment-no-replicas.yaml
kubectl apply --server-side -f deployment-no-replicas.yaml
kubectl get deployment my-app -o yaml --show-managed-fields
Посмотрим на вывод:
deployment.yaml
managedFields:
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:labels:
f:app: {}
f:spec:
f:replicas: {}
f:selector: {}
manager: helm
operation: Apply
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:labels:
f:app: {}
f:spec:
f:selector: {}
manager: kubectl
operation: Apply
# truncated
Вывод показывает: Helm по-прежнему владеет spec.replicas, тогда как kubectl больше не указывает его в разделе f:spec.
spec:
progressDeadlineSeconds: 600
replicas: 3
Значение по-прежнему равно 3, потому что Helm всё ещё управляет этим полем.
А что произойдёт, если Helm тоже утратит владение spec.replicas?
Проверим!
grep -v "replicas: 3" deployment.yaml > deployment-no-replicas.yaml
kubectl apply --server-side --field-manager=helm -f deployment-no-replicas.yaml
kubectl get deployment my-app -o yaml --show-managed-fields
kubectl get deployment my-app --template='{{.spec.replicas}}{{"\n"}}'
Вывод managedFields:
deployment.yaml
managedFields:
- fieldsV1:
f:metadata:
f:labels:
f:app: {}
f:spec:
f:selector: {}
# truncated
manager: helm
operation: Apply
time: "2026-05-22T20:41:54Z"
# truncated
Раздел f:spec больше не содержит f:replicas — Helm тоже перестал управлять этим полем.
А количество реплик теперь:
replicas
1
Теперь картина полная: когда менеджер отказывается от поля, если другой менеджер всё ещё владеет им, владение остаётся у этого менеджера и значение не меняется.
Если же ни один менеджер не владеет полем, оно либо удаляется, либо сбрасывается к значению по умолчанию, если оно существует.
Важно понимать, что это подразумевает возможность изменения значения — как в нашем примере: количество реплик сбросилось до 1.
Может показаться, что конфликты — это проблема.
На самом деле их стоит воспринимать как возможность.
Конфликт говорит о том, что два инструмента пытаются управлять одним и тем же полем, — а это почти всегда признак неверной конфигурации, которую стоит исправить.
Императивные записи по-прежнему обходят конфликты
Важно понимать, что разрешение конфликтов SSA имеет слепое пятно: оно срабатывает только для запросов с Content-Type: application/apply-patch+yaml.
Императивные операции — патч через strategic merge patch, PUT или обновление субресурса — по-прежнему могут перезаписывать поля SSA, не вызывая никаких ошибок конфликта.
Начнём заново с SSA:
kubectl delete deployment my-app
kubectl apply --server-side -f deployment.yaml
kubectl get deployment my-app -o yaml --show-managed-fields | grep -E "replicas|manager:|operation:"
Вывод:
deployment.yaml
f:replicas: {}
manager: kubectl
operation: Apply
f:replicas: {}
manager: k3s
operation: Update
replicas: 3
replicas: 3
Выделенные строки показывают, что kubectl владеет spec.replicas через SSA.
Императивный патч перезаписывает spec.replicas:
kubectl patch deployment my-app --subresource='scale' --type='merge' -p '{"spec":{"replicas":5}}'
kubectl get deployment my-app -o yaml --show-managed-fields | grep -E "replicas|manager:|operation:"
Вывод:
deployment.yaml
manager: kubectl
operation: Apply
f:replicas: {}
manager: kubectl-patch
operation: Update
f:replicas: {}
manager: k3s
operation: Update
replicas: 5
replicas: 5
Выделенные строки показывают, что kubectl-patch взял владение spec.replicas, а живое значение изменилось до 5 без какого-либо конфликта.
SSA позволяет явно выявлять конфликты между менеджерами, использующими SSA, но не защищает от императивных операций записи.
Это существенное ограничение, которое необходимо учитывать: в реальных кластерах устаревшие контроллеры и инструменты могут продолжать использовать императивные обновления наряду с ресурсами, управляемыми через SSA.
Разрешение конфликтов становится особенно актуальным, когда вы начинаете использовать Helm 4 с server-side apply.
Helm 3 использует client-side apply, а значит, конфликты молчаливы и владение полями неточно.
Helm 4 по умолчанию применяет SSA для новых релизов, превращая многие молчаливые перезаписи в явные конфликты.
Для существующих релизов Helm продолжает использовать прежний метод применения, пока вы явно не перейдёте на SSA.
Пример миграции Helm ниже демонстрирует, что это означает на практике.
Server-side Apply в Helm
Возможно, вы задаётесь вопросом: если у server-side apply столько преимуществ, почему client-side apply по-прежнему остаётся режимом по умолчанию для kubectl apply и других рабочих процессов?
SSA достиг общей доступности (general availability) в Kubernetes 1.22 в 2021 году, однако изменить поведение по умолчанию для существующих рабочих процессов по всей экосистеме без нарушения обратной совместимости было непросто.
Инструментам потребовалось время, чтобы догнать.
Helm, один из наиболее широко используемых инструментов Kubernetes, наконец принял SSA по умолчанию для новых релизов после выхода Helm 4 в ноябре 2025 года.
Это один из наиболее чётких сигналов того, что SSA становится стандартом взаимодействия инструментов с Kubernetes API.
Когда вы запускаете helm install или helm upgrade в Helm 3, Helm вычисляет трёхстороннее слияние между последним развёрнутым релизом, живым состоянием кластера и новым чартом.
Это именно тот механизм, который мы описали в начале статьи, с теми же ограничениями.
Helm 3 хранит состояние релиза в виде Secret в пространстве имён кластера.
Этот Secret содержит полный отрендеренный манифест последнего релиза чарта.
Helm использует его как аналог аннотации last-applied-configuration kubectl — и проблема та же: он отражает только то, что знает Helm, а не то, что другие инструменты сделали с теми же ресурсами.
Установим Deployment my-app с помощью Helm 3:
kubectl delete deployment my-app
mkdir -p my-chart/templates
cat << 'EOF' > my-chart/Chart.yaml
apiVersion: v2
name: my-app
description: A test chart
version: 0.1.0
EOF
cat << 'EOF' > my-chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: nginx:1.26
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
EOF
helm install my-app my-chart
Глядя на managedFields после установки через Helm 3, можно узнать паттерны, которые мы замечали раньше:
kubectl get deployment my-app -o yaml --show-managed-fields
Вывод:
managedFields:
- apiVersion: apps/v1
fieldsType: FieldsV1
fieldsV1:
f:metadata:
f:annotations:
.: {}
f:meta.helm.sh/release-name: {}
f:meta.helm.sh/release-namespace: {}
f:labels:
.: {}
f:app.kubernetes.io/managed-by: {}
f:spec:
f:progressDeadlineSeconds: {}
f:replicas: {}
f:revisionHistoryLimit: {}
f:strategy:
f:rollingUpdate:
.: {}
f:maxSurge: {}
f:maxUnavailable: {}
f:type: {}
f:template:
f:spec:
f:containers:
k:{"name":"my-app"}:
f:imagePullPolicy: {}
f:terminationMessagePath: {}
f:terminationMessagePolicy: {}
f:dnsPolicy: {}
f:restartPolicy: {}
f:schedulerName: {}
manager: helm
operation: Update
time: "2026-01-01T00:00:00Z"
# truncated
Выделенные поля — стандартные поля Kubernetes, которые Helm 3 присвоил себе, хотя чарт их не объявлял.
Выделенная строка operation: Update также подтверждает, что Helm 3 здесь не использует SSA.
Взглянем на Secret, хранящий состояние релиза.
Данные релиза сжаты gzip и дважды закодированы в base64.
Helm сжимает их перед сохранением в Secret.
kubectl get secret sh.helm.release.v1.my-app.v1 -o \
jsonpath='{.data.release}' | base64 -d | base64 -d | gunzip
{
"manifest": "---\n# Source: my-app/templates/deployment.yaml\n# truncated",
"version": 1,
"namespace": "default"
}
В Helm 4 с SSA этот Secret больше не используется для вычисления diff, однако по-прежнему применяется для отката (helm rollback) и просмотра истории (helm history).