architect(ET): auto-commit from architect run_id=102
This commit is contained in:
@@ -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
|
||||
434
docs/work-items/ET-015/07-infra-requirements.md
Normal file
434
docs/work-items/ET-015/07-infra-requirements.md
Normal file
@@ -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 <commit>` на 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)
|
||||
292
docs/work-items/ET-015/08-data-requirements.md
Normal file
292
docs/work-items/ET-015/08-data-requirements.md
Normal file
@@ -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» документа (наследие)
|
||||
372
docs/work-items/ET-015/10-tech-risks.md
Normal file
372
docs/work-items/ET-015/10-tech-risks.md
Normal file
@@ -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» документа (наследие)
|
||||
Reference in New Issue
Block a user