gendesign/docs/observability.md
bot-backend 309d273f3f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(observability): стек метрик и логов — Prometheus, Loki, Grafana, агенты на обоих хостах
Метрик в проекте не было ни одной: ни экспортеров, ни /metrics в бэкендах,
единственный канал наблюдения — journald, единственный сигнал об аварии —
исключение в GlitchTip. Из-за этого целый класс отказов невидим в принципе:
задача рапортует done, строк ноль, исключения нет. Так протухли данные на семь
месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль
newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992).

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

Наблюдатель поставлен у ДРУГОГО провайдера, чем наблюдаемое: серверная сторона
на Beget, рядом с GlitchTip. Если ляжет Poincare, мониторинг должен об этом
сказать, а не лечь вместе с ним.

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

Два контура доступа с разными учётками. Пароль приёмника по построению лежит
открытым на продуктовом хосте, значит его компрометация неизбежна вместе с
хостом; будь это учётка витрины, утёк бы и доступ к дашбордам.

GlitchTip читается прямым SQL, а не Sentry-плагином: у плагина на 6.1.6
stats_v2 отдаёт 500 (баг GlitchTip #381), Events/Discover — 404 (#416), а в
grafana/sentry-datasource слово glitchtip не встречается ни разу. Схема сверена
на живой базе: колонка времени называется timestamp, а не received, и отдельной
таблицы IssueIndex не существует — агрегаты лежат на самой issue_events_issue.

Алерты за профилем alerts: канал доставки — открытый вопрос #3078, и стек не
должен на нём стоять. Деплой предупреждает, что уведомлять пока некому.

Каждая настройка, способная отказать молча, закрыта явно: ретенция Prometheus
задана и по времени и по размеру, retention_enabled у компактора Loki (без него
retention_period не работает вовсе), путь к журналу и запуск Alloy от root
(иначе агент читает ноль записей без ошибки), проверка Caddy до перезагрузки
(на этом хосте тот же Caddy держит git, errors и obsidian).

Refs #3078
2026-08-26 10:45:54 +03:00

12 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 дополнительные запросы экспортера БД
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. Окна остаётся два: витрина и рабочее место. Это осознанно, а не недоделка.

Алерты

Пока выключены профилем. Канал доставки — открытый вопрос #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, из которого идёт деплой.

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

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