---
work_item: ORCH-062
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: 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()` (строки ~113–114 `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`.
</content>
</invoke>
