gendesign/ops/metrics/prometheus/rules/infra.yml
bot-backend 655652ae1c
All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI / changes (pull_request) Successful in 14s
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 / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): алерты уровня приложения и три слепые зоны мониторинга
- AppHighErrorRate / AppHighLatencyP95 (job="app", severity=critical,
  host="apps") — доля 5xx и p95 задержки по http_requests_total /
  http_request_duration_seconds_bucket теперь ловятся Prometheus'ом, а не
  только постфактум в GlitchTip. Пара critical+apps обязательна для
  маршрута telegram-clients в alertmanager.yml.tmpl.
- Inhibit по HostAgentDown больше не гасит critical того же хоста —
  target_matchers сужен до severity="warning" (падение node-exporter
  раньше молча забирало с собой PostgresLongTransactionCritical и
  critical-алерты cAdvisor).
- cAdvisor: keep-фильтр в alloy-infra.alloy резал у него `up` наравне с
  container_*-мусором — job "cadvisor" не публиковал свою же серию `up`.
  Пропущены up/scrape_samples_scraped, добавлен CadvisorDown.
- TradeInBackgroundContainerMissing по absent(container_last_seen) на
  tradein-tgbot/tradein-scraper — эти контейнеры не HTTP-сервисы и в
  up{} не участвуют вовсе; крэш без рестарта раньше не алертился.

Не закрыто: живой, но зависший процесс tgbot/scraper (container_last_seen
не про внутренний прогресс, а про то, что Docker видит контейнер running).

Refs #3471
2026-09-12 13:57:54 +03:00

316 lines
20 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."
# cAdvisor как job не публикует `up` естественным образом — до фикса
# keep-фильтра в alloy-infra.alloy/alloy-apps.alloy (#3471) эта серия
# вырезалась тем же правилом, что чистит container_* от мусорных
# лейблов. Итог — смерть cAdvisor молча гасила ContainerRestartLoop и
# ContainerNearMemoryLimit: обе метрики просто переставали поступать, а
# выглядело это как «событий не было».
- alert: CadvisorDown
expr: up{job="cadvisor"} == 0 or absent(up{job="cadvisor"})
for: 5m
labels:
severity: critical
annotations:
summary: "cAdvisor не отвечает"
description: "{{ if $labels.host }}{{ $labels.host }}: {{ end }}job=\"cadvisor\" вернул up=0 либо серия пропала целиком. Без неё контейнерные алерты этого хоста молчат вне зависимости от реального состояния контейнеров."
# ── Хост ────────────────────────────────────────────────────────────────────
- 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.
#
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `A and B` возвращает ЗНАЧЕНИЯ
# ЛЕВОЙ части, отфильтрованные правой, — то есть в `$value` попадает именно
# A. Прежняя запись (`limit > 0 and working_set/limit > 0.90`) слала в
# Telegram лимит В БАЙТАХ, отрендеренный как процент: боевое сообщение
# 12.09 — «2.684e+11% от mem_limit» при limit = 2 684 354 560 Б. Условие
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
# выбрасывает серии без лимита ДО деления.
- alert: ContainerNearMemoryLimit
expr: |
container_memory_working_set_bytes{name!=""}
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
for: 15m
labels:
severity: warning
annotations:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}`
# вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не
# поймать. `absent()` на каждое имя отдельно (не одним regex-селектором):
# regex-селектор с несколькими сериями считается «пустым» только когда
# ПРОПАЛИ ОБЕ — если жив хотя бы один из двух контейнеров, absent() по
# общему selector'у молчит и не заметит пропажу второго.
#
# ВАЖНО, чего это правило НЕ ловит: container_last_seen обновляется, пока
# Docker видит контейнер живым, — зависший, но не упавший процесс
# (внутренний цикл встал, контейнер по-прежнему числится running) эту
# метрику не тронет. Слепая зона «живой процесс с застрявшим циклом»
# остаётся открытой: подходящей метрики для неё сейчас нет.
- alert: TradeInBackgroundContainerMissing
expr: |
absent(container_last_seen{name="tradein-tgbot"})
or absent(container_last_seen{name="tradein-scraper"})
for: 5m
labels:
severity: critical
annotations:
summary: "Фоновый контейнер Меры пропал из cAdvisor"
description: "{{ $labels.name }}: серия container_last_seen исчезла — контейнер, судя по всему, не работает и не перезапускается."
# ── Приложение ──────────────────────────────────────────────────────────────
# `job="app"` — job из alloy-apps.alloy, лейбл `app` различает продукты
# (sitefinder / mera). Метрики отдаёт `MetricsMiddleware`
# (`backend/app/observability/metrics.py` у «Птицы»,
# `tradein-mvp/backend/app/observability/metrics.py` у «Меры») — счётчик
# `http_requests_total{method,route,status}` и гистограмма
# `http_request_duration_seconds{method,route}`. До этой группы доля 5xx и
# задержка были видны только постфактум в GlitchTip, без порога срабатывания
# (#3471).
#
# severity: critical + host: apps здесь ОБЯЗАТЕЛЬНЫ содержательно, не для
# красоты: именно эта пара матчится маршрутом telegram-clients в
# alertmanager.yml.tmpl — тот зовёт дежурного и напоминает каждые 30 минут.
# `host` выставлен статически: `sum by (app)` вырезает его из результата
# запроса, а job="app" в принципе существует только на продуктовом хосте.
- name: app
interval: 60s
rules:
# Гейт по RPS внутри знаменателя — тот же приём, что у
# PostgresLowHotUpdateRatio: делит только там, где трафик уже есть,
# иначе один упавший запрос при нулевой нагрузке даёт 100% и будит
# дежурного зря.
- alert: AppHighErrorRate
expr: |
sum by (app) (rate(http_requests_total{job="app", status=~"5.."}[5m]))
/ (sum by (app) (rate(http_requests_total{job="app"}[5m])) > 0.1) > 0.05
for: 5m
labels:
severity: critical
host: apps
annotations:
summary: "Доля 5xx выше 5%"
description: "{{ $labels.app }}: {{ $value | humanizePercentage }} ответов 5xx за последние 5 минут при RPS выше 0.1."
# Порог 5s — заведомо выше рабочего профиля обоих продуктов (у «Меры»
# типичный расчёт 90мс, у «Птицы» верхняя граница гистограммы — 60с под
# тяжёлую геометрию, но это единичные хвостовые запросы, не p95).
# Калибровка по реальному трафику — отдельная задача, не эта.
- alert: AppHighLatencyP95
expr: |
histogram_quantile(0.95, sum by (le, app) (rate(http_request_duration_seconds_bucket{job="app"}[10m]))) > 5
for: 10m
labels:
severity: critical
host: apps
annotations:
summary: "p95 задержки ответа выше 5 секунд"
description: "{{ $labels.app }}: p95 за 10 минут — {{ $value | humanizeDuration }}."
# ── 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 апдейтов на строку.
#
# Та же ловушка `A and B`, что и у ContainerNearMemoryLimit, и здесь она
# опаснее: в `$value` попадал `rate(tup_upd[6h])` — АПДЕЙТОВ В СЕКУНДУ, а
# текст называл это долей HOT. Боевое сообщение 12.09 — «доля HOT 75.21%»
# при пороге срабатывания «доля < 20%»: число само себе противоречило и
# выглядело правдоподобно, поэтому никто не заметил (замер 12.09 по той же
# таблице listings: rate(tup_upd[6h]) = 0.0411 → сообщение сказало бы
# «4.11%», настоящая доля HOT = 0.00%). Гейт по объёму апдейтов
# (> 0.5/с — «трафик есть, значит вопрос осмыслен») перенесён внутрь
# знаменателя: там он и фильтрует серии, и защищает от деления на ноль.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ (rate(pg_table_write_amplification_tup_upd[6h]) > 0.5) < 0.2
for: 6h
labels:
severity: warning
annotations:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
# Третье правило того же семейства `A and B` — и единственное, где текст
# верен: `$value` тут печатается без humanize, а слева стоит ровно то, что
# описание и называет («N мёртвых»). Совпадение, а не заслуга формы: если
# когда-нибудь захочется печатать здесь ДОЛЮ, отношение придётся вынести
# влево, как в двух правилах выше.
- 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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."