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