Kubernetes-операторы: теория управления на практике

За последнее десятилетие Kubernetes стал постоянным фоном большей части моей работы: я эксплуатировал кластеры, помогал строить managed-Kubernetes и писал операторы (Kubernetes operators). В PlanetScale это сейчас означает запуск stateful-систем — Postgres и MySQL — в production. У Kubernetes много обличий, но здесь я хочу говорить только об одном: почему он так хорош в запуске нагрузок в масштабе.

Меня часто спрашивают, что оператор вообще делает. Канонический ответ: «приводит фактическое состояние к желаемому» (reconciles desired state). Это правда, но из этого практически ничего не следует.

Оператор — это регулятор с обратной связью (feedback controller). Тот же замкнутый контур, который управляет термостатом или удерживает скорость автомобиля на круиз-контроле. В нашем случае объект управления — база данных. Я строю такие контуры уже много лет, и лучший способ понять их суть — поначалу вообще забыть про Kubernetes. Kubernetes насквозь пронизан теорией управления (control theory), даже если мы не называем её так в повседневной работе.

Прежде чем мы посмотрим хоть на одну строчку Kubernetes, мы вручную поднимем production-базу данных и дадим контуру обратной связи проявиться самому. Затем отобразим этот контур на Kubernetes со всем необходимым для production: хранилищем, подписками на изменения (watches), очередями, повторными попытками и прочим. В конце посмотрим, как такой контур выглядит в реальном операторе.

Примечание

Пригодится понимание контейнеров и kubectl, но быть экспертом по Kubernetes не обязательно. Я буду использовать термины вроде идемпотентный (idempotent), объединение входящих событий (fan-in) и итоговая согласованность (eventual consistency) — и буду объяснять нужные понятия по ходу.

Начнём медленно и будем постепенно ускоряться. Каждая часть опирается на предыдущую.

Часть 1: запускаем Postgres вручную

Один контейнер, одна машина

Начнём с нуля. Мне нужно запустить Postgres на Linux-сервере внутри контейнера. Запускаем:

docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  postgres:18

Всё. Postgres работает. Приложение подключается к нему, записывает данные — всё хорошо. Но потом машина исчезает: облачный провайдер забирает инстанс (аппаратный сбой или spot-инстанс изымается), или мы выкатываем новую версию конфигурации — останавливаем старый контейнер и поднимаем новый. В любом из этих случаев контейнер пересоздаётся, и данные пропадают. Хранилище контейнера было эфемерным, постоянный том к нему не подключался.

Между тем, чего я хочу (работающий Postgres с моими данными), и тем, что я имею (контейнер, чьё хранилище исчезает вместе с ним или с узлом), уже есть разрыв. Весь этот пост — о том разрыве и о механизмах, которые мы строим, чтобы его закрыть.

Выбираем узел вручную

Представим, что у нас есть сотни узлов (серверов). На них уже крутятся другие нагрузки. Нужно решить, на каком из них запустить базу данных. Захожу по ssh на тот, который выглядит наименее загруженным, и запускаю там контейнер.

ssh node-07 'docker run -d --name pg ... postgres:18'

Я выбрал node-07, потому что он казался достаточно свободным. Записываю его куда-нибудь, сохраняю в конфиг-файл, пушу в репозиторий.

Нужен настоящий диск

Хранилище контейнера эфемерно, значит, нужно подключить реальное блочное устройство. В облаке это EBS-том (например, на AWS), на bare metal — физический диск. Предполагая, что это блочное устройство, действуем так: создаём том, подключаем к узлу, форматируем, монтируем и указываем директорию данных Postgres на точку монтирования.

# сначала создаём и подключаем через cloud CLI, затем на узле:
mkfs.ext4 /dev/nvme1n1
mkdir -p /var/lib/pg-data
mount /dev/nvme1n1 /var/lib/pg-data
docker run -d --name pg \
  -e POSTGRES_PASSWORD=secret \
  -v /var/lib/pg-data:/var/lib/postgresql \
  postgres:18

Шагов много, и каждый может упасть на полпути. Если диск потом заполнится, Postgres перестанет принимать запись, и придётся вручную расширять том: сначала через провайдера, потом внутри файловой системы.

Одного экземпляра недостаточно

Единственный экземпляр Postgres — единая точка отказа. Нам нужна высокая доступность (high availability): один primary и два реплики на трёх разных машинах со стриминговой репликацией между ними. Повторяем все те же шаги трижды — на node-07, node-12 и node-19. Репликацию настраиваем вручную: primary_conninfo, слоты репликации — всё сами.

Теперь есть три узла с тремя экземплярами Postgres. Один из них — primary (здесь это node-07). Но появляются новые проблемы: что делать, если узел с primary упадёт?

Им нужно найти друг друга

Вот ещё одна задача. Реплики должны достучаться до primary, а primary — принять их подключения. И у каждого из них IP меняется при перезапуске контейнера.

Первое, что я делаю, — жёстко прописываю IP-адреса. Вношу адрес node-07 в конфиг реплик, список адресов реплик — в pg_hba.conf primary, веду небольшую таблицу /etc/hosts и сохраняю её куда-нибудь.

# в postgresql.auto.conf каждой реплики, пока primary не пересоздан с новым IP
primary_conninfo = 'host=10.4.7.21 port=5432 user=replicator ...'

Проблема остаётся: при первом же пересоздании primary с другим IP весь кластер разваливается.

Схема ручного кластера Postgres

Скрипт-сторож

Вот где мы начинаем думать, как решить все эти проблемы. Всё описанное выше может сломаться — и будет продолжать ломаться, даже если я что-то починил:

  • Процесс реплики умирает и не поднимается.

  • Диск заполняется.

  • Primary падает, и нужно промоутить реплику.

  • Конфиг я поменял на двух узлах, а про третий забыл. Теперь они рассинхронизированы.

Допустим, мы настроили простой монитор uptime и будем получать алерты во всех этих случаях. Чтобы не просыпаться ночью, делаем разумное: пишем скрипт. Решаем написать цикл, который раз в несколько секунд просматривает каждый узел и исправляет всё, что не так.

while true; do
  for node in node-07 node-12 node-19; do
    if ! ssh "$node" 'pg_isready -q'; then
      ssh "$node" 'docker start pg'  # упал, поднимаем
    fi
    usage=$(ssh "$node" "df --output=pcent /var/lib/pg-data | tail -1 | tr -dc 0-9")
    if [ "$usage" -gt 80 ]; then
      grow_volume "$node"  # диск заполняется, увеличиваем
    fi
  done
  sleep 5
done

Написано на Bash и, вероятно, полно ошибок. Но замечаете кое-что важное? Цикл не интересуется, как именно база данных оказалась в плохом состоянии. Каждые пять секунд он смотрит на текущее состояние мира и задаёт один вопрос: соответствует ли реальность тому, чего я хочу?

Если процесс упал — запустить его. Если диск заполняется — расширить его. Запустите цикл один раз или тысячу раз — результат одинаков, потому что каждое действие обусловлено текущим состоянием. Скрипт идемпотентен (idempotent).

Меняем параметр

Усложним задачу. Нужно увеличить max_connections со 100 до 500. Это не параметр, который применяется через reload. PostgreSQL говорит, что его можно задать только при старте сервера, — значит, вручную нужно зайти по ssh на каждую машину, отредактировать postgresql.conf, перезапустить Postgres и убедиться, что изменение применилось на всех трёх.

Поскольку вручную ходить по узлам я уже не хочу, делаем то же самое, что раньше: записываем желаемое значение в одном месте и учим цикл его поддерживать.

WANT_MAX_CONNECTIONS=500
for node in node-07 node-12 node-19; do
  have=$(ssh "$node" "psql -tAc 'show max_connections'")
  if [ "$have" != "$WANT_MAX_CONNECTIONS" ]; then
    ssh "$node" "sed -i 's/^max_connections.*/max_connections = $WANT_MAX_CONNECTIONS/' /var/lib/pg-data/postgresql.conf"
    ssh "$node" "docker restart pg"
  fi
done

Та же идея. Читаем то, чего мы хотим (переменная). Наблюдаем то, что есть (запрос). Если они расходятся, совершаем действие, чтобы устранить расхождение. Снова никакого отслеживания того, меняли ли мы что-то в прошлый раз. Только сравниваем и сводим к нужному значению (converge) — каждый цикл.

Что мы на самом деле построили

Я начал с желаемого состояния, записанного в одном месте: три экземпляра, такой-то размер диска, max_connections = 500. Каждые несколько секунд я наблюдаю фактическое состояние системы, вычисляю разницу, совершаю действие, которое её устраняет, — и так до бесконечности.

Это замкнутый контур обратной связи (closed feedback loop). Слово «замкнутый» важно: оно означает, что выход системы возвращается на вход следующего решения. Я не выполняю docker start и не считаю, что база в порядке. Я снова проверяю базу. Если всё ещё не то — снова действую. Если уже правильно — ничего не делаю.

Приятно то, что один и тот же цикл работает для разных проблем: перезапустить упавший процесс, расширить диск, или применить max_connections = 500. Действие меняется, а форма остаётся той же: читаем желаемое, наблюдаем фактическое, сравниваем, действуем, повторяем. Изображённое в виде блок-схемы с терминами теории управления это выглядит так:

Блок-схема замкнутого контура обратной связи

Вот как терминология из теории управления отображается на мой shell-скрипт:

  • Уставка (setpoint) — моё желаемое состояние: переменные в начале скрипта (размер диска, max_connections и т. д.).

  • Измеренный выход (measured output) — то, что я наблюдаю: pg_isready, df, show max_connections.

  • Ошибка (error, e) — разница между ними.

  • Регулятор (controller) — тело цикла, операторы if, которые решают, что делать. Не весь скрипт целиком.

  • Исполнительный механизм (actuator) — то, что выполняет действие: ssh плюс docker start.

  • Объект управления (plant) — управляемая система: Postgres и его диск.

Это также даёт нам хорошее понимание разомкнутого управления (open-loop control). Мой самый первый подход — зайти по ssh, выполнить команду и уйти — был разомкнутым: запустил действие и предположил, что оно сработало. Bash-скрипт — замкнутый, потому что он постоянно возвращает измеренное состояние на вход следующего решения.

Bash-цикл — не production control plane. Вот лишь часть проблем:

  • Нет контроля параллельного выполнения: два экземпляра скрипта могут вступить в гонку. Например, оба решат промоутить разные реплики.

  • Единственное реальное состояние — «нахожусь ли я в середине failover?» — хранится в shell-переменной, которая умрёт вместе с процессом.

  • Скрипт опрашивает все узлы каждые пять секунд вне зависимости от того, что-то изменилось или нет. Для трёх узлов это нормально, для трёх тысяч — слишком дорого.

  • Скрипт не знает, что делать, когда сам ssh завис по таймауту.

  • Как только понадобится второй тип ресурса — пулер соединений, задача резервного копирования, read-реплика в другом регионе — придётся копировать всю эту конструкцию.

А что, если упадёт сам скрипт? Кто его тогда запустит? Можно продолжать закалять этот скрипт, но посмотрите, к чему это ведёт: нам понадобится настоящее хранилище для желаемого состояния, подписки на изменения вместо опроса, рабочая очередь, повторные попытки, выбор лидера. Мы будем заново изобретать Kubernetes. Реальная платформа уже существует, и это Kubernetes.

Часть 2: как мы переизобрели Kubernetes

Теперь отобразим то, что мы сделали вручную в части 1, на Kubernetes. Практически всё это там уже есть. Оператор — та часть, которую мы пишем сами.

Другие контуры

Давайте разберём компоненты, которые мы строили вручную до написания скрипта-сторожа. Вы знаете их по именам. Но, возможно, вы не замечали, что они тоже работают как регуляторы (controllers).

Запуск контейнера: kubelet. Краткое определение: Pod — это наименьшая единица, которую запускает Kubernetes: один или несколько контейнеров, размещённых на одном узле и разделяющих его сеть. В нашем случае это контейнер Postgres. На каждом узле работает агент kubelet. Его желаемое состояние — множество Pod’ов, назначенных на этот узел (он получает их от API-сервера). Его наблюдаемое состояние — множество реально запущенных контейнеров (он получает это от container runtime). Когда они расходятся, kubelet запускает недостающий контейнер, убивает лишний или перезапускает упавший. Мой if ! pg_isready; then docker start; fi — это работа kubelet, только сделанная как следует. Kubelet не ходит никуда по ssh; он общается с containerd через gRPC-сокет, который общается с runc.

Выбор узла: планировщик (scheduler). Помните, как я выбирал node-07? Для этого и существует планировщик. Он следит за Pod’ами без назначенного узла, отфильтровывает неподходящие узлы, ранжирует оставшиеся и записывает решение в одно поле: pod.Spec.NodeName. Планировщик не запускает контейнер — он фиксирует размещение и позволяет kubelet подхватить работу. Вы заметите, что большинство компонентов Kubernetes так же развязаны между собой.

Подключение диска: CSI и синхронизация PV/PVC. Мой многошаговый скрипт с mkfs и mount превращается в PersistentVolumeClaim — декларативный запрос на хранилище. Драйвер Container Storage Interface (CSI) превращает этот запрос в реальный том. CSI сам по себе — это набор контроллеров и sidecar-контейнеров: один создаёт том, другой подключает, третий изменяет размер и т. д., а kubelet вызывает node-плагин драйвера для фактического монтирования. Это семейство контроллеров. Если есть PVC, но за ним нет диска, один контроллер создаёт диск. Если размер PVC увеличивается, другой контроллер вызывает API провайдера (например, ModifyVolume в AWS). Снова: я описываю намерение, контроллер делает работу. (Примечание: я написал один из первых production CSI-драйверов — csi-digitalocean — и большой пост о том, как его строить.)

Обнаружение друг друга: CNI и Services. Проблема с /etc/hosts решается на уровне, о котором нам больше не нужно думать. CNI-плагин даёт Pod’ам сетевую идентичность; Cilium, например, делает это с помощью eBPF вместо кучи правил iptables. Для stateful-нагрузок StatefulSet в сочетании с headless Service даёт каждой реплике собственное стабильное DNS-имя — именно то, что нужно реплике Postgres. Жёстко прописанный IP, из-за которого разваливался кластер, становится именем, которое продолжает работать. DNS — лишь один из инструментов; другие системы service discovery, такие как etcd, ZooKeeper и Consul, решают аналогичные задачи.

Все проблемы, которые мы решали с помощью ssh и скриптов, заменяются компонентами и драйверами Kubernetes. И это лишь часть из них:

Сопоставление ручных операций с контроллерами Kubernetes

Всё сказанное выше — полезный контекст, но нас интересует прежде всего наш цикл сторожа, потому что именно его мы пишем сами.

Это и есть оператор.

For-цикл в переводе на Kubernetes

В Kubernetes наш скрипт-сторож — это контроллер (controller), а стандартный способ написать его на Go — библиотека controller-runtime. В её основе лежит функция с простой сигнатурой:

func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    // req содержит namespace/name. Всё. Это весь входной параметр.
}

Заметьте, чего здесь нет: функции не передаётся информация о том, что изменилось. Нет diff’а. Ей не передаётся старый и новый объект. Ей не задаётся тип события. Она получает ключ — namespace и name — и ничего больше. Это минимализм по замыслу, потому что функция должна работать для множества разных контроллеров. Задача функции — получить объект по этому namespace/name, посмотреть на состояние мира и сдвинуть его к желаемому.

Уведомления по фронту, логика по уровню

Есть два способа построить любой замкнутый контур обратной связи:

По фронту (edge-triggered): реагировать на переходы, на события. «Диск перешёл за 80%.» «Pod был удалён.» «Число реплик увеличилось на 2.»

По уровню (level-triggered): реагировать на текущее состояние, независимо от того, как к нему пришли. «Диск занят на 85%.» «Pod отсутствует.» «Число реплик равно 3.»

Моя первая ментальная модель контроллеров — и, вероятно, ваша тоже поначалу — была основана на фронтах: слушать поток изменений и при каждом изменении пытаться привести систему к нужному состоянию.

Проблема в том, что это очень хрупко. В распределённых системах хрупкость одного компонента распространяется на остальную систему. Почему управление по фронту хрупко? Представьте, что контроллер был недоступен тридцать секунд. Он пропустил события за эти тридцать секунд, и его картина мира теперь навсегда неверна. Если два события приходят не по порядку — вы обрабатываете их не по порядку. Если событие доставлено дважды — вы действуете дважды. Вы восстанавливаете состояние из потока событий и наследуете все трудности event sourcing.

Вот конкретный пример. Допустим, у вас 1 реплика, и вы увеличиваете их число до 3. Поскольку вы подписаны только на изменения, либо:

  • вы пропустите событие (очередь или ваше приложение сбросило его из-за краша или переполненного буфера),

  • вы получите его дважды.

В первом случае самокоррекция будет невозможна. Во втором, если обработчик слепо применит дельту ещё раз, получится 5 реплик вместо 3 (перерегулирование).

Управление по уровню устраняет всё это. Наш shell-скрипт никогда не спрашивал «что изменилось?». Он спрашивал «что верно прямо сейчас?» — каждые пять секунд, с нуля. Пропустил один цикл — следующий наверстает. Запустил цикл дважды — получил тот же результат. Текущее состояние мира — единственный входной параметр, и оно всегда доступно для чтения. В управлении по уровню наш пример выглядит так: читаем replicas=3, проверяем текущее число реплик — оно равно 1, увеличиваем на 2.

Пропустили событие? Не важно. На следующем цикле reconcile всё подхватится. Приложение упало? Оно поднимется, снова прочитает состояние, обнаружит, что ещё не увеличило число реплик, и увеличит.

Контроллеры Kubernetes сочетают оба подхода: уведомления по фронту, логика по уровню (edge-triggered notifications, level-triggered logic).

События (фронты) — лишь намёк на то, что стоит взглянуть ещё раз. Они говорят когда запустить reconcile, но никогда — что делать. Сам reconcile основан на уровне: читает текущее состояние (например, replicas=3) и движет к желаемому (например, create 2 replicas), полностью игнорируя triggering event. Именно поэтому в Reconcile передаётся только ключ. Фреймворк намеренно затрудняет написание логики по фронту. Логика по фронту — это путь к контроллеру, который хрупок и навсегда ломается после первой же заминки.

Сравнение управления по фронту и по уровню при масштабировании

Наш Bash-скрипт случайно приобрёл это свойство — по крайней мере для целей примера. Но фреймворк controller-runtime даёт его намеренно. Именно поэтому контроллер Kubernetes может упасть, подняться десять минут спустя и корректно привести систему в нужное состояние без всякого кода восстановления. Кода восстановления нет. Есть только цикл. Контроллер может восстановить картину мира с нуля.

Информеры, рабочая очередь и кеш

Откуда берутся фронты? И что не даёт контроллеру положить API-сервер на лопатки, перечисляя всё каждые пять секунд, как мой скрипт?

Ответ — информер (informer). Информер открывает единственный watch-запрос к API-серверу на заданный тип ресурса, стримит каждое добавление, обновление и удаление и поддерживает полный in-memory кеш интересующих нас объектов. Здесь важны два момента.

Во-первых, информер превращает каждое watch-событие в ключ и помещает его в рабочую очередь (work queue). Очередь берёт на себя большую часть работы:

  • Схлопывает (coalesces): если один и тот же объект обновился пять раз до того, как до него дошли руки, reconcile выполняется один раз — по последнему состоянию (снова управление по уровню).

  • Ограничивает скорость (rate-limits): объект, который продолжает выдавать ошибки, откатывается с экспоненциальной задержкой вместо того, чтобы крутиться вхолостую. Это демпфирование (damping) — та же причина, по которой падающий контейнер не перезапускается мгновенно, а ждёт всё дольше.

  • Позволяет запустить пул воркеров, параллельно вытаскивающих ключи, — это ваш fan-out. События поступают из watch (fan-in), схлопываются в очереди и расходятся к воркерам (fan-out). Размер пула нужно настраивать. Чем он больше, тем сильнее нагрузка на систему: больше записей, больше вызовов API, больше нагрузки на провайдера, больше CPU у оператора.

Схема информера, рабочей очереди и контроллера

Во-вторых, и это деталь, которая часто неприятно удивляет: чтения и записи в Kubernetes идут в разные места.

В controller-runtime клиент, который вам передаётся, читает из локального кеша информера. Чтение из кеша дёшево — оно не касается API-сервера, именно поэтому контроллер может обрабатывать тысячи объектов, не разваливаясь. Но записи идут напрямую к API-серверу. Кеш узнаёт о вашей записи только когда соответствующее watch-событие вернётся обратно — через некоторое время.

Из-за этого чтение может быть устаревшим. К этому нужно быть готовым.

Если вы записали поле объекта, а потом снова прочитали тот же объект из кеша, reconciler может решить, что изменение ещё не применено. Вы пишете снова — и получаете ошибку Conflict. Повторить попытку с новым чтением иногда нормально, но слепое повторение с тем же устаревшим кешированным значением просто крутится впустую.

В большинстве случаев правильное решение — сбросить вызов и поставить в очередь повторно (requeue). В следующем reconcile GET увидит обновлённый объект, и ваша запись никогда не произойдёт повторно. Так всё и самоисправляется.

Вот ещё один крайний случай. Представьте такую последовательность внутри reconcile:

// Хочу N реплик. Вижу меньше, создаю недостающие.
existing, _ := r.listChildPods(ctx)    // читает из КЕША
for i := len(existing); i < desired; i++ {
    r.client.Create(ctx, newPod(i))    // пишет на API SERVER
}

Через секунду приходит ещё одно событие, а кеш ещё не обновился с учётом только что созданных Pod’ов. Вы снова читаете из кеша — новых Pod’ов там нет. Ваш код решает, что их всё ещё нужно создать, и вы создаёте дубликаты. Это классическая проблема двойного создания из-за устаревшего кеша (stale-cache double-create), и она неприятна тем, что воспроизводится только при таймингах, которые невозможно повторить у себя на ноутбуке.

Схема: чтения из кеша и записи через API в Kubernetes

Есть два выхода. Правильный — паттерн ожиданий (expectations pattern), тот же приём, который использует встроенный контроллер ReplicaSet: вы записываете в памяти, что ожидаете увидеть N созданий, и не действуете снова, пока кеш не догонит ваши записи. Это работает, но непросто в реализации и требует немало вспомогательного кода. Подробнее — в блоге Ахмета.

Прагматичный путь, который используют многие, — обойти кеш для тех чтений, где устаревшее значение может привести к двойному созданию или удалению, и обратиться напрямую к API-серверу:

// Кешированный клиент может быть устаревшим сразу после наших записей,
// что приведёт к неверному подсчёту и дублированию. Для этого одного чтения
// идём напрямую к API-серверу. Медленнее, но консистентно для данного решения.
err := r.apiReader.List(ctx, &instances, client.InNamespace(ns), labelSelector)

Это не только проблема Kubernetes. В любой системе с кешем чтений и путём сквозной записи чтение после записи (read-after-write) не консистентно, если специально это не обеспечить. В большинстве случаев кеш — именно то, что нужно: дешёвый, локальный и итогово согласованный (eventually consistent). Итоговая согласованность нормальна, потому что цикл запустится снова. Но как только решение окажется деструктивным или неидемпотентным при устаревшем чтении — нужно понимать, каким путём вы идёте. Kubernetes решает много сложных проблем, но и создаёт несколько новых.

Уставка и измеренная переменная: spec и status

Вернёмся к схеме управления. Мой скрипт хранил уставку в shell-переменных, а измеренное состояние — в выводе df и psql. Kubernetes даёт обоим постоянный дом — прямо на самом объекте.

.spec — это уставка (setpoint), желаемое состояние. Оно принадлежит тому, кто создал объект (человеку или другому контроллеру), и reconciler обращается с ним как с намерением только для чтения. Писать в .spec изнутри контроллера — антипаттерн. Если вы это делаете — прекратите читать и пойдите исправьте кодовую базу. Исключения единичны: контроллер в общем случае никогда не должен устанавливать свою собственную уставку.

.status — это измеренная переменная (measured variable), наблюдаемое состояние. Оно принадлежит контроллеру, записывается через отдельный status subresource и отражает то, что реально верно. Чем лучше status, тем точнее решения контроллера. Хорошее поле .status — это то, что делает контроллер приятным в эксплуатации. Само слово наблюдаемость (observability) пришло из теории управления; Калман ввёл его около 1960 года, задавшись вопросом: можно ли вывести внутреннее состояние системы из её выходов? .status — это также ваш ответ любой сторонней системе. Если кто-то хочет узнать результат ваших действий, нужно смотреть в .status.

Это разделение — вся декларативная модель в двух полях. К нему прилагается бухгалтерская запись из чистой теории управления: .metadata.generation увеличивается при каждом изменении желаемого состояния, и по соглашению контроллер записывает обратно .status.observedGeneration, как бы говоря: «состояние, которое я сообщаю, отражает эту версию вашего намерения».

Когда observedGeneration < generation, видимый вами статус ещё не отражает последнюю уставку. Это одно сравнение позволяет отличить «сошлось» от «ещё работаю над этим».

Spec и status как уставка и измеренная переменная

Вот почему reconcile не имеет состояния (stateless) — и почему это важно. Наш shell-скрипт хранил «нахожусь ли я в середине failover?» в переменной, которая умерла бы вместе с процессом. Kubernetes-контроллер не хранит ничего важного в памяти. Всё необходимое — на API-объекте: spec, к которому он движет, status, который он последний раз наблюдал, conditions, описывающие текущую ситуацию. Убейте контроллер, перезапустите его на другом узле — он продолжит с того же места не потому, что сохранял прогресс, а потому, что никакого прогресса в памяти никогда не было. Состояние живёт в кластере (API-сервер, etcd — вот где хранится состояние). Контроллер — просто цикл, который его читает.

Самовосстановление по замыслу

Это та часть, которая мне нравится больше всего.

Когда контроллер создаёт дочерний объект (Pod, PVC), он проставляет на дочернем объекте ownerReference — ссылку обратно на родителя. Эта ссылка делает две вещи. Она настраивает garbage collection: удалите родителя, и Kubernetes может каскадно удалить его детей. И она даёт контроллеру способ отображать изменения дочернего объекта обратно на родителя: «когда любой принадлежащий мне объект изменится — поставить родителя в очередь на reconcile». ownerReference позволяет связывать контроллеры друг с другом и выстраивать цепочки. При правильном устройстве все ваши контроллеры и системы вписываются в единое целое.

Вот пример. Проследите контур:

  • Узел умирает и тянет за собой Pod.

  • Удаление Pod — watch-событие, фронт.

  • Через связь по владению этот фронт становится запросом на reconcile для родителя.

  • Родитель выполняет reconcile, наблюдает своих детей (по уровню), видит, что одного не хватает и счётчик ниже уставки, и создаёт замену.

  • Замена — это неназначенный Pod, фронт для планировщика.

  • Планировщик обнаруживает неназначенный Pod, назначает узел.

  • Это событие — фронт для kubelet на данном узле, и он запускает контейнер.

Несколько контуров обратной связи, каждый следит за слоем ниже, каждый реагирует на фронт и приходит к своему уровню, — связанные через API-сервер, без всякого центрального оркестратора.

У теории управления есть название для такой конструкции — каскадное управление (cascade control). Выход внешнего контура становится уставкой внутреннего. Контроллер никогда не пишет в свой собственный .spec, но постоянно пишет в .spec других объектов. Мой оператор пишет в spec PVC, а этот spec — уставка, к которой сходятся CSI-контроллеры. Каждый контур заботится только о своём слое и доверяет контуру ниже.

Мы написали if ! pg_isready; then docker start; fi и, возможно, решили, что справились. Kubernetes превращает эту одну строчку в несколько независимых контроллеров, которые никогда не слышали друг о друге, но всё равно взаимодействуют, потому что разделяют API-сервер и следят за объектами друг друга. Мне это очень нравится. Никто не вызывает центральный оркестратор. Никто не передаёт приватные сообщения. Система исцеляет себя сама.

Что «наблюдение» значит в реальном операторе

До сих пор я немного уклонялся от описания шага «измерение», потому что в примерах измеренное состояние — это просто «список дочерних Pod’ов». Но в реальном операторе для базы данных всё гораздо сложнее.

Когда оператор, над которым я работаю, выполняет reconcile одного экземпляра Postgres, первое, что он делает до любого решения, — строит снимок реальности из всех источников, которые что-то знают о данном экземпляре. Не только Kubernetes. Kubernetes почти ничего не знает о том, действительно ли Postgres здоров.

Источники, которые собираются в начале каждого reconcile:

Кеш Kubernetes: Pod, его PVC, стоящий за ним PV, узел, на котором он находится, ConfigMap с конфигурацией. Это дешёвые локальные чтения — то, о чём мы уже говорили.

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

Собственный взгляд базы данных на своё здоровье. Её роль, здорова ли она, насколько отстают реплики, принимает ли она сейчас записи. Часть этой информации поступает от агентов, живущих рядом с базой и управляющих ею; часть мы получаем, открывая соединение и спрашивая базу напрямую. Эти вызовы выполняются с коротким таймаутом и могут завершаться неудачей — об этом чуть позже.

Фоновый сборщик. Некоторые сигналы слишком дороги или ограничены по частоте, чтобы получать их при каждом reconcile: использование диска или статус операции над томом, которую мы запустили ранее. Отдельный сборщик, часто фоновая горутина, собирает эти данные с медленным интервалом и хранит последнее значение на том в памяти. Reconcile читает это значение мгновенно, без блокировки. Думайте об этом как о пользовательских рабочих очередях, которые вы реализуете сами.

Схема объединения входящих данных наблюдения для reconciler

В коде снимок — просто структура, и первым делом reconcile её заполняет. Это упрощённо, но отражает реальную форму:

// Снимок наблюдения: всё, что мы знаем об этом экземпляре прямо сейчас.
type reconcileHandler struct {
    // Объект (.spec = уставка, .status = измеренное состояние).
    instance *v1.PostgresInstance
    // Объекты Kubernetes.
    pod  *corev1.Pod
    pvc  *corev1.PersistentVolumeClaim
    node *corev1.Node
    // Состояние базы данных.
    dbState DatabaseState
    // То, что Postgres реально загрузил, а не то, что мы последний раз записали.
    effectiveConfig map[string]string
    // Собрано вне основной полосы.
    diskUsage *resource.Quantity
    // Операция над томом, выполняемая прямо сейчас, если есть.
    storageOp *StorageOperation
}

func (r *Reconciler) newReconcileHandler(
    ctx context.Context,
    inst *v1.PostgresInstance,
) (*reconcileHandler, error) {
    h := &reconcileHandler{instance: inst}
    // Дешёвые локальные чтения.
    h.pod, h.pvc, h.node = r.fetchKubeObjects(ctx, inst)
    // Активные обращения к базе данных.
    h.dbState = r.queryDatabase(ctx, h.pod)
    h.effectiveConfig = r.readEffectiveConfig(ctx, h.pod)
    // Значения от сборщика/метрик.
    h.diskUsage = r.collector.Usage(h.pvc)
    h.storageOp = r.collector.InFlightOp(h.pvc)
    return h, nil
}

Несколько вещей здесь намеренны и выглядят очевидными только после того, как один-два раза наступишь на грабли.

Собираем один раз, в начале. Каждое под-решение в reconcile читает из этого единственного снимка. Мы не обращаемся к базе данных повторно в середине цикла и не читаем использование диска снова где-то в глубине вызовов. Если бы мы это делали, разные части одного reconcile могли бы видеть разные версии реальности. Это звучит как мелкая деталь, но меняет весь дизайн.

Например, база данных может быть лидером в момент проверки в начале и репликой к тому времени, когда другой вспомогательный метод проверит снова. Тогда получаются решения, каждое из которых разумно само по себе, но вместе — неверны. В кодовой базе у нас есть правило против сохранения состояния обратно в этот handler в середине reconcile для передачи между шагами, потому что это снова вносит именно ту несогласованность, которую мы избегаем, собирая снимок. Сделать reconcileHandler неизменяемым (immutable) — один из способов закрепить это правило в системе типов, а не надеяться на code review.

Частичные сбои допустимы. Соединение с базой данных может не удаться, пока чтения из Kubernetes проходят успешно. Это не всегда ошибка, которая прерывает reconcile. Это измеренный факт: «Postgres сейчас недоступен». Это само по себе нужно записать в status. Контур управления, который полностью останавливается при недоступности одного сенсора, — это контур, который часто простаивает. Вместо этого мы деградируем. Представьте автомобиль: если сломался датчик дождя для дворников, весь автомобиль не останавливается. Вы продолжаете ехать, но кое-что нужно включить вручную.

Как только снимок данных готов, reconcile — это последовательность небольших идемпотентных шагов, каждый из которых сравнивает один срез желаемого с наблюдаемым и действует, чтобы закрыть разрыв:

func (r *reconcileHandler) reconcile(ctx context.Context) (reconcile.Result, error) {
    var rb results.Builder
    rb.Merge(r.reconcileConfigMap(ctx)) // применить желаемый конфиг
    rb.Merge(r.reconcileDatabase(ctx))  // перезагрузить/перезапустить при дрейфе параметров
    rb.Merge(r.reconcilePVC(ctx))       // расширить диск при необходимости
    rb.Merge(r.reconcilePod(ctx))       // создать/заменить Pod
    rb.Merge(r.reconcileStatus(ctx))    // всегда последним: записать то, что наблюдали
    return rb.Result()
}

Status записывается последним намеренно, потому что это измеренная переменная: вы фиксируете то, что верно после того, как совершили действия и наблюдали результат. В наших операторах записать status в середине reconcile невозможно по устройству.

Каждый шаг независимо идемпотентен. Каждый возвращает результат — либо «готово», либо «поставь меня в очередь через 30 секунд, я жду чего-то», — и результаты объединяются. Читается это почти так же, как тело моего shell-цикла. Разница в том, что «наблюдать состояние» выросло от df и pg_isready до fan-in из нескольких систем, а «совершить действие» выросло от ssh до типизированных API-записей с обработкой конфликтов.

Вот что такое оператор. Kubelet, планировщик, CSI и CNI — это инфраструктура, которую мы получаем, используя Kubernetes. Этот цикл с его запутанным реальным шагом наблюдения — та часть, которую мы действительно пишем сами и с которой работаем. Понимая, как устроена нижележащая система, мы можем проектировать его, не воспринимая Kubernetes как чёрный ящик.

Не все фронты приходят от API-сервера

Есть ещё один компонент, и он позволяет мне закрыть петлю из части 1, которую я намеренно оставил открытой: проверку использования диска.

Мой shell-скрипт опрашивал df на каждом узле каждые пять секунд. Для трёх узлов — нормально. Для тысяч баз данных нельзя запускать reconcile каждые несколько секунд только чтобы проверить число, которое редко меняется: весь CPU уйдёт на получение снова и снова одного и того же ответа «всё ещё 40%, всё ещё 40%, всё ещё 40%». Это единственная реальная цена модели управления по уровню: перепроверять всё правильно, но не бесплатно.

Решение — добавить сенсор, который сам генерирует фронты. Фоновый сборщик опрашивает нашу метрическую систему на предмет использования диска с медленным интервалом, хранит последнее значение на том в памяти и генерирует событие только когда использование пересекает порог — не пока оно выше или ниже него:

// Обнаружение фронта. Мы срабатываем только при переходе через порог,
// а не каждый цикл, когда оказываемся выше него. Зависание на 81% — тишина;
// пересечение 80% вверх — событие.
crossedUp := previousUsage < pvc.GrowThreshold && usage >= pvc.GrowThreshold
if crossedUp {
    // -> generic event -> work queue -> reconcile
    relay.Send(Event{Key: pvc.Key})
}

Это событие попадает в ту же рабочую очередь, что и watch-события от API, и запускает обычный reconcile затронутого экземпляра. То же правило, что и прежде: событие будит нас, reconcile принимает решение на основе текущего состояния.

Например: есть диск на 10 ГиБ, на нём 8 ГиБ данных. Сборщик увидел пересечение порога и будит reconciler. Reconciler читает текущее использование, видит, что порог 80% пройден, и задаёт новый размер в PVC. Дальше CSI сам разберётся.

А поскольку фронты могут теряться (сборщик может быть недоступен, событие может выпасть из заполненного канала), существует ресинхронизатор (resyncer): периодический таймер, который ставит каждый объект в очередь на reconcile раз в минуту или около того — независимо от событий. Это страховочная сетка. Наш sleep 5. Есть и RequeueAfter — reconcile возвращает его, говоря «разбуди меня снова через 30 секунд»: так контроллер следит за медленной внешней операцией, не удерживая воркера.

Ещё два вопроса: как часто должен работать цикл и кто имеет право его запускать? Теория управления называет первое интервалом дискретизации (sampling interval). Практическое правило: действовать быстрее, чем меняется отслеживаемый процесс, но не быстрее, чем он успевает реагировать. Reconcile диска, который заполняется часами, каждые несколько миллисекунд — это просто трата CPU ради многократного получения одного и того же ответа.

Поэтому оператор устанавливает ограничения:

  • Задержка схлопывания (coalescing delay) справляется с шумными граничными событиями: всплеск событий для одного объекта превращается в один reconcile (это и есть fan-in), а не в тысячу.

  • Ресинхронизатор — страховочная сетка: каждый объект просматривается периодически, даже когда ничего не срабатывает.

  • Выбор лидера (leader election) отвечает на вопрос кто: только одна копия оператора запускает цикл в каждый момент времени. Два контроллера, пишущих в один и тот же объект базы данных — не «большая надёжность». Даже с идемпотентными контроллерами они будут переставлять друг друга в очередь из-за конфликтов и тратить лишние вычислительные ресурсы. В теории идеально написанный контроллер должен это выдерживать. На практике ПО редко бывает идеальным, и безопасная граница того стоит.

В завершение части 2 перерисуем контур управления. В части 1 на схеме было несколько простых блоков. Теперь тот же контур выглядит более реалистично как замкнутый контур обратной связи в production:

Схема контура обратной связи production-оператора
© 2026 meganuke