Метрики: экспортёр Celery наконец поднимается, тревоги про воркеров и память перестают врать #3547

Merged
bot-backend merged 1 commit from fix/3493-celery-exporter-memory-alert into main 2026-09-17 07:05:42 +00:00
4 changed files with 190 additions and 20 deletions

View file

@ -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

View file

@ -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:

View file

@ -149,16 +149,30 @@ groups:
# при этом срабатывало верно, врал только текст. Поэтому отношение стоит
# СЛЕВА, а отсев нулевого лимита убран внутрь знаменателя: `(X > 0)`
# выбрасывает серии без лимита ДО деления.
#
# ВТОРОЕ УСЛОВИЕ — АНОНИМНАЯ ПАМЯТЬ (#3493). working_set = usage inactive_file,
# то есть в нём остаётся АКТИВНЫЙ кэш страниц, а его ядро вытесняет само и
# OOM из-за него не наступает. У контейнера с базой ночные сканы поднимают
# активный кэш к потолку каждую ночь: tradein-postgres за 14 суток дал 5
# эпизодов (все 01:0903: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 на момент правки

View file

@ -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.0917.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-таски (парсинг, аналитика, синк слоёв) встали."