---
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
