Files
enduro-trails/docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md
claude-bot 4f80c250cf
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 10s
CI / build (push) Successful in 3s
architect(ET): auto-commit from architect run_id=102
2026-06-05 15:27:58 +00:00

25 KiB
Raw Blame History

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
agent:architect
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 (строки 2226):

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"]
  interval: 30s
  timeout: 5s
  retries: 3

Dockerfile (строки 113):

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.ymldocker 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-процесс на ~510 МБ RAM каждые 30 с — пренебрежимо.

Cons / Принимаем:

  • Стоимость fork+exec питона. Каждые 30 с поднимается отдельный процесс python (~80150 мс старт интерпретатора + ~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 по размеру (~57 МБ vs ~10 МБ).
  • Однострочник в compose такой же лаконичный.

Cons (отклонён):

  • Всё ещё +57 МБ к образу + 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 (отклонён)

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 (отклонён)

# 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 +57 МБ Нужен wget, libidn2, ... wget --spider неочевидный отклонён
D HEALTHCHECK в Dockerfile 0 МБ Нужен stdlib Тот же что A отклонён (ребилд + дублирование)
E Отдельный scripts/healthcheck.py 0 МБ Не нужен* stdlib Тот же что A отклонён (лишний файл)

* — при условии, что файл уже в образе. Если правка скрипта без ребилда — healthcheck не увидит новой версии.

Решение

  1. В 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 для смягчения окна холодного старта.

  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 с ~80150 мс старт интерпретатора. На 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