gendesign/ops/metrics/prometheus/rules/infra.yml
bot-backend 33714e464e
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m30s
CI / backend-tests (pull_request) Successful in 17m50s
fix(alerts): в тексте тревоги печаталась не та величина, которую текст называет
Боевые сообщения в Telegram 12.09:
  «apps / tradein-browser: 2.684e+11% от mem_limit. Дальше OOM-kill.»
  «apps / listings: доля HOT 75.21%.» — при пороге срабатывания «доля < 20%»

Причина общая и она про ФОРМУ выражения, а не про условие: в PromQL `A and B`
возвращает ЗНАЧЕНИЯ ЛЕВОЙ части, отфильтрованные правой. В `$value` попадало A:

  - ContainerNearMemoryLimit: слева стоял `container_spec_memory_limit_bytes` —
    в сообщение уходил лимит в байтах (2 684 354 560), отрендеренный как
    процент. Замер 12.09: настоящее потребление того контейнера — 1.2 % лимита.
  - PostgresLowHotUpdateRatio: слева стоял `rate(tup_upd[6h])` — апдейтов в
    секунду. Это опаснее: 0.7521 превращалось в «75.21%», попадало в
    правдоподобный диапазон и противоречило собственному порогу, но выглядело
    настоящим числом. Замер 12.09 по listings: rate(tup_upd[6h]) = 0.0411 →
    сообщение сказало бы «4.11%», настоящая доля HOT = 0.00%.

Условия срабатывания в обоих случаях были ВЕРНЫ — врал только текст, поэтому
дефект и прожил незамеченным.

Правка: отношение вынесено влево, а побочное условие — внутрь знаменателя
(`X / (Y > 0)`), где оно и фильтрует серии, и защищает от деления на ноль.
Проверено на живом Prometheus (только чтение): новое выражение памяти отдаёт
доли 0.35–0.71 (топ — gendesign-infra-postgres 70.8 %), новое выражение HOT —
доли 0.00–1.00. `promtool check rules` — SUCCESS, 16 rules.

Третье правило того же семейства (PostgresDeadTuplesHigh) верно, но верно
случайно: печатаемая величина совпала с левым операндом. Помечено комментарием,
чтобы его не «причесали» по образцу двух других.

Гейт: backend/tests/ops/test_alert_value_is_the_described_quantity.py — если
описание рендерит `$value` как долю (`humanizePercentage`), левая часть
выражения обязана содержать деление. Фальсификация: вернул файл правил с
origin/main → красные test_percentage_annotations_come_from_a_ratio и
test_known_two_rules_are_fixed; с правкой — 3 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:27:02 +05:00

229 lines
14 KiB
YAML
Raw Permalink 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.
#
# ФОРМА ВЫРАЖЕНИЯ ВАЖНА, а не только условие. `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."
# ── 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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."