489 lines
35 KiB
YAML
489 lines
35 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."
|
||
|
||
# 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:
|
||
# Диск. Инцидент 16.09: кэш архивов Forgejo ел 2 ГБ/ч на диске 145 ГБ
|
||
# (host=infra). Проценты на дисках разного размера значат разное —
|
||
# host=apps держит /-раздел ~910 ГБ, те же 85 % там это ещё ~135 ГБ
|
||
# запаса, а на infra (145 ГБ) — почти ничего. Порог переведён в
|
||
# АБСОЛЮТНЫЕ ГБ свободного места: он одинаково осмыслен на любом диске,
|
||
# потому что напрямую отвечает на вопрос «сколько времени есть до нуля
|
||
# при текущей скорости утечки», а не «какая доля занята».
|
||
#
|
||
# `mountpoint="/"` — намеренное сужение с прежнего «все fstype кроме
|
||
# tmpfs/overlay»: /boot и /boot/efi по обоим хостам меньше 1 ГБ
|
||
# целиком, с порогом 30/15 ГБ они бы горели ПОСТОЯННО (проверено живыми
|
||
# метриками 16.09 — infra:/boot/efi 98 МБ, apps:/boot 553 МБ). Данные
|
||
# приложения и Postgres лежат на "/", туда и целится алерт.
|
||
- alert: DiskSpaceLow
|
||
expr: |
|
||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||
/ 1073741824 < 30
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Свободно на диске меньше 30 ГБ"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ."
|
||
|
||
- alert: DiskSpaceCritical
|
||
expr: |
|
||
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}
|
||
/ 1073741824 < 15
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Свободно на диске меньше 15 ГБ"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободно {{ printf \"%.1f\" $value }} ГБ. Postgres при заполнении диска останавливается."
|
||
|
||
# Скорость, а не уровень. Ловит именно ту утечку, что была 16.09: пока
|
||
# DiskSpaceLow/Critical ещё не сработали (места вагон), но оно тает
|
||
# быстрее ~1 ГБ/ч устойчиво — это уже течь, а не органический рост.
|
||
# `deriv()` — линейная регрессия по 15-минутному окну (не мгновенная
|
||
# разница двух точек, ту дёргает шум). Знак минус спереди и деление
|
||
# переносят результат из "Б/с, отрицательное при убыли" в "ГБ/ч,
|
||
# положительное когда тает" — чтобы $value в тексте читался нормально
|
||
# (не "-2.1 ГБ/ч"). `for: 30m` поверх 15-минутного окна регрессии
|
||
# требует НЕПРЕРЫВНОЙ убыли около часа, чтобы не будить на разовый
|
||
# скачок (бэкап, ротация логов).
|
||
- alert: DiskSpaceDepletingFast
|
||
expr: |
|
||
-deriv(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[15m])
|
||
* 3600 / 1073741824 > 1
|
||
for: 30m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Диск пустеет быстрее ГБ в час"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: свободное место убывает на {{ printf \"%.1f\" $value }} ГБ/час устойчиво последние ~30 минут — независимо от того, сколько места осталось сейчас."
|
||
|
||
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в
|
||
# стену — но роль другая, чем у DiskSpaceDepletingFast выше. Тот ловит
|
||
# БЫСТРУЮ (>1 ГБ/ч) утечку рано, за счёт короткого 15-минутного окна.
|
||
# Этот ловит УМЕРЕННУЮ утечку (может быть медленнее 1 ГБ/ч), которую
|
||
# короткое окно не поймает, но которая всё равно приведёт к нулю в
|
||
# течение суток при текущем уровне занятости — окно 6h усредняет шум
|
||
# ценой более позднего срабатывания. На одной и той же быстрой утечке
|
||
# оба правила могут сработать (сначала это, следом то) — это
|
||
# ЗАДУМАННАЯ эскалация двумя разными сигналами (скорость сейчас →
|
||
# подтверждённый тренд на сутки), а не дублирующее письмо: тексты и
|
||
# время срабатывания разные. DiskSpaceLow/Critical выше добавляют
|
||
# третий, независимый от скорости сигнал — «места мало» само по себе,
|
||
# даже если утечки нет и ничего не тает быстро.
|
||
- alert: DiskWillFillIn24h
|
||
expr: |
|
||
(0 - predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", mountpoint="/"}[6h], 24*3600))
|
||
/ 1073741824 > 0
|
||
for: 30m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "По текущему темпу диск кончится за сутки"
|
||
description: "{{ $labels.host }} {{ $labels.mountpoint }}: по экстраполяции последних 6 часов через сутки не хватит ~{{ printf \"%.1f\" $value }} ГБ."
|
||
|
||
- 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)`
|
||
# выбрасывает серии без лимита ДО деления.
|
||
#
|
||
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage − inactive_file,
|
||
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
|
||
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
|
||
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
|
||
# эпизодов (все 01:09–03:44 UTC) при анонимной памяти не выше 11.4 % лимита и
|
||
# oom_kill = 0 — текст «дальше OOM-kill» был ложью. Калибровка этим самым
|
||
# выражением по истории 14 суток: tradein-postgres 5 → 0, tradein-browser
|
||
# 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие), остальные 0 → 0.
|
||
# `and` без `on()`: у working_set и rss один и тот же набор меток cAdvisor,
|
||
# совпадение проверено на проде (913 из 915 серий); слева по-прежнему доля.
|
||
- alert: ContainerNearMemoryLimit
|
||
expr: |
|
||
container_memory_working_set_bytes{name!=""}
|
||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.90
|
||
and
|
||
(container_memory_rss{name!=""}
|
||
/ (container_spec_memory_limit_bytes{name!=""} > 0) > 0.50)
|
||
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:
|
||
# Бэкенд «Меры» лежит или завис (#2214). До этого правила такой отказ не
|
||
# давал ни одного сигнала вне Poincare. GlitchTip-монитор 6 смотрит на
|
||
# лэндинг meraocenka.ru, а его отдаёт tradein-frontend из пререндер-кэша
|
||
# (x-nextjs-cache: HIT) — при мёртвом бэкенде там по-прежнему 200.
|
||
# Монитор 2 (gendsgn.ru/health) — это бэкенд «Птицы». Два правила ниже
|
||
# считают метрики, которые отдаёт сам бэкенд: мёртвый не отдаёт, и они
|
||
# молчат по построению.
|
||
#
|
||
# Скрейп идёт с Poincare (Alloy), но решение и доставка — здесь, на Beget.
|
||
# Зависший event loop даёт таймаут скрейпа (20s), удалённый контейнер —
|
||
# ошибку DNS; в обоих случаях up=0. Смерть всего хоста ловят
|
||
# HostAgentDown/RemoteWriteStalled; `absent()` — на случай, когда цель
|
||
# тихо пропала из alloy-apps.alloy.
|
||
#
|
||
# for: 5m — по истории up{app="mera"} 26.08–17.09 (61 857 точек): 140
|
||
# провалов, все короткие (окна деплоя), самый длинный — 4 нулевые точки
|
||
# подряд, ~2 минуты до восстановления. 5 минут — запас в 2.5 раза.
|
||
#
|
||
# Чего правило НЕ видит: публичный путь до бэкенда (DNS, TLS, Caddy).
|
||
# Для лэндинга его проверяет монитор 6; монитора на публичную ручку
|
||
# бэкенда (meraocenka.ru/trade-in/api/public/mera/stats) пока нет — он
|
||
# заводится в UI GlitchTip, а не кодом.
|
||
- alert: MeraBackendDown
|
||
expr: |
|
||
up{job="app", app="mera"} == 0
|
||
or absent(up{job="app", app="mera"})
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
host: apps
|
||
annotations:
|
||
summary: "Бэкенд «Меры» не отвечает"
|
||
description: "Агент на Poincare 5 минут не может снять /metrics с tradein-backend (up=0), либо цель пропала из скрейпа. Проверь `docker ps` и /health изнутри сети. Лэндинг meraocenka.ru может открываться из кэша и при мёртвом бэкенде — это не признак жизни."
|
||
|
||
# Гейт по 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 }}."
|
||
|
||
# ── Redis и очередь Celery (#3471) ───────────────────────────────────────────
|
||
# Слепая зона: до этих правил ни redis_*, ни celery_* не собирались вовсе.
|
||
# Redis — общий инстанс на три потребителя (celery-брокер Site Finder, кэш
|
||
# trade-in, glitchtip — см. docker-compose.prod.yml), поэтому его смерть
|
||
# клиентская, отсюда severity: critical без явного host: apps — серия
|
||
# приходит только с продуктового alloy (alloy-apps.alloy), host в неё
|
||
# проставляется через external_labels уже на месте.
|
||
#
|
||
# Имена метрик celery_queue_length / celery_worker_up / celery_task_failed_total
|
||
# сверены с исходником celery-exporter 0.12.2 (src/exporter.py) — см.
|
||
# комментарий у сервиса в docker-compose.metrics-agent.yml (#3493).
|
||
- name: redis-celery
|
||
interval: 60s
|
||
rules:
|
||
- alert: RedisDown
|
||
expr: up{job="redis"} == 0 or redis_up == 0
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Redis недоступен"
|
||
description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip."
|
||
|
||
# ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#3493). Прежнее
|
||
# `count(celery_worker_up == 1) == 0 or absent(celery_worker_up)` смешивало
|
||
# «воркер мёртв» с «экспортёр не поднят» под текстом первой: с 12.09 по 17.09
|
||
# оно горело без перерыва по ветке absent() при живом воркере (экспортёр не
|
||
# стартовал), повторяясь каждые ~3 часа. А первая ветка не сработала бы
|
||
# НИКОГДА: `count()` от пустого вектора возвращает пустой вектор, а не 0,
|
||
# поэтому `== 0` сравнивать не с чем — мёртвый воркер она бы пропустила.
|
||
#
|
||
# Здесь — только когда экспортёр ЖИВ (`up == 1`), но живых воркеров нет:
|
||
# либо все серии в 0, либо серий нет (экспортёр вычищает отвалившихся).
|
||
# Отсутствие самого экспортёра — QueueExporterDown ниже, со своим текстом.
|
||
- alert: NoActiveCeleryWorkers
|
||
expr: |
|
||
(sum(celery_worker_up) == 0 or absent(celery_worker_up))
|
||
and on() (up{job="celery"} == 1)
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Ни одного живого воркера Celery"
|
||
description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||
|
||
# Экспортёр очереди не отдаёт метрики. `absent(up{job="X"})` переносит
|
||
# job в метки результата, поэтому текст знает, какой из двух.
|
||
- alert: QueueExporterDown
|
||
expr: |
|
||
up{job=~"redis|celery"} == 0
|
||
or absent(up{job="redis"})
|
||
or absent(up{job="celery"})
|
||
for: 10m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Метрики очереди не собираются"
|
||
description: "{{ $labels.job }}-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению."
|
||
|
||
# Порог 150 ПРЕДВАРИТЕЛЬНЫЙ: реальных данных по глубине очереди нет (до
|
||
# этой правки метрика не собиралась). beat_schedule.py на момент правки
|
||
# содержит 44 periodic-задачи с разным временем срабатывания — даже
|
||
# маловероятный залп всех разом даёт кратно меньше 150. Порог взят с
|
||
# запасом сознательно и требует пересмотра через неделю наблюдений по
|
||
# факту `celery_queue_length`.
|
||
#
|
||
# `delta(...) >= 0` — очередь не УМЕНЬШАЕТСЯ за 15 минут (тот же приём,
|
||
# что и "растёт и не разгребается" в тексте задачи): просто высокое
|
||
# значение без этого условия поймало бы и здоровый кратковременный всплеск.
|
||
- alert: CeleryQueueGrowing
|
||
expr: |
|
||
celery_queue_length{queue_name="celery"} > 150
|
||
and delta(celery_queue_length{queue_name="celery"}[15m]) >= 0
|
||
for: 15m
|
||
labels:
|
||
severity: warning
|
||
annotations:
|
||
summary: "Очередь Celery растёт и не разгребается"
|
||
description: "В очереди {{ $value }} задач, за 15 минут меньше не стало. Похоже на залипший воркер или устойчивый рост нагрузки."
|
||
|
||
# ── 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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|