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
Третья часть #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
215 lines
17 KiB
Markdown
215 lines
17 KiB
Markdown
# Наблюдаемость: метрики, логи, алерты
|
||
|
||
Задача #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` | разовая подготовка учёток |
|
||
|
||
## Первый запуск
|
||
|
||
```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. Окна остаётся два: витрина и
|
||
рабочее место. Это осознанно, а не недоделка.
|
||
|
||
## Метрики приложений
|
||
|
||
У «Птицы» и «Меры» появился `/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 врут, врут и графики
|