From 251ca98c5c5a4f59af1ecf8aed9b85ee0cc27578 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 11:22:15 +0500 Subject: [PATCH] =?UTF-8?q?fix(metrics):=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D1=91=D1=80=20Celery=20=D0=BF=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F,=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B3=D0=B8=20=D0=BF=D1=80=D0=BE=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D0=B3=D0=BE=D0=B2=D0=BE=D1=80?= =?UTF-8?q?=D1=8F=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B4=D1=83=20(#3493)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две тревоги шумели в канале каждую ночь и каждые три часа, обе врали текстом. NoActiveCeleryWorkers горела с 12.09 без перерыва при живом воркере (`Up 29 hours`, 15 задач за 10 минут). Экспортёр не поднимался ни разу: тега danihodovic/celery-exporter:0.13.0 на Docker Hub не существует (`pull` → `not found`, логи джобы agent-apps 12.09 и 16.09), а под `set -e` упавший pull обрывал ВСЮ джобу — с 12.09 на Poincare не доезжало ничего из агента метрик: ни этот экспортёр, ни redis-exporter, ни новый конфиг Alloy. Второй дефект за первым: флага `--queue` в 0.12.2 нет, опция зовётся `--queues` — с одним исправленным тегом экспортёр упал бы при старте. Тег и флаг сверены с исходником (src/cli.py, src/exporter.py) и пробой на боевом брокере: временный контейнер 0.12.2 отдал `celery_worker_up{hostname="687f9212bebb"} 1.0`. Адрес брокера не тронут — алиас `gendesign-redis` на сети gendesign_shared существует и резолвится. Правило разделено на две новости. NoActiveCeleryWorkers — только когда экспортёр жив, а воркеров нет. QueueExporterDown (warning) — когда не отвечает сам экспортёр, с текстом «авария наблюдаемости, не продукта». Заодно прежняя ветка `count(celery_worker_up == 1) == 0` не сработала бы никогда: count() от пустого вектора пуст, мёртвый воркер она пропускала. ContainerNearMemoryLimit на tradein-postgres: working_set включает активный кэш страниц, ночные сканы поднимают его к потолку. Анонимная память за неделю 11–170 МБ из 3 ГБ, oom_kill = 0 за 21 сутки — «дальше OOM-kill» был ложью. Добавлено второе условие: анонимная память > 50 % лимита. Калибровка ЭТИМ выражением по 14 суткам истории: tradein-postgres 5 → 0 эпизодов, tradein-browser 2 → 2 (06.09 и 12.09, rss до 95 % — настоящие). Юнит-тесты правил (ops/metrics/prometheus/tests/infra_test.yml, 6 случаев, каждый уже случался на проде) исполняются деплоем перед reload. На старых правилах promtool test rules → rc=1 (6 расхождений), на новых → rc=0. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/deploy-metrics.yml | 14 ++- docker-compose.metrics-agent.yml | 22 ++-- ops/metrics/prometheus/rules/infra.yml | 57 ++++++++-- ops/metrics/prometheus/tests/infra_test.yml | 117 ++++++++++++++++++++ 4 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 ops/metrics/prometheus/tests/infra_test.yml diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 80a648ae..6d30ecb9 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -433,8 +433,18 @@ jobs: # # promtool проверяет ОБА файла ДО reload: битый конфиг не должен # положить работающий Prometheus молчаливым откатом на дефолты. + # + # Плюс юнит-тесты правил (#3493): синтаксически верное правило может + # врать по смыслу — `count(x == 1) == 0` от пустого вектора не + # срабатывает никогда, а под текстом «воркер мёртв» горел не поднятый + # экспортёр. Каталог tests/ в контейнер не смонтирован, поэтому + # одноразовый контейнер ТОГО ЖЕ образа поверх файлов с диска. if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \ - && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then + && docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml' \ + && docker run --rm --entrypoint promtool \ + -v /opt/gendesign/ops/metrics/prometheus:/work:ro \ + "$(docker inspect -f '{{.Config.Image}}' gendesign-prometheus)" \ + test rules /work/tests/infra_test.yml; then LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')" docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload @@ -456,7 +466,7 @@ jobs: fi echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)." else - echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге." + echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool (проверка конфига, правил или их юнит-тестов — смотри вывод выше) — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге." exit 1 fi diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml index 7755e2cd..ac383a5c 100644 --- a/docker-compose.metrics-agent.yml +++ b/docker-compose.metrics-agent.yml @@ -275,20 +275,26 @@ services: # брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация # check-keys + что-то ещё для остальных двух чисел. # - # ⚠️ ИМЕНА МЕТРИК НИЖЕ (celery_queue_length, celery_worker_up, - # celery_task_failed_total) — по документации проекта на момент правки, БЕЗ - # прогона на реальном брокере (агент писал этот файл без доступа к проду). - # Сверить с `curl http://gendesign-celery-exporter:9808/metrics` на хосте - # после первого деплоя и поправить `ops/metrics/prometheus/rules/infra.yml` - # при расхождении — иначе алерты будут молча ничего не ловить. + # ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#3493). Прежняя + # запись `0.13.0` + `--queue` не существовала вовсе: такого тега нет на Docker + # Hub (`pull` → `not found`), а в `src/cli.py` версии 0.12.2 опция зовётся + # `--queues` (список через запятую), `--queue` click отвергает при старте. + # Под `set -e` упавший `pull` обрывал ВСЮ джобу `agent-apps` деплоя метрик: + # с 12.09 до этой правки на Poincare не доезжало ничего из агента — ни этот + # экспортер, ни redis-exporter, ни новый конфиг Alloy, а NoActiveCeleryWorkers + # горел по ветке absent() при живом воркере. + # + # Имена метрик 0.12.2 (src/exporter.py, metric_prefix="celery_"): + # celery_worker_up{hostname}, celery_queue_length{queue_name}, + # celery_task_failed_total — совпадают с ops/metrics/prometheus/rules/infra.yml. celery-exporter: - image: danihodovic/celery-exporter:0.13.0 + image: danihodovic/celery-exporter:0.12.2 container_name: gendesign-celery-exporter restart: unless-stopped profiles: ["apps"] command: - "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}" - - "--queue=celery" + - "--queues=celery" expose: - "9808" networks: diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml index c064cd4a..6f050c92 100644 --- a/ops/metrics/prometheus/rules/infra.yml +++ b/ops/metrics/prometheus/rules/infra.yml @@ -149,16 +149,30 @@ groups: # при этом срабатывало верно, врал только текст. Поэтому отношение стоит # СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(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." + description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill." # tradein-tgbot и tradein-scraper не HTTP-сервисы — у них нет `up{}` # вообще, поэтому крэш-без-рестарта или удаление контейнера иначе не @@ -240,11 +254,9 @@ groups: # приходит только с продуктового 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). Сверить после - # первого деплоя. + # Имена метрик 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: @@ -257,16 +269,41 @@ groups: summary: "Redis недоступен" description: "redis_exporter не может достучаться до Redis (или сам процесс лёг). Разом теряют связь celery-брокер Site Finder, SearchCache trade-in и glitchtip." - # `absent()` — как у CadvisorDown: если сам celery-exporter не поднялся, - # серии celery_worker_up не будет вообще, а не будет со значением 0. + # ДВЕ РАЗНЫЕ НОВОСТИ — ДВА ПРАВИЛА (#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: count(celery_worker_up == 1) == 0 or absent(celery_worker_up) + 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-таски (парсинг, аналитика, синк слоёв) встали." + 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 на момент правки diff --git a/ops/metrics/prometheus/tests/infra_test.yml b/ops/metrics/prometheus/tests/infra_test.yml new file mode 100644 index 00000000..810f18d8 --- /dev/null +++ b/ops/metrics/prometheus/tests/infra_test.yml @@ -0,0 +1,117 @@ +# Юнит-тесты правил `promtool test rules` (#3493). Исполняются деплоем метрик +# перед reload Prometheus (.forgejo/workflows/deploy-metrics.yml): упавший тест +# оставляет работающий Prometheus на прежних правилах. +# +# Каждый случай — ровно тот, что уже случился на проде, а не придуманный. +rule_files: + - ../rules/infra.yml + +evaluation_interval: 1m + +tests: + # tradein-postgres, ночь 17.09: рабочий набор 98 % от лимита, но почти весь — + # кэш страниц; анонимная память ~1 %. OOM не грозит — тревоги быть не должно. + - interval: 1m + input_series: + - series: 'container_memory_working_set_bytes{host="apps",name="tradein-postgres"}' + values: '2940x40' + - series: 'container_memory_rss{host="apps",name="tradein-postgres"}' + values: '40x40' + - series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-postgres"}' + values: '3000x40' + alert_rule_test: + - eval_time: 30m + alertname: ContainerNearMemoryLimit + exp_alerts: [] + + # tradein-browser, 12.09: у потолка, и это анонимная память незакрытых + # инстансов браузера. Тревога обязана прийти, и в тексте — доля, а не байты. + - interval: 1m + input_series: + - series: 'container_memory_working_set_bytes{host="apps",name="tradein-browser"}' + values: '1960x40' + - series: 'container_memory_rss{host="apps",name="tradein-browser"}' + values: '1900x40' + - series: 'container_spec_memory_limit_bytes{host="apps",name="tradein-browser"}' + values: '2000x40' + alert_rule_test: + - eval_time: 30m + alertname: ContainerNearMemoryLimit + exp_alerts: + - exp_labels: + severity: warning + host: apps + name: tradein-browser + exp_annotations: + summary: "Контейнер у своего потолка памяти" + description: "apps / tradein-browser: 98% от mem_limit, и больше половины лимита — анонимная память процессов, которую ядро не вытеснит как кэш. Дальше OOM-kill." + + # 12.09–17.09: экспортёр Celery не поднят, серий нет вовсе, воркер при этом жив. + # Должна гореть QueueExporterDown про celery — и НЕ должна NoActiveCeleryWorkers. + - interval: 1m + input_series: + - series: 'up{job="redis",host="apps"}' + values: '1x40' + alert_rule_test: + - eval_time: 30m + alertname: NoActiveCeleryWorkers + exp_alerts: [] + - eval_time: 30m + alertname: QueueExporterDown + exp_alerts: + - exp_labels: + severity: warning + job: celery + exp_annotations: + summary: "Метрики очереди не собираются" + description: "celery-экспортёр на Poincare не отдаёт метрики (up=0 или серии нет вовсе). Это авария наблюдаемости, а не продукта: Redis и воркеры могут быть живы — проверь `docker ps`. Пока она горит, RedisDown и NoActiveCeleryWorkers молчат по построению." + + # Нормальная работа: экспортёр жив, воркер шлёт heartbeat — тишина. + - interval: 1m + input_series: + - series: 'up{job="celery",host="apps"}' + values: '1x40' + - series: 'up{job="redis",host="apps"}' + values: '1x40' + - series: 'celery_worker_up{hostname="celery@worker",host="apps"}' + values: '1x40' + alert_rule_test: + - eval_time: 30m + alertname: NoActiveCeleryWorkers + exp_alerts: [] + - eval_time: 30m + alertname: QueueExporterDown + exp_alerts: [] + + # Воркер умер: экспортёр жив, серия воркера в 0. Прежнее правило + # (`count(x == 1) == 0`) этот случай пропускало — count() от пустого вектора пуст. + - interval: 1m + input_series: + - series: 'up{job="celery",host="apps"}' + values: '1x40' + - series: 'celery_worker_up{hostname="celery@worker",host="apps"}' + values: '0x40' + alert_rule_test: + - eval_time: 30m + alertname: NoActiveCeleryWorkers + exp_alerts: + - exp_labels: + severity: critical + exp_annotations: + summary: "Ни одного живого воркера Celery" + description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали." + + # Воркер умер давно: экспортёр вычистил его серию, осталось только `up`. + - interval: 1m + input_series: + - series: 'up{job="celery",host="apps"}' + values: '1x40' + alert_rule_test: + - eval_time: 30m + alertname: NoActiveCeleryWorkers + exp_alerts: + - exp_labels: + severity: critical + exp_annotations: + summary: "Ни одного живого воркера Celery" + description: "celery-exporter работает, но не видит ни одного heartbeat от воркера Site Finder. Все periodic-таски (парсинг, аналитика, синк слоёв) встали."