gendesign/docker-compose.metrics-agent.yml
bot-backend 251ca98c5c
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m37s
CI / backend-tests (pull_request) Successful in 7m8s
fix(metrics): экспортёр Celery поднимается, тревоги про воркеров и память говорят правду (#3493)
Две тревоги шумели в канале каждую ночь и каждые три часа, обе врали текстом.

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 <noreply@anthropic.com>
2026-09-17 11:22:15 +05:00

345 lines
20 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на Beget
# (рядом с сервером), и на Poincare (рядом с продуктом).
#
# docker compose -p gendesign-metrics-agent -f docker-compose.metrics-agent.yml up -d
#
# Что делает: собирает метрики хоста (node-exporter), контейнеров (cAdvisor), баз
# (postgres-exporter) и логи journald, после чего Alloy отправляет всё на приёмник.
#
# ПОЧЕМУ АГЕНТ, А НЕ СКРЕЙП ИЗ ЦЕНТРА. Prometheus на Beget не может дотянуться до
# экспортеров на Poincare, не открыв там входящие порты — а ufw на Poincare намеренно
# держит только 22/80/443. Агент решает это push'ем: исходящий HTTPS уже разрешён.
# Побочный выигрыш — при обрыве канала Alloy копит в WAL и досылает, тогда как
# pull-скрейп просто теряет точки.
#
# КОНФИГУРАЦИЯ ЗАВИСИТ ОТ ХОСТА — задаётся переменными окружения, файл один:
#
# На Beget (инфраструктура, приёмник рядом):
# METRICS_ROLE=infra
# METRICS_ALLOY_CONFIG=alloy-infra.alloy # пишет напрямую в prometheus:9090 / loki:3100
# COMPOSE_PROFILES=infra
#
# На Poincare (продукт, приёмник за интернетом):
# METRICS_ROLE=apps
# METRICS_ALLOY_CONFIG=alloy-apps.alloy # пишет в https://metrics.gendsgn.ru под basic_auth
# COMPOSE_PROFILES=apps
# METRICS_INGEST_USER / METRICS_INGEST_PASSWORD — учётка приёмника
#
# Профили решают, какие postgres-exporter'ы поднимать: на Poincare две базы
# (Птица + МЕРА), на Beget одна (infra-postgres с forgejo и glitchtip).
x-logging: &default-logging
driver: journald
services:
# ── Alloy: единый агент метрик и логов ───────────────────────────────────────
# Почему Alloy, а не Promtail: у Promtail EOL 2 марта 2026. Alloy читает journald
# нативно и умеет одновременно скрейпить Prometheus-эндпоинты.
alloy:
image: grafana/alloy:v1.6.1
container_name: gendesign-alloy
restart: unless-stopped
# root нужен для чтения /var/log/journal. Штатный пользователь `alloy` требует
# членства в группах adm и systemd-journal — внутри контейнера этих групп с
# правильными gid хоста нет, и агент молча читает НОЛЬ записей. Проверено как
# известные грабли: отказ выглядит как «логов просто нет».
user: root
command:
- "run"
- "--server.http.listen-addr=0.0.0.0:12345"
- "--storage.path=/var/lib/alloy/data"
- "/etc/alloy/config.alloy"
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Метка хоста попадает во все ряды и логи — без неё графики двух машин
# сливаются в один и разобрать, где что, невозможно.
METRICS_HOST_LABEL: ${METRICS_ROLE:?zadaj METRICS_ROLE=infra ili apps}
METRICS_INGEST_USER: ${METRICS_INGEST_USER:-}
METRICS_INGEST_PASSWORD: ${METRICS_INGEST_PASSWORD:-}
volumes:
- ./ops/metrics/alloy/${METRICS_ALLOY_CONFIG:?zadaj METRICS_ALLOY_CONFIG}:/etc/alloy/config.alloy:ro
# Явный путь к журналу обязателен. Без него libsystemd применяет
# SD_JOURNAL_LOCAL_ONLY и хостовые логи из контейнера не видны — при этом
# ошибки нет, просто пустой поток.
- /var/log/journal:/var/log/journal:ro
- /etc/machine-id:/etc/machine-id:ro
- alloy_data:/var/lib/alloy/data
expose:
- "12345"
networks:
- shared
mem_limit: 512m
logging: *default-logging
healthcheck:
# В образе grafana/alloy нет wget/curl/nc, только bash — шлём
# сырой HTTP-запрос через /dev/tcp и проверяем код 200 в ответе.
test:
[
"CMD-SHELL",
"bash -c 'exec 3<>/dev/tcp/localhost/12345 && printf \"GET /-/ready HTTP/1.0\r\n\r\n\" >&3 && head -1 <&3 | grep -q 200' || exit 1",
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# ── node-exporter: CPU, память, диск, сеть, IO хоста ─────────────────────────
node-exporter:
image: prom/node-exporter:v1.8.2
container_name: gendesign-node-exporter
restart: unless-stopped
command:
- "--path.procfs=/host/proc"
- "--path.sysfs=/host/sys"
- "--path.rootfs=/host/root"
# Без исключения оверлеев метрика файловой системы засоряется десятками
# слоёв docker, и «свободное место на диске» перестаёт читаться глазами.
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/)"
- "--collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$$"
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro,rslave
expose:
- "9100"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── cAdvisor: память и CPU по контейнерам ────────────────────────────────────
# Читает докер-сокет, поэтому видит ВСЕ контейнеры хоста независимо от сетей —
# отдельно подключать его к gendesign_default не нужно.
cadvisor:
# v0.55.1, не v0.52.x: Docker 29 работает через containerd-snapshotter
# (Storage Driver = overlayfs), а 0.52 искал метаданные слоя в легаси
# /var/lib/docker/image/<driver>/layerdb, которого при снапшоттере
# просто нет. Итог был — ни одной метрики по контейнерам.
image: gcr.io/cadvisor/cadvisor:v0.55.1
container_name: gendesign-cadvisor
restart: unless-stopped
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
command:
# Урезаем набор метрик: полный cAdvisor выдаёт тысячи рядов на контейнер и
# раздувает TSDB на порядок ради данных, которые никто не смотрит.
- "--docker_only=true"
- "--housekeeping_interval=30s"
# disk/diskIO добавлены 27.08 по замеру. Именно они держат fsHandler,
# который в логе честно отчитывается: «disk usage and inodes count on
# following dirs took 1.31.8s» — обход overlay-слоёв на каждом цикле.
# Память растёт вместе с числом слоёв, а слои копятся от сборок.
#
# Прод: на продуктовом хосте cadvisor доходил до 359 МиБ из 384 (93 %
# лимита), на инфраструктурном за два часа поднялся со 105 до 291 МиБ.
# До OOM не дошло, но запас был 25 МиБ — то есть вопрос времени.
#
# Теряем метрики использования диска ПО КОНТЕЙНЕРАМ. Проверено, что их
# никто не использует: ни правила Prometheus, ни дашборды. Алерты по
# диску построены на node_exporter (`node_filesystem_*`), то есть на
# уровне файловой системы хоста — а это как раз то измерение, которое
# и нужно, чтобы вовремя увидеть кончающееся место.
- "--disable_metrics=disk,diskIO,percpu,sched,tcp,udp,advtcp,process,hugetlb,referenced_memory,cpu_topology,resctrl"
- "--store_container_labels=false"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
expose:
- "8080"
networks:
- shared
mem_limit: 384m
logging: *default-logging
# ── postgres-exporter: Птица (только на Poincare) ────────────────────────────
# WAL/сутки, n_tup_upd против n_tup_hot_upd, рост TOAST, коннекты, горизонт
# vacuum — то, из-за отсутствия чего раздутие копилось 91 день незамеченным.
# v0.18.0, а не v0.16.0: та печатала ПОЛНЫЙ DSN вместе с паролем в каждую
# ошибку сбора (#3114). Строка вида
# msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors"
# dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable"
# уходила в Loki при каждом скрейпе — около 210 строк в сутки с двух хостов,
# при ретенции 30 дней это тысячи паролей в хранилище логов.
#
# Воспроизведено на скретч-контейнерах 27.08 (постгрес + роль без прав +
# этот же queries.yml): v0.16.0 печатает пароль, v0.18.0 — нет.
#
# NB: форма DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS, которую
# предлагали как обходной путь, НЕ помогает — проверено тем же опытом:
# экспортер собирает DSN внутри и печатает его целиком точно так же.
# Утечку закрывает только версия образа.
#
# Паритет метрик проверен на том же стенде: все пять пользовательских
# запросов из queries.yml отдаются обеими версиями одинаково
# (PG_EXPORTER_EXTEND_QUERY_PATH в v0.18 работает), v0.18 добавляет три
# встроенные метрики и не теряет ни одной.
postgres-exporter-gendesign:
image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0
container_name: gendesign-pg-exporter-gendesign
restart: unless-stopped
profiles: ["apps"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
# `:-`, а НЕ `:?`. Compose интерполирует ВЕСЬ файл до фильтрации по
# профилям, поэтому `:?` роняет команду из-за переменной сервиса, который
# на этом хосте вообще не поднимается: продуктовый агент падал на
# INFRA_EXPORTER_DSN (профиль infra), инфраструктурный упал бы на двух
# продуктовых. Громкость не потеряна — перенесена в deploy-metrics.yml,
# где роль ИЗВЕСТНА и проверяется ровно тот DSN, который нужен здесь.
DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:-}
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
volumes:
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
expose:
- "9187"
networks:
- shared
- product
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: МЕРА (только на Poincare) ─────────────────────────────
postgres-exporter-tradein:
image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0
container_name: gendesign-pg-exporter-tradein
restart: unless-stopped
profiles: ["apps"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
# `:-`, а не `:?` — см. пояснение у экспортера Птицы выше.
DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:-}
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
volumes:
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
expose:
- "9187"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── redis-exporter: здоровье общего Redis (только на Poincare) ───────────────
# #3471. Redis — один инстанс на три потребителя: db0 celery-брокер Site
# Finder, db1 SearchCache trade-in, db2 glitchtip (см. комментарий у сервиса
# `redis` в docker-compose.prod.yml). Один `redis_up` покрывает риск для всех
# трёх разом — до этой правки Redis не измерялся вообще, переполнение
# брокера и обычная недоступность снаружи выглядели одинаково — тишиной.
#
# Адрес — через alias `gendesign-redis`, который `redis` регистрирует на
# сети `shared` (см. #2709 в docker-compose.prod.yml) — джойнить ещё и
# `product` не нужно, тем же путём уже идёт postgres-exporter-tradein.
#
# Пароль — из окружения, не хардкод: сегодня на Redis нет requirepass (нет
# переменной ни в docker-compose.prod.yml, ни здесь), но если он появится,
# значение подставляется через METRICS_REDIS_PASSWORD в /opt/gendesign/.env
# на хосте, а не в этот файл.
redis-exporter:
image: oliver006/redis_exporter:v1.65.0
container_name: gendesign-redis-exporter
restart: unless-stopped
profiles: ["apps"]
environment:
REDIS_ADDR: ${METRICS_REDIS_ADDR:-redis://gendesign-redis:6379}
REDIS_PASSWORD: ${METRICS_REDIS_PASSWORD:-}
expose:
- "9121"
networks:
- shared
mem_limit: 64m
logging: *default-logging
# ── celery-exporter: очередь Site Finder (только на Poincare) ────────────────
# #3471. Слепая зона: глубина очереди, число живых воркеров и счётчик
# неуспешных задач нигде не измерялись — залипший воркер и переполненная
# очередь снаружи неотличимы от тишины.
#
# ПОЧЕМУ ОТДЕЛЬНЫЙ ОБРАЗ, А НЕ redis-exporter --check-keys. check-keys дал
# бы LLEN дефолтной очереди "celery" (в backend/app/workers/celery_app.py
# НЕТ task_routes — все таски идут в один дефолтный queue, имя буквально
# "celery") без нового образа вообще. Но он НЕ умеет считать живых
# воркеров и неуспешные таски — то есть закрыл бы только треть минимума
# из задачи. celery-exporter слушает событийную шину Celery через тот же
# брокер и даёт все три метрики разом, поэтому выбран он, а не комбинация
# check-keys + что-то ещё для остальных двух чисел.
#
# ТЕГ И ФЛАГИ СВЕРЕНЫ С ИСХОДНИКОМ ОБРАЗА, а не с памятью (#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.12.2
container_name: gendesign-celery-exporter
restart: unless-stopped
profiles: ["apps"]
command:
- "--broker-url=${METRICS_CELERY_BROKER_URL:-redis://gendesign-redis:6379/0}"
- "--queues=celery"
expose:
- "9808"
networks:
- shared
mem_limit: 128m
logging: *default-logging
# ── postgres-exporter: инфраструктурная БД (только на Beget) ─────────────────
# forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip
# ничем не ограничен — политики ретенции у GlitchTip нет вообще.
postgres-exporter-infra:
image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0
container_name: gendesign-pg-exporter-infra
restart: unless-stopped
profiles: ["infra"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
# `:-`, а не `:?` — см. пояснение у экспортера Птицы выше.
DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:-}
PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml
volumes:
- ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro
expose:
- "9187"
networks:
- shared
- infra
mem_limit: 128m
logging: *default-logging
volumes:
alloy_data:
networks:
shared:
external: true
name: gendesign_shared
# Сеть продуктового стека Птицы — в ней gendesign-postgres-1 на Poincare.
product:
external: true
name: gendesign_default
# На Beget это та же по имени сеть, но с инфраструктурными контейнерами.
# Разные алиасы в файле нужны, чтобы профили не тянули лишнюю сеть на чужом хосте.
infra:
external: true
name: gendesign_default