From 655652ae1c36f0dc9a0bdf33e07f98c75bfdb148 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 13:57:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=D0=B0=D0=BB=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D1=8B=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20=D1=81=D0=BB=D0=B5=D0=BF=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=BE=D0=BD=D1=8B=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppHighErrorRate / AppHighLatencyP95 (job="app", severity=critical, host="apps") — доля 5xx и p95 задержки по http_requests_total / http_request_duration_seconds_bucket теперь ловятся Prometheus'ом, а не только постфактум в GlitchTip. Пара critical+apps обязательна для маршрута telegram-clients в alertmanager.yml.tmpl. - Inhibit по HostAgentDown больше не гасит critical того же хоста — target_matchers сужен до severity="warning" (падение node-exporter раньше молча забирало с собой PostgresLongTransactionCritical и critical-алерты cAdvisor). - cAdvisor: keep-фильтр в alloy-infra.alloy резал у него `up` наравне с container_*-мусором — job "cadvisor" не публиковал свою же серию `up`. Пропущены up/scrape_samples_scraped, добавлен CadvisorDown. - TradeInBackgroundContainerMissing по absent(container_last_seen) на tradein-tgbot/tradein-scraper — эти контейнеры не HTTP-сервисы и в up{} не участвуют вовсе; крэш без рестарта раньше не алертился. Не закрыто: живой, но зависший процесс tgbot/scraper (container_last_seen не про внутренний прогресс, а про то, что Docker видит контейнер running). Refs #3471 --- .../alertmanager/alertmanager.yml.tmpl | 11 ++- ops/metrics/alloy/alloy-infra.alloy | 16 +++- ops/metrics/prometheus/rules/infra.yml | 87 +++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl index 2cf58177..a5373367 100644 --- a/ops/metrics/alertmanager/alertmanager.yml.tmpl +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -71,9 +71,16 @@ route: repeat_interval: 3h inhibit_rules: - # Если хост целиком недоступен, не сыпать отдельно про каждый его сервис. + # Если хост целиком недоступен, не сыпать отдельно про каждый его + # warning-сервис. `target_matchers` НАМЕРЕННО ограничен одним warning + # (#3471): раньше сюда попадал и critical, и падение node-exporter молча + # гасило заодно PostgresLongTransactionCritical и все critical cAdvisor- + # алерты того же хоста — самое важное сообщение исчезало вместе с шумом, + # который оно должно было подавить. Warning того же хоста подавлять по- + # прежнему стоит (диск/память/своп неотличимы от «нет данных»), а critical + # обязан пережить это подавление и дойти до дежурного отдельно. - source_matchers: [alertname = "HostAgentDown"] - target_matchers: [severity =~ "warning|critical"] + target_matchers: [severity = "warning"] equal: ["host"] receivers: diff --git a/ops/metrics/alloy/alloy-infra.alloy b/ops/metrics/alloy/alloy-infra.alloy index 41d21770..e105ed60 100644 --- a/ops/metrics/alloy/alloy-infra.alloy +++ b/ops/metrics/alloy/alloy-infra.alloy @@ -57,9 +57,13 @@ prometheus.scrape "cadvisor" { prometheus.relabel "cadvisor_trim" { forward_to = [prometheus.remote_write.central.receiver] + // `up`/`scrape_samples_scraped` — служебные ряды самого скрейпа, не + // container_*-метрики. Без явного допуска этот keep-фильтр резал их вместе + // с прочим шумом, и у cAdvisor как job'а не было своей серии `up` вообще — + // его смерть выглядела так же, как «ничего не изменилось» (#3471). rule { source_labels = ["__name__"] - regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)" + regex = "up|scrape_samples_scraped|container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)" action = "keep" } @@ -70,9 +74,15 @@ prometheus.relabel "cadvisor_trim" { // // NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на // Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1. + // + // `up`/`scrape_samples_scraped` лейбла `name` не несут вовсе (они не про + // конкретный контейнер, а про сам скрейп) — фильтр по нему вырезал бы и их. + // Поэтому здесь смотрим на пару (__name__, name): для служебных рядов + // достаточно самого __name__, для контейнерных метрик по-прежнему обязателен + // непустой name. rule { - source_labels = ["name"] - regex = ".+" + source_labels = ["__name__", "name"] + regex = "up;.*|scrape_samples_scraped;.*|container_[^;]*;.+" action = "keep" } } diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml index 91d9a154..ee906111 100644 --- a/ops/metrics/prometheus/rules/infra.yml +++ b/ops/metrics/prometheus/rules/infra.yml @@ -44,6 +44,21 @@ groups: 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 @@ -145,6 +160,78 @@ groups: 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 }}." + # ── Postgres ──────────────────────────────────────────────────────────────── - name: postgres interval: 60s -- 2.45.3