Третья часть #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
17 KiB
Наблюдаемость: метрики, логи, алерты
Задача #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_issue—count,first_seen,last_seen,status,level,project_id,title,culpritissue_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 врут, врут и графики