Files
orchestrator/docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md

18 KiB
Raw Permalink Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-062 architecture architect proposed 2026-06-09 claude-opus-4-8

ADR-001: Авто-prune docker build cache — фоновый heartbeat-демон, выполняющий docker builder prune на хосте через ssh

Work Item: ORCH-062 — INFRA: авто-prune docker build cache на mva154 Стадия: architecture Сквозная регистрация: docs/architecture/adr/adr-0025-build-cache-pruner.md (кросс-каттинг — вводит новый фоновый компонент в ряду reconciler/job_reaper/disk_watchdog).

Статус

Proposed

Контекст

07.06.2026 хост-диск mva154 тихо дорос до 100% и положил весь self-hosting-конвейер всех проектов (один прод-инстанс orchestrator на общей БД/очереди обслуживает и enduro-trails, и orchestrator). Доминирующий «пожиратель» — docker build cache (≈11 ГБ), накопленный частыми пересборками (docker compose up -d --build при прод-деплое; пересборка staging-образа --profile staging; build-once retag за check_staging_image_fresh, ORCH-058). ORCH-063 ввёл disk-watchdog, который только сигнализирует (Telegram-алерт ≥85%) и явно отложил авто-очистку в отдельную задачу. ORCH-062 — эта задача.

BRD/ТЗ ставят развилку реализации (06-adr решает):

  • A — heartbeat-демон в приложении (src/build_cache_pruner.py), 1:1 на src/disk_watchdog.py.
  • B — host daemon.json builder.gc.defaultKeepStorage (BuildKit GC, инфра-процедура Owner).
  • C — host-cron docker builder prune -af --filter until=24h (инфра-процедура Owner).

Факты, сверенные с кодом (важно для выбора):

  • Контейнер orchestrator НЕ содержит docker CLI. Dockerfile:11 ставит только openssh-client git curl ca-certificates. src/image_freshness.py::image_revision прямо фиксирует: «docker lives on the HOST (the container ships only openssh-client git), so when ssh_target is given the inspect runs over ssh». → Любая docker-операция приложения над хостом идёт через ssh на хост (ssh deploy_ssh_user@deploy_ssh_host docker …), как уже делают image_freshness и self_deploy (Phase B). Допущение BRD A-1 («docker.sock смонтирован → приложение может вызвать docker builder prune») верно на уровне сокета, но не даёт готового CLI; raw-HTTP-over-UDS — лишний код против существующего ssh-канала.
  • В оркестраторе уже три проверенных фоновых daemon-потока с единым каркасом (threading.Thread(daemon=True) + threading.Event, start()/stop(timeout)/status(), per-tick never-raise, kill-switch, снимок в GET /queue): reconciler (ORCH-053), job_reaper (ORCH-065), disk_watchdog (ORCH-063, src/disk_watchdog.py).
  • ssh-канал на хост сконфигурирован и доступен: settings.deploy_ssh_user (дефолт slin), settings.deploy_ssh_host; ключи проброшены ro (~/.orchestrator-ssh → /home/slin/.ssh, ORCH-040); slin — в группе docker (деплой-хук запускает docker compose на хосте).
  • docker builder prune по контракту BuildKit затрагивает только build cache, не останавливает контейнеры и не удаляет образы запущенных сервисов (основа BR-3).

Решение

Сводка

Выбран Вариант A — фоновый heartbeat-демон src/build_cache_pruner.py, смоделированный 1:1 на src/disk_watchdog.py (тот же каркас, контракт, kill-switch, never-raise, блок в GET /queue), который периодически выполняет docker builder prune на ХОСТЕ через ssh — тем же каналом deploy_ssh_user@deploy_ssh_host, что уже используют image_freshness и self_deploy. Это «вторая половина» disk-watchdog: watchdog сигналит — pruner убирает.

Варианты B и C отклонены (см. «Альтернативы»). Вариант C сохраняется как задокументированный ручной fallback в 07-infra-requirements.md на случай, если ssh-канал недоступен.

D1 — Механизм: фоновый демон приложения (A), не host-инфра (B/C) — BR-1/FR-1

Новый leaf-модуль src/build_cache_pruner.py (без обратных зависимостей на stage_engine/stages/qg, как disk_watchdog/serial_gate/task_deps). Класс BuildCachePruner с каркасом disk_watchdog: daemon-поток, чистый стоп через _stop.wait(interval), контракт start()/stop(timeout)/status(), модульный singleton build_cache_pruner. Каждые build_cache_prune_interval_s (дефолт 21600с = 6ч, NFR-4 «порядка часов») один тик выполняет уборку. Выбор A над B/C даёт: наблюдаемость в GET /queue, kill-switch из конфига, golden-source-в-git, юнит-тесты, и симметрию с disk-watchdog (один паттерн на два смежных эксплуатационных демона) — это снижает стоимость сопровождения и когнитивную нагрузку следующего агента.

D2 — Команда и политика удержания: строго BuildKit GC с возрастным фильтром — BR-2/BR-3/FR-2/FR-3

  • Команда уборки — строго docker builder prune -f --filter until=<until> (BuildKit GC). Дефолт until=24h (build_cache_prune_until, ориентир из бизнес-запроса): удаляется build cache старше 24ч, свежий тёплый кэш недавних сборок сохраняется (BR-2/AC-2).
  • Флаг -a/--allтолько опционально (build_cache_prune_all, дефолт False) и всегда в паре с возрастным фильтром; «снести весь кэш» (prune -af без until) запрещён дефолтом.
  • Жёстко запрещены docker image prune, docker system prune, любое удаление образов запущенных сервисов, любая остановка/рестарт контейнеров. Затрагивается только build cache (BR-3/AC-3). Уборка никогда не рестартит/не роняет прод-контейнер orchestrator (групповой риск self-hosting).

D3 — Канал исполнения: ssh на хост (CLI в контейнере нет) — BR-3/FR-3/NFR-1

  • Уборка исполняется на хосте: ssh -o StrictHostKeyChecking=no <deploy_ssh_user@deploy_ssh_host> "docker builder prune -f --filter until=<until>", по образцу image_freshness.image_revision (ssh_target-ветка). Это где физически живёт build cache (host docker daemon).
  • Нет ssh-таргета (deploy_ssh_host пуст) → тик no-op (лог + status() отражает причину). Это естественно скоупит фичу на self-hosting-прод (где ssh настроен) и делает дефолт безопасным для любого окружения без host-доступа — параллель тому, как self_deploy/ image_freshness деградируют без _ssh_target().
  • Вызов bounded таймаутом (build_cache_prune_timeout_s, дефлот 120с) и неблокирующий конвейер (отдельный daemon-поток). Любой сбой — ниже D6.

D4 — Конфиг, kill-switch, дефолты — BR-5/BR-6/FR-5/NFR-3

Новый блок флагов в src/config.py рядом с disk_monitor_* (env-префикс ORCH_BUILD_CACHE_PRUNE_*):

Поле (settings.*) env Дефолт Назначение
build_cache_prune_enabled ORCH_BUILD_CACHE_PRUNE_ENABLED True kill-switch; False → демон не стартует, поведение 1:1 как до задачи (NFR-3)
build_cache_prune_interval_s ORCH_BUILD_CACHE_PRUNE_INTERVAL_S 21600 (6ч) период тика, сек
build_cache_prune_until ORCH_BUILD_CACHE_PRUNE_UNTIL 24h возраст удержания (--filter until=)
build_cache_prune_all ORCH_BUILD_CACHE_PRUNE_ALL False добавить -a (только в паре с until)
build_cache_prune_timeout_s ORCH_BUILD_CACHE_PRUNE_TIMEOUT_S 120 таймаут ssh-команды, сек
build_cache_prune_notify_min_gb ORCH_BUILD_CACHE_PRUNE_NOTIFY_MIN_GB 0 Telegram при освобождении ≥ N ГБ; 0 → тихо (без нотификаций)

Дефолт enabled=True (обоснование, не самоочевидно): (а) бизнес-цель BR-1 — авто-предотвращение инцидента без ручного вмешательства; дефолт False означал бы, что оператор обязан вспомнить и включить флаг, что подрывает саму задачу; (б) операция документированно-безопасна (только build cache, never images/containers/restart — D2/A-2); (в) при отсутствии ssh-таргета тик no-op (D3) → фича безопасна-по-построению в любом окружении без host-доступа; (г) полностью обратима kill-switch. Это сознательный, явно зафиксированный компромисс «безопасный дефолт vs авто-цель» в пользу авто-цели, при сохранённой обратимости. Параллель: disk_monitor_enabled тоже дефолт True.

Валидаторы (паттерн _disk_positive_int/_disk_threshold_pct из config.py): невалидный interval_s/timeout_s (не-int / ≤0) → лог-warning + дефолт; невалидный until (не матчит ^\d+[smhdw]?$) → лог-warning + 24h. Невалидное значение никогда не роняет старт (AC-6).

D5 — Наблюдаемость — BR-4/FR-4

Аддитивный read-only блок build_cache_prune в GET /queue (как disk_monitor): enabled, interval_s, until, all, last_run_ts, last_reclaimed (распарсенное Total reclaimed space: … из вывода docker builder prune, best-effort), last_error (строка причины последнего сбоя/no-op, или null). status() — never-raise (минимум {"enabled": …} при ошибке). Опционально — send_telegram при освобождении ≥ notify_min_gb (по образцу recovery-сообщения watchdog'а; дефолт выключено).

D6 — Инварианты и never-raise — NFR-1/NFR-2/NFR-5/FR-6, AC-4/AC-8

  • STAGE_TRANSITIONS, реестр QG_CHECKS, check_*, _parse_*, src/stage_engine.py, схема БД (src/db.py) — не изменяются. Pruner — эксплуатационный демон, не Quality Gate (категория reconciler/job_reaper/disk_watchdog).
  • Без миграции БД: учёт «когда убирали в последний раз»/последний результат — in-memory, best-effort; сброс при рестарте безопасен (максимум одна лишняя безопасная уборка, NFR-5).
  • never-raise на двух уровнях: per-команда (ненулевой rc / таймаут / OSError / недоступность ssh / parsing-ошибка вывода → лог + проглот, тик жив) и per-tick (внешний try/except в _run, как disk_watchdog._run). Фоновый цикл и конвейер не падают.
  • Self-hosting: ssh выполняет docker builder prune на хосте под slin (в группе docker); команда не трогает образы/контейнеры запущенных сервисов; прод не рестартится. Обслуживание enduro-trails в общем инстансе не затронуто.

D7 — Жизненный цикл (main.lifespan)

Старт демона — последним, сразу после disk_watchdog.start() (строки ~113114 main.py); стоп — первым в reverse-порядке, перед disk_watchdog.stop(). start() чтит kill-switch (no-op при enabled=False), как DiskWatchdog.start().

Альтернативы

  • Вариант B — host daemon.json builder.gc.defaultKeepStorageотвергнуто: применение конфигурации требует рестарта docker daemon на mva154, что останавливает ВСЕ контейнеры хоста (прод orchestrator + всё остальное) → катастрофический self-hosting blast radius (BRD C-1/R-3). Дополнительно: политика BuildKit GC — по объёму (defaultKeepStorage), а не по возрасту (BR-2 хочет until=24h); состояние не наблюдаемо в GET /queue (только хостовый docker system df); конфигурация — off-git host-артефакт.
  • Вариант C — host-cron docker builder prune -af --filter until=24hотвергнуто как основное (сохранено как ручной fallback в 07): off-git невидимая инфра (следующий оператор/агент её не видит), нет наблюдаемости в GET /queue, нет kill-switch из конфига, не покрывается tests/ — ломает принцип self-contained/reproducible/observable, которому следуют остальные демоны.
  • A через raw-HTTP по docker.sock (без ssh)отвергнуто: требует ручного HTTP-over-UDS клиента (chunked-ответы, версионирование API) — лишний код против уже существующего, проверенного ssh-канала image_freshness/self_deploy.
  • A через docker CLI, вкомпилированный в образотвергнуто: раздувает образ и требует пересборки/рестарта прода ради уборки; ssh-канал на хост уже есть и не трогает образ.

Последствия

  • + Корень инцидента 07.06 (build cache → 100% диска) устраняется автоматически, без ручного вмешательства; тёплый кэш ≤24ч сохранён → штатные пересборки не «холодные».
  • + Знакомый паттерн фонового демона (калька disk_watchdog) → низкий риск, наблюдаемость в GET /queue, обратимость одним флагом, юнит-тестируемость, golden-source-в-git.
  • + Без новых внешних зависимостей и без рестарта docker daemon/прода (принцип «всё в Docker на одном сервере, минимум зависимостей»); ssh-канал переиспользован.
  • Зависимость от ssh-доступа на хост (как у image_freshness/self_deploy); при отсутствии — тик no-op (наблюдаемо в status().last_error), фича просто не работает, но ничего не ломает. Митигейшн: документированный host-prerequisite + fallback-cron (07).
  • In-memory учёт результата (без миграции) — допустим для эксплуатационного демона (не SLA).
  • Откат: ORCH_BUILD_CACHE_PRUNE_ENABLED=false → демон не стартует, поведение 1:1 как до задачи; миграций БД нет, удалять нечего.

Ссылки

  • BRD: docs/work-items/ORCH-062/01-brd.md
  • TRZ: docs/work-items/ORCH-062/02-trz.md
  • Acceptance: docs/work-items/ORCH-062/03-acceptance-criteria.md
  • Инфра-требования: docs/work-items/ORCH-062/07-infra-requirements.md
  • Тех-риски: docs/work-items/ORCH-062/10-tech-risks.md
  • Сквозной ADR: docs/architecture/adr/adr-0025-build-cache-pruner.md
  • Сверено по коду: src/disk_watchdog.py (каркас-образец), src/image_freshness.py (image_revision/_ssh_target — ssh-канал к host docker), src/config.py (disk_monitor_* + валидаторы, deploy_ssh_user/host), src/main.py (lifespan старт/стоп демонов, GET /queue), Dockerfile:11 (нет docker CLI в образе).
  • Родственные компоненты: docs/architecture/adr/adr-0024-disk-watchdog.md (ORCH-063), adr-0007-reconciler.md, adr-0011-job-reaper-lease-reclaim.md.