gendesign/docs/observability.md
bot-backend 124cfb3d5d
All checks were successful
CI / backend-tests (pull_request) Successful in 17m30s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m22s
CI Trade-In / backend-tests (pull_request) Successful in 4m51s
feat(observability): /metrics в обоих бэкендах — счётчики, задержка, дашборд
Третья часть #3078 и единственная, трогающая прод-код.

До неё числовых рядов у приложений не было вовсе: только логи и исключения в
GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт 401 потоком» в такой
картине невидим — исключения нет, строка в логе выглядит обычной, а продукт
при этом не работает.

Метка route — ШАБЛОН маршрута, а не путь запроса. Это несущее решение, а не
деталь: кадастровый номер или идентификатор заявки в метке даёт новый временной
ряд на каждую сущность, а ряд у Prometheus стоит памяти постоянно, а не в момент
запроса. Самый известный способ уронить мониторинг тем самым мониторингом.
Незаматченные пути (404, сканеры) сведены в одну метку, иначе тот же взрыв
устроит любой бот, перебирающий адреса. Оба свойства сторожатся тестами, а не
комментарием: тест бьёт тремя разными идентификаторами и требует ОДИН ряд.

Слой регистрируется последним и потому оказывается самым внешним. Изнутри
RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на
резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим
I/O в middleware (#1202). Упавший исключением запрос считается как 500 в
finally: без этого он просто отсутствовал бы в счётчике, то есть ровно тогда,
когда метрики нужнее всего.

Путь публичен ВНУТРИ и закрыт СНАРУЖИ — это два разных периметра. Скрейп идёт
из docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому
/metrics внесён в _PUBLIC_PATHS обоих бэкендов; иначе агент получал бы 401 и
метрик не было бы вовсе. Наружу путь не открывается ни через gendsgn.ru, ни
через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках —
чтобы закрытость осталась решением, а не следствием текущего порядка директив.

Ограничитель частоты и аудит «Меры» не трогались: оба смотрят только на пути
под /api/, скрейп под них не попадает. Проверено тестом, а не чтением.

Прод-поведение не меняется ничем, кроме нового публичного пути: ни один
существующий обработчик, гвард или маршрут не тронут.

Refs #3078
2026-08-26 11:30:18 +03:00

17 KiB
Raw Blame History

Наблюдаемость: метрики, логи, алерты

Задача #3078. Стек живёт по адресу https://metrics.gendsgn.ru/.

Зачем это заведено

Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни /metrics в бэкендах. Единственным каналом наблюдения оставался journald, а единственным сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был невидим в принципе: задача рапортует done, строк ноль, исключения нет, метрики нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв house_imv_backfill (#2698), так 8 суток писал ноль записей newbuilding_enrich (#2767), так 91 день копилось раздутие listings (#2992).

Grafana не заменяет GlitchTip. Ошибки остаются там: Grafana OSS не принимает Sentry DSN ни одним компонентом, а 549 строк скрубберов в before_send — это требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных — числовые ряды и алерты по трендам вместо алертов по событиям.

Топология

        Poincare (188.246.224.93) — продукт          Beget (46.173.16.127) — инфраструктура
        ┌──────────────────────────────┐             ┌────────────────────────────────────┐
        │ node-exporter                │             │ Prometheus  ← приёмник remote_write │
        │ cAdvisor                     │  HTTPS      │ Loki        ← приёмник логов        │
        │ postgres-exporter × 2        │ ──────────► │ Grafana     ← витрина               │
        │ Alloy  ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts)   │
        └──────────────────────────────┘             │ node-exporter, cAdvisor, Alloy      │
                                                     │ postgres-exporter (infra)           │
                                                     └────────────────────────────────────┘

Наблюдатель стоит у другого провайдера, чем наблюдаемое. Если ляжет Poincare, мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним.

Транспорт — push. Prometheus не ходит на Poincare: там агент сам шлёт исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть именно в аварии, ради которой мониторинг и заводится.

Что где лежит

Файл Назначение
docker-compose.metrics.yml серверная сторона, только Beget
docker-compose.metrics-agent.yml агенты, оба хоста; профили apps / infra
ops/metrics/prometheus/ конфиг и правила алертов
ops/metrics/loki/ конфиг Loki
ops/metrics/alloy/alloy-infra.alloy агент на Beget — пишет напрямую по docker-сети
ops/metrics/alloy/alloy-apps.alloy агент на Poincare — пишет по HTTPS под basic_auth
ops/metrics/postgres/queries.yml дополнительные запросы экспортера БД
backend/app/observability/metrics.py счётчики и /metrics «Птицы»
tradein-mvp/backend/app/observability/metrics.py то же для «Меры»
ops/metrics/grafana/ источники данных и дашборды
caddy/sites/infra.caddy site-блок metrics.gendsgn.ru
scripts/setup-metrics-secrets.sh разовая подготовка учёток

Первый запуск

# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши.
ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh'

# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и
#    caddy/metrics-ingest.caddy.snippet, закоммитить.

# 3. Деплой.
#    Forgejo → Actions → Deploy Metrics → Run workflow

Пароль витрины смотреть на хосте, в переписку не копировать:

ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime"

Два контура доступа, две учётки

metrics.gendsgn.ru/ingest/* — машинный, для агента. Пароль этого контура по построению лежит в открытом виде на продуктовом хосте: агенту нужно им авторизоваться. Значит, компрометация Poincare раскрывает его автоматически. Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью.

Всё остальное — витрина Grafana под отдельным паролем. Это второй слой: у Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи. Если два запроса пароля подряд неудобны — убрать одну строку import caddy/metrics-ui.caddy.snippet из infra.caddy; вход Grafana останется.

GlitchTip читается прямым SQL, а не плагином

Плагин grafana/sentry-datasource GlitchTip официально документирует, но на нашей 6.1.6 половина путей нерабочая: stats_v2 с фильтром по проекту отдаёт 500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416), Metrics/Spans/Tags вообще Sentry-only. В самом grafana/sentry-datasource слово «glitchtip» не встречается ни разу — апстрим связку не тестирует.

Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only пользователь (scripts/setup-metrics-grafana-role.sh, прав на запись нет). Схема проверена на живой базе 26.08:

  • issue_events_issuecount, first_seen, last_seen, status, level, project_id, title, culprit
  • issue_events_issueaggregate(issue_id, organization_id, date, count), партиционирована по неделям с почасовыми под-партициями
  • issue_events_issueevent — колонка времени называется timestamp (плюс created); колонки received в этой версии нет

Отдельной таблицы IssueIndex не существует — все агрегаты лежат на самой issue_events_issue. В более ранних описаниях этой задачи она упоминалась; это была ошибка, проверено глазами.

Граница: доступ read-only. Тренды — в Grafana, а assign / resolve / ignore, стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и рабочее место. Это осознанно, а не недоделка.

Метрики приложений

У «Птицы» и «Меры» появился /metrics — HTTP-счётчики, гистограмма времени ответа, число запросов в работе и версия сборки, плюс process_* от реестра prometheus_client (память процесса, дескрипторы, сборщик мусора).

Метка route — это шаблон маршрута, /api/v1/parcels/{cad_num}, а не путь запроса. Разница принципиальная, а не косметическая: кадастровый номер в метке даёт новый временной ряд на каждый участок, а ряд у Prometheus стоит памяти постоянно, а не в момент запроса. Это самый известный способ уронить мониторинг тем самым мониторингом. Всё незаматченное сведено в одну метку __unmatched__ — иначе тот же взрыв рядов устроит любой бот, перебирающий адреса. Свойство сторожится тестами (tests/test_metrics.py в обоих проектах), а не комментарием.

Слой регистрируется последним и потому оказывается самым внешним (add_middleware вставляет в начало списка). Порядок несущий: изнутри RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на резолв сессии в БД auth, — а именно этот путь уже давал инцидент с блокирующим I/O в middleware (#1202).

Путь публичен внутри и закрыт снаружи — это два разных периметра. Скрейп идёт изнутри docker-сети, где заголовка X-Authenticated-User нет ни у кого, поэтому /metrics внесён в _PUBLIC_PATHS обоих бэкендов: без этого агент получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не открывается — ни через gendsgn.ru, ни через meraocenka.ru, и вдобавок закрыт явным respond 404 в обоих site-блоках. Явный отказ стоит там ради регрессии: одной строки handle /metrics { reverse_proxy backend:8000 }, добавленной когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее устройство продукта.

Ограничитель частоты и аудит запросов «Меры» трогать не пришлось: первый смотрит только на пути под /api/, второй — на /api/ и известного пользователя. Скрейп раз в 30 секунд не попадает ни под один; иначе метрики пропадали бы пачками под нагрузкой, а user_events получала бы 2880 строк в сутки ни о чём.

Один процесс — один реестр. Оба контейнера запускают uvicorn без --workers, поэтому значения счётчиков целостны. Появятся воркеры — счётчики станут per-process, каждый скрейп попадёт в случайный из них, и график начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным режимом prometheus_client (PROMETHEUS_MULTIPROC_DIR + MultiProcessCollector); делать это заранее незачем, но связь --workers → сломанные графики стоит знать до, а не после.

Celery-воркеры своего /metrics не отдают: у них нет HTTP-сервера, а поднимать его в каждом воркере ради счётчиков — отдельная конструкция со своим временем жизни. Прогоны фоновых задач будут видны иначе — через scrape_runs (часть 4), и это лучше: там уже лежит история, а не только то, что происходит прямо сейчас.

Алерты

Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик работает и без них, но при срабатывании правила никто не будет уведомлён — деплой пишет об этом предупреждением, чтобы это не стало сюрпризом.

Включение: задать METRICS_TELEGRAM_BOT_TOKEN и METRICS_TELEGRAM_CHAT_ID в окружении инфраструктурного хоста и перезапустить деплой. Профиль alerts включится сам.

Alertmanager шлёт в Telegram напрямую с Beget, а не через бэкенд МЕРЫ, хотя рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг Poincare, не должно идти через сервис на Poincare.

Отдельно живёт правило Watchdog — оно горит всегда и раз в 12 часов подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела. Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного сообщения с 30 мая, и выяснилось это случайно (#2673).

Известные грабли

  • Alloy запущен от root. Штатный пользователь alloy требует членства в группах adm и systemd-journal; внутри контейнера этих групп с нужными gid нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов просто нет».
  • Путь к журналу задан явно (/var/log/journal). Без него libsystemd применяет SD_JOURNAL_LOCAL_ONLY, и хостовый журнал из контейнера не виден — тоже без ошибки.
  • retention_period у Loki не работает без retention_enabled у компактора. Loki примет конфиг и будет копить вечно.
  • Ретенция Prometheus задана и по времени, и по размеру. Только по времени — значит, объём в байтах зависит от числа рядов, которое растёт само.
  • Caddy проверяется до перезагрузки. На Beget тот же Caddy обслуживает git., errors. и obsidian.; ошибка в infra.caddy положила бы их все, включая Forgejo, из которого идёт деплой.

Что ещё не сделано

  • экспортер поверх scrape_runs: success_ratio, свежесть приёмника, утилизация, счётчик cancelled (часть 4)
  • алерты и синтетический heartbeat (часть 5)
  • pg_stat_statements для кластера Птицы — требует рестарта прод-БД, отдельно
  • честные started_at / finished_at у прогонов (#2702) — предусловие для графиков пропускной способности: пока start/finish врут, врут и графики