25 KiB
type, work_item_id, adr_id, title, status, created_at, updated_at, authors, supersedes, superseded_by, labels
| type | work_item_id | adr_id | title | status | created_at | updated_at | authors | supersedes | superseded_by | labels | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| adr | ET-015 | ADR-020 | ADR-020: Container healthcheck выполнять через python `urllib.request` one-liner вместо `curl` | accepted | 2026-06-05 | 2026-06-05 |
|
|
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):
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
interval: 30s
timeout: 5s
retries: 3
Dockerfile (строки 1–13):
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:
@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 приводится к виду:
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. Старый composestart_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 (отклонён)
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 (отклонён)
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-slimwgetотсутствует наравне сcurl(мы специально проверили — пакетwgetне входит в slim-вариант).
Вариант D — HEALTHCHECK директива в 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-hocdocker runне используется. Преимущество «работает без compose» пустое.
Вариант E — отдельный файл scripts/healthcheck.py (отклонён)
# 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 не увидит новой версии.
Решение
-
В
docker-compose.ymlсекцияhealthcheckсервисаappзаменяется на: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для смягчения окна холодного старта. -
Dockerfileне меняется. Никакогоapt-get install curl/wget. -
src/api/main.pyне меняется. Эндпоинт/api/health(main.py:1224) уже отдаёт HTTP 200 + JSON, дальнейших правок не требует (BRD §7, AC-08). -
gps-collectorне получает healthcheck (BRD §7 out of scope:restart: "no", batch profile, нет открытого порта). -
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.mdAC-01..AC-10 - Test plan:
docs/work-items/ET-015/04-test-plan.yamlST-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