gendesign/docker-compose.metrics-agent.yml
bot-backend b746134976
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
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 10s
CI / backend-tests (pull_request) Successful in 17m34s
fix(ops/metrics): postgres-exporter печатал пароль БД в лог при каждой ошибке
Версия v0.16.0 при КАЖДОЙ неудаче сбора печатала полный DSN вместе с паролем:

    msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors"
    dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable"

Строки уходят в Loki — около 210 в сутки с двух хостов, при ретенции 30 дней
это тысячи паролей в хранилище логов (#3114).

Проверял опытом, а не документацией. Стенд на скретч-контейнерах: чистый
постгрес, роль без прав, тот же queries.yml что на проде — то есть ровно та
ошибка, что случалась в бою.

    v0.16.0 → строк с паролем: 1
    v0.18.0 → строк с паролем: 0

ОБХОДНОЙ ПУТЬ ИЗ ISSUE НЕ РАБОТАЕТ, и это важнее самой правки. Предлагалось
передавать параметры через DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS
вместо единой строки — «тогда в лог попадать нечему». Проверил на том же
стенде: экспортер собирает DSN внутри и печатает его целиком точно так же,
1 строка с паролем. Реализация этого варианта была бы работой вхолостую при
полном ощущении, что дыра закрыта.

Паритет метрик проверен там же: все пять пользовательских запросов из
queries.yml отдаются обеими версиями одинаково (PG_EXPORTER_EXTEND_QUERY_PATH
в v0.18 работает), v0.18 добавляет три встроенные метрики и не теряет ни одной.

Два теста сторожат нижнюю границу версии на всех трёх экспортерах.
Прогон: 80 ops-тестов зелёные, ruff чист.

Refs #3114
2026-08-27 15:55:24 +03:00

274 lines
15 KiB
YAML
Raw 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
# ── 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