Боль, которую это решает, знакома любому, кто администрировал что-то до k8s. Раньше ты писал скрипты-инструкции: сделай ssh, поставь пакет, запусти процесс, если упал - перезапусти, если нагрузка выросла - добавь сервер. Это императивный подход - ты описываешь шаги. Проблема в том, что шаги ломаются от любого расхождения с реальностью. Сервер уже обновлён наполовину, процесс уже запущен, диск кончился на третьем шаге - и скрипт встаёт колом, потому что он не знает, в каком состоянии система сейчас. Kubernetes переворачивает это: ты описываешь не шаги, а результат. "Хочу пять реплик такого образа". А уже система сама разбирается, как из текущего состояния попасть в желаемое, сколько бы раз ты ни просил.
Желаемое состояние против текущего: spec и status
Возьми любой объект Kubernetes и открой его целиком. Почти у каждого есть два больших раздела: spec и status. Это не случайное деление, это ось всей платформы. spec - это желаемое состояние, то, что записал ты (или другой контроллер). status - это текущее наблюдаемое состояние, то, что записывает сам кластер. Ты пишешь в spec, кластер пишет в status. Это разделение - фундамент.
Посмотрим на живой Deployment:
Код: Выделить всё
kubectl get deployment web -o yaml
Код: Выделить всё
spec:
replicas: 5
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
status:
observedGeneration: 4
replicas: 5
updatedReplicas: 5
readyReplicas: 3
availableReplicas: 3
conditions:
- type: Available
status: "False"
reason: MinimumReplicasUnavailable

Reconciliation: петля, которая сводит концы с концами
Теперь главное слово урока - reconciliation, согласование. За каждым типом объекта стоит контроллер - программа в составе kube-controller-manager (или отдельный оператор). Контроллер крутит бесконечный цикл, reconciliation loop, и на каждом обороте делает одно и то же: смотрит spec, смотрит status (то есть реальный мир), вычисляет разницу и предпринимает ровно те действия, которые сокращают эту разницу. Затем засыпает до следующего события и повторяет. Reconciliation в Kubernetes - это и есть тот самый механизм, благодаря которому кластер самовосстанавливается.
Разберём на Deployment с replicas: 3. ReplicaSet-контроллер на каждом обороте считает: сколько подов с нужным селектором живо? Если два - создаёт ещё один. Если четыре (кто-то руками добавил) - удаляет лишний. Если три - не делает ничего. Вот это "если совпало - не делает ничего" критично: контроллер не выполняет команды, он устраняет расхождение. Удали под руками - через долю секунды появится новый, потому что наблюдаемое (2) разошлось с желаемым (3). Никто не "перезапускал под" в привычном смысле; петля просто свела текущее состояние к желаемому. Это уровень контроля - level-based логика, а не edge-based. Контроллеру не важно, сколько событий он пропустил или что именно произошло; ему важно лишь текущее расхождение прямо сейчас. Поэтому он устойчив к потерянным событиям, рестартам и гонкам - в отличие от реакции "пришло событие удаления пода -> создай под", которая ломается, стоит событию потеряться.
Эта же модель объясняет важнейшее свойство: kubernetes декларативность означает, что система сходится к описанию, а не выполняет твою команду один раз. Сервер упал ночью, под уехал на другую ноду, кто-то скейлил вручную - неважно. Петля всё равно вернёт мир к тому, что записано в etcd как желаемое.
Watch и informers: откуда контроллер узнаёт об изменениях
Возникает разумный вопрос: если каждый контроллер на каждом обороте читает состояние, он же закидает API-сервер запросами? Тысячи объектов, десятки контроллеров - API-сервер ляжет от одного только опроса. Так и было бы при наивном polling. Поэтому здесь работает другая механика - watch и informers.
Контроллер не опрашивает API-сервер по кругу. Он открывает один долгоживущий HTTP-стрим - watch - на нужный тип ресурса и получает события (Added, Modified, Deleted) пушем, как только что-то меняется. Поверх watch работает informer - библиотечный компонент, который держит локальный in-memory кэш всех интересных объектов и поддерживает его в актуальном состоянии. Внутри informer'а есть Reflector: он делает первичный LIST (полный снимок), затем переключается на WATCH (поток дельт) и зеркалит всё в локальное хранилище-Store. Все чтения контроллера (Get, List) бьют в этот кэш, а не в API-сервер - вот почему сотни контроллеров не валят кластер.
Что важно для эксплуатации: informer умеет восстанавливаться. Сеть моргнула, watch-соединение порвалось - informer делает re-list и re-watch, заново синхронизируя кэш, и прячет сбой от твоего кода. Из этого вытекает практический нюанс: кэш informer'а eventually consistent, он может на доли секунды отставать от реальности. Поэтому грамотный контроллер пишет изменения в API-сервер, но при конфликте версии (resourceVersion устарел) спокойно ловит ошибку 409 Conflict и идёт на следующий оборот reconciliation - там кэш уже догонит. На события informer навешивают обработчики, которые не делают работу напрямую, а кладут ключ объекта (namespace/name) в рабочую очередь. Очередь дедуплицирует: десять событий по одному объекту схлопнутся в одну задачу reconcile. Это ещё одна причина, почему петля думает категориями "текущее расхождение", а не "обработай каждое событие".
Декларативно против императивно: kubectl apply и идемпотентность
Теперь спустимся к практике, к тому, что ты набираешь в терминале. Есть два стиля работы.
Императивный:
Код: Выделить всё
kubectl runКод: Выделить всё
kubectl create deploymentКод: Выделить всё
kubectl scale --replicas=5Код: Выделить всё
kubectl editДекларативный: ты держишь YAML-файлы (в git), а в кластер их вкатываешь через kubectl apply.
Код: Выделить всё
kubectl apply -f deployment.yaml
Код: Выделить всё
deployment.apps/web configured
deployment.apps/web unchanged
Server-side apply и владение полями
Со временем у apply вылез нюанс. Старый клиентский apply хранил предыдущую версию в аннотации kubectl.kubernetes.io/last-applied-configuration и вычислял разницу на твоей машине. Это плохо работало, когда один и тот же объект меняют несколько действующих лиц - ты, HPA (он крутит replicas), оператор. Кто кого затирает - непонятно.
Решение - server-side apply (SSA, стабилен с 1.22), включается флагом:
Код: Выделить всё
kubectl apply --server-side -f deployment.yaml
Код: Выделить всё
metadata:
managedFields:
- manager: kubectl
operation: Apply
fieldsV1:
f:spec:
f:template:
f:spec:
f:containers: {}
- manager: kube-controller-manager
operation: Update
fieldsV1:
f:spec:
f:replicas: {}
Код: Выделить всё
--force-conflictsВсё - объект API. CRD и операторы
Заметь закономерность: Pod, Service, ConfigMap, Deployment - всё это объекты с единообразным устройством (apiVersion, kind, metadata, spec, status), всё читается через API-сервер, всё подчиняется apply и watch. В Kubernetes вообще всё - объект REST API, хранящийся в etcd. Это не деталь реализации, это дизайн: единый протокол для любого ресурса. И раз так - почему бы не добавить свои типы?
Это делают Custom Resource Definitions, CRD. Ты регистрируешь новый kind, и kubernetes начинает обслуживать его как родной: тот же apply, тот же watch, тот же kubectl get. Минимальный CRD:
Код: Выделить всё
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: backups.ops.cyberlake.ru
spec:
group: ops.cyberlake.ru
scope: Namespaced
names:
plural: backups
singular: backup
kind: Backup
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
schedule: {type: string}
retention: {type: integer}
Код: Выделить всё
kubectl apply -fКод: Выделить всё
kubectl get backupsТут на сцену выходит оператор - это просто контроллер, написанный под твой CRD. Он крутит ту же самую петлю reconciliation: смотрит на spec твоего Backup, смотрит, что реально создано (CronJob, PVC, права), и сводит одно к другому. Оператор PostgreSQL так разворачивает кластер БД с репликами и бэкапами; оператор cert-manager так выпускает и продлевает TLS-сертификаты. CRD плюс оператор - это и есть способ расширить Kubernetes своей предметной логикой, не трогая ядро. В RU-реалиях это повсюду: Yandex Managed Kubernetes и VK Cloud отдают managed-кластеры, а Deckhouse целиком построен на десятках встроенных операторов; даже лёгкий k3s внутри обслуживает CRD ровно так же. CRD kubernetes и операторы превратили платформу из "запускалки контейнеров" в универсальный движок согласования для чего угодно - от баз данных до выписки сертификатов.
Типичные грабли и антипаттерны
- Мешать императив и декларатив на одном объекте. Сделал kubectl scale руками на объекте, которым управляет apply из git - при следующем apply твой ручной скейл откатится. Источник истины должен быть один.
- Класть в манифест поля, которыми владеет контроллер. Заявил spec.replicas рядом с активным HPA - получишь либо вечную борьбу (client-side apply), либо конфликт (SSA). Убери поле из манифеста.
- Ждать мгновенной реакции. Reconciliation асинхронна. apply вернул "configured" - это значит "желаемое записано", а не "уже сделано". Смотри в status и conditions, а не в код возврата команды.
- Редактировать status руками. status пишет контроллер. Твои правки он сотрёт на следующем обороте. Ты управляешь только через spec.
- Думать, что под "перезапускает" какой-то демон. Никто не реагирует на конкретное событие падения - петля просто видит расхождение желаемого и текущего. Поймёшь это - перестанешь искать несуществующий "сервис автоперезапуска".
Нужен любой кластер (minikube, kind, k3s).
- Создай деплой декларативно. Положи в web.yaml Deployment с replicas: 3, образ nginx:1.27, селектор по label app: web. Накати:
Код: Выделить всё
kubectl apply -f web.yaml - Запусти apply повторно - убедись, что вышло unchanged. Это идемпотентность.
- Открой наблюдение в одном терминале:
Код: Выделить всё
kubectl get pods -l app=web -w - В другом терминале удали один под: . Смотри, как в первом окне почти сразу появляется новый - петля свела текущее (2) к желаемому (3).
Код: Выделить всё
kubectl delete pod <имя-пода> - Посмотри расхождение в цифрах:
Код: Выделить всё
kubectl get deploy web -o jsonpath='{.spec.replicas} / {.status.readyReplicas}' - Сравни управление полями: , затем
Код: Выделить всё
kubectl apply --server-side -f web.yaml- найди свой manager в managedFields.Код: Выделить всё
kubectl get deploy web --show-managed-fields -o yaml - Зарегистрируй CRD Backup из урока, сделай apply, проверь и
Код: Выделить всё
kubectl get crd. Оператора нет, поэтому объект просто лежит в etcd - почувствуй разницу между "тип зарегистрирован" и "кто-то его согласует".Код: Выделить всё
kubectl explain backup.spec
- Чем spec отличается от status и кто пишет в каждое из этих полей?
- Почему reconciliation устойчива к потерянным событиям watch - что именно сравнивает контроллер на каждом обороте?
- Зачем нужны informers и локальный кэш, и какую проблему они снимают по сравнению с прямым опросом API-сервера?
- Что такое managedFields в server-side apply и как они предотвращают войну за spec.replicas между тобой и HPA?
Kubernetes - это машина согласования. Ты декларируешь желаемое состояние в spec, контроллеры через watch и informers непрерывно крутят reconciliation loop и сводят наблюдаемое к желаемому, а kubectl apply делает это идемпотентно и (в server-side режиме) с честным учётом владения полями. Всё в кластере - объект API, поэтому CRD и операторы расширяют ту же модель на любую твою предметную область. Усвоишь эту петлю - и перестанешь воспринимать k8s как набор команд: увидишь единый принцип, на котором держится самовосстановление, скейлинг, апдейты и GitOps.