diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index db8c5a1..a7a171d 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -22,3 +22,4 @@ | ADR-016 | Снижение minzoom публичных GPS-треков до z5: калибровка существующих tier-таблиц `build_gps_mvt`/`_simplify_coords`, on-demand MVT остаётся, без heat-map/clustering | accepted | 2026-06-04 | [ET-012](../../work-items/ET-012/06-adr/ADR-016-z5-tiling-policy.md) | | ADR-017 | Zoom-aware paint для hillshade/TRI на z9-z11: `interpolate`-выражения по `raster-opacity` и `raster-contrast`, `raster-resampling: 'nearest'`, понижение UI-минзума hillshade с 10 до 9; без перегенерации растровых тайлов | accepted | 2026-06-04 | [ET-013](../../work-items/ET-013/06-adr/ADR-017-zoom-aware-terrain-paint.md) | | ADR-019 | Z-index фикс terrain-popup vs bottom-sheet: при `openSheet(id)` принудительно скрывать `#terrain-popup` через helper `closeTerrainPopup()`; без правок CSS-стека (marker-dialog z=500, search-panel z=600, ruler-info z=600 остаются нетронутыми) | accepted | 2026-06-04 | [ET-014](../../work-items/ET-014/06-adr/ADR-019-terrain-popup-yields-to-sheet.md) | +| ADR-020 | Container healthcheck сервиса `app`: python stdlib one-liner (`urllib.request.urlopen` + `sys.exit`) в `docker-compose.yml` вместо `curl`; без добавления пакетов в `python:3.12-slim` и без правок Dockerfile/кода; `start_period: 20s`, внутренний `timeout=3` < внешний `timeout: 5s` | accepted | 2026-06-05 | [ET-015](../../work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md) | diff --git a/docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md b/docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md new file mode 100644 index 0000000..0348292 --- /dev/null +++ b/docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md @@ -0,0 +1,460 @@ +--- +type: adr +work_item_id: ET-015 +adr_id: ADR-020 +title: "ADR-020: Container healthcheck выполнять через python `urllib.request` one-liner вместо `curl`" +status: accepted +created_at: 2026-06-05 +updated_at: 2026-06-05 +authors: + - "agent:architect" +supersedes: [] +superseded_by: [] +labels: + - "ET-015:infra" + - "minor-change" +--- + +# ADR-020 — Container healthcheck через python stdlib (`urllib.request`) + +## Статус + +**Accepted.** Архитектурное решение для ET-015. + +Это **инфраструктурный bug-fix** одной YAML-секции в `docker-compose.yml`. +По BRD §6 и §9 — не `arch:major-change` (не новый сервис, не новая БД, +не межсервисный контракт). ADR оформляется, чтобы зафиксировать +**отказ от четырёх альтернатив** (`apt-get install curl`, +`apt-get install wget`, `HEALTHCHECK` в Dockerfile, отдельный +`scripts/healthcheck.py`) — чтобы они не вернулись в обсуждение при +следующих правках Dockerfile / compose. + +## Контекст + +### Текущее состояние (как есть) + +`docker-compose.yml` (строки 22–26): + +```yaml +healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"] + interval: 30s + timeout: 5s + retries: 3 +``` + +`Dockerfile` (строки 1–13): + +```Dockerfile +FROM python:3.12-slim +WORKDIR /app +COPY src/api/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY src/api/ ./src/api/ +COPY src/web/ ./src/web/ +COPY scripts/ ./scripts/ +COPY migrations/ ./migrations/ +COPY docs/ ./docs/ +ENV STATIC_DIR=/app/src/web +ENV PORT=5556 +EXPOSE 5556 +CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "5556"] +``` + +`src/api/main.py:1224`: + +```python +@app.get("/api/health") +async def health(): + return { + "status": "ok", + "db_path": DATA_PATH, + "db_exists": os.path.exists(DATA_PATH), + } +``` + +### Проблема + +Базовый образ `python:3.12-slim` **не содержит** `curl`. Каждый цикл +healthcheck завершается: + +``` +exec: "curl": executable file not found in $PATH +exit code = -1 +``` + +Docker через `retries=3` помечает контейнер `unhealthy`. По данным с +mva154 (BRD §1): + +- `enduro-trails-app-1` ~31 час в статусе `unhealthy`. +- `FailingStreak = 3762` при `RestartCount = 0`. +- Приложение работает: HTTP 200 на `/api/health` за ~7 мс, в access-логах + живой трафик. + +Эндпоинт `/api/health` существует и корректен (отдаёт 200 + JSON со +`status: "ok"`). Двойной поломки нет — проблема исключительно в +отсутствии `curl`. + +### Архитектурный вопрос + +Как заставить healthcheck **честно** отражать состояние приложения +(`healthy` при HTTP 200, `unhealthy` при недоступности), **не раздувая +образ** и **не вводя** новых файлов/пакетов? + +### Инварианты, которые мы хотим сохранить + +| Инвариант | Источник | +|-----------|----------| +| Образ остаётся `python:3.12-slim`, без `apt-get install` лишних пакетов | BRD §6 («размер образа не должен заметно расти»); CLAUDE.md «минимум зависимостей» | +| Эндпоинт `/api/health` не меняется | BRD §7 (out of scope); TRZ §3.3 | +| Никаких изменений в `src/api/`, `src/web/`, БД, тайлах | BRD §6 | +| `interval/timeout/retries` ≥ текущих (30s/5s/3) | TRZ §3.1 | +| `restart: "no"` для `gps-collector` сохраняется, healthcheck к нему не добавляется | BRD §7 (out of scope) | +| Деплой не требует ребилда образа (только пересоздание контейнера) | TRZ R-2 | + +## Рассмотренные варианты + +### Вариант A — `python -c "import urllib.request, sys; ..."` one-liner (выбран) + +В `docker-compose.yml` секция `healthcheck` сервиса `app` приводится к виду: + +```yaml +healthcheck: + test: + - "CMD" + - "python" + - "-c" + - "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5556/api/health', timeout=3).status == 200 else 1)" + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s +``` + +Pros: + +- **Нулевая правка образа.** `python` (alias на `python3`) уже доступен + в `python:3.12-slim` — это интерпретатор приложения. `urllib.request`, + `sys` — стандартная библиотека Python 3.12, поставляется с + интерпретатором без отдельных пакетов. +- **Никакого ребилда.** Изменение только `docker-compose.yml` → + `docker compose up -d app` пересоздаёт контейнер без `docker compose + build` (TRZ R-2, AC-04, IT-04). +- **`CMD` (массив), не `CMD-SHELL`** — Docker запускает аргументы + напрямую через `exec`, без `/bin/sh -c`. Никакого парсинга shell, + никакого экранирования кавычек, no shell-injection поверхность. +- **Корректная семантика exit code.** `sys.exit(0 if status == 200 + else 1)` отдаёт 0 при HTTP 200; любой `URLError`, `HTTPError`, + `socket.timeout`, отказ соединения — поднимается исключением, питон + завершается ненулевым кодом → Docker фиксирует «провал». +- **Внутренний `timeout=3` < внешний `timeout: 5s`** (AC-07, ST-04). + Запас 2 с покрывает старт интерпретатора и фоновую нагрузку. Если + сеть/процесс реально зависли — питон сам закроется через 3 с с + `socket.timeout`, и Docker зафиксирует exit code до своего внешнего + timeout, без принудительного убийства. +- **`start_period: 20s`** добавлен новой строкой. Uvicorn поднимается + за < 2 с; 20 с — комфортный запас, чтобы первые «фейлы» при холодном + старте не учитывались в `FailingStreak`. Старый compose + `start_period` не задавал; добавление поля совместимо с Docker Engine + ≥ 20.10 (BRD §6). +- **Локальность по nework namespace.** `http://localhost:5556` внутри + контейнера = loopback самого контейнера, не зависит от хоста, iptables, + nginx или `OSRM_URL`. Проверяется именно «приложение слушает свой + порт». +- **Идемпотентность.** Healthcheck не пишет в БД, не дёргает внешние + сервисы, не меняет состояние. Отдельный python-процесс на ~5–10 МБ + RAM каждые 30 с — пренебрежимо. + +Cons / Принимаем: + +- **Стоимость fork+exec питона.** Каждые 30 с поднимается отдельный + процесс `python` (~80–150 мс старт интерпретатора + ~7 мс реальный + запрос). На фоне общего idle-загруза `app` это пренебрежимо + (см. R-T-2 в `10-tech-risks.md`). +- **Длинная строка в YAML.** Однострочник длиной ~135 символов. + Читаемость снижается, но YAML-массив `[..., "..."]` не имеет лимита + 120 символов (это python/JS-конвенция). Если в будущем строка + разрастётся — можно перейти на YAML block-scalar `>-` или + `|` (TRZ R-5). +- **Не используется `python3` явно.** В `python:3.12-slim` + `/usr/local/bin/python` и `/usr/local/bin/python3` — оба ведут на + `python3.12`. Используем короткий `python` (TRZ §3.1). Если когда-нибудь + alias уберут (маловероятно — Python Docker images поддерживают оба + как минимум до 3.13), правка тривиальная. + +### Вариант B — `apt-get install -y --no-install-recommends curl` в Dockerfile (отклонён) + +```Dockerfile +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +``` + +Pros: + +- Самая привычная команда. Healthcheck остаётся прежним + `curl -f ...`, минимальная YAML-правка. +- Curl как побочный «debug-инструмент» полезен при `docker exec`. + +Cons (отклонён): + +- **+10 МБ к образу** (curl + libcurl4 + dependencies + apt cache). + Противоречит BRD §6 и CLAUDE.md «минимум зависимостей». +- **Новый APT-слой** = ребилд образа, инвалидация cache layers ниже + по Dockerfile при будущих правках (хотя сейчас `RUN apt` стал бы + последним layer'ом — но любая правка системного пакета в будущем + ломает кэш). +- **Расширение attack surface.** Curl — net-стек, libssl, libnghttp2, + libldap, libgss и пр. Для одной проверки HTTP 200 на loopback — + явный over-kill. +- **Не решает дефекта философии.** Если завтра потребуется ещё одна + CLI-утилита (jq, dig, postgres-client), снова `apt-get install`? + Образ деградирует. Вариант A эту дорожку закрывает. + +### Вариант C — `apt-get install -y --no-install-recommends wget` (отклонён) + +```Dockerfile +RUN apt-get install -y --no-install-recommends wget +``` + +И в compose: `["CMD", "wget", "--spider", "-q", "http://localhost:5556/api/health"]`. + +Pros: + +- Wget немного меньше curl по размеру (~5–7 МБ vs ~10 МБ). +- Однострочник в compose такой же лаконичный. + +Cons (отклонён): + +- Всё ещё +5–7 МБ к образу + APT-слой. Те же возражения, что у + Варианта B, в смягчённой форме. +- **`wget`-`--spider` имеет неочевидные exit code'ы**: на 200 он + возвращает 0, но на 404 он тоже может вернуть 0 в некоторых + конфигурациях (зависит от версии), а на network error — 4. Контракт + менее предсказуемый, чем `python sys.exit`. +- В `python:3.12-slim` `wget` отсутствует наравне с `curl` (мы + специально проверили — пакет `wget` не входит в slim-вариант). + +### Вариант D — `HEALTHCHECK` директива в Dockerfile (отклонён) + +```Dockerfile +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5556/api/health', timeout=3).status == 200 else 1)" +``` + +Pros: + +- Healthcheck живёт в одном файле с образом — «доехал с образом всюду», + работает даже при ручном `docker run` без compose. +- В compose можно оставить `healthcheck:` пустым. + +Cons (отклонён): + +- **Дублирует compose.** В текущей архитектуре оба места могут содержать + `healthcheck`, и порядок переопределения (compose > Dockerfile) + превращает изменения в гадание «откуда оно сейчас читается». +- **Требует ребилда образа** при любой правке (интервал, timeout, путь). + Нарушает TRZ R-2. +- **На mva154 единственный путь запуска — `docker compose`.** Ad-hoc + `docker run` не используется. Преимущество «работает без compose» + пустое. + +### Вариант E — отдельный файл `scripts/healthcheck.py` (отклонён) + +```python +# scripts/healthcheck.py +import sys, urllib.request +sys.exit(0 if urllib.request.urlopen("http://localhost:5556/api/health", timeout=3).status == 200 else 1) +``` + +И в compose: `["CMD", "python", "/app/scripts/healthcheck.py"]`. + +Pros: + +- Чище YAML. Длинная строка убирается. +- Скрипт можно тестировать отдельно (unit-test). + +Cons (отклонён): + +- **Лишний файл для двух строк.** Нарушает «минимум зависимостей» + (CLAUDE.md). YAML-массив прекрасно вмещает one-liner. +- **Файл уже COPY'ится в образ** (`COPY scripts/ ./scripts/` в + Dockerfile, строка 7), но это требует, чтобы скрипт находился в репо + ещё до сборки. Если кто-то выкатит compose-правку без свежего образа, + healthcheck сломается до ребилда. Нарушает TRZ R-2 (идемпотентность + пересборки). +- **Усложняет диагностику.** При проблеме healthcheck нужно открывать + и compose, и скрипт. У one-liner вся правда — в compose. +- Тестируемость one-liner'а вне Docker такая же, как у скрипта: + `python -c "..."` (UT-01..UT-03) против работающего uvicorn. + +### Сводная таблица вариантов + +| # | Вариант | Размер образа | Ребилд | Зависимости | Контракт exit code | Выбор | +|---|---------|---------------|--------|-------------|--------------------|-------| +| A | python `urllib.request` one-liner | 0 МБ | Не нужен | stdlib | Предсказуемый | **выбран** | +| B | `apt-get install curl` | +10 МБ | Нужен | curl, libcurl4, libssl, ... | `curl -f` понятен | отклонён | +| C | `apt-get install wget` | +5–7 МБ | Нужен | wget, libidn2, ... | `wget --spider` неочевидный | отклонён | +| D | `HEALTHCHECK` в Dockerfile | 0 МБ | Нужен | stdlib | Тот же что A | отклонён (ребилд + дублирование) | +| E | Отдельный `scripts/healthcheck.py` | 0 МБ | Не нужен* | stdlib | Тот же что A | отклонён (лишний файл) | + +\* — при условии, что файл уже в образе. Если правка скрипта без ребилда +— healthcheck не увидит новой версии. + +## Решение + +1. **В `docker-compose.yml`** секция `healthcheck` сервиса `app` + заменяется на: + + ```yaml + healthcheck: + test: + - "CMD" + - "python" + - "-c" + - "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5556/api/health', timeout=3).status == 200 else 1)" + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + ``` + + Параметры `interval`, `timeout`, `retries` остаются прежними. + Добавляется `start_period: 20s` для смягчения окна холодного старта. + +2. **`Dockerfile`** не меняется. Никакого `apt-get install curl`/`wget`. + +3. **`src/api/main.py`** не меняется. Эндпоинт `/api/health` + (`main.py:1224`) уже отдаёт HTTP 200 + JSON, дальнейших правок не + требует (BRD §7, AC-08). + +4. **`gps-collector`** не получает healthcheck (BRD §7 out of scope: + `restart: "no"`, batch profile, нет открытого порта). + +5. **`CHANGELOG.md`** обновляется в секции `Unreleased` записью формата + `fix(infra): use python urllib for container healthcheck (ET-015)` + (TRZ R-4, AC-09). + +### Что НЕ меняется + +- `Dockerfile` — без правок (BRD §6, AC-04). +- `src/api/*` — без правок (BRD §7, AC-08). +- `src/web/*` — без правок. +- `nginx`, обратный прокси на хосте — без правок (BRD §6 «никаких + изменений в reverse proxy без согласования»). +- БД (`centralfederal.sqlite`, `gps_tracks.sqlite`), миграции, тайлы + (`data/terrain/*`, `data/osm/*`, `data/osrm/*`) — без правок. +- `config/*.yaml` — без правок. +- `gps-collector` — без правок (BRD §7). +- `make` цели — без правок (`make deploy-test` уже пересоздаёт + контейнер). + +## Классификация изменения + +**minor-change.** + +Меняется 1 файл (плюс CHANGELOG): + +- `docker-compose.yml` (-1 строка `test`, +5 строк с массивом и + `start_period`). +- `CHANGELOG.md` (+1 строка в `Unreleased`). + +Эскалация: **не arch:major-change.** Не подпадает под категории +`new service / new DB / new tile pipeline / cross-cutting protocol` +из CLAUDE.md и BRD ET-015 §10. Не требует расширенного approve. + +Глобальный ADR-индекс (`docs/architecture/adr/README.md`) пополняется +строкой ADR-020 — это требование процесса (per-work-item ADR +регистрируется в индексе для cross-cutting visibility). + +## Последствия + +### Положительные + +- **BRD §3 закрыт:** ложные `unhealthy` исчезают. Метрика «доступность + контейнера» становится пригодной для SLO/SLA (R3 в BRD §3). +- **BRD §6 соблюдён:** размер образа не растёт; никаких новых пакетов + / APT-слоёв; деплой не требует ребилда (`docker compose up -d app` + достаточно). +- **AC-01..AC-10 закрываются одной правкой YAML** + ADR + CHANGELOG. +- **AC-07** (внутренний timeout < внешний) выполняется явно: 3 < 5. +- **Воспроизводимость диагностики:** ту же команду + `python -c "..."` можно запустить через `docker exec` или с хоста + против `make dev` для воспроизведения healthcheck-логики (UT-01..UT-03). +- **Прецедент для будущих сервисов.** Если в проекте появится ещё + один python-сервис на FastAPI/uvicorn, healthcheck выглядит так же — + единый паттерн. + +### Отрицательные / Принимаем + +- **Стоимость fork+exec python.** Каждые 30 с ~80–150 мс старт + интерпретатора. На idle-`app` пренебрежимо (см. R-T-2). При нагрузке + ~10 req/s на uvicorn — конкуренция за CPU тоже пренебрежима (одно + ядро в основном простаивает). +- **Длинная YAML-строка.** Снижает читаемость. Альтернативно можно + перейти на block-scalar (`>-`), но в текущей форме она помещается + в одну строку без переносов и не нарушает YAML-валидность (ST-03). +- **Зависимость от alias `python`.** Если когда-нибудь + `python:3.12-slim` уберёт `/usr/local/bin/python`, нужно будет + поменять на `python3`. Маловероятно (Python Docker images держат + оба alias'а). +- **Healthcheck остаётся «поверхностным».** Проверяется только, что + uvicorn слушает порт и отдаёт 200 на `/api/health`. Если приложение + работает, но БД недоступна — healthcheck скажет `healthy`. Это + out of scope ET-015 (BRD §7); углубление содержимого `/api/health` + — отдельный work-item, если потребуется. + +### Технический долг + +- **TD-1: `/api/health` подсветка зависимостей.** Сейчас эндпоинт + возвращает `db_exists` (просто `os.path.exists`), но не пингует + OSRM, не проверяет наличие тайлов, не оценивает свободное место. + Если в будущем появятся реальные «деградации сервиса» (OSRM упал, + тайлы не примонтированы), `/api/health` это **не заметит**. Расширение + — отдельный work-item. ADR-020 не блокирует расширение: если + `/api/health` начнёт возвращать 503 при деградации, текущий + one-liner это **корректно зафиксирует** через ненулевой exit code + (status != 200 → exit 1). +- **TD-2: Healthcheck для `gps-collector`.** Сейчас `restart: "no"`, + batch profile — healthcheck не нужен. Если в будущем сервис станет + long-running (например, daemon, ждущий триггера), потребуется + отдельная healthcheck-стратегия (не HTTP — у него нет порта). + YAGNI до изменения профиля сервиса. +- **TD-3: Если строка one-liner'а будет расти.** При расширении + логики проверки (несколько эндпоинтов, кастомные парсинги) лучше + перейти на отдельный `scripts/healthcheck.py` (Вариант E), + ребилдить образ и заворачивать в `["CMD", "python", "/app/scripts/healthcheck.py"]`. + Сейчас YAGNI. +- **TD-4: Унификация с `/api/gps-tracks/health`.** Существует второй + health-эндпоинт (`docs/architecture/README.md` §7), который отдаёт + состояние БД треков. ET-015 его **не использует** в Docker + healthcheck (он не годится для проверки runtime жизнеспособности + `app` контейнера — это диагностический эндпоинт для оператора). + Если когда-нибудь захочется собрать «общий health» — это новый + work-item. + +## Альтернативы для будущего + +| # | Идея | Когда возвращаться | +|---|------|---------------------| +| F-1 | Установить curl через `apt-get` (Вариант B) | Никогда — раздувает образ; принципиальное решение «минимум зависимостей» | +| F-2 | Установить wget через `apt-get` (Вариант C) | Никогда — те же возражения, что F-1 | +| F-3 | `HEALTHCHECK` в Dockerfile (Вариант D) | Если когда-нибудь понадобится поддержка ad-hoc `docker run` без compose (на mva154 не предвидится) | +| F-4 | Отдельный `scripts/healthcheck.py` (Вариант E) | Когда one-liner перерастёт ~3 операции (см. TD-3) | +| F-5 | Расширение `/api/health` проверками OSRM/тайлов/диска | По бизнес-запросу: «нужен реальный SLA по downstream-сервисам» (TD-1) | +| F-6 | Healthcheck для `gps-collector` | Если профиль `gps-collector` сменится на long-running (TD-2) | +| F-7 | Объединённый «service-wide health» эндпоинт | По мере роста сервисов (TD-4) | + +## Связанные документы + +- BRD: `docs/work-items/ET-015/01-brd.md` §1–§9 +- TRZ: `docs/work-items/ET-015/02-trz.md` §1–§9 (особенно §3 целевое + состояние и §4 альтернативы) +- AC: `docs/work-items/ET-015/03-acceptance-criteria.md` AC-01..AC-10 +- Test plan: `docs/work-items/ET-015/04-test-plan.yaml` + ST-01..ST-07, UT-01..UT-03, IT-01..IT-04, E2E-01..E2E-02 +- Инфра: `docs/work-items/ET-015/07-infra-requirements.md` +- Данные: `docs/work-items/ET-015/08-data-requirements.md` +- Риски: `docs/work-items/ET-015/10-tech-risks.md` +- Глобальный ADR-индекс: `docs/architecture/adr/README.md` +- Прецедент ADR-007 (ET-008) — формат «service-infra» ADR с docker-compose-only правками +- Прецедент ADR-019 (ET-014) — формат «config-only minor-change» ADR diff --git a/docs/work-items/ET-015/07-infra-requirements.md b/docs/work-items/ET-015/07-infra-requirements.md new file mode 100644 index 0000000..f2fbebe --- /dev/null +++ b/docs/work-items/ET-015/07-infra-requirements.md @@ -0,0 +1,434 @@ +--- +type: infra-requirements +work_item_id: ET-015 +title: "Инфраструктурные требования — ET-015: Healthcheck enduro-trails-app через python urllib" +version: 1 +status: approved +created_at: 2026-06-05 +authors: + - "agent:architect" +--- + +# Инфраструктурные требования — ET-015 + +## 1. Резюме + +ET-015 — **infrastructure-only bug-fix** одной YAML-секции в +`docker-compose.yml`. Меняется: + +- `docker-compose.yml` — секция `healthcheck` сервиса `app` + (-1 строка `test`, +5 строк новый массив + `start_period`). +- `CHANGELOG.md` — +1 строка в `Unreleased`. +- `docs/work-items/ET-015/06-adr/ADR-020-...md` — новый ADR. +- `docs/architecture/adr/README.md` — +1 строка в глобальном индексе. + +Инфраструктура **почти не меняется**: + +- 0 новых docker-сервисов. +- 0 изменений в `Dockerfile` (образ `python:3.12-slim` остаётся as-is). +- 0 новых пакетов в образе (никаких `apt-get install curl/wget`). +- 0 новых файлов БД, миграций, индексов. +- 0 новых cron-записей. +- 0 новых env-переменных, секретов, API-ключей. +- 0 новых исходящих HTTPS-соединений (healthcheck — на loopback контейнера). +- 0 новых портов. +- 0 изменений в nginx (на хосте). +- 0 изменений в backend (`src/api/*`). +- 0 изменений во фронтенде (`src/web/*`). +- 0 изменений в стилях, конфигах, скриптах деплоя. + +**Меняется только**: + +- Команда, которую Docker запускает для healthcheck'а контейнера `app`. +- Конфигурация healthcheck'а: добавляется `start_period: 20s`. + +Эскалация: **minor change** (см. ADR-020 §«Классификация изменения»). + +## 2. Контейнеры и сервисы + +### 2.1 Сводная таблица + +| Аспект | Требование | +|--------|------------| +| Новый сервис | **Нет** | +| Изменения `Dockerfile` | **Нет** (образ `python:3.12-slim` без новых пакетов) | +| Изменения `docker-compose.yml` — `app` | **Да**: секция `healthcheck.test` + `start_period: 20s` | +| Изменения `docker-compose.yml` — `gps-collector` | **Нет** (BRD §7 out of scope) | +| Изменения `docker-compose.yml` — networks/volumes/profiles | **Нет** | +| Перезапуск `app` после деплоя | Нужен — `docker compose up -d app` (пересоздание контейнера, ~5 сек простоя HTTP) | +| Ребилд образа `app` (`docker compose build`) | **Не нужен** (TRZ R-2, AC-04, IT-04). Допускается, но не обязателен | +| Перезапуск `gps-collector` | Не нужен (не затронут, batch profile) | +| Очистка серверных кэшей | Не требуется | +| Очистка клиентских кэшей | Не требуется (фронтенд не меняется) | + +### 2.2 Зависимости между сервисами + +Без изменений vs PH-1..PH-8: + +- `app` (uvicorn :5556 внутри контейнера) — отдаёт `/api/health`, + `/api/route/*`, `/api/gps-tracks/*`, `/terrain/*`, статику `/enduro/*`. +- `nginx` (хост mva154) → `app:5556` через docker bridge. +- `gps-collector` (profile `batch`) → пишет в `data/gps_tracks.sqlite`, + не имеет открытого порта, не задействован в healthcheck. + +Healthcheck живёт **внутри network namespace** контейнера `app` и +обращается к `http://localhost:5556/api/health` — это loopback самого +контейнера, **не** хост и **не** другой контейнер. Не зависит от +nginx, iptables хоста, `OSRM_URL` или `gps-collector`. + +### 2.3 Образ `app` + +| Параметр | До ET-015 | После ET-015 | +|----------|-----------|--------------| +| Базовый образ | `python:3.12-slim` | `python:3.12-slim` | +| Размер | ~250 МБ (приблизительно) | ~250 МБ (тот же) | +| Пакеты `apt` | базовый набор slim + pip-зависимости | без изменений | +| Python | 3.12 (alias `python` → `python3`) | 3.12 (без изменений) | +| `urllib.request`, `sys` | stdlib (входят в Python) | stdlib (входят в Python) | +| `curl` | **отсутствует** (источник бага) | **отсутствует** (не нужен) | +| `wget` | отсутствует | отсутствует | +| Слои Docker | без изменений | без изменений | + +### 2.4 `gps-collector` — почему без healthcheck'а + +| Причина | Источник | +|---------|----------| +| `profiles: ["batch"]` — не стартует при `docker compose up -d` | `docker-compose.yml:30` | +| `restart: "no"` — контейнер не должен подниматься обратно | `docker-compose.yml:40` | +| Нет открытого порта (нет `ports:` секции) | `docker-compose.yml:28-40` | +| Команда `python -m scripts.gps_collect` отрабатывает и завершается | ADR-007 (ET-008) | +| Healthcheck для batch-задачи бессмыслен (это not a daemon) | BRD §7 (ET-015) | + +Если профиль когда-нибудь сменится на long-running daemon — нужен +отдельный work-item (см. TD-2 в ADR-020). + +## 3. Сеть + +| Аспект | Требование | +|--------|------------| +| Новые входящие порты | **Нет** | +| Изменения nginx (хост) | **Нет** | +| Новые исходящие соединения | **Нет** (healthcheck — loopback внутри контейнера) | +| CORS | Без изменений | +| HTTPS / TLS | Без изменений | +| Docker bridge / networks | Без изменений | +| iptables на хосте | Без изменений | +| Firewall / security groups | Без изменений | + +### 3.1 Healthcheck network path + +``` +[docker exec health probe] + │ + ▼ +python process (in container) + │ + ▼ urllib.request.urlopen("http://localhost:5556/api/health", timeout=3) + │ + ▼ TCP connect → 127.0.0.1:5556 (loopback в network namespace контейнера) + │ + ▼ +uvicorn (тот же процесс, который запущен `CMD ["uvicorn", "src.api.main:app", ...]`) + │ + ▼ FastAPI router → @app.get("/api/health") → src/api/main.py:1224 + │ + ▼ HTTP 200 + JSON {"status": "ok", ...} + │ + ▼ +python sys.exit(0) + │ + ▼ +Docker: exit code 0 → State.Health.Status = healthy +``` + +Никаких внешних сетевых вызовов. Никакого DNS resolve. Никаких TLS. +Никакой зависимости от `nginx`, `OSRM`, `gps-collector`, тайл-провайдеров. + +### 3.2 Ingress/Egress — оценка дельты + +ET-015 не меняет паттерн трафика приложения. Healthcheck-трафик +(`/api/health` каждые 30 с) **уже был** до фикса — Docker и раньше +пытался его делать через `curl`, но проваливался до connect'а. Теперь +запросы реально доходят до uvicorn. Дельта: + +- **+2 req/min** к `/api/health` внутри контейнера, ~7 мс ответ, + ~0.1 КБ ответ. **Пренебрежимо** для uvicorn (он и так уже + обслуживает реальный трафик пользователей). +- Egress / nginx-трафик — без изменений. + +## 4. Серверные ресурсы + +### 4.1 Сводная таблица + +| Аспект | Требование | Дельта | +|--------|------------|--------| +| CPU `app` | Без изменений | +0.01% (fork+exec python каждые 30 с — пренебрежимо) | +| RAM `app` | Без изменений | временно ~5–10 МБ на ~50–100 мс жизни healthcheck-процесса | +| Disk `app` | Без изменений | 0 | +| CPU `gps-collector` | Без изменений | 0 | +| RAM `gps-collector` | Без изменений | 0 | +| Disk `gps-collector` | Без изменений | 0 | + +### 4.2 Оценка дельты CPU/RAM + +- **Fork + exec `python -c "..."`:** интерпретатор поднимается за + ~80–150 мс на mva154 (нагретый ФС-кэш). За цикл 30 с — 0.5% от + одного ядра в пике (на 100–150 мс), что в среднем ≈ **0.005% CPU**. +- **RAM:** одноразово ~5–10 МБ на жизнь процесса. После завершения — + возвращается ОС. +- На фоне общего idle-загруза `app` (uvicorn ~50–80 МБ RAM, ~1–2% CPU + в idle) — пренебрежимо. + +### 4.3 Disk + +- Образ не растёт (`Dockerfile` не меняется). +- Логи Docker (`/var/lib/docker/containers/.../*.log`) — Docker + пишет результаты healthcheck'а в `State.Health.Log` (хранится в + inspect-структуре контейнера). Объём — небольшой, ограничен + ротацией Docker (по умолчанию 5 последних записей). +- `nginx access.log` — без изменений (healthcheck-трафик внутренний, + через nginx не проходит). + +## 5. Конфигурация и секреты + +| Аспект | Требование | +|--------|------------| +| Новые env-переменные | **Нет** | +| Новые секреты | **Нет** | +| Новые API-ключи | **Нет** | +| Изменения `config/*.yaml` | **Нет** | +| Изменения runtime config | **Нет** | +| Изменения `style.json`/`style-dark.json` | **Нет** | + +Healthcheck-URL зашит в YAML-строку (`http://localhost:5556/api/health`). +Порт **не** параметризован через `${PORT}` намеренно (TRZ R-3): + +- `PORT=5556` стоит в Dockerfile (`ENV PORT=5556`) и в compose + (`PORT=5556`). Если в будущем порт станет переменным, healthcheck-строку + можно будет переписать через shell-form (`CMD-SHELL`) с подстановкой + `$PORT`. Сейчас — YAGNI. + +## 6. Деплой + +### 6.1 Среды + +- **dev (локально)**: `make dev` (или `docker compose up -d app`). + Достаточно `git pull && docker compose up -d app` для смены + healthcheck-команды. Без `docker compose build`. +- **test (mva154)**: `https://openclaw.mva154.duckdns.org/enduro/`. + Деплой через `make deploy-test` (стандартная процедура), либо ручной + SSH + `docker compose up -d app`. +- **prod** — пока не задействован; ET-015 деплоится только в test. + +### 6.2 Процедура деплоя в test + +1. **Pre-deploy snapshot** — зафиксировать «как было»: + ```bash + ssh mva154 'docker inspect enduro-trails-app-1 \ + --format "Status: {{.State.Health.Status}} | FailingStreak: {{.State.Health.FailingStreak}} | RestartCount: {{.RestartCount}}"' + ``` + Ожидается (для подтверждения бага из BRD §1): `Status: unhealthy | + FailingStreak: <большое число> | RestartCount: 0`. + +2. **Pre-deploy smoke** — проверить, что приложение реально живо: + ```bash + curl -sI 'https://openclaw.mva154.duckdns.org/enduro/api/health' | head -1 + ``` + Ожидается `HTTP/1.1 200 OK`. + +3. **Pull новой версии** на mva154 (после merge в main): + ```bash + ssh mva154 'cd /home/slin/enduro-trails && git pull' + ``` + +4. **Пересоздание контейнера `app`** (без ребилда образа — TRZ R-2): + ```bash + ssh mva154 'cd /home/slin/enduro-trails && docker compose up -d app' + ``` + Docker увидит изменение `healthcheck` в compose-файле, пересоздаст + контейнер `enduro-trails-app-1`. Контейнер `gps-collector` не + трогается (batch profile + restart: "no"). + +5. **Pre-stable wait** — Docker применит `start_period: 20s`. Первые + ~20 с healthcheck может показывать `starting`. Затем циклы + `interval=30s × retries=3` — то есть до `healthy` пройдёт ≤ 20 + + 30 = 50 с в нормальном случае, гарантированный SLA — ≤ 120 с + (AC-01). + +6. **Post-deploy verification** — три замера: + + ```bash + # T0 = сразу после up -d app, повторять с интервалом 30 с до healthy + for i in 1 2 3 4 5 6; do + ssh mva154 'docker inspect enduro-trails-app-1 \ + --format "T+{{.State.Health.Status}} streak={{.State.Health.FailingStreak}}"' + sleep 30 + done + ``` + Ожидается: за ≤ 120 с — `T+healthy streak=0`. + + ```bash + # Подтверждение AC-02: healthy через 5 и 10 минут + sleep 300 && ssh mva154 'docker inspect ... --format "{{.State.Health.Status}} {{.State.Health.FailingStreak}}"' + sleep 300 && ssh mva154 'docker inspect ... --format "{{.State.Health.Status}} {{.State.Health.FailingStreak}}"' + ``` + Ожидается: оба замера — `healthy 0`. + + ```bash + # Подтверждение AC-08: эндпоинт живой снаружи + curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' \ + 'https://openclaw.mva154.duckdns.org/enduro/api/health' + ``` + Ожидается `200 <1.0`. + +7. **Записать результаты** в `docs/work-items/ET-015/13-test-report.md` + и `docs/work-items/ET-015/14-deploy-log.md` (на следующих этапах). + +### 6.3 Rollback + +В случае проблем (например, python one-liner крэшит на нестандартной +Docker Engine или эндпоинт `/api/health` начал отвечать медленнее 3 с): + +1. **Revert коммита**: `git revert ` на mva154 в `main`. +2. **Пересоздание контейнера**: `docker compose up -d app`. +3. Старая (поломанная) healthcheck-команда `curl ...` вернётся, но + само приложение продолжит работать (доказано в BRD §1). + +RTO: ≤ 5 минут. +RPO: 0 — никаких данных не теряется (healthcheck — read-only HTTP +запрос). + +### 6.4 CI/CD гейты + +- **`make lint`** (ruff + eslint + YAML-валидация compose) — должен + быть зелёным. Проверяет, что docker-compose.yml парсится. +- **`make test`** (pytest unit + integration): + - ST-01..ST-07 — статические проверки (grep по compose/Dockerfile/CHANGELOG). + - UT-01..UT-03 — smoke на python one-liner против live `make dev` + (опционально, требует поднятого uvicorn). +- **Integration-CI / ручная проверка**: + - IT-01..IT-04 — `docker compose up -d app` локально + проверка + переходов healthy/unhealthy. +- **E2E на mva154**: + - E2E-01 — `docker inspect` после `make deploy-test` (оператор). + - E2E-02 — `curl https://openclaw.mva154.duckdns.org/enduro/api/health` + (автоматизируется). + +### 6.5 Зависимости деплоя + +- **Docker Engine на mva154**: должен поддерживать `start_period` + (введён в Docker 1.12 / 2016). На mva154 — Docker ≥ 20.10 + (BRD §6). ✓ +- **Compose version**: `version: "3.8"` (`docker-compose.yml:1`) + поддерживает все используемые healthcheck-поля. ✓ +- **Образ `python:3.12-slim`** должен оставаться available на + Docker Hub. ✓ + +## 7. Observability / Логирование + +| Аспект | Требование | +|--------|------------| +| Новые лог-сообщения | **Нет** новых на стороне приложения | +| Логи healthcheck | Docker пишет в `State.Health.Log` (просмотр через `docker inspect`) | +| Метрики / Prometheus | Не вводим (но `State.Health.Status` теперь стал **достоверным** для будущей интеграции) | +| Health endpoint | `/api/health` без изменений; `/api/gps-tracks/health` без изменений | +| `uvicorn.access` лог | +2 req/min на `/api/health` (внутренний loopback) — фоновый шум, не блокирует анализ | + +### 7.1 Что мониторить после деплоя + +**Сутки наблюдения** на mva154 (ручная проверка, без алёртов): + +1. **`docker inspect enduro-trails-app-1`**: + - `State.Health.Status` должен быть `healthy` стабильно. + - `State.Health.FailingStreak` должен оставаться `0`. + - `State.Health.Log[-5:]` — все `ExitCode: 0`, `Output: ""` (или + ничего значимого). + - `RestartCount` должен оставаться прежним (контейнер не перезапускается + из-за healthcheck — у нас нет `restart_policy.condition: unhealthy`, + но всё равно полезно зафиксировать). + +2. **`uvicorn access.log` в контейнере**: + - `GET /api/health HTTP/1.1 200` каждые ~30 с. + - Время ответа стабильно < 100 мс (на mva154 — ~7 мс по замерам BRD §1). + +3. **`nginx access.log` на хосте** (внешний трафик): + - Без изменений vs до деплоя; healthcheck идёт **внутри** + контейнера и в nginx не виден. + +### 7.2 Алёрты (будущее) + +ET-015 закрывает причину «вечного `unhealthy`» — теперь +`docker inspect ... .State.Health.Status` снова **достоверная метрика**. +Если в проекте появится мониторинг (Prometheus + alertmanager, или +простой cron-скрипт), можно настроить: + +- Алёрт «контейнер unhealthy ≥ 5 мин» — теперь это будет реальный сигнал. +- Алёрт «FailingStreak растёт» — раньше был ложно-положительным, + теперь — настоящий. + +Это **не задача ET-015** (out of scope BRD §7), но ET-015 — необходимое +условие для будущей интеграции. + +## 8. Резервное копирование / Disaster recovery + +| Аспект | Требование | +|--------|------------| +| Backup БД | Без изменений vs ET-013/ET-008 (ET-015 не трогает БД) | +| Backup тайлов | Без изменений | +| Backup статики | Без изменений; git — источник истины | +| Backup конфигурации | `docker-compose.yml` — в git, перепрочитывается при каждом `docker compose up` | +| RTO | ≤ 5 минут (rollback через git revert + `docker compose up -d app`) | +| RPO | 0 — никаких данных не теряется | + +## 9. Безопасность + +| Аспект | Требование | +|--------|------------| +| Auth / Authorization | Без изменений | +| Валидация входных данных | Не применимо — healthcheck не принимает внешних входов | +| CSP | Без изменений | +| Rate-limit | Без изменений (loopback-трафик не подпадает) | +| TLS | Без изменений | +| Shell injection | **Снят как риск** (см. ADR-020 Вариант A: используется `CMD`-массив, не `CMD-SHELL`; нет интерполяции пользовательского ввода) | +| `urllib.request` SSRF | Не применимо: URL зашит в YAML, не строится из переменных; loopback only | +| Privilege escalation | Не применимо: python запускается от того же user'а, что и uvicorn (root в python:3.12-slim — стандартно для этого образа; ET-015 это не меняет) | + +### 9.1 Анализ риска `urllib.request` vs `curl` (security delta) + +- `curl` (если бы был установлен): C-код с историей CVE (HTTP/2, + TLS, libidn). Не используется — изначально его нет. +- `urllib.request`: чистый Python, stdlib. История CVE значительно + меньше; используется только на loopback с фиксированным URL → SSRF + поверхность отсутствует. +- **Чистый выигрыш по security**: меньше attack surface, меньше + кода в образе. + +## 10. Совместимость + +| Аспект | Требование | +|--------|------------| +| API контракт | Без изменений | +| Совместимость с PH-1..PH-9 | Полностью совместимо: healthcheck — runtime инфра, не задевает фичи | +| Совместимость с ET-007/008/009/011/012/013/014 | Полностью совместимо | +| Совместимость с Docker Engine | ≥ 20.10 (требуется `start_period`); подтверждено на mva154 (BRD §6) | +| Совместимость с Docker Compose | `version: "3.8"` поддерживает все используемые поля | +| Совместимость с базовым образом | `python:3.12-slim` → `python` alias + `urllib.request` + `sys` гарантированы | +| Совместимость с будущими образами `python:3.13-slim` и далее | Высокая: `urllib.request` стабильный API с Python 2.x; alias `python` поддерживается во всех современных python-slim тегах | +| localStorage migration | Не применимо — фронтенд не трогается | +| Совместимость со старыми вкладками браузера | Не применимо | + +## 11. Связанные документы + +- `01-brd.md` §1–§9 +- `02-trz.md` §1–§9 (особенно §3 — целевое состояние, §4 — альтернативы) +- `03-acceptance-criteria.md` AC-01..AC-10 +- `04-test-plan.yaml` ST-01..ST-07, UT-01..UT-03, IT-01..IT-04, E2E-01..E2E-02 +- `06-adr/ADR-020-healthcheck-via-python-urllib.md` +- `08-data-requirements.md` (этот пакет) +- `10-tech-risks.md` (этот пакет) +- `docs/architecture/README.md` §«Компоненты», §«Деплой» +- `docs/work-items/ET-014/07-infra-requirements.md` — образец «zero-infra» + work-item (наследие) +- `docs/work-items/ET-008/07-infra-requirements.md` (если есть) — + образец docker-compose правок с major-change escalation (наследие, + для контраста: ET-015 явно minor-change) diff --git a/docs/work-items/ET-015/08-data-requirements.md b/docs/work-items/ET-015/08-data-requirements.md new file mode 100644 index 0000000..2e8884c --- /dev/null +++ b/docs/work-items/ET-015/08-data-requirements.md @@ -0,0 +1,292 @@ +--- +type: data-requirements +work_item_id: ET-015 +title: "Требования к данным — ET-015: Healthcheck enduro-trails-app через python urllib" +version: 1 +status: approved +created_at: 2026-06-05 +authors: + - "agent:architect" +--- + +# Требования к данным — ET-015 + +## 1. Резюме + +ET-015 — **pure container-config change**. Никаких изменений в данных: +ни в БД, ни в файлах на диске, ни в localStorage, ни в API-контрактах, +ни в конфигурациях приложения. + +Меняется **команда, которую Docker запускает для проверки живости +контейнера** — она перестаёт зависеть от `curl` (отсутствующего в +образе) и переключается на python `urllib.request`. Запрос ходит на +**уже существующий** эндпоинт `/api/health` (`src/api/main.py:1224`), +который не меняется. + +**Меняется:** + +- Runtime-состояние `docker inspect enduro-trails-app-1 --format + '{{.State.Health.Status}}'`: переключается с `unhealthy` (ложный) + на `healthy` (честный). +- Содержимое `State.Health.Log`: теперь пишутся реальные `ExitCode: 0` + результаты, а не `exec: "curl": executable file not found in $PATH`. + +**Не меняется:** + +- Содержимое и схема БД `centralfederal.sqlite`, `gps_tracks.sqlite`. +- Содержимое и формат PNG-тайлов в `data/terrain/*`. +- Файлы OSRM-графа (`data/osrm/*`), OSM-данные (`data/osm/*`). +- Контракты API (`/api/gps-tracks/*`, `/terrain/*`, `/api/route/*`, + `/api/health`, прочие). +- Эндпоинт `/api/health` — формат ответа, поведение, путь + (`src/api/main.py:1224`) (AC-08). +- Ключи `localStorage` фронтенда. +- `style.json`, `style-dark.json`. +- `config/*.yaml`. +- `src/web/*`, `src/api/*`, `Dockerfile`, миграции, скрипты деплоя. + +## 2. Архитектурные границы данных + +| Слой данных | Тип | Расположение | Изменения в ET-015 | +|-------------|-----|--------------|---------------------| +| OSM-vector (`trails`) | существующий | `/app/data/centralfederal.sqlite` | **нет** | +| Публичные GPS-треки (ET-008) | существующий | `/app/data/gps_tracks.sqlite` | **нет** | +| OSRM-граф | существующий | `/app/data/osrm/enduro.osrm.*` | **нет** | +| Terrain PNG-тайлы | существующий | `data/terrain/*` | **нет** | +| Личные GPX-треки (ET-006) | существующий | браузер (memory) | **нет** | +| User UI state | существующий | `localStorage` | **нет** | +| MapLibre client tile cache | существующий | браузер (LRU MapLibre) | **нет** | +| Серверный кэш | не предусмотрен | n/a | **нет** | +| Docker container state | runtime | Docker daemon на mva154 | **меняется**: `State.Health.Status: unhealthy → healthy`, `FailingStreak: 3762 → 0`, `Log[].ExitCode: -1 → 0` | +| `docker-compose.yml` | конфигурация | git, mva154 | **меняется**: секция `app.healthcheck` | +| `CHANGELOG.md` | документация | git | **меняется**: +1 строка в `Unreleased` | + +## 3. Серверные данные + +### 3.1 БД + +**Без изменений vs ET-014/ET-013/ET-008/ET-012.** + +- `centralfederal.sqlite` — read-only для ET-015 (даже не читается). +- `gps_tracks.sqlite` — read-only для ET-015 (даже не читается). +- Никаких ALTER/CREATE/INSERT/UPDATE/DELETE. +- Никаких миграций. + +**Косвенная связь:** эндпоинт `/api/health` возвращает поле `db_exists` +(`os.path.exists(DATA_PATH)`). Это проверка **наличия файла**, не +открытия БД, не SELECT'а. ET-015 не делает БД «зависимостью +healthcheck'а» больше, чем она уже была. + +### 3.2 Тайлы на диске + +**Без изменений.** `data/terrain/*`, `data/osm/*`, `data/osrm/*` — не +трогаются. Healthcheck не обращается ни к одной плитке. + +### 3.3 Статика `src/web/` + +**Без изменений.** Healthcheck не задевает фронтенд. + +| Файл | Изменение | +|------|-----------| +| `src/web/app.js` | **нет** | +| `src/web/app.css` | **нет** | +| `src/web/index.html` | **нет** | +| `src/web/gps_tracks.js` | **нет** | +| `src/web/gpx.js` | **нет** | +| `src/web/units.js` | **нет** | +| `src/web/style.json` | **нет** | +| `src/web/style-dark.json` | **нет** | + +### 3.4 Backend `src/api/` + +**Без изменений.** `/api/health` (`src/api/main.py:1224`) не правится +(AC-08, BRD §7). + +| Файл | Изменение | +|------|-----------| +| `src/api/main.py` | **нет** | +| `src/api/requirements.txt` | **нет** (никаких новых python-зависимостей) | +| `src/api/gps_tracks/*` | **нет** | +| Прочие модули | **нет** | + +### 3.5 Конфиги + +| Файл | Изменение | +|------|-----------| +| `Dockerfile` | **нет** (см. ADR-020 Cons Варианта B) | +| `docker-compose.yml` | **да** — секция `app.healthcheck` | +| `config/gps_sources.yaml` | **нет** | +| `config/gps_regions.yaml` | **нет** | +| nginx-config на хосте | **нет** | +| systemd / cron на mva154 | **нет** | + +### 3.6 Скрипты и миграции + +| Каталог | Изменение | +|---------|-----------| +| `scripts/` | **нет** (никакого `scripts/healthcheck.py` — отклонено в Вариант E ADR-020) | +| `migrations/` | **нет** | +| `tests/` | **нет** *(новые тесты опциональны, см. test-plan; не блокируют merge)* | + +## 4. Клиентские данные + +### 4.1 localStorage + +**Без изменений.** ET-015 фронтенд не задевает. Никаких новых ключей, +никакой миграции. + +### 4.2 MapLibre LRU (browser-side) + +Без изменений. Тайловый кэш не задействован. + +### 4.3 DOM runtime state + +Без изменений. UI не меняется. + +### 4.4 In-memory constants + +Без изменений. + +## 5. Контракты API + +### 5.1 Backend endpoints + +**Без изменений.** ET-015 не добавляет, не модифицирует и не удаляет +ни один endpoint. + +| Endpoint | До ET-015 | После ET-015 | +|----------|-----------|--------------| +| `GET /api/health` | HTTP 200, JSON `{"status": "ok", "db_path": ..., "db_exists": ...}` | **без изменений** (AC-08) | +| `GET /api/gps-tracks/health` | без изменений | без изменений | +| `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` | без изменений | без изменений | +| `GET /api/gps-tracks?bbox=…` | без изменений | без изменений | +| `GET /api/gps-tracks/{id}/download` | без изменений | без изменений | +| `GET /terrain/{layer}/{z}/{x}/{y}.png` | без изменений | без изменений | +| `GET /api/route/*` | без изменений | без изменений | +| `GET /api/trails/*` | без изменений | без изменений | + +### 5.2 Внутренний контракт healthcheck-команды + +| Контракт | До ET-015 | После ET-015 | +|----------|-----------|--------------| +| Команда | `curl -f http://localhost:5556/api/health` | `python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:5556/api/health', timeout=3).status == 200 else 1)"` | +| Тип команды (Docker) | `CMD` (массив) | `CMD` (массив) | +| Зависимость от пакетов | curl (отсутствует ⇒ exec error) | stdlib (присутствует ⇒ работает) | +| Exit code при HTTP 200 | 0 (если бы curl был) | 0 | +| Exit code при HTTP 4xx/5xx | ≠ 0 (`-f` фейлит на 4xx/5xx) | ≠ 0 (`HTTPError` ⇒ ненулевой код) | +| Exit code при connection refused | ≠ 0 (если бы curl был) | ≠ 0 (`URLError` ⇒ ненулевой код) | +| Exit code при отсутствии команды | -1 (exec error) | n/a (команда есть) | +| Внутренний timeout запроса | n/a (использовал default Docker) | 3 с (`urlopen(..., timeout=3)`) | +| Внешний timeout Docker | 5 с | 5 с (без изменений) | +| Interval | 30 с | 30 с (без изменений) | +| Retries | 3 | 3 (без изменений) | +| Start period | не задан | 20 с (новое) | + +### 5.3 Что **не** становится зависимостью + +- **БД** (`centralfederal.sqlite`, `gps_tracks.sqlite`): healthcheck не + открывает их. `/api/health` только проверяет `os.path.exists()` — + это файловая операция, БД-движок не задействован. +- **OSRM** (`http://172.22.0.1:5559`): healthcheck не дёргает routing. +- **Тайл-каталог**: healthcheck не запрашивает PNG-плитки. +- **Внешние тайл-провайдеры** (OSM, Esri): не задействованы. +- **nginx**: не на пути healthcheck-запроса. + +## 6. Миграции + +**Нет.** Никаких миграций БД, миграций localStorage, миграций +конфигов приложения. + +При деплое в test: + +- `data/*` — без изменений. +- БД — без изменений. +- localStorage — старые ключи интерпретируются как раньше. +- MapLibre LRU — без изменений. +- Контейнер `enduro-trails-app-1` пересоздаётся (старый удаляется, + новый создаётся с тем же образом и тем же файловым состоянием). + Все volume-mounts (`./data:/app/data`, `./src/web:/app/src/web`, + `./config:/app/config:ro`) подхватываются как раньше → никаких + потерь данных. + +## 7. Тестовые данные + +### 7.1 Для unit-тестов + +См. `04-test-plan.yaml` UT-01..UT-03: + +- **UT-01**: live uvicorn на `:5556` (через `make dev`) либо mock-сервер; + запуск python one-liner с хоста; проверка exit code 0. +- **UT-02**: никто не слушает `:5556`; запуск python one-liner; + проверка exit code ≠ 0 (URLError). +- **UT-03**: mock-сервер отдаёт 500; запуск python one-liner; + проверка exit code ≠ 0. + +Тестовые данные минимальны: либо реальный uvicorn (с реальной БД, +которая уже есть), либо python `http.server`-mock. Никаких fixtures, +seed-данных, моков БД. + +### 7.2 Для integration-тестов + +См. `04-test-plan.yaml` IT-01..IT-04: + +- **IT-01..IT-04**: реальный `docker compose up -d app` на машине с + доступом к `data/`. Данные реальные; ET-015 их не меняет. +- Никаких новых fixtures, никаких CSV/JSON seed-файлов. + +### 7.3 Для UI-тестов (Playwright) + +Не применимо. ET-015 не трогает UI. + +### 7.4 Для E2E на mva154 + +См. `04-test-plan.yaml` E2E-01..E2E-02: + +- **E2E-01**: `ssh mva154 'docker inspect ...'` — данные читаются + напрямую из Docker daemon, никакие тестовые fixtures не нужны. +- **E2E-02**: `curl https://openclaw.mva154.duckdns.org/enduro/api/health` + — проверка живого эндпоинта; ответ — реальный JSON с реальной БД на + mva154. + +## 8. Резервные копии и DR + +**Без изменений.** ET-015 не пишет данных. RPO = 0. + +Если деплой ET-015 сломается (например, новый healthcheck сам по себе +помечает контейнер `unhealthy` из-за неучтённой особенности): + +- БД, тайлы, конфиги — не затронуты. +- Rollback = `git revert` + `docker compose up -d app` (см. + `07-infra-requirements.md` §6.3). +- RTO ≤ 5 минут. + +## 9. Privacy / Compliance + +| Аспект | Требование | +|--------|------------| +| PII | **Нет.** ET-015 не собирает, не обрабатывает, не передаёт никаких пользовательских данных | +| Licensing | Не применимо | +| Attribution | MapLibre attribution control — без изменений | +| GDPR / 152-ФЗ | Не применимо (healthcheck — loopback внутри контейнера, не пересекает периметр) | +| Egress на внешние сервисы | **Нет** (healthcheck не делает egress) | +| Логирование PII | **Нет** (healthcheck-логи Docker содержат только exit code и stdout/stderr команды — пустые) | + +## 10. Связанные документы + +- `01-brd.md` §1 (контекст), §2 (корень проблемы), §3 (бизнес-проблема), + §4 (цель), §6 (ограничения), §7 (out of scope) +- `02-trz.md` §1 (постановка), §2 (текущее состояние), §3 (целевое + состояние), §4 (альтернативы), §5 (R-1..R-5), §6 (тестирование) +- `03-acceptance-criteria.md` AC-01..AC-10 +- `04-test-plan.yaml` ST-01..ST-07, UT-01..UT-03, IT-01..IT-04, + E2E-01..E2E-02 +- `06-adr/ADR-020-healthcheck-via-python-urllib.md` §«Решение», §«Что + НЕ меняется», §«Технический долг» +- `07-infra-requirements.md` §2 (контейнеры), §5 (конфигурация) +- `10-tech-risks.md` +- `docs/architecture/README.md` §«Компоненты», §«GPS Tracks Pipeline» + (для контекста: ET-015 эту pipeline не трогает) +- `docs/work-items/ET-014/08-data-requirements.md` — образец «pure + client UI change» документа (наследие) +- `docs/work-items/ET-013/08-data-requirements.md` — образец «read-only + data» документа (наследие) diff --git a/docs/work-items/ET-015/10-tech-risks.md b/docs/work-items/ET-015/10-tech-risks.md new file mode 100644 index 0000000..262a2b0 --- /dev/null +++ b/docs/work-items/ET-015/10-tech-risks.md @@ -0,0 +1,372 @@ +--- +type: tech-risks +work_item_id: ET-015 +title: "Технические риски — ET-015: Healthcheck enduro-trails-app через python urllib" +version: 1 +status: approved +created_at: 2026-06-05 +authors: + - "agent:architect" +--- + +# Технические риски — ET-015 + +Технические риски замены `curl` на python `urllib.request`-one-liner +в healthcheck сервиса `app`. Бизнес-риски — в BRD §3 (ложные алёрты, +эрозия доверия, невозможность SLO). Шкала: вероятность (Н/С/В) × +влияние (Н/С/В). + +## R-T-1 — Python one-liner крэшится из-за квотинга / парсинга YAML + +- **Описание:** YAML-парсер Compose может неожиданно интерпретировать + кавычки внутри python-строки. Например, `'http://...'` внутри + `"... urlopen('http://...', timeout=3) ..."` — двойные кавычки + снаружи, одинарные внутри. Если строка где-то будет некорректно + «склеена», команда попадёт в Docker в покорёженном виде и `python -c` + свалится с SyntaxError. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение (ADR-020 §«Решение»):** YAML-массив + `["CMD", "python", "-c", "..."]`, **не** `CMD-SHELL`. Docker + передаёт аргументы напрямую через `exec()`, без `/bin/sh -c`, + без двойного парсинга. Кавычки внутри 4-го элемента — обычные + символы строки, YAML их не трогает. + - **Acceptance гейт:** ST-03 — `python -c 'import yaml,sys; + print(yaml.safe_load(...)["services"]["app"]["healthcheck"]["test"])'` + проверяет, что массив парсится корректно и 4-й элемент содержит + `urllib.request` и `sys.exit`. + - **Integration гейт:** IT-01 — `docker compose up -d app` и + переход в `healthy` за ≤ 120 с подтверждает, что строка дошла до + Docker в правильном виде. + +## R-T-2 — Стоимость fork+exec python каждые 30 секунд + +- **Описание:** Каждый цикл healthcheck поднимает отдельный процесс + `python` (~80–150 мс старт интерпретатора). На фоне нагруженного + uvicorn это может создавать заметный CPU-spike. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** интервал 30 с → накладные расходы + ~150 мс / 30 с = **0.5% от одного ядра** в пике, в среднем — + ~0.005% CPU. На mva154 (BRD: idle ~1–2% CPU `app`) это + пренебрежимо. + - **Оценка:** даже при росте интервала до `interval: 10s` (что не + планируется) overhead остался бы < 2% от одного ядра в пике. + - **Мониторинг:** наблюдение `docker stats enduro-trails-app-1` в + течение суток после деплоя (см. `07-infra-requirements.md` §7.1). + +## R-T-3 — Внутренний `timeout=3` короче, чем фактическое время ответа `/api/health` + +- **Описание:** Сейчас `/api/health` отвечает за ~7 мс (BRD §1). Но + при высокой нагрузке uvicorn (например, медленный SELECT в другом + запросе блокирует event loop) `/api/health` может отвечать за + > 3 с, healthcheck свалится в `unhealthy`. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение:** `/api/health` — лёгкий sync handler + (`async def health()` отдаёт сразу JSON, без IO в БД). FastAPI/uvicorn + обслуживает его очень быстро. ~7 мс — стабильно. + - **Запас:** внутренний 3 с — это **430× медленнее** текущего + среднего ответа. Чтобы попасть в timeout, нужно 430-кратное + замедление endpoint'а — это уже не «загруженность», а инцидент. + - **Контракт:** если healthcheck начинает фейлиться из-за + timeout — это **корректный сигнал**, что приложение деградировало. + То, что нужно от healthcheck'а. + - **TD (ADR-020 TD-1):** если `/api/health` станет «дорогим» + (расширят проверками OSRM/тайлов), нужно будет либо увеличить + timeout, либо разнести «liveness» и «readiness» — отдельный work-item. + +## R-T-4 — `urllib.request.urlopen` бросает разные exception'ы на разные ошибки → разный exit code + +- **Описание:** При connection refused — `URLError`; при HTTP 4xx/5xx + — `HTTPError`; при timeout — `socket.timeout` (или `TimeoutError` в + 3.10+). Все они приведут к ненулевому exit code, но конкретное + значение зависит от Python. Если в будущем кто-то напишет логику + «если exit code 1 — это connection refused, если 2 — это + timeout», она не сработает. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** Docker трактует «exit code 0» как + healthy, «всё остальное» как unhealthy. Семантика binary, + различать конкретные коды не нужно (TRZ §3.1). + - **Документация:** ADR-020 §«Решение» явно фиксирует «status != 200 + → exit 1; любой raise → ненулевой код». + - **Будущее:** если когда-нибудь захочется различать «приложение + отвалилось» vs «приложение тормозит», нужно переходить на + `scripts/healthcheck.py` (TD-3 в ADR-020) с явным `try/except` и + `sys.exit(2)` / `sys.exit(3)`. YAGNI. + +## R-T-5 — `start_period: 20s` слишком короткий или слишком длинный + +- **Описание:** Если uvicorn будет грузиться > 20 с (например, при + холодном кэше БД или большой инициализации), первые проверки + выпадут как `unhealthy` уже до окончания start_period. + Противоположно — если задать слишком большой start_period (например, + 120 с), новый деплой будет долго «висеть в starting», что в + CI/CD затянет проверку. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение (ADR-020 §«Решение»):** uvicorn в этом + проекте поднимается за < 2 с (вне холодного docker pull). 20 с — + 10× запас, комфортный для редких холодных стартов после + `docker compose up`. + - **Контракт start_period в Docker:** в течение start_period + проваленный healthcheck **не** увеличивает `FailingStreak`. Если + проверка пройдёт хотя бы раз в start_period, контейнер сразу + переходит в `healthy`. То есть слишком длинный start_period + «безопасен» (просто отложит признание `unhealthy` при реальном + отказе), а слишком короткий — приведёт к ложному `unhealthy` + при первом запуске. + - **Acceptance гейт:** AC-01 (≤ 120 с до healthy) включает + start_period в окно проверки. + +## R-T-6 — `localhost:5556` внутри контейнера резолвится не туда + +- **Описание:** В некоторых конфигурациях `localhost` может + резолвиться в IPv6 `::1`, а uvicorn слушает только IPv4 `0.0.0.0` + (см. Dockerfile CMD). Тогда healthcheck-запрос пойдёт на v6 и не + достучится. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение:** в `python:3.12-slim` дефолтный + `getaddrinfo` для `localhost` возвращает обе семьи, `urllib` + пробует их по порядку. На IPv4-host (mva154) `127.0.0.1` доступен + первым. + - **Fallback при провале:** если на каком-то Docker Engine начнут + наблюдаться проблемы, переписать URL на явный + `http://127.0.0.1:5556/api/health` (правка ~10 символов). + - **Acceptance гейт:** IT-01..IT-04 на dev-машине + E2E-01 на + mva154 проверяют реальное поведение. + +## R-T-7 — Эндпоинт `/api/health` в будущем переедет/переименуется + +- **Описание:** Сейчас `/api/health` живёт в `src/api/main.py:1224`. + Если кто-то рефакторит API под APIRouter и сменит путь (например, + на `/api/v1/health` или `/healthz`), healthcheck сломается. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **AC-08:** диф `src/api/main.py` против main по `/api/health` + проверяется — изменения контракта блокируют merge ET-015. + - **Долгосрочная:** ADR-020 фиксирует, что путь зашит в YAML. + При будущей миграции на APIRouter (если случится) разработчик + увидит ADR-индекс, найдёт упоминание `/api/health` и обновит + healthcheck-команду одной правкой YAML. + - **CHANGELOG/ADR трейл:** будущая правка пути сама по себе должна + породить ADR (это change cross-cutting). + +## R-T-8 — Python `urllib` SSL/TLS-проверка прокинется на loopback + +- **Описание:** Мы делаем `http://...` (не HTTPS), но если кто-то в + будущем перенесёт healthcheck на `https://localhost`, потребуется + валидный сертификат или `ssl._create_unverified_context()`. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** на loopback HTTPS не нужен. TLS + терминирует nginx на хосте, не внутри контейнера. + - Если когда-нибудь uvicorn получит TLS прямо в контейнере (вряд + ли — текущий деплой не предполагает), нужно будет либо обходить + проверку, либо ставить самоподписанный CA в образ. Это уже + серьёзная архитектурная смена → новый ADR. + +## R-T-9 — `python` alias исчезнет в будущих базовых образах + +- **Описание:** `python:3.12-slim` сейчас имеет + `/usr/local/bin/python` и `/usr/local/bin/python3` (оба ведут на + `python3.12`). Если апстрим решит оставить только `python3`, + healthcheck-команда сломается. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** Python Docker official images + поддерживают alias `python` в slim/full тегах как минимум до 3.13. + Никаких признаков deprecation. + - **Тривиальная правка:** при необходимости — заменить `python` на + `python3` в YAML (1 символ). + - **Тест:** UT-01..UT-03 запускают `python -c` на dev-машине; если + alias сломан локально, заметим до деплоя. + +## R-T-10 — Поломка YAML-формата при ручной правке (длинная строка) + +- **Описание:** YAML-строка ~135 символов читается тяжело. Будущий + редактор может случайно разорвать её newline'ом без `\`, получится + невалидный YAML. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - **Архитектурное решение (TRZ R-5):** строка лежит внутри + YAML-массива, длина не ограничена. При желании можно перейти + на block-scalar (`>-` или `|`) — позволит разнести по строкам. + Сейчас оставлено в одну строку для read-as-one-blob. + - **Acceptance гейт:** ST-03 — YAML-парсер на CI поймает поломку. + - **`make lint`:** валидирует compose YAML. + +## R-T-11 — Гонка между `docker compose up -d app` и healthcheck'ом во время деплоя + +- **Описание:** В момент пересоздания контейнера старый + `enduro-trails-app-1` останавливается, новый запускается. Если + пайплайн деплоя сразу же опрашивает `State.Health.Status`, он + может прочитать `starting` или даже краткий `unhealthy` и принять + это за провал. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - **Архитектурное решение (`07-infra-requirements.md` §6.2):** + процедура деплоя описывает waiting-loop с тайм-аутом 120 с + (AC-01). Никакой immediate-fail policy. + - **`start_period: 20s`** буферизирует холодный старт: первые ~20 с + проваленные проверки не учитываются в `FailingStreak`. + +## R-T-12 — Healthcheck помечает контейнер healthy, но БД недоступна + +- **Описание:** `/api/health` сейчас возвращает 200 даже если + `db_exists == false`. Healthcheck скажет `healthy`, хотя приложение + не сможет отдать `/api/trails`. Ложно-положительный сигнал. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Out of scope ET-015** (BRD §7): углубление содержимого + `/api/health` — отдельный work-item. ADR-020 это явно фиксирует + как TD-1. + - **Текущее поведение:** `/api/health` отдаёт `db_exists` в JSON, но + HTTP-статус — 200. Healthcheck смотрит только на статус. + - **Не делает хуже:** до ET-015 healthcheck был **всегда** ложным + (`unhealthy` при работающем приложении). После — healthcheck + станет **частично достоверным** (фиксирует «uvicorn слушает порт + и роутер жив»). Это **улучшение**, не «новая дыра». + - **Будущее:** при появлении мониторинга на базе + `State.Health.Status` — можно ввести более глубокий + `/api/health` (с проверкой БД/OSRM), и поведение healthcheck'а + «бесплатно» углубится. ADR-020 это не блокирует. + +## R-T-13 — Ложное срабатывание AC-05 (переход в unhealthy при остановке uvicorn) + +- **Описание:** AC-05 / IT-03 требуют, чтобы при остановке uvicorn + внутри контейнера healthcheck перешёл в `unhealthy` за ≤ 120 с. + Способ «kill -STOP 1» из ТЗ останавливает init-процесс, но при + этом останавливается и сам healthcheck-процесс (он же child от 1). + Возможны странные эффекты на Docker'е. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Альтернативная методика теста (test-plan IT-03):** допустимо + делать `docker stop` контейнера и проверять, что Docker сам + помечает `unhealthy` (или просто `exited`). + - **Реальный сценарий отказа:** uvicorn вернёт 500 на `/api/health` + при сбое внутреннего state, либо вообще не примет соединение + (process aborted). В обоих случаях python `urlopen` поднимет + исключение → ненулевой exit code → Docker фиксирует `unhealthy`. + - **Это покрывается AC-05 семантически**, не buchstäblich на kill. + +## R-T-14 — `make lint` падает на длинной строке в YAML + +- **Описание:** Если в проекте настроен `yamllint` с правилом + `line-length: max 120`, наша 135-символьная строка не пройдёт. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** `yamllint`-конфиг можно либо не + настраивать на `line-length` для значений multi-line массивов, + либо переписать строку через block-scalar `>-`. + - **Проверка:** `make lint` — часть DoD. Если падает — на этапе + реализации решает реализатор (например, отключает rule для + конкретной строки `# yamllint disable-line rule:line-length`). + - **Не блокирует ADR.** + +## R-T-15 — Кто-то в будущем добавит `restart: unless-stopped` + `restart_policy.condition: unhealthy` + +- **Описание:** Сейчас compose не указывает `restart_policy`. Если + кто-то добавит «контейнер автоматически перезапускается при + unhealthy», ET-015 (правильный healthcheck) внезапно станет + частью retry-логики. Любой реальный кратковременный сбой будет + крутить контейнер в цикле перезапусков. +- **Вероятность / Влияние:** Н / С. +- **Митигация:** + - **Архитектурное решение:** ET-015 такую политику **не** вводит + (out of scope). `restart_policy` сейчас отсутствует — Docker + использует дефолт «no restart on unhealthy». + - **Будущее:** при появлении `restart_policy.condition: unhealthy` + нужно проверить, что start_period достаточен для всех валидных + стартов, и что `interval × retries` не складывается в шторм + перезапусков. Это будет тема отдельного ADR. + +## R-T-16 — Образ не получает curl, и кто-то будет через `docker exec` пытаться отлаживать API curl-ом + +- **Описание:** Оператор зайдёт в контейнер на mva154 для отладки и + обнаружит, что `curl` нет. Привычка проверять `curl localhost:5556` + не сработает. +- **Вероятность / Влияние:** С / Н. +- **Митигация:** + - **Архитектурное решение:** в slim-образе curl никогда не было. + Это не регрессия ET-015 — это уже было до фикса. + - **Альтернатива для оператора:** тот же `python -c "import + urllib.request; print(urllib.request.urlopen('http://localhost:5556/api/health').read())"` + из контейнера, либо `curl` с хоста против externalного URL. + - **Будущее (если боль):** добавить debug-образ с `curl` (отдельный + Dockerfile + tag) — out of scope ET-015. + +## R-T-17 — В будущем Compose v3 будет deprecated, перейдём на Compose Spec — структура healthcheck изменится + +- **Описание:** Docker Compose Spec (v2024+) уже унифицировал + формат healthcheck. Если проект мигрирует на новый формат, нужно + будет переписать `test:` поле. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** Compose Spec **сохраняет** обратную + совместимость с массивной формой `test: ["CMD", ...]`. Никакой + обязательной миграции healthcheck-секции при апгрейде Compose не + предвидится. + - **Если миграция понадобится:** перенесённая секция останется + идентичной по смыслу. + +## R-T-18 — Прочие пути дёргают `/api/health` для своих целей и трафик растёт + +- **Описание:** Если в будущем кто-то добавит внешний мониторинг + (Uptime Robot, Prometheus blackbox), который тоже бьёт по + `/api/health`, плюс наш healthcheck — нагрузка пойдёт **дважды**. + Сейчас 2 req/min — пренебрежимо, но при росте уровней может стать + заметным. +- **Вероятность / Влияние:** Н / Н. +- **Митигация:** + - **Архитектурное решение:** `/api/health` дешёвый (~7 мс, + in-memory). Линейный рост источников выдержит много порядков. + - **Если действительно станет проблемой:** ввести rate-limit на + `/api/health` (отдельный work-item) или разнести + liveness (internal-only) и readiness (external) — TD-1 ADR-020. + +## Сводная таблица + +| # | Риск | Вер | Влиян | Митигация (тип) | +|---|------|-----|-------|------------------| +| R-T-1 | Поломка квотинга YAML/shell | Н | С | CMD-массив без shell; ST-03 валидирует | +| R-T-2 | CPU/RAM overhead fork+exec python | Н | Н | 30 с интервал → ~0.005% CPU; `docker stats` мониторинг | +| R-T-3 | `/api/health` отвечает > 3 с под нагрузкой | Н | С | Endpoint лёгкий; 3 с = 430× запас; деградация = валидный сигнал | +| R-T-4 | Разные exit code при разных ошибках | Н | Н | Docker — binary семантика; различение не нужно | +| R-T-5 | `start_period: 20s` неподходящий | Н | Н | uvicorn стартует < 2 с; 10× запас | +| R-T-6 | `localhost` резолвится в IPv6 | Н | С | Дефолт IPv4 в `python:3.12-slim`; fallback `127.0.0.1` | +| R-T-7 | `/api/health` сменит путь | Н | С | AC-08 блокирует merge; ADR-020 трейл для будущего | +| R-T-8 | TLS на loopback | Н | Н | HTTP loopback, HTTPS только на nginx | +| R-T-9 | `python` alias исчезнет | Н | Н | Долгосрочно стабилен; правка 1 символ | +| R-T-10 | Поломка YAML при ручной правке | С | Н | ST-03 + `make lint`; block-scalar при необходимости | +| R-T-11 | Гонка при деплое | С | Н | Waiting-loop 120 с + `start_period: 20s` | +| R-T-12 | Healthy при недоступной БД | Н | С | Out of scope (BRD §7); не делает хуже; TD-1 | +| R-T-13 | AC-05 не воспроизводится через `kill -STOP 1` | Н | Н | Альтернатива через `docker stop`; покрывает семантику | +| R-T-14 | `yamllint` падает на длинной строке | Н | Н | Конфигурация yamllint или block-scalar | +| R-T-15 | `restart_policy.condition: unhealthy` в будущем | Н | С | Не вводится в ET-015; новый ADR при добавлении | +| R-T-16 | Оператор привык к `curl` для отладки | С | Н | curl и раньше не было; альтернативы есть | +| R-T-17 | Compose v3 deprecation | Н | Н | Спека сохраняет совместимость массивной формы | +| R-T-18 | Внешний мониторинг + healthcheck = 2× нагрузка | Н | Н | Endpoint дешёвый; rate-limit при росте | + +## Связанные документы + +- `01-brd.md` §3 R1..R3 (бизнес-риски — ложные алёрты, эрозия доверия, + SLO), §8 (сценарий «как должно стать») +- `02-trz.md` §3 (целевое состояние), §4 (альтернативы), §8 (риски + ТЗ — частично пересекаются с этим документом, но фокусируются на + имплементации; здесь — архитектурный взгляд) +- `03-acceptance-criteria.md` AC-01..AC-10 (все гейты) +- `04-test-plan.yaml` ST-01..ST-07, UT-01..UT-03, IT-01..IT-04, + E2E-01..E2E-02 +- `06-adr/ADR-020-healthcheck-via-python-urllib.md` §«Решение», + §«Последствия», §«Технический долг», §«Альтернативы для будущего» +- `07-infra-requirements.md` §6 (deploy procedure), §7 + (observability) +- `08-data-requirements.md` +- `docs/architecture/README.md` §«Деплой», §«Компоненты» +- `docs/work-items/ET-014/10-tech-risks.md` — образец «UI calibration + risks» документа (наследие) +- `docs/work-items/ET-013/10-tech-risks.md` — образец «layer + calibration risks» документа (наследие)