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
Боевые сообщения в 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>
229 lines
14 KiB
YAML
229 lines
14 KiB
YAML
# Правила алертов: инфраструктура и здоровье самого мониторинга.
|
||
#
|
||
# Принцип отбора — тот же, что у задачи #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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|