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