--- 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» документа (наследие)