Перейти к основному содержимому

ADR 0025 — Фаза 5 (сессия 2): деплой в Kubernetes с Kustomize, метрика очереди и локальный overlay

  • Статус: принят
  • Дата: 2026-07-26
  • Фаза: 5 (сессия 2 — пункт 3 объёма, плюс метрика, которой требует HPA)
  • Следует за: ADR 0024, который поставил production-образы и CI и зафиксировал пять ограничений — первое из них («VITE_* задаётся на этапе компиляции; решить это — предварительное условие для Kubernetes следующей сессии») решается здесь.

Контекст​

После сессии 1 существовал production-путь для трёх образов и CI, который его проверял, но ни одного артефакта деплоя: ни манифеста, ни chart, ни docs/runbooks/. Пункт 3 Фазы 5 требует деплоя в Kubernetes с HPA для engine по глубине очереди Oban, и критерий приёмки исполним — make deploy-local на чистом кластере заканчивается всеми pod в Ready, зелёным smoke, а искусственное заполнение очереди запускает HPA.

Трудной частью было не написание YAML. А то, что масштабирование engine более чем на одну реплику — операция, которую код Фаз 1–4 не поддерживал, и три из пяти препятствий ниже существуют только потому, что эта сессия вводит HPA.

Решения​

1. Kustomize base+overlays, а не Helm​

Критерии, запрошенные в формулировке, с ответами:

  • Необходимость шаблонизации: низкая. Это четыре наших компонента, без условий, без циклов, без sub-charts. Между окружениями меняются значения (URL, StorageClass, реплика, CIDR), а не структура. Язык шаблонов заплатил бы за гибкость, которая не используется.
  • Overlays по окружениям: это родная форма Kustomize, и overlays — валидный YAML: kubectl apply --dry-run=server и kubeconform проверяют их ещё до появления кластера. values.yaml у Helm становится YAML только после рендеринга; ревью diff values — это ревью входа, а не результата.
  • Опыт обновления: kubectl apply -k плюс kubectl rollout undo по workload, с git как источником истины. Мы сознательно отказываемся от helm rollback и понятия release: взамен не существует состояния release, которое могло бы разойтись с репозиторием, — а это самый частый режим отказа Helm, которым управляют несколько человек.

Сторонние компоненты по-прежнему приходят через Helm. External Secrets Operator, CloudNativePG, Prometheus, prometheus-adapter и metrics-server устанавливаются deploy/k8s/bootstrap.sh через helm upgrade --install с версией, закреплённой в deploy/k8s/helm/charts.env. Переписать их на Kustomize означало бы поддерживать форк upstream-манифеста — чистая работа без выигрыша. Разделение таково: операторы через Helm, приложение через Kustomize.

2. Engine — это Deployment, а не StatefulSet​

StatefulSet предлагает две вещи, и ни одна здесь не подходит:

PVC на реплику был бы активно ошибочен. Worktrees dev agents находятся в <PROJECT_WORKSPACES_ROOT>/<project_id>/.worktrees/<agent_id>, внутри того же тома, который монтирует api, — api создаёт bare repo, записывает абсолютный путь в Postgres, а engine читает этот путь из базы и использует его буквально в git push (ADR 0024, решение 5). Дать каждой реплике собственный том — значит сломать идентичность пути, от которой зависит push. Worktree — не состояние реплики: это состояние проекта, охраняемое lock-ом workspace (ADR 0017).

Стабильная сетевая идентичность не нужна. HTTP-трафик входит через Service, который балансирует; а DNSCluster обнаруживает пиров через headless Service (engine-headless), что одинаково работает поверх Deployment.

Следствие: общий PVC — ReadWriteMany. Это не предпочтение, а требование — api и engine пишут в один том и могут оказаться на разных узлах.

3. prometheus-adapter, а не KEDA​

Сохраняет нативный HPA Kubernetes и на один оператор меньше. Цена реальна: одно правило discovery (deploy/k8s/helm/prometheus-adapter-values.yaml) и агрегированный APIService, который является самой хрупкой частью схемы.

Цену делает приемлемой тест: режим отказа adapter молчалив. Если правило не совпадает, HPA остаётся в <unknown> и просто не масштабирует, при pods в Ready и всём остальном зелёном. Поэтому deploy/k8s/smoke.sh не доверяет HPA — он опрашивает external.metrics.k8s.io напрямую и падает, если тот не отдаёт метрику.

4. Пять препятствий, которые пришлось исправить в коде​

Это не добровольный рефакторинг Фаз 1–4. Это разница между «YAML применяется» и «система работает», и три из них существуют только из-за HPA.

4.1. force_ssl перенаправлял probes​

В apps/engine/config/prod.exs было закомментировано paths: ["/health"] в списке исключений force_ssl. Kubelet вызывает probe по IP pod, а не через localhost, поэтому исключение по хосту его не покрывало: /live и /ready отвечали бы 301 на https://, и pod никогда не стал бы Ready. Целый деплой застрял бы на одной закомментированной строке.

4.2. Очистка worktree удаляла живую работу другой реплики​

Engine.Dev.WorktreeCleanup.live_agents/1 обращался к Engine.Dev.Registry, который локален для узла. Пока engine был единственной репликой, «жив в Registry» и «жив» были синонимами. С томом, общим для реплик, реплика A просматривает worktrees агентов реплики B, не находит их в собственном Registry и удаляет их как осиротевшие — пока dev agent ещё пишет в них.

Исправление: источником истины стал dev_agent_states, глобальный по построению (именно из него начинается регидратация), чья строка удаляется, когда агент завершается. Множество эквивалентно на одном узле и корректно на N узлах.

4.3. Engine.Sessions.Monitor удалял session_states при любом :DOWN​

Engine.Dev.Monitor уже различал падение узла и завершение агента (forget?/1); монитор сессий — нет. И эффект был хуже гонки: поскольку порядок остановки дерева роняет SessionSupervisor раньше Monitor, тот остаётся жив, чтобы обработать каждый :DOWN, удалить каждую активную сессию и ещё сообщить о ней в api как о closed_abnormally. Каждый rollout и каждый scale-down помечал бы аномальными именно здоровые сессии.

Исправление — не копия Dev.Monitor: там {:shutdown, _} всегда означает падение узла, здесь {:shutdown, :heartbeat_timeout} — законное завершение, и строка должна уйти, иначе сессия будет регидратироваться вечно.

4.4. :global.trans без кластера Erlang ничего не сериализует​

Engine.Actions.Workspace.ensure! сериализует инициализацию workspace по проекту через :global.trans, который глобален, только если узлы объединены в кластер. Без этого две реплики выполняют параллельный git init в одном каталоге общего тома. DNSCluster уже был в дереве супервизии с Фазы 1, управляемый DNS_CLUSTER_QUERY, — не хватало направить его на headless Service и зафиксировать порт распределения (inet_dist_listen_min/max), чтобы NetworkPolicy могла его разрешить.

4.5. URL web задавались на этапе компиляции​

Долг, зафиксированный в ADR 0024. Vite встраивает import.meta.env.VITE_* в бандл, поэтому каждое окружение требовало собственного образа — противоположность продвижению артефакта, прошедшего CI. Теперь entrypoint nginx генерирует /config.js из окружения контейнера, а apps/web/src/lib/runtime-config.ts его читает, сохраняя VITE_* как резервный вариант для pnpm dev:web, где nginx нет.

Две детали, определившие реализацию:

  • Cache-Control: no-store для config.js входит в map $uri, никогда в add_header дочернего блока — ловушка nginx, которую документирует решение 7 ADR 0024 (add_header в дочернем блоке отбрасывает все унаследованные заголовки).
  • Пустое значение считается отсутствующим. envsubst пишет "" для неопределённой переменной, а '' ?? default в JavaScript — это '': без такой обработки отсутствующий в ConfigMap ключ заставляет приложение указывать на пустой origin и падать с ошибкой CORS, которая ничего не говорит о причине.

5. Probes: три разных вопроса​

В обоих сервисах был единственный /health, и он обращается к базе. Это корректный readiness и неверный liveness: при медленном Postgres liveness, привязанный к базе, перезапускает все реплики одновременно — деградация превращается в полное падение, исполненное самим kubelet.

startuplivenessreadiness
api/live/live/health (база)
engine/live, широкое окно/live/ready (база + регидратация)
web/healthz/healthz/healthz
keycloak:9000/health/started:9000/health/live:9000/health/ready

«Readiness только после регидратации» перестало быть эмерджентным свойством. Гарантия существовала, но неявно: оба регидратора стоят перед Endpoint в дереве супервизии, а Supervisor.start_link/2 последователен. Этого достаточно в Docker; недостаточно в Kubernetes, потому что probe должен различать «ещё регидратирую» и «готов», а без наблюдаемого сигнала единственной разницей был бы закрытый порт, который kubelet читает как мёртвый pod. Теперь Engine.Readiness отмечает каждую стадию в :persistent_term, а /ready её читает, что также делает правило тестируемым, а не зависящим от порядка, который любое будущее переупорядочивание сломало бы молча.

6. Метрика: oban_queue_depth, с измерением по state​

Выставлена на /metrics engine через telemetry_metrics_prometheus_core — только агрегатор плюс scrape/1, обслуживаемый уже существующим router. PromEx принёс бы собственные plug и HTTP-сервер плюс загрузчик дашбордов Grafana, что является работой пункта 5.

Измерение — это SQL-агрегация по engine.oban_jobs, а не Oban.check_queue/1: последний возвращает состояние локального producer, а вопрос HPA — сколько работы ждёт во всём кластере, это свойство таблицы, а не узла.

Фильтр state="available" обязателен. Три worker перепланируют себя сами (OutboxDrainWorker каждые 2 с, WorktreeCleanupWorker каждые 60 с, AnamneseSchedulerWorker), вставляя собственного преемника: в нормальном режиме таблица никогда не пуста, всегда есть jobs в scheduled. HPA, считающий таблицу, читал бы простаивающую систему как насыщенную и держал бы engine на максимуме реплик вечно.

Метрика также явно обнуляет опустевшие очереди. Без этого gauge «залипает»: опустевшая очередь пропадает из запроса, Prometheus продолжает отдавать последнее значение, а HPA держит реплики поднятыми из-за несуществующего backlog.

7. Сеть: список из формулировки неполон​

Объём требует «web→api, api→db, engine→api, engine→db; больше ничего». Применённый буквально, этот список оставляет систему поднятой и сломанной. Мы реализовали default-deny для ingress и egress плюс ровно те потоки, которые система использует, а именно:

потокпочему он здесь
api → keycloakсоздание сессии запрашивает токен client-credentials. Отсутствует в списке
api → engineсоздание сессии вызывает engine по внутреннему HTTP. Отсутствует в списке
engine → keycloakпроверяет полученный токен по JWKS
engine → apiзаписывает событие, создаёт задачу, сообщает о завершении
engine ↔ engineраспределение Erlang (см. 4.4)
prometheus → engine:4000без этого HPA никогда не получит метрику
все → kube-dnsбез DNS ничего не разрешается, и остальное неважно
api/engine → dbпо метке brabo.dev/role: database; в prod — по ipBlock

И web→api не является потоком pod: web — это nginx, отдающий статику, а api вызывает браузер, извне кластера. Дать egress pod web означало бы ослабить политику, ничего не включив.

Оговорка о применении: NetworkPolicy действует, только если CNI её реализует. k3s поставляет встроенный контроллер; kindnet в kind не реализует NetworkPolicy и молча игнорирует манифесты. Поэтому bootstrap по умолчанию использует k3d, даже когда установлен только kind, а smoke сообщает, когда кластер не применяет политики.

8. Внешний Postgres по умолчанию, CloudNativePG для dev/staging​

Base не знает, где находится база: подключение приходит только через DATABASE_URL из Secret. Локальный overlay поднимает Cluster CloudNativePG в собственном namespace (brabo-db), потому что default-deny namespace приложения потребовал бы egress-правил, специфичных для оператора, — не имеющих ничего общего с приложением. Правило allow-db-egress пересекает namespaces, потому что совпадает по метке, а не по имени.

9. Secrets: ESO по умолчанию, sealed-secrets как резерв​

Никаких секретов в версионируемом манифесте. ExternalSecret в base идентичны в каждом окружении; меняется только SecretStore — единственный ресурс пути секретов, объявляемый overlay. Локально store читает из Secret-источника, который bootstrap.sh создаёт императивно с помощью openssl rand.

Следствие: realm.json Keycloak стал шаблоном. Оригинал содержит секреты clients открытым текстом, и положить его в ConfigMap было бы ровно тем «секретом в плоском манифесте», который объём запрещает. initContainer рендерит его со значениями ESO в emptyDir в памяти и громко падает, если остаётся хоть один маркер, — наполовину импортированный realm поднимается зелёным и ломается только на входе.

10. Локальный overlay без ingress: NodePorts на портах compose​

Локальный overlay отображает NodePorts на 3000/4000/8080/8088, ровно порты docker-compose.prod.yml. Это сохраняет валидными dev-realm и значения по умолчанию docker/smoke.sh без трансляции и избавляет от ingress controller и разрешения DNS — которые стоили бы памяти на машине разработчика и принесли бы целый класс офлайн-сбоев (nip.io и подобные требуют интернета). Ingress существует только в staging/prod.

По той же причине Prometheus — это chart одного сервера, без Alertmanager, Pushgateway, node-exporter или Grafana: kube-prometheus-stack не помещается, а дашборды, подготовленные как код, — это пункт 5.

11. Как проверяется критерий приёмки HPA​

make hpa-test вставляет jobs в available в очередь, которая не объявлена в конфигурации Oban. Без настроенного producer их никто не потребляет: они остаются в available, пока их не удалят, и это то условие, которое нужно наблюдать. Альтернативы отброшены из-за побочного эффекта — вставка в очередь default заставила бы engine их выполнять (измерялось бы опустошение, а не backlog), приостановка очереди изменила бы поведение системы во время теста, а создание no-op worker поместило бы тестовый код в домен.

Последствия​

  • Существует реальный деплой в Kubernetes, проверяемый одной командой, и расстояние между «образ поднимается в compose» и «система работает в кластере» перестало быть невидимым — это было пять дефектов, все молчаливые.
  • Engine стал поддерживать более одной реплики на деле, а не только в манифесте.
  • Один и тот же образ web обслуживает любое окружение; долг № 1 ADR 0024 погашен.
  • bandit поднят с 1.12.0 до 1.12.3 (в пределах уже объявленного ~> 1.5): mix deps.get этой сессии указал на EEF-CVE-2026-65623 (HIGH, квадратичный взрыв CPU на фрагментированном WebSocket) — а engine обслуживает каналы Phoenix через websocket.

Известные ограничения (зафиксированы, не решены)​

  1. Нет дренирования сессий при остановке. Это пункт 4 Фазы 5, и он не входил в шесть пунктов этой сессии. Манифесты уже резервируют terminationGracePeriodSeconds и точку подключения preStop, а исправление 4.3 не даёт scale-down уничтожить состояние, — но опускающаяся реплика по-прежнему завершает размещённые на ней сессии без перехода closing с причиной node_shutdown. Поэтому scaleDown.stabilizationWindowSeconds равен 600 у engine: опускаться асимметрично дороже, чем удерживать.
  2. Ни один образ не публикуется в registry. CI использует load: true с фиксированными тегами :prod. Overlays staging/prod уже содержат поле images: наготове, с REPLACE_WITH_DIGEST — публикация по digest — их предварительное условие, а не локального.
  3. Keycloak остаётся в start-dev, унаследовано от ADR 0024 (ограничение 4). Что изменилось — секреты перестали быть открытым текстом. Production-режим, внешняя база и фиксированный hostname по-прежнему вне объёма.
  4. Локальный overlay использует ReadWriteOnce, а не RWX. Это работает, потому что кластер из одного узла, а RWO означает «один УЗЕЛ», а не «один pod». Base остаётся RWX, что правильно; ослабляет overlay, и в настоящем кластере такая конфигурация разместила бы api и engine на разных узлах, и push упал бы с remote unpack failed.
  5. Overlays staging и prod не проверяются никаким тестом, кроме kustomize build + kubeconform. Они зависят от управляемого Postgres, StorageClass RWX, provider секретов и registry — ничего из этого нет на машине разработчика. Маркеры REPLACE_ME намеренны: правдоподобное значение по умолчанию там было бы хуже маркера, потому что прошло бы незамеченным.
  6. k3d image import не импортирует busybox для initContainer. Падает с content digest ... not found — docker pull создаёт мультиархитектурный индекс с манифестом аттестации, который ctr узла отвергает. Bootstrap считает импорт этого образа необязательным и позволяет kubelet скачать его из registry (мы уже зависим от интернета для charts). Для НАШИХ образов импорт остаётся обязательным: их нет ни в одном registry, и сбой там — настоящая ошибка; скрипт стал проверять вывод, потому что k3d выходит с кодом 0, даже когда падает.
  7. pgvector нет в Postgres локального overlay. Расширение создаётся только docker/postgres/init.sql, никогда миграцией (проверено); ни одной колонки vector сегодня не существует. Когда появится первая, CloudNativePG понадобится образ с расширением.