All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
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
Prometheus не видел ни одной серии celery_*/redis_* — переполнение очереди Site Finder и залипший воркер снаружи выглядели одинаково, тишиной (issue #3471). Добавлено (только на продуктовом хосте, профиль apps): - redis-exporter (oliver006/redis_exporter) — здоровье общего Redis (db0 celery-брокер Site Finder, db1 SearchCache trade-in, db2 glitchtip), адрес через alias gendesign-redis на сети shared, без нового сетевого доступа. - celery-exporter (danihodovic/celery-exporter) — глубина очереди, число живых воркеров, счётчик неуспешных задач. Выбран вместо redis-exporter --check-keys, потому что дефолтная очередь "celery" дала бы только глубину, но не воркеров и не failures. - Скрейп обоих в alloy-apps.alloy. - Алерты в infra.yml: RedisDown, NoActiveCeleryWorkers, CeleryQueueGrowing (порог 150 предварительный — реальных данных по глубине очереди ещё нет, пересмотр через неделю наблюдений). Имена метрик celery-exporter (celery_queue_length, celery_worker_up, celery_task_failed_total) — по документации проекта, без прогона на реальном брокере; сверить после первого деплоя, см. комментарий у сервиса. promtool check rules — 23 правила, SUCCESS. Refs #3471
373 lines
24 KiB
YAML
373 lines
24 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:
|
||
# Диск. На 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 }}."
|
||
|
||
# ── 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 на момент
|
||
# написания правил, без проверки на реальном брокере (см. комментарий у
|
||
# сервиса celery-exporter в docker-compose.metrics-agent.yml). Сверить после
|
||
# первого деплоя.
|
||
- 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."
|
||
|
||
# `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся,
|
||
# серии celery_worker_up не будет вообще, а не будет со значением 0.
|
||
- alert: NoActiveCeleryWorkers
|
||
expr: count(celery_worker_up == 1) == 0 or absent(celery_worker_up)
|
||
for: 5m
|
||
labels:
|
||
severity: critical
|
||
annotations:
|
||
summary: "Ни одного живого воркера Celery"
|
||
description: "celery-exporter не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."
|
||
|
||
# Порог 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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|