Files
enduro-trails/docs/work-items/ET-015/10-tech-risks.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

373 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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` (~80150 мс старт интерпретатора). На фоне нагруженного
uvicorn это может создавать заметный CPU-spike.
- **Вероятность / Влияние:** Н / Н.
- **Митигация:**
- **Архитектурное решение:** интервал 30 с → накладные расходы
~150 мс / 30 с = **0.5% от одного ядра** в пике, в среднем —
~0.005% CPU. На mva154 (BRD: idle ~12% 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» документа (наследие)