Client-side vs Server-side Apply в Kubernetes

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).

© 2026 meganuke