ADR 0027 — Backup/restore, усиление api, открытая поверхность и релиз
Контекст
Сессии 1–3 Фазы 5 (ADR 0024, 0025 и 0026) поставили образы продакшена, CI, деплой Kubernetes и наблюдаемость. Оставались пункты 6 (backup и restore с проверенным runbook) и 7 (усиление api) из рамок.
Эта сессия закрывает оба и добавляет то, чего не хватало, чтобы назвать систему «готовой к продакшену»: операционные runbook, ревизию открытой поверхности и версионирование.
Решения
1. Хранение backup по КОЛИЧЕСТВУ, а не по возрасту
Удаление по возрасту (--older-than 7d или правило lifecycle в bucket)
стирает хороший backup, когда CronJob не запускается несколько дней, — ровно в
той ситуации, когда он важнее всего. Хранение N самых свежих деградирует
хорошо: без нового запуска ничего не стирается. BACKUP_KEEP_DAILY=7 и
BACKUP_KEEP_WEEKLY=4.
1b. Образ backup: Alpine + aws-cli из apk, а не postgres:16-alpine + mc
Первая версия использовала postgres:16-alpine с mc (клиент MinIO),
обоснованно как единственный статический бинарник, не тянущий рантайм Python.
Trivy в CI провалил это с 48 исправимыми находками HIGH/CRITICAL, и ни одну
из них не могли исправить мы:
- 33 от бинарника
mc: статический Go, с CVE встроенной stdlib. Последний релиз mc — сентябрь 2025 года — проект остановился, и без нового релиза нет патча; - 15 от
gosu, который образpostgresприносит для смены пользователя в entrypoint, — возможность, которую этот образ даже не использует, потому что сразу работает как non-root.
Оба случая помещались в .trivyignore.yaml, но соглашение этого файла
требует expired_at именно для того, чтобы долг вновь появился и был оплачен.
Здесь он никогда не мог бы быть оплачен: результатом был бы постоянно
красный gate на образе, несущем учётные данные чтения всей базы.
Заменили на alpine + postgresql16-client + aws-cli + jq, всё из apk и,
значит, обновляемое apk upgrade при каждой пересборке. Измерено: 48 → 0.
Рантайм Python, бывший доводом против aws-cli, стоит несколько МБ;
сканирование показало, что реальная цена была на другой стороне.
Тег базы привязан к MAJOR Postgres (postgresql16-client существует в
Alpine 3.20): поднятие одного без другого возвращает «server version mismatch»
из решения 4.
2. Метрика backup берётся из ТАБЛИЦЫ, а не из Pushgateway
CronJob записывает результат в backup_runs, а DomainGaugesCollector api —
который уже работает по таймеру и уже скрейпится — публикует
brabo_backup_{last_success_timestamp_seconds,age_seconds,last_status,size_bytes}.
Pushgateway был бы лишним компонентом, вторым источником истины и местом, где метрика переживает описываемый ею факт (серия продолжает публиковаться после того, как job исчез). Таблица ещё и даёт запрашиваемую историю, которой пользуется runbook restore, чтобы ответить «когда был последний хороший backup».
Последствие: -1 используется для «backup никогда не было», отличая это от
«backup 1970 года» — без этого алерт возраста срабатывал бы в первый день
любого нового окружения.
3. Повтор ВНУТРИ процесса, а не пересоздание pod
k3s программирует правила NetworkPolicy после того, как pod получил IP.
Job, обращающийся к сети первой же инструкцией, получает connection refused
(REJECT, а не таймаут) от правила, которое появится секундой позже.
Пересоздание pod пересоздаёт окно — шесть попыток подряд упали одинаково,
пока это не стало ясно.
Backup, restore и создание bucket в bootstrap повторяют изнутри того же контейнера. Это годится и в продакшене: у объектного хранилища бывает временная недоступность, а ежедневный backup, сдающийся при первой сетевой ошибке, превращается в день без backup.
4. PostgreSQL 16, закреплённый в CloudNativePG
Локальный кластер поднимал major оператора по умолчанию (17.4), тогда как
CLAUDE.md решает PostgreSQL 16, а compose использует pgvector/pgvector:pg16.
Расхождение было невидимым до backup: pg_dump 16 отказывает серверу 17 с
«server version mismatch», а дамп, созданный в одном окружении, не
восстанавливается в другом.
imageName: ghcr.io/cloudnative-pg/postgresql:16.10, с закреплённым minor —
если оставить его плавающим, проблема вернётся в тот день, когда оператор
сменит значение по умолчанию.
5. Rate limit со скользящим окном в Postgres
CLAUDE.md запрещает Redis (очереди живут в Postgres через Oban). Окно живёт в
rate_limit_hits, с INSERT и подсчётом в одном statement через CTE.
Принятая цена: один INSERT на каждый учитываемый запрос. Redis решил бы
лучше; RATE_LIMIT_ENABLED=false выключает. Очистка входит в таймер уже
существующего collector, а не в собственный CronJob.
Деталь, которая проявляется только на краю: в Postgres строка, вставленная
пишущим CTE, не видна остальной части того же statement. Подсчёт только
таблицы вернул бы попадания до этого, и лимит действовал бы на один больше
настроенного. Отсюда явная сумма (select count(*) from novo).
Освобождены: маршруты @Public() (душение /health заставляет kubelet
перезапускать pod, превращая пик в падение) и клиент engine-service (engine
вызывает api на каждое событие агента; ограничивать его — система душит саму
себя).
Отказ базы пропускает запрос: этот guard защищает от злоупотребления, а
не от неправомерного доступа — авторизует JwtAuthGuard, который уже
выполнился.
6. CORS отказывает закрыто в продакшене
WEB_ORIGIN начинает принимать список через запятую. Без неё или с *, при
NODE_ENV=production загрузка падает. Раньше молчаливым значением по
умолчанию было http://localhost:5173: забыть переменную при деплое не ломало
ничего видимого, лишь оставляло api разрешительной. Ошибка при старте шумная и
обратимая; разрешительная api — молчаливая и нет.
7. helmet в api, CSP только в web
Api не выдавала ни одного заголовка безопасности. helmet входит с
contentSecurityPolicy: false: она отдаёт JSON, а CSP — дело web, где он уже
есть с сессии 1 и более специфичен (connect-src, собираемый по окружению).
crossOriginResourcePolicy: false, потому что web — другой origin: значение по
умолчанию same-origin заблокировало бы всё приложение, с симптомом,
путаемым с CORS.
8. mix_audit в дополнение к mix hex.audit
mix hex.audit сообщает об отозванном пакете, а не об уязвимости. Сам по
себе gate engine был бы декоративным: ни одна CVE не провалила бы сборку.
mix_audit читает базу advisories Elixir и действительно обнаруживает CVE.
Работают оба; это разные вопросы. Gate — на критических, как требуют
рамки: провал на moderate в монорепозитории с таким деревом стал бы
постоянной блокировкой, и реакцией было бы выключить gate целиком.
9. Документ поверхности — ИСТОЧНИК теста
docs/security-surface.md перечисляет 110 маршрутов с их классификацией.
route-surface.spec.ts поднимает AppModule, перечисляет маршруты,
зарегистрированные во время выполнения (через DiscoveryService, а не
grep), и сравнивает с разобранной таблицей markdown: маршрут без строки
проваливается, расходящаяся классификация проваливается, осиротевшая строка
проваливается.
В engine тест поведенческий: каждый зарегистрированный маршрут получает
запрос без токена, и утверждается то, что увидел бы клиент (401 для всего вне
списка из четырёх исключений). Первая версия читала pipe_through и была
отброшена по двум причинам — __routes__/0 этой версии Phoenix не раскрывает
pipeline, а даже если бы раскрывал, она утверждала бы об АННОТАЦИИ: pipeline
:internal, по ошибке опустошённый, оставался бы «корректным».
10. Ротация мастер-ключа потребовала изменить сервис
EnvelopeEncryptionService выводил ОДИН ключ, и ни одна строка не фиксирует,
каким ключом она обёрнута. Смена переменной делала нечитаемыми все
существующие учётные данные, без ошибки при загрузке — отказ проявлялся только
при первом использовании. Runbook об этом был бы вымыслом.
Решение: CREDENTIALS_MASTER_KEY_PREVIOUS, пробуемый, когда текущий не
подходит, плюс src/scripts/rewrap-deks.ts, переобёртывающий весь массив.
Скрипт живёт в src/ (а не в apps/api/scripts/, который в .dockerignore),
потому что должен быть внутри продакшен-образа. Меняется только конверт;
шифротекст секрета остаётся байт в байт тем же, так что прерывание посередине
оставляет массив согласованным.
11. Релиз по тегу, changelog собственным скриптом
.github/workflows/release.yml запускается на v* и проверяет, что четыре
манифеста объявляют версию тега, — тег v0.2.0 при package.json на 0.1.0
дал бы образы, помеченные версией, которую код не объявляет.
scripts/changelog.mjs (~140 строк) вместо standard-version/changesets:
они несут мнение о bump, коммите и теге — трёх вещах, которые здесь являются
решением пользователя. Скрипт лишь генерирует текст.
Последствия
Принятые:
- Один INSERT на запрос на аутентифицированном пути (решение 5).
- Четвёртый образ для поддержки (
brabo-backup), под теми же gates, что и остальные: non-root, trivy и hadolint. - MinIO в локальном overlay — лишний компонент в кластере разработки, в обмен на то, что путь S3 проверяется в каждом окружении, а не только в продакшене.
- Два мастер-ключа, принимаемые во время окна ротации, удваивают поверхность утёкшего ключа. Api предупреждает в логе при каждой загрузке, пока это длится.
ALTER ROLE brabo CREATEDBв локальном кластере для теста restore. В продакшене так не делается: restore использует собственные административные учётные данные черезRESTORE_ADMIN_URL.
Вне рамок, зафиксировано:
- Публикация образа в registry. Overlay продакшена всё ещё указывает на
ghcr.io/OWNER/*. Workflow релиза собирает и тегирует, но не публикует. - Создание тега
v0.1.0— это действие пользователя, согласующееся с правилом CLAUDE.md о защищённой ветке. - PITR. Гранулярность — последний дамп. WAL archiving в CloudNativePG решил бы это и не был сделан.
- Backup Keycloak и PVC. Рамки требуют Postgres.
GET /, «Hello World!» из scaffold NestJS, по-прежнему зарегистрирован и аутентифицирован. Задокументирован как кандидат на удаление; его удаление — продуктовое решение.- Алерты остаются правилами Grafana, а не Prometheus — отклонение, уже зафиксированное в ADR 0026 и сохранённое здесь для двух новых алертов backup.