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-зависимость) — тот же механизм, которыйglobalSetupvitest уже использовал. - 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; mcp1.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 с областью по образу.
Известные ограничения (зафиксированы, не решены)
VITE_*задаётся на этапе компиляции. Vite встраиваетimport.meta.env.VITE_*в бандл, поэтому URL api/engine/Keycloak запекаются в образ web: это один образ на окружение, а не один и тот же образ, продвигаемый между ними. Решить это (внедрение в runtime) — предварительное условие для Kubernetes следующей сессии.CSP_CONNECT_SRCnginx, напротив, задаётся в runtime.- Node остаётся в образе engine, потому что DevAgent запускает набор тестов
управляемого проекта внутри (
TerminalExecutor). Не масштабируется на произвольные стеки; настоящий выход — sandbox на проект, вне объёма Фазы 5. rtkостался за пределами образа — без проверяемого источника для закрепления по контрольной сумме. Детектор уже деградирует, и метрика остаётсяnil.docker-compose.prod.ymlне укрепляет сторонние компоненты. Keycloak остаётся вstart-devс dev-realm (захардкоженный секрет,admin123), а Postgres — в контейнере с паролем по умолчанию. Цель того файла — наши три образа; укрепление Keycloak и Postgres — работа другой сессии, и это сказано в начале файла.- Защита ветки сегодня неприменима в этом репозитории. 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 MB | 5,2 MB |
| образ api | 274 MB | 457 MB¹ |
| образ engine | 1,17 GB | 796 MB |
| образ web | 255 MB | 93,5 MB |
| набор тестов engine | 254 passed, 9 excluded | 263 passed, 0 excluded |
| исправимые trivy HIGH/CRITICAL | — | 0 во всех трёх |
¹ Production-образ api больше, потому что dev-образ не несёт
ни кода, ни зависимостей: использует bind mount и тома. Они не сравнимы как
«до и после» одной и той же вещи. Из 457 MB 165 MB — production node_modules
(из которых 51,7 MB — gpt-tokenizer).