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

ADR 0024 — Фаза 5 (сессия 1): production-образы, compose.prod, CI и smoke-тест

  • Статус: принят
  • Дата: 2026-07-26
  • Фаза: 5 (сессия 1 — пункты 1–5 формулировки)

Контекст​

До сих пор всё, что было в репозитории связано с Docker, было исключительно для разработки. Три образа были single-stage, работали от root, без USER, EXPOSE или HEALTHCHECK, и ни один из них не копировал код: все зависели от bind mount репозитория и устанавливали зависимости при старте контейнера. web был vite dev — нигде не было ни nginx, ни статической сборки. У engine не было конфигурации mix release.

И не было CI: два workflow в .github/workflows/ — помощники Claude Code, ни один не запускает тесты, lint или сборку.

Эта сессия поставляет пункты 1–5 Фазы 5. Вне объёма (пункты 6+): Kubernetes/Helm, graceful shutdown с preStop, OpenTelemetry/Prometheus, backup/restore, rate limit.

Решения​

1. .dockerignore до любого образа: 742 MB → 5,2 MB контекста​

Не существовало ни одного .dockerignore, а оба контекста сборки были корнем репозитория. Каждая сборка отправляла демону node_modules (543 MB), .pnpm-store (220 MB), .git и .env.

Измерено образом-зондом (FROM alpine + COPY . /ctx), потому что скрипт оценки ошибается: два первых моих измерения были неверными из-за моих багов с префиксами, а не из-за бага .dockerignore. С файлом: 5,2 MB.

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

  • design/tokens.css — вход сборки, а не документация. apps/web/src/index.css делает @import '../../../design/tokens.css', и Vite разрешает это в файловой системе. Исключение design/ целиком ломает vite build. Только .md оттуда являются документацией.
  • docker/web/ должен входить в контекст. Образ web копирует оттуда nginx.conf и entrypoint, в отличие от двух других.

2. Латентный баг в сборке api, который никогда не проявлялся​

У apps/api/tsconfig.build.json не было include. Без него tsc также компилировал drizzle.config.ts, vitest.config.ts и scripts/, выводил корень пакета как rootDir и выдавал dist/src/main.js — тогда как start:prod — это node dist/main. Production-команда была сломана всегда, и никто этого не видел, потому что разработка использует start:dev. Первый production-образ это и выявил.

3. Миграция — это one-shot-сервис, а не шаг загрузки​

Если бы каждая реплика мигрировала при подъёме, две поднимающиеся одновременно конкурировали бы за одну и ту же миграцию. В docker-compose.prod.yml это два сервиса, которые запускаются один раз и завершаются, а приложения зависят от них через service_completed_successfully.

Их два, потому что это два владельца схемы в разных образах:

  • api: drizzle-kit — это devDependency, и production-образ не несёт ни одной. Мы создали src/db/migrate.ts, который использует программный мигратор drizzle-orm (runtime-зависимость) — тот же механизм, который globalSetup vitest уже использовал.
  • engine: в release нет Mix; mix ecto.migrate в образе не существует. Engine.Release (канонический шаблон Ecto) вызывается через bin/engine eval "Engine.Release.migrate()".

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

4. В production-образе установка сканера — FAIL-HARD​

В разработке установка gitleaks/hadolint/semgrep выполняется best-effort (|| echo) намеренно: детектор замечает отсутствие, и gate пропускается. В production это неприемлемо, и причина записана в самих предыдущих ADR: без hadolint gate QA для infra одобряет любой Dockerfile (ADR 0021), а без gitleaks gate SecOps работает без проверки секретов (ADR 0020). Сетевой сбой при сборке дал бы зелёный образ, чей gate безопасности — no-op.

Поэтому: без || echo, с проверенной контрольной суммой SHA256 (контрольная сумма гарантирует, что бинарник тот, что ожидался, а не только что он скачался), версиями, закреплёнными в ARG, и финальным шагом проверки, выполняющим каждый бинарник, — включая настоящий semgrep scan, а не только --version. Этот шаг окупился: см. решение 8.

5. Записываемые тома engine (пункт 2 формулировки)​

Две точки монтирования, объявленные через VOLUME и с владельцем, настроенным при сборке:

путьчто записывает
/data/project-workspacesрабочее дерево по проекту и worktrees по агенту в <workspace>/.worktrees/<agent_id>; также permissions.json
/data/git-reposлокальные bare repos, записываемые git push dev agent

Плюс /tmp (4 детектора и временное дерево InfraGateRunner) и $HOME (semgrep скачивает и кэширует правила registry при первом запуске) — эти два как tmpfs, поскольку rootfs только для чтения.

Оба пути должны быть ИДЕНТИЧНЫ в api и в engine. Api сохраняет абсолютный путь bare repo в базе, а engine использует его буквально; монтирование в разных местах ломает push с remote unpack failed.

Следствие, которое проявляется только при запуске: каталоги должны существовать в обоих образах, с правильным владельцем. Когда именованный том рождается пустым, Docker копирует содержимое и владельца пути в образе — если пути не существует, том рождается root, и процесс non-root не может писать. Поэтому образ api тоже создаёт /data и делает chown node:node; node и engine оба uid 1000, поэтому два контейнера делят тома без конфликтов (проверено в обоих направлениях).

6. check_origin Phoenix ломал панель команды в production​

В :prod по умолчанию Phoenix сравнивает origin websocket с url: [host: ...], то есть с PHX_HOST. Живая панель команды (Фаза 4a, пункт 7) общается через канал Phoenix из web, отдаваемого с другого origin, — handshake был бы отклонён, и панель онемела бы без видимой ошибки на сервере.

runtime.exs стал принимать WEB_ORIGIN (список через запятую, та же переменная, которую api уже использует для CORS). Без неё сохраняется строгое значение по умолчанию.

7. nginx: заголовки безопасности пропадали на маршруте, отдающем приложение​

add_header в nginx отбрасывает все унаследованные заголовки, когда дочерний блок объявляет любой add_header. Поскольку политика кэша требовала add_header Cache-Control внутри location = /index.html, заголовки безопасности из server исчезали ровно на / — CSP существовал в файле и не доходил до браузера. Найдено через curl -D-, а не чтением.

Исправление: политика кэша стала map $uri, заголовки объявлены один раз в server, и ни один дочерний блок не использует add_header. /healthz использует default_type вместо add_header Content-Type по той же причине.

Проверено по маршрутам: /, маршрут router (fallback), /assets/<hash>.js, /healthz и 404 несуществующего asset (который должен быть 404, а не index.html, иначе отсутствующий asset приходит в браузер как HTML, и ошибка выглядит как невалидный синтаксис).

Кроме того, официальный entrypoint образа nginx здесь не подходит: он запускает скрипты из /docker-entrypoint.d только когда процесс — root (работая как nginx, он печатает «skipping auto-configuration», и подстановка переменных никогда не происходит) и пишет отрендеренный conf в /etc/nginx, который только для чтения. Отсюда docker/web/entrypoint.sh, который рендерит в /tmp и проверяет через nginx -t перед стартом.

8. Безопасность образов: что исправлено и что принято​

trivy с --severity HIGH,CRITICAL --ignore-unfixed начал сообщать о находках во всех трёх образах. exit-code: 1 в CI родился бы красным; ответом было не ослабить gate, а исправить:

исправлениеэффект
apk upgrade --no-cache во всех трёх (закреплённый тег замораживает и патчи Alpine)обнулил CVE системных пакетов; у web осталось 0 находок
удаление npm/npx/corepack, упакованных в базовый образ Nodeобнулило 24 находки api — все шли из /usr/local/lib/node_modules/npm, ни одной из наших зависимостей. Runtime выполняет node main.js; менеджер пакетов там — лишь поверхность атаки
пин gitleaks 8.21.2 → 8.30.1бинарник 8.21.2 скомпилирован Go 1.23.2 и нёс 15 CVE stdlib, 1 CRITICAL. Проверено, что mix test --only gitleaks по-прежнему проходит

Осталось то, что не исправимо нами, принято в .trivyignore.yaml с тремя обязательными условиями на запись — сторонний бинарник, который мы не компилируем, уже на последнем опубликованном релизе, и expired_at (2026-10-31), чтобы долг снова ломал CI, а не гнил молча:

  • Go stdlib и golang.org/x/crypto внутри официального бинарника gitleaks;
  • mcp 1.23.3, фиксированная зависимость (mcp==1.23.3, не диапазон) semgrep 1.171.0, который уже является последним релизом. Мы пытались удалить пакет, поскольку gates вызывают только semgrep scan и никогда semgrep mcp: не работает — semgrep/cli.py безусловно импортирует semgrep.commands.mcp, и бинарник умирает с ModuleNotFoundError. Поймал это настоящий semgrep scan на шаге проверки Dockerfile (решение 4), а не набор тестов.

9. Lint в режиме проверки выявил 31 накопленную ошибку​

Скрипты lint репозитория работают с --fix/--write, поэтому никто никогда не видел режим проверки. В check mode: 31 ошибка. Тридцать были форматированием (исправлены автоматически), а одна — no-unnecessary-type-assertion. Остались два no-unused-vars: мёртвый import (удалён) и _modules из rest-destructuring, где существование переменной без использования и есть смысл — решено настройкой argsIgnorePattern: '^_' в eslint, что является соглашением, уже используемым в репозитории. Набор тестов api перезапущен после: 508 проходят.

10. Форматтер хоста не был форматтером проекта​

mix format --check-formatted под закреплённым Elixir 1.17.3 (Dockerfiles и CI) провалил 11 уже закоммиченных файлов. Это был не дрейф по небрежности: правила форматтера менялись между версиями, а код был отформатирован более новым Elixir — тем, что на хосте разработки (1.20.2 на этой машине). Запуск mix format на хосте «чинил» файлы для одной версии и ломал для другой, по кругу.

Поскольку закреплённая версия — та, что объявлена во всей инфраструктуре, она — источник истины: 11 файлов отформатированы внутри контейнера 1.17.3, а README стал указывать форматировать там, когда хост расходится. Это была первая находка CI ещё до того, как он появился удалённо.

Заметка о методе: первое прочтение этой проверки показалось мне зелёным, потому что обёртка замаскировала код выхода, а grep | head обрезал список файлов. Только явный echo $? показал, что происходит.

11. Защита от ложно-зелёного в CI​

test_helper.exs исключает теги :gitleaks/:hadolint/:yamllint, когда бинарника нет на машине, — а эти три модуля ровно те регрессии, которые доказывают, что gates не одобряют вслепую (ADR 0020/0021). На голом runner они молча исчезают, и CI остаётся зелёным, ни разу не протестировав gates безопасности.

Job test-engine устанавливает все три в тех же закреплённых версиях, что и Dockerfile.prod, и после mix test явно падает, если вывод содержит любой исключённый тест. Локально до: 254 passed, 9 excluded. С присутствующими бинарниками: 263 passed, 0 excluded.

По той же причине gitleaks репозитория работает с полной историей (fetch-depth: 0): секрет, удалённый в последнем коммите, остаётся восстановимым. 3 находки были двумя синтетическими PAT, которые существуют для того, чтобы их находили (фикстуры регрессий), и placeholder из boilerplate NestJS — занесены в allowlist в .gitleaks.toml с областью по правилу и точному пути, никогда по широкому шаблону, потому что широкий allowlist — тот же no-op, который описывают предыдущие ADR.

12. Сборка, сканирование и smoke в одном job​

Три образа вместе ~1,3 GB; передача их между jobs через artifact стоила бы больше времени загрузки/выгрузки, чем вся сборка. Они остаются в локальном демоне runner. Кэш слоёв через type=gha с областью по образу.

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

  1. VITE_* задаётся на этапе компиляции. Vite встраивает import.meta.env.VITE_* в бандл, поэтому URL api/engine/Keycloak запекаются в образ web: это один образ на окружение, а не один и тот же образ, продвигаемый между ними. Решить это (внедрение в runtime) — предварительное условие для Kubernetes следующей сессии. CSP_CONNECT_SRC nginx, напротив, задаётся в runtime.
  2. Node остаётся в образе engine, потому что DevAgent запускает набор тестов управляемого проекта внутри (TerminalExecutor). Не масштабируется на произвольные стеки; настоящий выход — sandbox на проект, вне объёма Фазы 5.
  3. rtk остался за пределами образа — без проверяемого источника для закрепления по контрольной сумме. Детектор уже деградирует, и метрика остаётся nil.
  4. docker-compose.prod.yml не укрепляет сторонние компоненты. Keycloak остаётся в start-dev с dev-realm (захардкоженный секрет, admin123), а Postgres — в контейнере с паролем по умолчанию. Цель того файла — наши три образа; укрепление Keycloak и Postgres — работа другой сессии, и это сказано в начале файла.
  5. Защита ветки сегодня неприменима в этом репозитории. API GitHub отвечает 403: «Upgrade to GitHub Pro or make this repository public to enable this feature». Целевая конфигурация задокументирована ниже; её применение — ручной шаг пользователя, когда план позволит.

Целевая конфигурация защиты ветки dev​

Применить в Settings → Branches → Add branch protection rule, dev:

  • Require a pull request before merging — без прямого push.
  • Require status checks to pass before merging, с Require branches to be up to date: Lint, Testes TS (api + web), Testes do engine (ExUnit), Gitleaks no repositório, Build, scan e smoke das imagens de produção.
  • Require conversation resolution before merging.
  • Do not allow bypassing the above settings (включая администраторов).
  • НЕ включать auto-merge и merge queue: CLAUDE.md определяет, что merge в защищённую ветку всегда выполняется пользователем вручную, без возможности автоматизации.

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

  • Существует реальный production-путь для трёх приложений, проверяемый локально одной командой, и разница между «набор тестов проходит» и «образ поднимается» перестала быть невидимой.
  • CI покрывает lint, тесты, сборку, сканирование образов и секретов и smoke-тест — и по-настоящему тестирует gates безопасности, что было самой большой дырой.
  • Образы работают non-root с rootfs только для чтения, что также устраняет класс трения, уже появлявшийся в разработке (dist/, принадлежащий root, для удаления которого понадобился контейнер).
  • Остаётся явный и датированный долг: .trivyignore.yaml истекает 2026-10-31, а пять ограничений выше — вход следующей сессии.

Числа​

до (dev)после (prod)
контекст сборки742 MB5,2 MB
образ api274 MB457 MB¹
образ engine1,17 GB796 MB
образ web255 MB93,5 MB
набор тестов engine254 passed, 9 excluded263 passed, 0 excluded
исправимые trivy HIGH/CRITICAL—0 во всех трёх

¹ Production-образ api больше, потому что dev-образ не несёт ни кода, ни зависимостей: использует bind mount и тома. Они не сравнимы как «до и после» одной и той же вещи. Из 457 MB 165 MB — production node_modules (из которых 51,7 MB — gpt-tokenizer).