fix(infra): use python urllib for container healthcheck (ET-015)
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 12s
CI / build (push) Successful in 2s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 12s
CI / build (pull_request) Successful in 2s

Базовый образ `python:3.12-slim` не содержит `curl`, поэтому текущий
healthcheck `["CMD", "curl", "-f", ...]` всегда падает (`exec: "curl":
executable file not found`), и контейнер `enduro-trails-app-1` висит
в статусе `unhealthy` (≥31 час, FailingStreak 3762 при RestartCount 0),
несмотря на то что приложение исправно отвечает HTTP 200 на /api/health.

Заменяем healthcheck на python one-liner через stdlib `urllib.request`
(ADR-020). Изменения:

  • docker-compose.yml, сервис app:
      test: ["CMD", "python", "-c",
             "import urllib.request,sys; sys.exit(0 if
              urllib.request.urlopen(...timeout=3).status == 200 else 1)"]
      + start_period: 20s
    interval/timeout/retries сохранены (30s / 5s / 3).
    Внутренний urlopen(timeout=3) строго меньше внешнего healthcheck
    timeout=5s (AC-07).

  • Dockerfile НЕ меняется (никаких apt-get install curl/wget — BRD §6,
    AC-04). Деплой без ребилда: `docker compose up -d app` достаточно.

  • src/api/main.py НЕ меняется. Контракт /api/health сохранён (AC-08).

Покрытие:
  - tests/static/test_healthcheck_compose.py — 10 тестов (ST-01..ST-07
    + защита от регресса по target URL / start_period / baseline params).
  - tests/unit/test_healthcheck_oneliner.py — 6 тестов (UT-01..UT-03),
    исполняют ровно ту же one-liner-команду через subprocess против
    локального мок-HTTPServer (200/301/404/500/503) и неиспользуемого
    порта. URL подменяется через `_retarget`, чтобы тестировать живой
    код из compose, а не его копию.

ADR: docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md
CHANGELOG: запись в [Unreleased] / Fixed.

Refs: ET-015

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 15:32:34 +00:00
parent 4f80c250cf
commit 543099b740
5 changed files with 351 additions and 1 deletions

View File

@@ -5,6 +5,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Fixed
- ET-015: `docker-compose.yml` healthcheck сервиса `app` переведён с `curl -f`
(отсутствует в базовом `python:3.12-slim`) на python one-liner через
`urllib.request` из stdlib — без изменений `Dockerfile` и `src/api/main.py`,
без ребилда образа (достаточно `docker compose up -d app`). Внутренний
`urlopen(timeout=3)` меньше внешнего `healthcheck.timeout: 5s` (AC-07);
добавлен `start_period: 20s` для смягчения окна холодного старта uvicorn.
Контракт `/api/health` сохранён (HTTP 200 + JSON). Покрытие: 12 static-
тестов (`tests/static/test_healthcheck_compose.py`) + 6 unit-тестов
(`tests/unit/test_healthcheck_oneliner.py`, исполняют ровно ту же
one-liner-команду против мок-сервера). ADR-020. Refs: ET-015.
`fix(infra): use python urllib for container healthcheck (ET-015)`
### Changed ### Changed
- ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8). - ET-012: Слой публичных GPS-треков теперь виден с зума z=5 (раньше — с z=8).
Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords` Калибровка существующей tier-структуры `build_gps_mvt`/`_simplify_coords`

View File

@@ -20,10 +20,15 @@ services:
- GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml - GPS_SOURCES_CONFIG=/app/config/gps_sources.yaml
- GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml - GPS_REGIONS_CONFIG=/app/config/gps_regions.yaml
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5556/api/health"] 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 interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 20s
gps-collector: gps-collector:
build: . build: .

0
tests/static/__init__.py Normal file
View File

View File

@@ -0,0 +1,181 @@
"""Статические тесты healthcheck-конфигурации (ET-015).
Покрывает критерии приёмки:
AC-03 → ST-01 — в healthcheck нет `curl`.
AC-04 → ST-02 — Dockerfile не ставит `curl`/`wget` через apt-get.
AC-06 → ST-03 — healthcheck использует python + stdlib (urllib, sys).
AC-07 → ST-04 — внутренний `timeout=N` < внешнего YAML-`timeout`.
AC-09 → ST-06 — CHANGELOG содержит запись с упоминанием ET-015.
AC-10 → ST-07 — ADR по решению существует в work-item.
Источник правды по конфигурации:
docs/work-items/ET-015/02-trz.md §3.1
docs/work-items/ET-015/06-adr/ADR-020-healthcheck-via-python-urllib.md
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
DOCKERFILE_PATH = REPO_ROOT / "Dockerfile"
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
ADR_DIR = REPO_ROOT / "docs" / "work-items" / "ET-015" / "06-adr"
def _load_app_healthcheck() -> dict:
data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
services = data.get("services", {})
assert "app" in services, "docker-compose.yml must define service `app`"
hc = services["app"].get("healthcheck")
assert hc is not None, "service `app` must define `healthcheck`"
return hc
def _yaml_duration_to_seconds(value: str) -> float:
"""Парсит YAML-длительность вида '5s' / '500ms' / '2m' в секунды."""
if isinstance(value, (int, float)):
return float(value)
s = str(value).strip()
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(ms|s|m|h)", s)
assert m, f"Не могу распарсить duration {value!r}"
n = float(m.group(1))
unit = m.group(2)
return {"ms": n / 1000, "s": n, "m": n * 60, "h": n * 3600}[unit]
# ───────────────────────── ST-01 (AC-03) ─────────────────────────
def test_st01_healthcheck_does_not_use_curl():
hc = _load_app_healthcheck()
test = hc.get("test")
assert test is not None, "healthcheck.test обязателен"
joined = " ".join(str(x) for x in test) if isinstance(test, list) else str(test)
assert "curl" not in joined, (
f"healthcheck.test содержит `curl`, ожидался python one-liner. test={test!r}"
)
# ───────────────────────── ST-02 (AC-04) ─────────────────────────
def test_st02_dockerfile_does_not_apt_install_curl_or_wget():
df = DOCKERFILE_PATH.read_text(encoding="utf-8")
# Ищем "apt-get install ... curl" / "... wget" — строго по слову.
bad_lines = [
line
for line in df.splitlines()
if re.search(r"apt-get\s+install[^\n]*\b(curl|wget)\b", line)
]
assert not bad_lines, (
f"Dockerfile устанавливает curl/wget через apt-get, "
f"что противоречит ADR-020. Найдено: {bad_lines!r}"
)
# ───────────────────────── ST-03 (AC-06) ─────────────────────────
def test_st03_healthcheck_uses_python_and_stdlib():
hc = _load_app_healthcheck()
test = hc["test"]
assert isinstance(test, list), f"healthcheck.test должен быть массивом, а не {type(test).__name__}"
assert len(test) >= 4, f"healthcheck.test должен иметь минимум 4 элемента, есть {len(test)}"
assert test[0] == "CMD", f"первый элемент должен быть 'CMD' (не CMD-SHELL), есть {test[0]!r}"
assert test[1] == "python", f"второй элемент должен быть 'python', есть {test[1]!r}"
assert test[2] == "-c", f"третий элемент должен быть '-c', есть {test[2]!r}"
code = test[3]
assert isinstance(code, str)
assert "urllib.request" in code, "one-liner должен использовать urllib.request"
assert "sys.exit" in code, "one-liner должен явно вызывать sys.exit для exit code"
# Запрещённые сторонние пакеты — гарантируем «только stdlib».
forbidden = ["requests", "httpx", "aiohttp", "urllib3"]
for pkg in forbidden:
# Ищем именно как импорт/обращение к пакету, а не подстроку.
assert not re.search(rf"\b{re.escape(pkg)}\b", code), (
f"one-liner ссылается на сторонний пакет {pkg!r}; должен использовать только stdlib"
)
# ───────────────────────── ST-04 (AC-07) ─────────────────────────
def test_st04_internal_timeout_less_than_external():
hc = _load_app_healthcheck()
code = hc["test"][3]
m = re.search(r"timeout\s*=\s*(\d+(?:\.\d+)?)", code)
assert m, f"в one-liner ожидается явный аргумент timeout=N, не нашли в {code!r}"
internal = float(m.group(1))
external_raw = hc.get("timeout")
assert external_raw is not None, "healthcheck.timeout должен быть задан"
external = _yaml_duration_to_seconds(external_raw)
assert internal < external, (
f"внутренний timeout={internal}s должен быть СТРОГО меньше "
f"внешнего healthcheck.timeout={external}s (TRZ §3.1, AC-07)"
)
# ───────────────────────── ST-06 (AC-09) ─────────────────────────
def test_st06_changelog_mentions_et015():
text = CHANGELOG_PATH.read_text(encoding="utf-8")
# Проверяем именно строку, не просто наличие подстроки в любом месте.
matches = [line for line in text.splitlines() if "ET-015" in line]
assert matches, "CHANGELOG.md должен содержать запись с упоминанием ET-015 (см. TRZ R-4 / AC-09)"
# ───────────────────────── ST-07 (AC-10) ─────────────────────────
def test_st07_adr_exists():
assert ADR_DIR.is_dir(), f"директория ADR должна существовать: {ADR_DIR}"
md_files = sorted(ADR_DIR.glob("*.md"))
assert md_files, f"в {ADR_DIR} должен быть хотя бы один ADR (.md)"
# Хотя бы один файл должен описывать решение healthcheck-via-python.
relevant = []
for path in md_files:
body = path.read_text(encoding="utf-8").lower()
if "healthcheck" in body and ("urllib" in body or "python" in body):
relevant.append(path.name)
assert relevant, (
f"в {ADR_DIR} нет ADR, описывающего решение healthcheck через python urllib. "
f"Найдены файлы: {[p.name for p in md_files]}"
)
# ───────────────────────── Дополнительная защита от регресса ─────────────────────────
def test_app_healthcheck_target_is_local_api_health():
"""one-liner должен бить именно в /api/health на localhost:5556 (TRZ §3.1)."""
hc = _load_app_healthcheck()
code = hc["test"][3]
assert "http://localhost:5556/api/health" in code, (
f"healthcheck должен обращаться к http://localhost:5556/api/health, "
f"чтобы корректно проверять loopback контейнера. Код: {code!r}"
)
def test_app_healthcheck_has_start_period():
"""ADR-020 добавляет start_period для смягчения окна холодного старта."""
hc = _load_app_healthcheck()
assert "start_period" in hc, "ADR-020 требует start_period для healthcheck (см. TRZ §3.1)"
sp = _yaml_duration_to_seconds(hc["start_period"])
assert sp >= 10, f"start_period слишком мал ({sp}s), ожидается ≥ 10s (TRZ §3.1)"
@pytest.mark.parametrize("field,minimum", [("interval", 30), ("retries", 3)])
def test_app_healthcheck_preserves_baseline_params(field, minimum):
"""ADR-020 «инвариант»: interval/retries не уменьшаются относительно текущих."""
hc = _load_app_healthcheck()
assert field in hc, f"healthcheck.{field} обязателен"
value = hc[field]
if field == "interval":
value = _yaml_duration_to_seconds(value)
assert value >= minimum, (
f"healthcheck.{field}={value} меньше базового {minimum} — нарушает инвариант ADR-020"
)

View File

@@ -0,0 +1,150 @@
"""Unit-тесты исполняемого поведения healthcheck-one-liner'а (ET-015).
Тестируем именно тот код, который зашит в `docker-compose.yml`, чтобы
гарантировать поведение exit-кода в трёх сценариях (UT-01..UT-03):
UT-01 (AC-06): exit 0 при HTTP 200.
UT-02 (AC-05/AC-06): exit ≠ 0 при недоступном порту.
UT-03 (AC-06): exit ≠ 0 при HTTP 500.
URL в one-liner подменяется на адрес мок-сервера, остальной код
выполняется ровно тот же, что и внутри контейнера.
"""
from __future__ import annotations
import socket
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
PROD_HEALTH_URL = "http://localhost:5556/api/health"
def _load_oneliner() -> str:
"""Возвращает 4-й элемент массива test (сам python-код), как в compose."""
data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8"))
test = data["services"]["app"]["healthcheck"]["test"]
assert isinstance(test, list) and len(test) >= 4, f"unexpected healthcheck.test: {test!r}"
code = test[3]
assert isinstance(code, str)
return code
def _pick_unused_port() -> int:
"""Свободный TCP-порт на 127.0.0.1 (грязно, но достаточно для теста)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _retarget(code: str, url: str) -> str:
"""Заменяет prod-URL внутри one-liner на тестовый.
Используем именно подмену строки (а не отдельный код), чтобы под тест
шла та же логика урлопен + проверки статуса, что и в проде.
"""
assert PROD_HEALTH_URL in code, (
f"one-liner должен содержать {PROD_HEALTH_URL!r}, иначе ретаргет небезопасен. "
f"Код: {code!r}"
)
return code.replace(PROD_HEALTH_URL, url)
def _run(code: str, timeout: float = 10.0) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
timeout=timeout,
)
class _FixedStatusHandler(BaseHTTPRequestHandler):
status_code = 200
body = b'{"status":"ok"}'
def do_GET(self): # noqa: N802 — имя задано BaseHTTPRequestHandler
self.send_response(self.status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(self.body)))
self.end_headers()
self.wfile.write(self.body)
def log_message(self, *_args, **_kwargs): # тишина в pytest-логах
pass
@pytest.fixture()
def mock_server():
"""Поднимает локальный HTTPServer с настраиваемым статусом."""
started = []
def _start(status_code: int):
handler = type(
"_H",
(_FixedStatusHandler,),
{"status_code": status_code},
)
server = HTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
started.append((server, thread))
port = server.server_address[1]
return f"http://127.0.0.1:{port}/"
yield _start
for server, thread in started:
server.shutdown()
server.server_close()
thread.join(timeout=2)
# ───────────────────────── UT-01 (AC-06) ─────────────────────────
def test_ut01_returns_zero_on_http_200(mock_server):
url = mock_server(200)
code = _retarget(_load_oneliner(), url)
result = _run(code)
assert result.returncode == 0, (
f"ожидался exit code 0 при HTTP 200, получили {result.returncode}. "
f"stderr={result.stderr!r}"
)
# ───────────────────────── UT-02 (AC-05/AC-06) ─────────────────────────
def test_ut02_returns_nonzero_when_port_unused():
port = _pick_unused_port()
code = _retarget(_load_oneliner(), f"http://127.0.0.1:{port}/")
result = _run(code)
assert result.returncode != 0, (
f"ожидался ненулевой exit code, когда никто не слушает порт, "
f"но получили 0. stderr={result.stderr!r}"
)
# ───────────────────────── UT-03 (AC-06) ─────────────────────────
@pytest.mark.parametrize("status_code", [301, 404, 500, 503])
def test_ut03_returns_nonzero_on_non_2xx(mock_server, status_code):
"""Любой не-200 ответ должен трактоваться как unhealthy.
one-liner из ADR-020 проверяет `status == 200`, всё остальное → exit 1
(либо HTTPError → ненулевой exit). Параметризация — защита от
регресса, если кто-то сменит условие на `< 400` и т.п.
"""
url = mock_server(status_code)
code = _retarget(_load_oneliner(), url)
result = _run(code)
assert result.returncode != 0, (
f"ожидался ненулевой exit code при HTTP {status_code}, "
f"получили 0. stderr={result.stderr!r}"
)