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