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

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.