gendesign/ops/metrics/prometheus/rules/infra.yml
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

208 lines
12 KiB
YAML
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: сюда попадает только то, что уже
# ломалось молча. Правила «на всякий случай» не заводим: лишний алерт, который
# никто не разбирает, обесценивает остальные.
groups:
# ── Здоровье самого наблюдателя ─────────────────────────────────────────────
# Пустая панель неотличима от «всё хорошо». Эти правила закрывают именно это.
- name: monitoring-self
interval: 60s
rules:
# Всегда горит. Существует ради того, чтобы его ОТСУТСТВИЕ было заметно:
# раз в 12 часов приходит подтверждение, что цепочка правило → Alertmanager
# → Telegram → человек цела. Молчащий канал — самый частый способ узнать
# об аварии последним (в проекте МЕРЫ наружу не ушло ни одного сообщения
# с 30 мая, и это выяснилось случайно).
- alert: Watchdog
expr: vector(1)
labels:
severity: none
annotations:
summary: "Сторож мониторинга"
# Агент замолчал. На Poincare это единственный источник всех метрик:
# если он умер, графики просто перестанут обновляться, оставаясь зелёными.
- alert: HostAgentDown
expr: up{job="node"} == 0 or absent(up{job="node", host="apps"})
for: 5m
labels:
severity: critical
annotations:
summary: "Агент метрик не отвечает"
description: "Хост {{ $labels.host }}: node-exporter недоступен более 5 минут. Метрики этого хоста больше не поступают."
# Приёмник перестал принимать push. Симптом со стороны центра.
- alert: RemoteWriteStalled
expr: |
absent_over_time(up{job="node", host="apps"}[15m])
for: 5m
labels:
severity: critical
annotations:
summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
# ── Хост ────────────────────────────────────────────────────────────────────
- name: host
interval: 60s
rules:
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
- alert: DiskSpaceLow
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
for: 15m
labels:
severity: warning
annotations:
summary: "Диск занят больше 85 %"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
- alert: DiskSpaceCritical
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
for: 5m
labels:
severity: critical
annotations:
summary: "Диск почти кончился"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
- alert: DiskWillFillIn24h
expr: |
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "По текущему темпу диск кончится за сутки"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
- alert: MemoryPressure
expr: |
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
for: 15m
labels:
severity: warning
annotations:
summary: "Памяти доступно меньше 10 %"
description: "{{ $labels.host }}: свободной памяти {{ $value | humanizePercentage }} от общей."
# Своп сам по себе не беда, но резкий рост означает, что что-то перестало
# помещаться. На Beget своп в 1,78 ГБ был симптомом сосуществования прода
# и инфраструктуры — и рассосался ровно в момент переезда.
- alert: SwapGrowing
expr: |
node_memory_SwapTotal_bytes > 0
and (1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes) > 0.50
for: 30m
labels:
severity: warning
annotations:
summary: "Своп занят больше половины"
description: "{{ $labels.host }}: обычно означает, что рабочий набор перестал помещаться в память."
# ── Контейнеры ──────────────────────────────────────────────────────────────
- name: containers
interval: 60s
rules:
# Цикл перезапусков. Контейнер в restart-loop снаружи выглядит «поднятым»,
# а деплой при этом зелёный — именно так чуть не уехал в прод пустой пароль
# у infra-postgres (#3061).
- alert: ContainerRestartLoop
expr: |
changes(container_start_time_seconds{name!=""}[30m]) > 3
for: 5m
labels:
severity: warning
annotations:
summary: "Контейнер перезапускается по кругу"
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
- alert: ContainerNearMemoryLimit
expr: |
container_spec_memory_limit_bytes{name!=""} > 0
and container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.90
for: 15m
labels:
severity: warning
annotations:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres
interval: 60s
rules:
# Забытая транзакция держит горизонт vacuum и отравляет весь кластер.
# Разбор #2607: осиротевшие запросы висели 46 часов.
- alert: PostgresLongTransaction
expr: pg_activity_horizon_oldest_xact_age_s > 3600
for: 10m
labels:
severity: warning
annotations:
summary: "Транзакция открыта больше часа"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Пока она жива, vacuum не может убрать мёртвые строки во ВСЕЙ базе."
- alert: PostgresLongTransactionCritical
expr: pg_activity_horizon_oldest_xact_age_s > 21600
for: 10m
labels:
severity: critical
annotations:
summary: "Транзакция открыта больше шести часов"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Это уже влияет на размер базы."
- alert: PostgresIdleInTransaction
expr: pg_activity_horizon_idle_in_transaction > 3
for: 15m
labels:
severity: warning
annotations:
summary: "Соединения висят в открытой транзакции"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value }} шт. Обычно это незакрытая сессия в коде."
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
and
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
for: 6h
labels:
severity: warning
annotations:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
- alert: PostgresDeadTuplesHigh
expr: |
pg_table_write_amplification_dead_tup > 1000000
and pg_table_write_amplification_dead_tup
/ (pg_table_write_amplification_live_tup + 1) > 0.5
for: 1h
labels:
severity: warning
annotations:
summary: "Мёртвых строк больше половины от живых"
description: "{{ $labels.host }} / {{ $labels.table }}: {{ $value }} мёртвых. Autovacuum не справляется либо заблокирован долгой транзакцией."
# WAL. Замер 20.08: 7 ГБ/сутки при четырёх пользовательских расчётах.
- alert: PostgresWalRateHigh
expr: rate(pg_wal_bytes_wal_bytes_total[1h]) > 104857600 / 3600
for: 2h
labels:
severity: warning
annotations:
summary: "WAL пишется быстрее 100 МБ/час"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."