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