feat(observability): стек метрик и логов — Prometheus, Loki, Grafana на Beget, агенты на обоих хостах #3099

Merged
lekss361 merged 2 commits from feat/observability-metrics-stack into main 2026-08-26 08:25:24 +00:00
Owner

Части 1-2 из пяти по #3078: фундамент плюс метрики хоста и баз. Прод-код не тронут ни строкой.

Что появляется

Метрик в проекте не было ни одной — grep prometheus|statsd|opentelemetry по репозиторию давал ноль. Единственным каналом наблюдения оставался journald, единственным сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был невидим в принципе: задача рапортует done, строк ноль, исключения нет, метрики нет.

Именно так протухли данные на семь месяцев (#2998), 34 дня был мёртв house_imv_backfill (#2698), 8 суток писал ноль записей newbuilding_enrich (#2767), 91 день копилось раздутие listings (#2992).

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

Решения, которые стоит проверить при ревью

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

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

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

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

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

Проверено на живой инфраструктуре

Схема GlitchTip 6.1.6 сверена глазами, и две вещи из прежнего описания задачи оказались неверны:

Считалось На самом деле
колонка времени received timestamp (плюс created); received не существует
агрегаты в IssueIndex такой таблицы нетcount, first_seen, last_seen, status, level лежат на самой issue_events_issue

issue_events_issueaggregate (issue_id, organization_id, date, count) подтверждена, партиционирована по неделям с почасовыми под-партициями. Событий в базе 31 703.

Caddy проверен caddy validate до коммита — в обоих режимах, Valid configuration. Первый прогон поймал настоящую ошибку: import резолвится относительно файла, где написан, поэтому из caddy/sites/ путь должен быть ../metrics-*.caddy.snippet, как это уже сделано в apps.caddy:219. Без проверки это положило бы git., errors. и obsidian. разом — включая Forgejo, из которого идёт деплой. По той же причине в workflow validate стоит перед reload, и при неудаче работающий Caddy не трогается.

Ресурсы посчитаны по факту, а не по оценке. Beget после переезда и уборки: занято 2987 МБ из 11960, доступно 8972, диск 62 из 145 ГБ. Стек с mem_limit просит ~3,8 ГБ потолка при ожидаемом потреблении заметно ниже.

Настройки, способные отказать молча — закрыты явно

  • ретенция Prometheus задана и по времени, и по размеру: только по времени означает, что объём в байтах зависит от числа рядов, а оно растёт само
  • retention_enabled у компактора Loki — без него retention_period не работает вовсе, конфиг принимается и данные копятся вечно
  • --web.enable-remote-write-receiver — без флага приёмник отдаёт 404, агент молча копит в WAL, графики остаются пустыми
  • Alloy запущен от root и с явным путём к журналу: штатный пользователь без групп adm/systemd-journal и без path читает ноль записей без ошибки
  • ALTER DEFAULT PRIVILEGES для grafana_ro: таблицы событий партиционированы по неделям, без этого свежие данные отдавались бы пустыми при «рабочем» датасорсе

Test plan

  • caddy validate при CADDY_SITES=infra и CADDY_SITES=apps — обе Valid configuration
  • caddy adapt показывает четыре хоста при infra: errors, git, metrics, obsidian
  • схема GlitchTip проверена на живой базе
  • A-запись metrics.gendsgn.ru46.173.16.127 заведена и разошлась (проверено через 1.1.1.1)
  • учётки сгенерированы на хосте, пароли в переписку и в git не попадали — только bcrypt-хеши
  • после мержа: Deploy Metrics → Grafana открывается, хостовые графики живые с обоих хостов
  • после мержа: git., errors., obsidian. продолжают отвечать (Caddy перезагружался)
  • после мержа: метрики Poincare доезжают — метка host="apps" присутствует в Prometheus

Что ещё впереди

Части 3-5: /metrics в обоих бэкендах (единственная часть, трогающая прод-код), экспортер поверх scrape_runs с воспроизведением кейса «done при нуле строк», алерты и синтетический heartbeat.

Refs #3078

Части 1-2 из пяти по #3078: фундамент плюс метрики хоста и баз. Прод-код не тронут ни строкой. ## Что появляется Метрик в проекте не было ни одной — `grep prometheus|statsd|opentelemetry` по репозиторию давал ноль. Единственным каналом наблюдения оставался journald, единственным сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики нет. Именно так протухли данные на семь месяцев (#2998), 34 дня был мёртв `house_imv_backfill` (#2698), 8 суток писал ноль записей `newbuilding_enrich` (#2767), 91 день копилось раздутие `listings` (#2992). Grafana **не заменяет** GlitchTip и не претендует: Grafana OSS не принимает Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это 152-ФЗ, а не фича трекера. Появляется другой класс данных: числовые ряды и алерты по трендам вместо алертов по событиям. ## Решения, которые стоит проверить при ревью **Наблюдатель у другого провайдера, чем наблюдаемое.** Серверная сторона на Beget, рядом с GlitchTip. Ляжет Poincare — мониторинг обязан выжить и сказать об этом. **Транспорт push, а не pull.** Агент на Poincare шлёт исходящим HTTPS, поэтому там не открывается ни одного входящего порта сверх 22/80/443. При обрыве канала Alloy копит в WAL и досылает; pull-скрейп в той же ситуации терял бы точки — то есть ровно в аварии, ради которой мониторинг и заводится. **Две учётки, а не одна.** Пароль приёмника по построению лежит открытым на продуктовом хосте — агенту нужно им авторизоваться. Значит, компрометация Poincare раскрывает его автоматически. Будь это учётка витрины, вместе с ним утёк бы доступ к дашбордам и к Prometheus, где видна вся инфраструктура. **GlitchTip читается прямым SQL, а не Sentry-плагином.** У плагина на нашей 6.1.6: `stats_v2` с фильтром по проекту → 500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover → 404 (#416), Metrics/Spans/Tags — Sentry-only. В самом `grafana/sentry-datasource` слово «glitchtip» не встречается ни разу. Роль `grafana_ro` без единого права на запись: датасорс доступен всякому, кто вошёл в интерфейс, и позволяет произвольный SQL — владельческая роль сделала бы витрину способом уронить трекер. **Алерты за профилем.** Канал доставки — открытый вопрос #3078 (тот же чат, что у вебхука GlitchTip, или отдельный; порог ночной побудки). Стек не должен на нём стоять, поэтому Alertmanager не поднимается, пока токен не задан, а деплой пишет предупреждением, что уведомлять пока некому. Поднимать его с пустым токеном нельзя: он стартует зелёным и молча ничего не шлёт. ## Проверено на живой инфраструктуре **Схема GlitchTip 6.1.6 сверена глазами**, и две вещи из прежнего описания задачи оказались неверны: | Считалось | На самом деле | |---|---| | колонка времени `received` | **`timestamp`** (плюс `created`); `received` не существует | | агрегаты в `IssueIndex` | **такой таблицы нет** — `count`, `first_seen`, `last_seen`, `status`, `level` лежат на самой `issue_events_issue` | `issue_events_issueaggregate (issue_id, organization_id, date, count)` подтверждена, партиционирована по неделям с почасовыми под-партициями. Событий в базе 31 703. **Caddy проверен `caddy validate` до коммита** — в обоих режимах, `Valid configuration`. Первый прогон поймал настоящую ошибку: `import` резолвится относительно файла, где написан, поэтому из `caddy/sites/` путь должен быть `../metrics-*.caddy.snippet`, как это уже сделано в `apps.caddy:219`. Без проверки это положило бы `git.`, `errors.` и `obsidian.` разом — включая Forgejo, из которого идёт деплой. По той же причине в workflow `validate` стоит **перед** `reload`, и при неудаче работающий Caddy не трогается. **Ресурсы посчитаны по факту, а не по оценке.** Beget после переезда и уборки: занято 2987 МБ из 11960, доступно 8972, диск 62 из 145 ГБ. Стек с `mem_limit` просит ~3,8 ГБ потолка при ожидаемом потреблении заметно ниже. ## Настройки, способные отказать молча — закрыты явно - ретенция Prometheus задана **и по времени, и по размеру**: только по времени означает, что объём в байтах зависит от числа рядов, а оно растёт само - `retention_enabled` у компактора Loki — без него `retention_period` не работает вовсе, конфиг принимается и данные копятся вечно - `--web.enable-remote-write-receiver` — без флага приёмник отдаёт 404, агент молча копит в WAL, графики остаются пустыми - Alloy запущен от root и с явным путём к журналу: штатный пользователь без групп `adm`/`systemd-journal` и без `path` читает ноль записей **без ошибки** - `ALTER DEFAULT PRIVILEGES` для `grafana_ro`: таблицы событий партиционированы по неделям, без этого свежие данные отдавались бы пустыми при «рабочем» датасорсе ## Test plan - [x] `caddy validate` при `CADDY_SITES=infra` и `CADDY_SITES=apps` — обе `Valid configuration` - [x] `caddy adapt` показывает четыре хоста при `infra`: `errors`, `git`, `metrics`, `obsidian` - [x] схема GlitchTip проверена на живой базе - [x] A-запись `metrics.gendsgn.ru` → `46.173.16.127` заведена и разошлась (проверено через 1.1.1.1) - [x] учётки сгенерированы на хосте, пароли в переписку и в git не попадали — только bcrypt-хеши - [ ] после мержа: `Deploy Metrics` → Grafana открывается, хостовые графики живые с обоих хостов - [ ] после мержа: `git.`, `errors.`, `obsidian.` продолжают отвечать (Caddy перезагружался) - [ ] после мержа: метрики Poincare доезжают — метка `host="apps"` присутствует в Prometheus ## Что ещё впереди Части 3-5: `/metrics` в обоих бэкендах (единственная часть, трогающая прод-код), экспортер поверх `scrape_runs` с воспроизведением кейса «`done` при нуле строк», алерты и синтетический heartbeat. Refs #3078
lekss361 added 1 commit 2026-08-26 07:47:39 +00:00
feat(observability): стек метрик и логов — Prometheus, Loki, Grafana, агенты на обоих хостах
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
309d273f3f
Метрик в проекте не было ни одной: ни экспортеров, ни /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
Author
Owner

Ревью: технических возражений нет. Мержить не стал — вопрос в сроке, а он твой

Прошёл по диффу (2463 строки, все добавления, прод-код действительно не тронут). Отдельно проверил то, что в таких PR ломается чаще всего.

Безопасность нового публичного контура — сделано правильно

metrics.gendsgn.ru — новая точка в интернете, причём с ingest-эндпоинтами, а незащищённый приёмник Prometheus/Loki был бы серьёзной дырой. Здесь:

  • раздельные учётки приёма и витрины, с явным обоснованием: пароль приёмника лежит в открытом виде в окружении Poincare, поэтому его компрометация не должна открывать дашборды и Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью — правильная граница;
  • в git только bcrypt-хеши, пароли в окружении;
  • вход Grafana остаётся вторым слоем (GF_USERS_ALLOW_SIGN_UP=false), внешний basic_auth — не единственный;
  • формат base64(bcrypt) для Caddy 2.11+ учтён.

Ретенция задана честно

--storage.tsdb.retention.time=30d и --storage.tsdb.retention.size=8GB — по обоим измерениям, а не только по времени. Ловушка Loki (retention_period не работает без retention_enabled у компактора) вынесена в документацию. Это ровно тот класс граблей, на которых такие стеки съедают диск молча.

Что измерил: бюджет памяти на Beget

Лимиты по compose:

контур сервисы сумма
стек (только Beget) prometheus 2g + loki 1g + grafana 512m + alertmanager 256m 3.75 ГБ
агент (оба хоста) alloy 512m + cadvisor 384m + node-exporter 128m + 3×postgres-exporter 384m 1.4 ГБ
итого на Beget ~5.15 ГБ

Состояние Beget сейчас (26.08, замер):

всего 11960 МБ, занято 3370, доступно 8590
swap: 4095 МБ, занято 316
OOM-событий за 7 дней: 0
диск: 81 ГБ свободно

Запас есть — 8.59 ГБ против 5.15 ГБ лимитов, и лимиты это потолки, а не резервирование. Диска под 8 ГБ ретенции Prometheus хватает с большим избытком.

Чего в замере НЕТ и что стоит посмотреть после включения: пика во время сборки CI. Раннеры в покое едят 20–80 МБ, но job-контейнер на сборке образов — совсем другая величина, и в снимке выше сборка не шла. Beget несёт Forgejo и все три раннера, то есть OOM там ударил бы по самому деплой-конвейеру. Предлагаю после первого включения посмотреть free -m во время ближайшей сборки, прежде чем добавлять части 3-5.

Свободные 840 МБ рядом: на Beget до сих пор работает tradein-postgres — замороженная копия (последняя запись 25.08 17:23, живая база на Poincare ушла вперёд), которую я оставил как якорь отката. Она ест 840 МБ. Если решишь её гасить, это почти четверть бюджета стека.

Почему не мержу сам

CI зелёный, замечаний по коду у меня нет — merge-кнопка не заблокирована ничем техническим. Но это +5 ГБ и новый публичный домен на машине, несущей CI, за четыре дня до окна переезда. Срок относительно окна — решение твоё, не моё, поэтому оставляю открытым. Скажешь «вливай» — волью сразу, ревью закончено.

Мелочь на будущее: A-запись metrics.gendsgn.ru заведена 26.08 и смотрит на 46.173.16.127 (Beget) — то есть в #3027 к восьми доменам добавился девятый. На переезд не влияет: Beget остаётся.

## Ревью: технических возражений нет. Мержить не стал — вопрос в сроке, а он твой Прошёл по диффу (2463 строки, все добавления, прод-код действительно не тронут). Отдельно проверил то, что в таких PR ломается чаще всего. ### Безопасность нового публичного контура — сделано правильно `metrics.gendsgn.ru` — новая точка в интернете, причём с **ingest-эндпоинтами**, а незащищённый приёмник Prometheus/Loki был бы серьёзной дырой. Здесь: - **раздельные учётки** приёма и витрины, с явным обоснованием: пароль приёмника лежит в открытом виде в окружении Poincare, поэтому его компрометация не должна открывать дашборды и Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью — правильная граница; - в git только bcrypt-хеши, пароли в окружении; - вход Grafana остаётся вторым слоем (`GF_USERS_ALLOW_SIGN_UP=false`), внешний basic_auth — не единственный; - формат base64(bcrypt) для Caddy 2.11+ учтён. ### Ретенция задана честно `--storage.tsdb.retention.time=30d` **и** `--storage.tsdb.retention.size=8GB` — по обоим измерениям, а не только по времени. Ловушка Loki (`retention_period` не работает без `retention_enabled` у компактора) вынесена в документацию. Это ровно тот класс граблей, на которых такие стеки съедают диск молча. ### Что измерил: бюджет памяти на Beget Лимиты по compose: | контур | сервисы | сумма | |---|---|---| | стек (только Beget) | prometheus 2g + loki 1g + grafana 512m + alertmanager 256m | **3.75 ГБ** | | агент (оба хоста) | alloy 512m + cadvisor 384m + node-exporter 128m + 3×postgres-exporter 384m | **1.4 ГБ** | | **итого на Beget** | | **~5.15 ГБ** | Состояние Beget сейчас (26.08, замер): ``` всего 11960 МБ, занято 3370, доступно 8590 swap: 4095 МБ, занято 316 OOM-событий за 7 дней: 0 диск: 81 ГБ свободно ``` Запас есть — 8.59 ГБ против 5.15 ГБ лимитов, и лимиты это потолки, а не резервирование. Диска под 8 ГБ ретенции Prometheus хватает с большим избытком. **Чего в замере НЕТ и что стоит посмотреть после включения:** пика во время сборки CI. Раннеры в покое едят 20–80 МБ, но job-контейнер на сборке образов — совсем другая величина, и в снимке выше сборка не шла. Beget несёт Forgejo и все три раннера, то есть OOM там ударил бы по самому деплой-конвейеру. Предлагаю после первого включения посмотреть `free -m` во время ближайшей сборки, прежде чем добавлять части 3-5. **Свободные 840 МБ рядом:** на Beget до сих пор работает `tradein-postgres` — замороженная копия (последняя запись 25.08 17:23, живая база на Poincare ушла вперёд), которую я оставил как якорь отката. Она ест 840 МБ. Если решишь её гасить, это почти четверть бюджета стека. ### Почему не мержу сам CI зелёный, замечаний по коду у меня нет — merge-кнопка не заблокирована ничем техническим. Но это +5 ГБ и новый публичный домен на машине, несущей CI, **за четыре дня до окна переезда**. Срок относительно окна — решение твоё, не моё, поэтому оставляю открытым. Скажешь «вливай» — волью сразу, ревью закончено. Мелочь на будущее: A-запись `metrics.gendsgn.ru` заведена 26.08 и смотрит на 46.173.16.127 (Beget) — то есть в [#3027](https://git.gendsgn.ru/lekss361/gendesign/issues/3027#issuecomment-28105) к восьми доменам добавился девятый. На переезд не влияет: Beget остаётся.
bot-backend added 1 commit 2026-08-26 08:23:55 +00:00
feat(observability): дашборд баз данных — раздутие, горизонт vacuum, WAL
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
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
7b6832e90f
Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но
смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет.

Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют».
Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198
апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого
содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой
транзакции — потому что осиротевшие запросы висели 46 часов и держали
горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей
базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских
расчётах это диспропорция, заметная только на ряде. Размер баз — потому что
у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения.

Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не
объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ.

Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят
одинаково, и различать их должно что-то явное.

Refs #3078
lekss361 merged commit afd881d6b1 into main 2026-08-26 08:25:24 +00:00
lekss361 deleted branch feat/observability-metrics-stack 2026-08-26 08:25:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3099
No description provided.