architect(ET): auto-commit from architect run_id=102
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 10s
CI / build (push) Successful in 3s

This commit is contained in:
2026-06-05 15:27:58 +00:00
parent c2cf8280ca
commit 4f80c250cf
5 changed files with 1559 additions and 0 deletions

View File

@@ -0,0 +1,372 @@
---
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» документа (наследие)