Kubernetes для самых маленьких. Часть 3: API изнутри

Все части серии «Kubernetes для самых маленьких»

Всё, что происходит в кластере, проходит через API: в первой части контроллеры читали из него желаемое состояние, во второй мы видели, что kube-apiserver — единственный, кто говорит с etcd, а остальные компоненты — его клиенты. Сегодня заглянем в сам API: как устроен объект, что такое группы, версии, ресурсы и подресурсы, как выглядят HTTP-запросы kubectl, как два клиента не затирают изменения друг друга, как работает подписка на изменения, как удаляются объекты и чем server-side apply отличается от обычного kubectl apply. Это фундамент для всех остальных частей: любой объект дальше — Pod, Service, PersistentVolumeClaim (заявка на том), Application в Argo CD (инструменте GitOps из заключительной части) — устроен одинаково.

Команды выполняются на рабочей станции на учебном кластере (его установка — в части 4), неймспейс default. Для примеров нужен Deployment web из первой части:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.29
          ports:
            - containerPort: 80

Объект: apiVersion, kind, metadata, spec, status

Посмотрим, что лежит в кластере после kubectl apply -f web.yaml:

$ kubectl get deployment web -o yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{},"labels":{"app":"web"},"name":"web","namespace":"default"},"spec":{"replicas":3,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"image":"nginx:1.29","name":"nginx","ports":[{"containerPort":80}]}]}}}}
  creationTimestamp: "2026-10-21T09:12:40Z"
  generation: 1
  labels:
    app: web
  name: web
  namespace: default
  resourceVersion: "1843217"
  uid: 7d1e9c2a-4b3f-4e8a-9c6d-2f0b1a5e8c34
spec:
  progressDeadlineSeconds: 600
  replicas: 3
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: web
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - image: nginx:1.29
        imagePullPolicy: IfNotPresent
        name: nginx
        ports:
        - containerPort: 80
          protocol: TCP
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
status:
  availableReplicas: 3
  conditions:
  - lastTransitionTime: "2026-10-21T09:12:44Z"
    lastUpdateTime: "2026-10-21T09:12:44Z"
    message: Deployment has minimum availability.
    reason: MinimumReplicasAvailable
    status: "True"
    type: Available
  - lastTransitionTime: "2026-10-21T09:12:40Z"
    lastUpdateTime: "2026-10-21T09:12:44Z"
    message: ReplicaSet "web-7c5ddbdf54" has successfully progressed.
    reason: NewReplicaSetAvailable
    status: "True"
    type: Progressing
  observedGeneration: 1
  readyReplicas: 3
  replicas: 3
  terminatingReplicas: 0
  updatedReplicas: 3

Объект стал вдвое длиннее манифеста. Разберём по частям.

apiVersion и kind — что это за объект: тип Deployment из группы apps версии v1. По этой паре kube-apiserver находит схему, а kubectl — путь, куда отправлять запрос.

metadata — общая для всех объектов часть:

Поле Кто задаёт Смысл
name клиент имя, уникальное в пределах неймспейса и типа
namespace клиент неймспейс; у объектов уровня кластера (Node, Namespace, PersistentVolume) его нет
uid сервер уникальный идентификатор на всё время жизни кластера: объект, удалённый и созданный с тем же именем, — уже другой объект с другим uid
resourceVersion сервер версия этого состояния объекта; меняется при каждой записи
generation сервер номер версии spec; растёт только при изменении желаемого состояния
creationTimestamp сервер время создания
labels клиент метки для выборки: селекторы сервисов, Deployment, kubectl get -l (часть 12)
annotations клиент и контроллеры произвольные данные, по которым не ищут: служебные пометки, конфигурация дополнений
ownerReferences контроллеры кто владелец объекта; на этом держится каскадное удаление
finalizers контроллеры что должно произойти перед удалением объекта
deletionTimestamp сервер объект удаляется и ждёт финализаторов
managedFields сервер какие поля каким клиентом заданы (server-side apply); kubectl get скрывает их, показывает --show-managed-fields

Аннотация kubectl.kubernetes.io/last-applied-configuration — след клиентского kubectl apply, к ней вернёмся ниже.

spec — желаемое состояние. Сервер дополнил его значениями по умолчанию: стратегия обновления RollingUpdate с 25 % на maxSurge и maxUnavailable, imagePullPolicy: IfNotPresent (потому что тег не latest), restartPolicy: Always, 30 секунд на корректное завершение и т. д. Каждое такое значение — решение, принятое за вас, и их стоит знать: в частях про поды и Deployment мы разберём почти все.

status — наблюдаемое состояние, которое пишет контроллер. Обычно в нём есть условия (conditions): список фактов вида «тип — истинно/ложно — причина — сообщение — когда изменилось». У Deployment это Available («реплик достаточно») и Progressing («обновление идёт или завершено»). Условия читают люди, kubectl wait --for=condition=Available и другие контроллеры.

Не у всех объектов есть пара spec/status. У ConfigMap и Secret вместо них data, у Event — поля самого события, у RBAC-ролей — rules. Но apiVersion, kind и metadata есть у всех.


Группы, версии и ресурсы

API разбит на группы — наборы связанных типов, которые развиваются независимо. Типы, появившиеся первыми (Pod, Service, ConfigMap, Secret, Node, Namespace), живут в группе core, у которой нет имени: в apiVersion пишется просто v1. Остальные — в именованных группах: apps (Deployment, StatefulSet, DaemonSet, ReplicaSet), batch (Job, CronJob), networking.k8s.io (Ingress, NetworkPolicy), rbac.authorization.k8s.io, storage.k8s.io и т. д. Свои группы добавляют дополнения: сетевой плагин Calico — crd.projectcalico.org и projectcalico.org, cert-manager (выпуск TLS-сертификатов) — cert-manager.io.

У каждой группы есть версии с понятной стабильностью:

  • alpha (v1alpha1) — экспериментальная, выключена по умолчанию, может исчезнуть без предупреждения;
  • beta (v1beta1) — проверенная, но схема ещё может поменяться; устаревшие бета-версии удаляются через несколько выпусков после объявления;
  • GA, стабильная (v1, v2) — не удаляется, пока жив Kubernetes 1.x.

Один и тот же объект можно читать через разные версии одной группы, если сервер их обслуживает: kube-apiserver хранит объект в etcd в одной версии (storage version) и конвертирует при чтении. Когда версия объявлена устаревшей, сервер добавляет к ответу заголовок Warning, и kubectl печатает его: Warning: <группа>/<версия> <тип> is deprecated in v1.X+, unavailable in v1.Y+. Такие предупреждения в выводе kubectl apply — повод обновить манифесты до того, как обновление кластера их сломает (часть 36).

Kind и ресурс — разные вещи. Kind — тип объекта в теле запроса (Deployment, с заглавной, в единственном числе). Ресурс — имя коллекции в URL (deployments, строчными, во множественном числе). Обычно они соответствуют один к одному. Особый случай — подресурсы: отдельного хранимого объекта у них нет, они читают и меняют часть родительского, а kind у них либо тот же, что у родителя (deployments/status отдаёт Deployment), либо служебный (deployments/scale отдаёт Scale).

 /apis/apps/v1/namespaces/default/deployments/web/scale
  │    │    │             │       │           │   │
  │    │    │             │       │           │   └─ подресурс
  │    │    │             │       │           └─ имя объекта
  │    │    │             │       └─ ресурс: тип во множественном числе
  │    │    │             └─ неймспейс
  │    │    └─ версия
  │    └─ группа
  └─ /apis — именованные группы, /api — группа core
Действие Метод и путь
список подов в неймспейсе GET /api/v1/namespaces/default/pods
список подов во всех неймспейсах GET /api/v1/pods
один под GET /api/v1/namespaces/default/pods/test
создать под POST /api/v1/namespaces/default/pods
заменить объект целиком PUT /apis/apps/v1/namespaces/default/deployments/web
изменить часть объекта PATCH /apis/apps/v1/namespaces/default/deployments/web
удалить DELETE /apis/apps/v1/namespaces/default/deployments/web
подписаться на изменения GET /api/v1/namespaces/default/pods?watch=1
объект уровня кластера GET /api/v1/nodes/k8s-node-1

В терминах авторизации HTTP-методы превращаются в глаголы: get, list, watch, create, update (PUT), patch, delete, deletecollection. Правила RBAC пишутся именно в них (часть 30).

Подресурсы — дополнительные пути у объекта со своими правами и своей семантикой:

Подресурс Зачем
/status запись status отдельно от spec: контроллер меняет только статус и не может случайно затереть желаемое состояние, а пользователь через основной путь не может подделать статус
/scale единый интерфейс «сколько реплик» для Deployment, StatefulSet, ReplicaSet и своих типов; им пользуются kubectl scale и автомасштабирование
/log логи контейнера пода (kubectl logs); kube-apiserver проксирует запрос в kubelet
/exec, /attach, /portforward интерактивные потоки к контейнеру через kube-apiserver и kubelet
/binding назначение пода на узел — им пользуется планировщик
/eviction вежливое выселение пода с учётом PodDisruptionBudget — ограничения на число одновременно недоступных подов приложения (часть 14)
/ephemeralcontainers добавление отладочного контейнера в работающий под (kubectl debug, часть 32)

Права на подресурс выдаются отдельно: роль с pods не даёт pods/exec и pods/log, и это хорошо — читать описание подов и выполнять в них команды — совсем разные полномочия.

Список групп, ресурсов и подресурсов сервер отдаёт сам (это называется discovery), и kubectl строит по нему отображение «тип → путь»:

$ kubectl api-resources --api-group=apps
NAME                  SHORTNAMES   APIVERSION   NAMESPACED   KIND
controllerrevisions                apps/v1      true         ControllerRevision
daemonsets            ds           apps/v1      true         DaemonSet
deployments           deploy       apps/v1      true         Deployment
replicasets           rs           apps/v1      true         ReplicaSet
statefulsets          sts          apps/v1      true         StatefulSet

$ kubectl get --raw /apis/apps/v1 | jq -r '.resources[] | select(.name|startswith("deployments")) | "\(.name)  \(.kind)  \(.verbs|join(","))"'
deployments  Deployment  create,delete,deletecollection,get,list,patch,update,watch
deployments/scale  Scale  get,patch,update
deployments/status  Deployment  get,patch,update

kubectl api-resources без аргументов покажет все типы кластера, включая добавленные Calico; колонка NAMESPACED отвечает, живёт ли тип в неймспейсе.


kubectl explain: схема без документации в браузере

Сервер публикует схему всех типов в формате OpenAPI — стандартном машиночитаемом описании HTTP API (/openapi/v3), и kubectl explain читает её — с описаниями полей той версии, которая работает в кластере:

$ kubectl explain deployment.spec.strategy
GROUP:      apps
KIND:       Deployment
VERSION:    v1

FIELD: strategy <DeploymentStrategy>

DESCRIPTION:
    The deployment strategy to use to replace existing pods with new ones.
    DeploymentStrategy describes how to replace existing pods with new ones.

FIELDS:
  rollingUpdate <RollingUpdateDeployment>
    Rolling update config params. Present only if DeploymentStrategyType =
    RollingUpdate.

  type  <string>
  enum: Recreate, RollingUpdate
    Type of deployment. Can be "Recreate" or "RollingUpdate". Default is
    RollingUpdate.

    Possible enum values:
     - `"Recreate"` Kill all existing pods before creating new ones.
     - `"RollingUpdate"` Replace the old ReplicaSets by new one using rolling
    update i.e gradually scale down the old ReplicaSets and scale up the new
    one.

Полезные варианты: kubectl explain pod.spec --recursive — дерево всех полей без описаний, kubectl explain ippools --api-version=crd.projectcalico.org/v1 — схема типа из дополнения (если автор CRD — CustomResourceDefinition, описания своего типа объектов, о котором в части 29, — задал схему). Это быстрее и точнее поиска по интернету: документация в сети может быть для другой версии, а explain показывает именно ваш кластер.


Как kubectl разговаривает с сервером

Ключ -v (уровень подробности журнала) показывает запросы kubectl: -v=6 — метод, URL и код ответа, -v=8 — ещё заголовки и начало тела, -v=9 — тело целиком.

$ kubectl get deployment web -v=8
I1021 12:20:31.101874   48211 loader.go:407] Config loaded from file:  /home/admin/.kube/config
I1021 12:20:31.102331   48211 envvar.go:195] "Feature gate default state" feature="InformerResourceVersion" enabled=true
I1021 12:20:31.102331   48211 envvar.go:195] "Feature gate default state" feature="WatchListClient" enabled=true
...
I1021 12:20:31.104876   48211 helper.go:105] "Request Body" body=""
I1021 12:20:31.104876   48211 round_trippers.go:527] "Request" verb="GET" url="https://k8s-api.example.com:6443/apis/apps/v1/namespaces/default/deployments/web" headers=<
    Accept: application/json;as=Table;v=v1;g=meta.k8s.io,application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/json
    User-Agent: kubectl/v1.36.5 (linux/amd64) kubernetes/4c1b3e8
 >
I1021 12:20:31.118302   48211 round_trippers.go:632] "Response" status="200 OK" headers=<
    Audit-Id: 9b2f6d1e-3c4a-4f5b-8e7d-0a1c2b3d4e5f
    Cache-Control: no-cache, private
    Content-Type: application/json
    Date: Wed, 21 Oct 2026 09:20:31 GMT
    X-Kubernetes-Pf-Flowschema-Uid: 0d4e2c1b-7a6f-4b3e-9c8d-1e2f3a4b5c6d
    X-Kubernetes-Pf-Prioritylevel-Uid: 5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d
 > milliseconds=13
I1021 12:20:31.118911   48211 helper.go:105] "Response Body" body="{\"kind\":\"Table\",\"apiVersion\":\"meta.k8s.io/v1\",\"metadata\":{\"resourceVersion\":\"1843590\"},\"columnDefinitions\":[{\"name\":\"Name\",\"type\":\"string\",\"format\":\"name\",\"description\":\"Name must be unique within a namespace. ... [truncated 3539 chars]"
NAME   READY   UP-TO-DATE   AVAILABLE   AGE
web    3/3     3            3           8m

Несколько наблюдений:

  • Строки Feature gate default state — состояние функциональных флагов (feature gates) самой клиентской библиотеки client-go; к запросу они отношения не имеют, поэтому здесь оставлены две из них, а остальные, как и середина длинного тела ответа, заменены многоточием.
  • kubectl get просит у сервера не объект, а таблицу (Accept: application/json;as=Table): колонки READY, UP-TO-DATE вычисляет сервер, поэтому kubectl старой версии правильно показывает типы, о которых ничего не знает. С -o yaml или -o json заголовок будет просто application/json.
  • Audit-Id — идентификатор запроса в журнале аудита kube-apiserver, если аудит включён.
  • X-Kubernetes-Pf-* — в какую группу и уровень приоритета API Priority and Fairness (механизма, который делит пропускную способность сервера между клиентами) попал запрос.

Произвольный путь API можно запросить через kubectl get --raw — с теми же учётными данными, что и обычные команды:

$ kubectl get --raw /api
{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0","serverAddress":"192.168.10.17:6443"}]}

$ kubectl get --raw /apis/apps/v1/namespaces/default/deployments/web/scale | jq .
{
  "kind": "Scale",
  "apiVersion": "autoscaling/v1",
  "metadata": {
    "name": "web",
    "namespace": "default",
    "uid": "7d1e9c2a-4b3f-4e8a-9c6d-2f0b1a5e8c34",
    "resourceVersion": "1843217",
    "creationTimestamp": "2026-10-21T09:12:40Z"
  },
  "spec": {
    "replicas": 3
  },
  "status": {
    "replicas": 3,
    "selector": "app=web"
  }
}

Подресурс /scale вернул объект другого kind — Scale из группы autoscaling — с единственным полем в spec. Так автомасштабированию не нужно знать устройство Deployment. При этом uid и resourceVersion у него те же, что у самого Deployment: Scale нигде не хранится, сервер собирает его из Deployment при каждом запросе, а запись в spec.replicas объекта Scale меняет spec.replicas Deployment.

Для запросов, которые kubectl не умеет (например, с методом PUT), удобен kubectl proxy: он поднимает на рабочей станции локальный HTTP-порт и проксирует его в API с вашими учётными данными. Им мы воспользуемся ниже. Прокси слушает только 127.0.0.1, и открывать его на другие адреса нельзя: любой, кто достучится до порта, получит ваши права в кластере.


Путь запроса внутри kube-apiserver

 запрос по HTTPS
   │
   ▼
 аутентификация: кто это? ─────────────────────────► 401 Unauthorized
   │
   ▼
 авторизация (Node, RBAC): можно ли? ──────────────► 403 Forbidden
   │
   ▼
 mutating admission: дополнить или изменить объект
   │
   ▼
 проверка схемы и полей ───────────────────────────► 422 Invalid
   │
   ▼
 validating admission: принять или отклонить ──────► отказ с сообщением
   │
   ▼
 запись в etcd со сверкой resourceVersion ─────────► 409 Conflict
   │
   ▼
 ответ 200 OK или 201 Created, событие подписчикам

Admission — модули, которые видят объект после авторизации и до записи. Mutating-модули могут его менять, validating — только принять или отклонить. Источники:

  • встроенные плагины, включённые в kube-apiserver: NamespaceLifecycle (нельзя создавать объекты в удаляемом неймспейсе), LimitRanger и ResourceQuota (значения по умолчанию и квоты, часть 11), ServiceAccount (учётная запись и токен пода), DefaultStorageClass, NodeRestriction (kubelet меняет только свой узел), PodSecurity (стандарты безопасности подов, часть 31) и другие;
  • политики на языке CEL (Common Expression Language — небольшой язык выражений, который kube-apiserver выполняет сам): ValidatingAdmissionPolicy и, начиная с 1.36 в стабильной версии, MutatingAdmissionPolicy. Не требуют отдельного сервиса;
  • вебхуки: ValidatingWebhookConfiguration и MutatingWebhookConfiguration — kube-apiserver отправляет объект по HTTPS во внешний сервис (обычно под в кластере) и ждёт решения. Так работают cert-manager, Calico, движки политик вроде Kyverno (проверка и дополнение объектов по правилам, описанным на YAML).

У вебхуков есть цена: если сервис вебхука недоступен, а в его конфигурации стоит failurePolicy: Fail, kube-apiserver отклоняет все запросы, которые вебхук должен проверять. Сломанный вебхук на подах — и в кластере нельзя создать ни один под, включая под самого вебхука. Поэтому вебхуки ограничивают селекторами неймспейсов (и исключают kube-system), а там, где можно, заменяют политиками CEL: их выполняет сам kube-apiserver, и отказывать там нечему.

Пример полезной политики — запрет образов без тега или с тегом latest. Тег latest не говорит, какая версия запущена, и при перезапуске пода на другом узле может приехать другая сборка; почему правильнее теги с версией, а ещё лучше дайджесты — в части 6.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-image-tag
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >-
        object.spec.containers.all(c,
          c.image.contains(':') && !c.image.endsWith(':latest'))
      message: "образ должен быть указан с тегом версии, не latest"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: require-image-tag
spec:
  policyName: require-image-tag
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels:
        kubernetes.io/metadata.name: default

Политика описывает правило, привязка — где и как его применять: Deny отклоняет запрос, Warn только предупреждает клиента, Audit пишет в журнал аудита. Начинать новую политику разумно с Warn, чтобы увидеть, что она сломала бы, и лишь потом переходить к Deny. Проверка в примере упрощённая (она не смотрит на init-контейнеры и считает тегом двоеточие в адресе реестра с портом), но идею показывает:

$ kubectl apply -f require-image-tag.yaml
validatingadmissionpolicy.admissionregistration.k8s.io/require-image-tag created
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-image-tag created

$ kubectl run bad --image=nginx
The pods "bad" is invalid: : ValidatingAdmissionPolicy 'require-image-tag' with binding 'require-image-tag' denied request: образ должен быть указан с тегом версии, не latest

$ kubectl run good --image=nginx:1.29 --dry-run=server
pod/good created (server dry run)

--dry-run=server прогоняет запрос через весь путь — аутентификацию, авторизацию, admission, проверку схемы — но не записывает в etcd. Это лучший способ проверить манифест: --dry-run=client только печатает объект и ничего не проверяет на сервере.


resourceVersion и оптимистичная блокировка

Два администратора одновременно открыли один объект, каждый поменял своё поле и сохранил. Без защиты второй затрёт изменение первого. Kubernetes решает это оптимистичной блокировкой: никто ничего не блокирует, но каждая запись проверяет, что объект не изменился с момента чтения.

Инструмент — metadata.resourceVersion. Это непрозрачная строка: сейчас она совпадает с номером ревизии etcd, на котором объект последний раз записан, но клиенты не должны её разбирать или сравнивать на «больше-меньше» — только передавать обратно. Запрос PUT с объектом, в котором указан resourceVersion, выполнится, только если в etcd лежит ровно эта версия; иначе — ответ 409 Conflict.

Проверим. Создадим ConfigMap, прочитаем его в файл, изменим объект «из другого окна» и попытаемся записать устаревшую копию:

$ kubectl create configmap demo --from-literal=color=blue
configmap/demo created

$ kubectl proxy --port=8001 &
Starting to serve on 127.0.0.1:8001

$ curl -s localhost:8001/api/v1/namespaces/default/configmaps/demo > demo.json
$ jq .metadata.resourceVersion demo.json
"1843702"

$ kubectl patch configmap demo -p '{"data":{"size":"large"}}'
configmap/demo patched

$ jq '.data.color = "green"' demo.json | curl -s -X PUT -H 'Content-Type: application/json' \
    --data-binary @- localhost:8001/api/v1/namespaces/default/configmaps/demo | jq '{code, reason, message}'
{
  "code": 409,
  "reason": "Conflict",
  "message": "Operation cannot be fulfilled on configmaps \"demo\": the object has been modified; please apply your changes to the latest version and try again"
}

Правильная реакция клиента на 409 — прочитать объект заново, повторно применить своё изменение и попробовать ещё раз. Контроллеры делают это постоянно, и строки the object has been modified в их журналах — норма, а не ошибка. Тот же конфликт получит kubectl replace -f с файлом, в котором остался старый resourceVersion, а kubectl edit при конфликте сохраняет вашу правку во временный файл и предлагает повторить.

Не всякая запись проверяет версию. PATCH без resourceVersion в теле применяется к текущему объекту, каким бы он ни был: kubectl patch выше прошёл без конфликта. Это удобно, когда меняется одно поле, и опасно, когда изменение зависит от прочитанного значения (например, «добавить элемент в список, если его нет»): для таких случаев resourceVersion в патч добавляют явно.


Watch: подписка на изменения

Контроллер не опрашивает API, он подписывается. Запрос — тот же GET списка с параметром watch=1 и версией, с которой начинать:

$ curl -s localhost:8001/api/v1/namespaces/default/pods | jq .metadata.resourceVersion
"1843755"

$ curl -sN 'localhost:8001/api/v1/namespaces/default/pods?watch=1&resourceVersion=1843755&allowWatchBookmarks=true' \
    | jq -c '{type, name: .object.metadata.name, rv: .object.metadata.resourceVersion, phase: .object.status.phase}'
{"type":"ADDED","name":"test","rv":"1843760","phase":"Pending"}
{"type":"MODIFIED","name":"test","rv":"1843761","phase":"Pending"}
{"type":"MODIFIED","name":"test","rv":"1843763","phase":"Pending"}
{"type":"MODIFIED","name":"test","rv":"1843768","phase":"Pending"}
{"type":"MODIFIED","name":"test","rv":"1843770","phase":"Pending"}
{"type":"MODIFIED","name":"test","rv":"1843774","phase":"Running"}
{"type":"BOOKMARK","name":null,"rv":"1844105","phase":null}
{"type":"MODIFIED","name":"test","rv":"1844228","phase":"Running"}
{"type":"MODIFIED","name":"test","rv":"1844230","phase":"Running"}
{"type":"MODIFIED","name":"test","rv":"1844231","phase":"Running"}
{"type":"MODIFIED","name":"test","rv":"1844235","phase":"Succeeded"}
{"type":"MODIFIED","name":"test","rv":"1844238","phase":"Succeeded"}
{"type":"DELETED","name":"test","rv":"1844239","phase":"Succeeded"}

В соседнем терминале в это время выполнялись kubectl run test --image=nginx:1.29 и kubectl delete pod test. Ответ — бесконечный поток JSON-объектов, по одному на событие: ADDED, MODIFIED, DELETED с полным объектом после изменения. Первый MODIFIED — планировщик назначил узел. Следующие три — kubelet по шагам обновляет статус пода: условия, адрес, состояние контейнера, пока скачивается образ; пятый — контейнер запущен, фаза Running. При удалении первый MODIFIED — под получил deletionTimestamp; дальше kubelet останавливает контейнер и отчитывается о каждом шаге: nginx штатно завершился по SIGTERM, и фаза стала Succeeded; последним изменением kubelet подтверждает, что контейнеров больше нет, и только после этого приходит DELETED — запись удалена.

Схема «list, потом watch с версии списка» гарантирует, что между чтением и подпиской ничего не потеряется. Но у неё есть предел: kube-apiserver помнит историю изменений ограниченное время (порядка нескольких минут), а etcd периодически уплотняет (compaction) старые ревизии. Клиент, который переподключается с давно устаревшей версией, получает:

{"type":"ERROR","object":{"kind":"Status","apiVersion":"v1","metadata":{},"status":"Failure","message":"too old resource version: 1200000 (1843500)","reason":"Expired","code":410}}

410 Gone означает: «с этого места продолжить нельзя, перечитай список заново». Перечитывание списка в большом кластере дорого, и от этого защищают закладки (bookmarks). Если клиент попросил allowWatchBookmarks=true, сервер время от времени присылает событие BOOKMARK — в нём нет объекта, только свежий resourceVersion: «всё до этой версии ты уже получил». В примере выше закладка пришла, когда в неймспейсе ничего не происходило, но кластер жил дальше. Переподключившись после обрыва, клиент продолжит с версии закладки, а не со старой версии последнего события своего неймспейса, и с большой вероятностью не получит 410. Гарантий сервер не даёт: закладки — оптимизация, а клиент должен уметь и перечитывать список.

Для первого чтения больших списков есть ещё один механизм — потоковый список: watch с sendInitialEvents=true присылает текущее состояние как серию событий ADDED, а затем закладку, после которой начинаются обычные изменения. Так сервер не собирает в памяти огромный ответ на list. Механизм в бета-статусе и включён по умолчанию; библиотеки клиентов используют его сами.

Сам kubectl тоже умеет подписываться: kubectl get pods -w — это watch, а kubectl get pods -w --output-watch-events показывает и типы событий.


generation и observedGeneration

resourceVersion меняется при любой записи — в том числе когда контроллер обновил status или кто-то поставил метку. Чтобы отличать изменения желаемого состояния, у объектов с подресурсом status есть metadata.generation: сервер увеличивает его, только когда меняется spec. Контроллер, обработав очередную версию spec, пишет её номер в status.observedGeneration.

$ kubectl get deploy web -o jsonpath='{.metadata.generation} {.status.observedGeneration}{"\n"}'
1 1

$ kubectl label deployment web tier=frontend
deployment.apps/web labeled
$ kubectl get deploy web -o jsonpath='{.metadata.generation} {.status.observedGeneration}{"\n"}'
1 1

$ kubectl patch deployment web -p '{"spec":{"replicas":4}}'
deployment.apps/web patched
$ kubectl get deploy web -o jsonpath='{.metadata.generation} {.status.observedGeneration}{"\n"}'
2 2

Метка — изменение метаданных, generation не тронут. Число реплик — изменение spec, generation стал 2, и контроллер почти сразу подтвердил, что видел его. Пока observedGeneration < generation, статус описывает старую версию желаемого состояния, и верить ему нельзя: «3/3 готово» может означать, что контроллер ещё не начал выкатывать новый образ. Именно это проверяет kubectl rollout status и любые скрипты выкатки, которые хотят дождаться результата, а не увидеть статус прошлого успеха.


Удаление: finalizers и ownerReferences

Удаление в Kubernetes — не мгновенное стирание записи, а процесс.

Finalizers. Если в metadata.finalizers объекта есть хотя бы одна строка, DELETE не удаляет его, а только ставит deletionTimestamp. Объект виден и помечен как удаляемый. Менять его по-прежнему можно, но добавить новый финализатор уже нельзя — только убрать существующие; сервер ответит metadata.finalizers: Forbidden: no new finalizers can be added if the object is being deleted. Каждый финализатор — обещание какого-то контроллера сделать работу перед удалением: например, kubernetes.io/pvc-protection не даёт удалить заявку на том, пока её использует под, а финализатор контроллера внешнего балансировщика сначала удаляет балансировщик в облаке. Сделав работу, контроллер убирает свою строку; когда список пуст, kube-apiserver удаляет объект.

$ kubectl patch configmap demo --type=merge -p '{"metadata":{"finalizers":["example.com/cleanup"]}}'
configmap/demo patched

$ kubectl delete configmap demo --wait=false
configmap "demo" deleted from default namespace

$ kubectl get configmap demo -o jsonpath='{.metadata.deletionTimestamp} {.metadata.finalizers}{"\n"}'
2026-10-21T10:05:12Z ["example.com/cleanup"]

$ kubectl patch configmap demo --type=json -p '[{"op":"remove","path":"/metadata/finalizers"}]'
configmap/demo patched

$ kubectl get configmap demo
Error from server (NotFound): configmaps "demo" not found

kubectl честно написал deleted, хотя объект продолжал существовать: удаление запрошено, а не выполнено. Без --wait=false команда ждала бы бесконечно — финализатор example.com/cleanup никто не обрабатывает. Это классическая картина «неймспейс висит в Terminating»: какой-то объект внутри держит финализатор контроллера, которого уже нет (удалили оператор раньше его ресурсов). Снимать финализатор руками, как в примере, — крайняя мера: сначала нужно понять, что контроллер должен был сделать, и сделать это самому, иначе останутся висеть ресурсы снаружи кластера.

ownerReferences. Объекты, созданные контроллером, знают своего владельца:

$ kubectl get rs -l app=web -o jsonpath='{.items[0].metadata.ownerReferences}' | jq .
[
  {
    "apiVersion": "apps/v1",
    "blockOwnerDeletion": true,
    "controller": true,
    "kind": "Deployment",
    "name": "web",
    "uid": "7d1e9c2a-4b3f-4e8a-9c6d-2f0b1a5e8c34"
  }
]

Ссылка указывает на uid, а не только на имя: новый Deployment с тем же именем — другой владелец. controller: true — этот владелец управляет объектом (у объекта может быть только один такой). Цепочка Deployment → ReplicaSet → Pod целиком построена на этих ссылках, и сборщик мусора (garbage collector в kube-controller-manager) удаляет объекты, владельца которых больше нет.

Как удалять зависимых, задаёт propagationPolicy запроса, в kubectl — ключ --cascade:

--cascade Политика Что происходит
background (по умолчанию) Background владелец удаляется сразу, зависимых сборщик мусора удаляет следом
foreground Foreground владелец получает финализатор foregroundDeletion и ждёт, пока удалятся все зависимые с blockOwnerDeletion: true
orphan Orphan владелец удаляется, у зависимых убирается ссылка на него — они остаются «сиротами»

orphan полезен, когда нужно пересоздать объект-владелец, не трогая работающие поды — например, поменять у Deployment неизменяемое поле selector:

$ kubectl delete deployment web --cascade=orphan
deployment.apps "web" deleted from default namespace

$ kubectl get rs,pods -l app=web
NAME                             DESIRED   CURRENT   READY   AGE
replicaset.apps/web-7c5ddbdf54   4         4         4       52m

NAME                       READY   STATUS    RESTARTS   AGE
pod/web-7c5ddbdf54-4xkqp   1/1     Running   0          52m
...

$ kubectl apply -f web.yaml
deployment.apps/web created

Новый Deployment найдёт ReplicaSet с подходящим селектором и тем же шаблоном и усыновит его — пропишет себя владельцем, не перезапуская поды, и приведёт число реплик к трём из манифеста.


apply: клиентский и серверный

Способов изменить объект три: PUT (заменить целиком, с проверкой resourceVersion), PATCH (изменить часть) и apply. Патчи бывают нескольких видов, тип задаётся заголовком Content-Type:

Тип Content-Type Как работает
JSON Patch application/json-patch+json список операций add, remove, replace по путям, RFC 6902
JSON Merge Patch application/merge-patch+json частичный объект, поля сливаются, списки заменяются целиком, RFC 7386
Strategic Merge Patch application/strategic-merge-patch+json как merge, но списки сливаются по ключу (контейнеры — по name); только для встроенных типов
Apply application/apply-patch+yaml server-side apply

Клиентский apply (kubectl apply по умолчанию) работает в kubectl. Он хранит последний применённый манифест в аннотации last-applied-configuration и при следующем запуске делает трёхстороннее сравнение: прошлый манифест, новый манифест и текущий объект. Поля, которые были в прошлом манифесте и исчезли из нового, удаляются; поля, которых в манифестах никогда не было (например, проставленные контроллером), не трогаются. Отсюда его ограничения: знание о том, «чьё» поле, есть только у одного клиента и только в аннотации; аннотация с большим манифестом упирается в предел размера аннотаций (256 КиБ) — поэтому большие наборы CRD, например у Calico, ставят через kubectl create, а не apply; два инструмента, применяющих один объект, ничего не знают друг о друге.

Server-side apply (SSA) переносит логику на сервер. Клиент отправляет PATCH с типом application/apply-patch+yaml и именем менеджера полей (field manager), а сервер ведёт для каждого объекта учёт: какие поля каким менеджером заданы. Этот учёт — metadata.managedFields. Чтобы не смешивать режимы на одном объекте, начнём с чистого листа:

$ kubectl delete deployment web
deployment.apps "web" deleted from default namespace

$ kubectl apply --server-side --field-manager=team-a -f web.yaml
deployment.apps/web serverside-applied

$ kubectl get deployment web --show-managed-fields -o yaml | yq '.metadata.managedFields'
- apiVersion: apps/v1
  fieldsType: FieldsV1
  fieldsV1:
    f:metadata:
      f:labels:
        f:app: {}
    f:spec:
      f:replicas: {}
      f:selector: {}
      f:template:
        f:metadata:
          f:labels:
            f:app: {}
        f:spec:
          f:containers:
            k:{"name":"nginx"}:
              .: {}
              f:image: {}
              f:name: {}
              f:ports:
                k:{"containerPort":80,"protocol":"TCP"}:
                  .: {}
                  f:containerPort: {}
  manager: team-a
  operation: Apply
  time: "2026-10-21T10:21:07Z"
- apiVersion: apps/v1
  fieldsType: FieldsV1
  fieldsV1:
    f:metadata:
      f:annotations:
        .: {}
        f:deployment.kubernetes.io/revision: {}
    f:status:
      f:availableReplicas: {}
      f:conditions:
        .: {}
        k:{"type":"Available"}:
          .: {}
          f:lastTransitionTime: {}
          f:lastUpdateTime: {}
          f:message: {}
          f:reason: {}
          f:status: {}
          f:type: {}
        k:{"type":"Progressing"}:
          .: {}
          f:lastTransitionTime: {}
          f:lastUpdateTime: {}
          f:message: {}
          f:reason: {}
          f:status: {}
          f:type: {}
      f:observedGeneration: {}
      f:readyReplicas: {}
      f:replicas: {}
      f:terminatingReplicas: {}
      f:updatedReplicas: {}
  manager: kube-controller-manager
  operation: Update
  subresource: status
  time: "2026-10-21T10:21:09Z"

Менеджер team-a владеет полями из своего манифеста, kube-controller-manager — аннотацией ревизии и статусом: обе записи он сделал через подресурс status, поэтому запись о нём одна, с subresource: status. Элементы списков адресуются по ключу — контейнеры по имени (k:{"name":"nginx"}), условия по типу (k:{"type":"Available"}), — поэтому два менеджера могут владеть разными контейнерами одного пода.

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

# replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 5
$ kubectl apply --server-side --field-manager=team-b -f replicas.yaml
error: Apply failed with 1 conflict: conflict with "team-a": .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), убрать его из своего манифеста или согласиться с текущим значением и стать совладельцем. Это и есть главное преимущество SSA: конфликты между инструментами видны, а не обнаруживаются через неделю как «кто-то откатил мою настройку».

Типичный реальный конфликт — автомасштабирование. Если числом реплик управляет HorizontalPodAutoscaler (часть 33), поле replicas в манифесте Deployment оставлять нельзя: при каждом apply оно будет возвращать число реплик к значению из файла. Правильно — удалить replicas из манифеста и оставить поле автомасштабированию.

Отличие удаления полей. При SSA поле, которое менеджер перестал указывать в манифесте, удаляется, только если больше никто им не владеет; при клиентском apply — по аннотации. Переключиться с клиентского на серверный можно в любой момент: kubectl apply --server-side на объекте, созданном клиентским apply, переносит владение полями из аннотации на менеджер kubectl. Обратный переход и смешивание двух режимов на одном объекте ведут к путанице, поэтому для объектов выбирают один способ. Для новых проектов разумно сразу использовать --server-side: на нём по умолчанию работают Flux (GitOps-контроллер, который применяет манифесты из Git) и Helm 4 (менеджер пакетов манифестов, часть 28), а в Argo CD он включается опцией синхронизации.

kubectl diff перед apply показывает, что изменится. Он отправляет объект на сервер с dryRun=All — со всеми значениями по умолчанию, мутациями admission и учётом владения полями — и сравнивает результат с текущим объектом. Код выхода 0 — различий нет, 1 — есть, больше 1 — ошибка; это удобно в CI (системе автоматической сборки и проверки). С --server-side diff учитывает правила SSA. Программу сравнения можно заменить переменной KUBECTL_EXTERNAL_DIFF, например на diff -u --color.


Смотрим руками

Найдём наш Deployment в etcd. Сначала — служебные поля записи:

$ kubectl exec -n kube-system etcd-k8s-master-1 -- etcdctl \
    --endpoints=https://127.0.0.1:2379 \
    --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
    --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
    get /registry/deployments/default/web -w json | jq '.kvs[0] | {create_revision, mod_revision, version}'
{
  "create_revision": 1844489,
  "mod_revision": 1844517,
  "version": 6
}

$ kubectl get deployment web -o jsonpath='{.metadata.resourceVersion}{"\n"}'
1844517

mod_revision — ревизия etcd последней записи ключа — совпадает с resourceVersion объекта, version — сколько раз ключ записывали с момента создания. Само значение — объект в Protobuf (двоичном формате сериализации), его удобно просмотреть через strings (здесь и дальше ключи с сертификатами заменены на ...):

$ kubectl exec -n kube-system etcd-k8s-master-1 -- etcdctl ... \
    get /registry/deployments/default/web --print-value-only | strings -n 3 | head -6
k8s
apps/v1
Deployment
web
default
*$5e3c8a1f-2b7d-4f6e-9a0c-8d4b1e7f3a62

Префикс k8s и пара «версия-тип» — заголовок сериализации Kubernetes; дальше идут поля объекта (символы *$ перед uid — служебные байты Protobuf: номер поля и длина строки). resourceVersion в значении не хранится: kube-apiserver подставляет его из ревизии etcd при чтении. Секреты лежат здесь так же, открытым Protobuf, если не включено шифрование — об этом в части 13.

Сколько запросов стоит создание объекта двумя способами apply. Удалим Deployment и создадим его клиентским apply, отфильтровав из журнала запросы к самому объекту:

$ kubectl delete deployment web
deployment.apps "web" deleted from default namespace

$ kubectl apply -f web.yaml -v=6 2>&1 | grep -o '"Response" verb="[A-Z]*" url="[^"]*deployments[^"]*" status="[^"]*"'
"Response" verb="GET" url="https://k8s-api.example.com:6443/apis/apps/v1/namespaces/default/deployments/web" status="404 Not Found"
"Response" verb="POST" url="https://k8s-api.example.com:6443/apis/apps/v1/namespaces/default/deployments?fieldManager=kubectl-client-side-apply&fieldValidation=Strict" status="201 Created"

Клиентский apply сначала читает объект: нет — создаёт его POST с аннотацией last-applied-configuration, есть — вычисляет патч и отправляет PATCH. Параметр fieldValidation=Strict просит сервер отклонить манифест с неизвестными или повторяющимися полями — опечатка replcas: 3 даст ошибку, а не молча проигнорируется. Даже клиентский apply записывает в managedFields своего менеджера — kubectl-client-side-apply, с операцией Update.

Теперь то же серверным apply:

$ kubectl delete deployment web
deployment.apps "web" deleted from default namespace

$ kubectl apply -f web.yaml --server-side -v=6 2>&1 | grep -o '"Response" verb="[A-Z]*" url="[^"]*deployments[^"]*" status="[^"]*"'
"Response" verb="PATCH" url="https://k8s-api.example.com:6443/apis/apps/v1/namespaces/default/deployments/web?fieldManager=kubectl&fieldValidation=Strict&force=false" status="201 Created"

Один PATCH с типом apply, который создал объект (201 Created): решать, создавать или изменять, теперь дело сервера.

И последнее — как увидеть список всех подписок сервера по типам. kube-apiserver отдаёт метрики, и среди них — число открытых watch:

$ kubectl get --raw /metrics | grep '^apiserver_longrunning_requests{.*verb="WATCH"' | sort -t' ' -k2 -nr | head -5
apiserver_longrunning_requests{component="apiserver",group="",resource="configmaps",scope="resource",subresource="",verb="WATCH",version="v1"} 12
apiserver_longrunning_requests{component="apiserver",group="",resource="pods",scope="cluster",subresource="",verb="WATCH",version="v1"} 9
apiserver_longrunning_requests{component="apiserver",group="",resource="nodes",scope="cluster",subresource="",verb="WATCH",version="v1"} 7
apiserver_longrunning_requests{component="apiserver",group="",resource="services",scope="cluster",subresource="",verb="WATCH",version="v1"} 7
apiserver_longrunning_requests{component="apiserver",group="discovery.k8s.io",resource="endpointslices",scope="cluster",subresource="",verb="WATCH",version="v1"} 5

Подписки на поды держат kubelet каждого узла, планировщик, менеджер контроллеров, Calico; на сервисы и EndpointSlice — kube-proxy и CoreDNS. Строка с scope="resource" — подписки на отдельные объекты: kubelet следит за каждым ConfigMap и Secret, смонтированным в поды его узла, — хотя бы за kube-root-ca.crt в каждом неймспейсе, где есть поды. Это и есть «все — клиенты API» из второй части в цифрах. Счётчики относятся к тому экземпляру kube-apiserver, который ответил на запрос: в кластере с несколькими мастерами у каждого экземпляра свои подписки.

Убираем за собой: kubectl delete deployment web, kubectl delete -f require-image-tag.yaml, остановить kubectl proxy (kill %1).


Источники


Дальше — ставим учебный кластер с нуля: подготовка трёх узлов Ubuntu, containerd, kubeadm init с файлом конфигурации и разбор каждой его фазы, Calico через оператор и подключение рабочих узлов.

Оставить комментарий могут только зарегистрированные пользователи.

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