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

164 lines
12 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.

# Наблюдаемость: метрики, логи, алерты
Задача #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` | разовая подготовка учёток |
## Первый запуск
```bash
# 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
```
Пароль витрины смотреть на хосте, в переписку не копировать:
```bash
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`, `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 врут, врут и графики