feat(observability): стек метрик и логов — Prometheus, Loki, Grafana на Beget, агенты на обоих хостах #3099

Merged
lekss361 merged 2 commits from feat/observability-metrics-stack into main 2026-08-26 08:25:24 +00:00
22 changed files with 2763 additions and 0 deletions

View file

@ -0,0 +1,267 @@
name: Deploy Metrics
# Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно:
#
# server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana,
# Alertmanager. Наблюдатель намеренно живёт у другого провайдера,
# чем наблюдаемое.
# agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy.
# Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается
# ни одного входящего порта.
#
# Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы,
# deploy.yml остаётся в стороне.
on:
push:
branches: [main]
paths:
- "docker-compose.metrics.yml"
- "docker-compose.metrics-agent.yml"
- "ops/metrics/**"
- "caddy/sites/infra.caddy"
- "caddy/metrics-ui.caddy.snippet"
- "caddy/metrics-ingest.caddy.snippet"
- "scripts/setup-metrics-secrets.sh"
- ".forgejo/workflows/deploy-metrics.yml"
workflow_dispatch:
concurrency:
group: deploy-metrics
cancel-in-progress: false
jobs:
# ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════
server:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток
# берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком
# Poincare — гарантированный отказ.
- name: Адресат и подлинность инфраструктурного хоста
id: target
env:
INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }}
INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
run: |
set -euo pipefail
if [ -n "${INFRA_HOST:-}" ]; then
HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}"
SRC="INFRA_DEPLOY_SSH_FINGERPRINT"
else
HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}"
SRC="DEPLOY_SSH_FINGERPRINT"
fi
case "${HOST_FINGERPRINT}" in
*[![:print:]]*)
echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2
exit 1
;;
esac
echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT"
if [ -z "${HOST_FINGERPRINT:-}" ]; then
echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)."
fi
- name: Поднять серверный стек
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ steps.target.outputs.fingerprint }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
# Окружение нужно в shell, а не только в env_file: проверки вида
# ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА.
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
# ── Проверка ДО подъёма, а не после ────────────────────────────
# Пустой токен даёт Alertmanager, который стартует зелёным и молча
# ничего не шлёт. Это ровно тот класс тихого отказа, ради которого
# весь стек и заводится, — ловим на пороге.
missing=""
for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \
METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do
eval "val=\${$v:-}"
[ -z "$val" ] && missing="$missing $v"
done
if [ -n "$missing" ]; then
echo "ОШИБКА: в окружении хоста не заданы:$missing"
echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh"
exit 1
fi
# Алерты включаются, только когда канал доставки реально задан.
# Поднимать Alertmanager с пустым токеном нельзя: он стартует
# зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради
# которого весь стек и заводится.
PROFILES=""
if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then
PROFILES="alerts"
mkdir -p ops/metrics/alertmanager
METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID}' \
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
> ops/metrics/alertmanager/alertmanager.yml
chmod 600 ops/metrics/alertmanager/alertmanager.yml
echo "Алерты: канал задан, Alertmanager поднимается."
else
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# ── read-only роль для датасорса GlitchTip ─────────────────────
# Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana
# обязан быть безопасен даже при полном доступе к дашбордам.
bash scripts/setup-metrics-grafana-role.sh
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans
# ── Caddy: СНАЧАЛА проверить, потом применять ──────────────────
# На этом хосте тот же Caddy обслуживает git., errors. и obsidian.
# Синтаксическая ошибка в infra.caddy положила бы их все, включая
# сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен,
# и reload делается только после успешной проверки.
if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then
if docker compose -p gendesign -f docker-compose.prod.yml \
exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then
docker compose -p gendesign -f docker-compose.prod.yml \
exec -T caddy caddy reload --config /etc/caddy/Caddyfile
echo "Caddy: конфиг проверен и перезагружен."
else
echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен."
echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять."
exit 1
fi
fi
# ── Приёмка ────────────────────────────────────────────────────
for i in $(seq 1 30); do
if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then
break
fi
sleep 3
done
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
agent-apps:
runs-on: ubuntu-latest
needs: server
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Агент на продуктовом хосте
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
git fetch origin main
git reset --hard origin/main
docker network inspect gendesign_shared >/dev/null 2>&1 \
|| docker network create gendesign_shared
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then
echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться."
echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh"
exit 1
fi
# DSN экспортеров собираются из уже имеющихся паролей БД, если их
# ещё нет. Отдельных секретов не заводим — лишняя копия пароля это
# лишнее место, откуда он может утечь.
bash scripts/setup-metrics-exporter-dsn.sh
set -a; . backend/.env.runtime; set +a
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES=apps \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml pull --quiet
METRICS_ROLE=apps \
METRICS_ALLOY_CONFIG=alloy-apps.alloy \
COMPOSE_PROFILES=apps \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
sleep 10
METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES=apps \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps
agent-infra:
runs-on: ubuntu-latest
needs: server
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Агент на инфраструктурном хосте
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }}
fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
command_timeout: 15m
script: |
set -euo pipefail
cd /opt/gendesign
if [ -f backend/.env.runtime ]; then
set -a; . backend/.env.runtime; set +a
fi
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES=infra \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml pull --quiet
METRICS_ROLE=infra \
METRICS_ALLOY_CONFIG=alloy-infra.alloy \
COMPOSE_PROFILES=infra \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml up -d
sleep 10
METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES=infra \
docker compose -p gendesign-metrics-agent \
-f docker-compose.metrics-agent.yml ps

4
.gitignore vendored
View file

@ -98,3 +98,7 @@ ds-bundle/
.design-sync/.cache/
.design-sync/learnings/
.design-sync/node_modules
# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml):
# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает.
ops/metrics/alertmanager/alertmanager.yml

View file

@ -0,0 +1,15 @@
# Приём метрик и логов от агентов — машинный контур metrics.gendsgn.ru/ingest/*.
#
# ОТДЕЛЬНАЯ УЧЁТКА, А НЕ ТА ЖЕ, ЧТО У ВИТРИНЫ. Пароль приёмника лежит в открытом
# виде в окружении продуктового хоста (агенту нужно им авторизоваться), то есть
# компрометация Poincare раскрывает его автоматически. Если бы это была учётка
# витрины, вместе с ней утёк бы и доступ к самим дашбордам и к Prometheus, где
# видна вся картина инфраструктуры. Разделение ограничивает ущерб записью.
#
# Пароль — METRICS_INGEST_PASSWORD, задан на ОБОИХ хостах (на Beget как источник
# истины, на Poincare для агента). Сюда попадает только bcrypt-хеш.
# Сгенерирован scripts/setup-metrics-secrets.sh 2026-08-26.
basic_auth bcrypt "GenDesign Metrics Ingest" {
alloy JDJhJDE0JGMvTmxIenZUbW00cEtJVm01OGl0dmVRL0VSNk1mNjIwbDFvQWl1VTRwaEVaa1FHWXFUdEVl # агент Alloy, пароль в METRICS_INGEST_PASSWORD
}

View file

@ -0,0 +1,19 @@
# Витрина мониторинга — внешний контур доступа к metrics.gendsgn.ru.
#
# Пароль живёт в окружении хоста как METRICS_UI_PASSWORD (backend/.env.runtime),
# сюда попадает только bcrypt-хеш — его публикация ничего не раскрывает.
# Сгенерирован ops/../scripts/setup-metrics-secrets.sh 2026-08-26.
#
# ЭТО ВТОРОЙ СЛОЙ, А НЕ ЕДИНСТВЕННЫЙ. У Grafana есть собственный вход с ролями
# (GF_USERS_ALLOW_SIGN_UP=false), и он остаётся включённым. Внешний basic_auth
# нужен потому, что дашборд смотрит в интернет: он отсекает сканеры и любую
# будущую дыру в самой Grafana до того, как она станет доступной снаружи.
# Если два запроса пароля подряд окажутся неудобны — убрать ОДНУ строку
# `import caddy/metrics-ui.caddy.snippet` из infra.caddy, вход Grafana останется.
#
# Формат: username base64(bcrypt_hash) # комментарий
# Caddy 2.11+ требует именно base64 от bcrypt-хеша.
basic_auth bcrypt "GenDesign Metrics" {
metrics JDJhJDE0JFpObUNJUzVGZnd3blRKQXE3cDIxVWVEaG1udjY0cHVyVmY3OE9Ga2xubjE5RC90elZkL0dD # витрина, пароль в METRICS_UI_PASSWORD на Beget
}

View file

@ -62,3 +62,44 @@ git.gendsgn.ru {
output file /var/log/caddy/git.gendsgn.ru.log
}
}
# Мониторинг — Grafana + приёмник метрик и логов (задача #3078).
# DNS: A-record metrics.gendsgn.ru → 46.173.16.127 (заведена 2026-08-26).
#
# ПОЧЕМУ ЗДЕСЬ, А НЕ В apps.caddy. Наблюдатель сознательно поставлен у ДРУГОГО
# провайдера, чем наблюдаемое: продукт живёт на Selectel Poincare, и если ляжет
# он, мониторинг обязан выжить и сказать об этом. Стек поднимается отдельным
# compose-проектом gendesign-metrics и виден Caddy через сеть gendesign_shared.
#
# ДВА КОНТУРА С РАЗНЫМИ УЧЁТКАМИ:
# /ingest/* — машинный. Агент Alloy с Poincare шлёт сюда remote_write и логи
# исходящим HTTPS. Благодаря этому на Poincare не открыт НИ ОДИН
# входящий порт сверх 22/80/443.
# всё прочее — витрина Grafana под отдельным паролем.
# Пароль приёмника по построению лежит в открытом виде на продуктовом хосте;
# разделив учётки, мы не отдаём вместе с ним доступ к дашбордам.
metrics.gendsgn.ru {
encode zstd gzip
# handle_path срезает префикс: Prometheus получает /api/v1/write,
# как если бы обращались к нему напрямую.
handle_path /ingest/prometheus/* {
import ../metrics-ingest.caddy.snippet
reverse_proxy prometheus:9090
}
# Loki ожидает путь /loki/api/v1/push — он и остаётся после среза /ingest/loki.
handle_path /ingest/loki/* {
import ../metrics-ingest.caddy.snippet
reverse_proxy loki:3100
}
handle {
import ../metrics-ui.caddy.snippet
reverse_proxy grafana:3000
}
log {
output file /var/log/caddy/metrics.gendsgn.ru.log
}
}

View file

@ -0,0 +1,223 @@
# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на 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:
test: ["CMD-SHELL", "wget -q --spider http://localhost:12345/-/ready || 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:
image: gcr.io/cadvisor/cadvisor:v0.52.1
container_name: gendesign-cadvisor
restart: unless-stopped
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
command:
# Урезаем набор метрик: полный cAdvisor выдаёт тысячи рядов на контейнер и
# раздувает TSDB на порядок ради данных, которые никто не смотрит.
- "--docker_only=true"
- "--housekeeping_interval=30s"
- "--disable_metrics=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 день незамеченным.
postgres-exporter-gendesign:
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
container_name: gendesign-pg-exporter-gendesign
restart: unless-stopped
profiles: ["apps"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:?nyzhen v backend/.env.runtime}
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.16.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:?nyzhen v backend/.env.runtime}
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.16.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:?nyzhen v backend/.env.runtime}
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

202
docker-compose.metrics.yml Normal file
View file

@ -0,0 +1,202 @@
# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (Beget).
#
# Деплоится ОТДЕЛЬНО от продуктового стека, своим project-name:
# docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d
#
# ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ПРОДОМ. После переезда 25.08 продукт живёт на Selectel
# Poincare, инфраструктура (Forgejo, GlitchTip, CouchDB) осталась на Beget. Наблюдатель
# сознательно поставлен у ДРУГОГО провайдера, чем наблюдаемое: если ляжет Poincare,
# мониторинг должен об этом сказать, а не лечь вместе с ним.
#
# ТРАНСПОРТ — PUSH, А НЕ PULL. Prometheus не ходит на Poincare за метриками: там агент
# Alloy сам шлёт remote_write и loki.write исходящим HTTPS через metrics.gendsgn.ru.
# Поэтому на Poincare не открывается НИ ОДНОГО входящего порта — ufw там остаётся
# 22/80/443. Приёмник включён флагом --web.enable-remote-write-receiver ниже.
#
# СЕТИ. Обе внешние, обе уже существуют на Beget:
# gendesign_shared — через неё Caddy ходит на grafana:3000 (и couchdb, garmin-mcp)
# gendesign_default — в ней infra-postgres, glitchtip, forgejo: их скрейпит агент
# Тот же приём, что у Caddy — он подключён к обеим.
#
# ПОРТЫ НАРУЖУ НЕ ПУБЛИКУЮТСЯ. Только expose. Наружу выходит один хост
# metrics.gendsgn.ru через Caddy под basic_auth (caddy/sites/infra.caddy).
# 3000/9090/3100/9093 на 0.0.0.0 не биндятся сознательно: на Beget уже был случай,
# когда redis уехал в интернет из-за лишней записи ports в базовом compose-файле.
x-logging: &default-logging
driver: journald
services:
# ── Prometheus: хранилище временных рядов + движок правил ────────────────────
prometheus:
image: prom/prometheus:v3.1.0
container_name: gendesign-prometheus
restart: unless-stopped
user: "65534:65534" # nobody — том создаётся снаружи, см. deploy-metrics.yml
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
# Ретенция задана ЯВНО и по обоим измерениям. Без --storage.tsdb.retention.size
# диск съедается молча: по умолчанию ограничение только по времени, а сколько
# это в байтах — зависит от числа рядов, которое растёт само.
- "--storage.tsdb.retention.time=30d"
- "--storage.tsdb.retention.size=8GB"
# Приёмник push-метрик от агентов. Без флага remote_write отвечает 404,
# и агент на Poincare будет молча копить в WAL, а графики останутся пустыми.
- "--web.enable-remote-write-receiver"
# Нужен для перезагрузки правил без рестарта (deploy-metrics.yml дёргает).
- "--web.enable-lifecycle"
- "--web.external-url=https://metrics.gendsgn.ru/prometheus"
- "--web.route-prefix=/"
volumes:
- ./ops/metrics/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./ops/metrics/prometheus/rules:/etc/prometheus/rules:ro
- prometheus_data:/prometheus
expose:
- "9090"
networks:
- shared
- infra
mem_limit: 2g
logging: *default-logging
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# ── Loki: логи. Monolithic-режим — рекомендован до ~20 ГБ/сутки ──────────────
# У нас ~39 МБ/сутки (замер 24.08), запас в 500 раз. Микросервисный режим здесь
# был бы чистой сложностью без выигрыша.
loki:
image: grafana/loki:3.3.2
container_name: gendesign-loki
restart: unless-stopped
user: "10001:10001"
command: ["-config.file=/etc/loki/loki-config.yml"]
volumes:
- ./ops/metrics/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
expose:
- "3100"
networks:
- shared
mem_limit: 1g
logging: *default-logging
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"]
interval: 30s
timeout: 10s
retries: 10
start_period: 60s
# ── Alertmanager: маршрутизация и дедупликация алертов ───────────────────────
# Шлёт в Telegram НАПРЯМУЮ с Beget, а не через бэкенд МЕРЫ. Это осознанно:
# доставка алерта не должна зависеть от хоста, про который алерт. Если Poincare
# лёг, сообщение об этом обязано уйти без его участия.
alertmanager:
image: prom/alertmanager:v0.28.0
container_name: gendesign-alertmanager
restart: unless-stopped
user: "65534:65534"
# За профилем: канал доставки алертов — открытый вопрос #3078 (тот же чат,
# что у вебхука GlitchTip, или отдельный; порог ночной побудки). Пока он не
# решён, стек метрик поднимается и работает без Alertmanager, а не ждёт его.
# Включается добавлением `alerts` в COMPOSE_PROFILES — см. часть 5.
profiles: ["alerts"]
command:
- "--config.file=/etc/alertmanager/alertmanager.yml"
- "--storage.path=/alertmanager"
- "--web.external-url=https://metrics.gendsgn.ru/alertmanager"
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Токен и чат берутся из окружения — в репозиторий не попадают.
# Отсутствие проверяется в deploy-metrics.yml ДО подъёма: пустой токен
# даёт Alertmanager, который стартует зелёным и молча ничего не шлёт.
METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-}
METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-}
volumes:
- ./ops/metrics/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
expose:
- "9093"
networks:
- shared
mem_limit: 256m
logging: *default-logging
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"]
interval: 30s
timeout: 10s
retries: 5
# ── Grafana: витрина ─────────────────────────────────────────────────────────
grafana:
image: grafana/grafana:11.5.1
container_name: gendesign-grafana
restart: unless-stopped
user: "472:472"
env_file:
- path: ./backend/.env.runtime
required: false
- path: ./backend/.env
required: false
environment:
# Доступ закрыт Caddy basic_auth снаружи. Внутренний вход Grafana оставлен
# включённым намеренно: анонимный Viewer + отключённый логин лишили бы
# возможности что-то настроить, а один общий пароль в Caddy не даёт
# разделения ролей внутри.
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
# Датасорс к БД GlitchTip: пароль read-only роли из окружения.
GLITCHTIP_RO_PASSWORD: ${GLITCHTIP_RO_PASSWORD:-}
TRADEIN_RO_PASSWORD: ${TRADEIN_RO_PASSWORD:-}
GENDESIGN_RO_PASSWORD: ${GENDESIGN_RO_PASSWORD:-}
volumes:
- ./ops/metrics/grafana/provisioning:/etc/grafana/provisioning:ro
- ./ops/metrics/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
expose:
- "3000"
networks:
- shared
- infra
mem_limit: 512m
logging: *default-logging
depends_on:
- prometheus
- loki
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
prometheus_data:
loki_data:
grafana_data:
alertmanager_data:
networks:
shared:
external: true
name: gendesign_shared
infra:
external: true
name: gendesign_default

164
docs/observability.md Normal file
View file

@ -0,0 +1,164 @@
# Наблюдаемость: метрики, логи, алерты
Задача #3078. Стек живёт по адресу `https://metrics.gendsgn.ru/`.
## Зачем это заведено
Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни `/metrics`
в бэкендах. Единственным каналом наблюдения оставался journald, а единственным
сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был
невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики
нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв
`house_imv_backfill` (#2698), так 8 суток писал ноль записей `newbuilding_enrich`
(#2767), так 91 день копилось раздутие `listings` (#2992).
Grafana **не заменяет** GlitchTip. Ошибки остаются там: Grafana OSS не принимает
Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это
требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных —
числовые ряды и алерты по трендам вместо алертов по событиям.
## Топология
```
Poincare (188.246.224.93) — продукт Beget (46.173.16.127) — инфраструктура
┌──────────────────────────────┐ ┌────────────────────────────────────┐
│ node-exporter │ │ Prometheus ← приёмник remote_write │
│ cAdvisor │ HTTPS │ Loki ← приёмник логов │
│ postgres-exporter × 2 │ ──────────► │ Grafana ← витрина │
│ Alloy ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts) │
└──────────────────────────────┘ │ node-exporter, cAdvisor, Alloy │
│ postgres-exporter (infra) │
└────────────────────────────────────┘
```
**Наблюдатель стоит у другого провайдера, чем наблюдаемое.** Если ляжет Poincare,
мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним.
**Транспорт — push.** Prometheus не ходит на Poincare: там агент сам шлёт
исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего
порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и
досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть
именно в аварии, ради которой мониторинг и заводится.
## Что где лежит
| Файл | Назначение |
|---|---|
| `docker-compose.metrics.yml` | серверная сторона, только Beget |
| `docker-compose.metrics-agent.yml` | агенты, оба хоста; профили `apps` / `infra` |
| `ops/metrics/prometheus/` | конфиг и правила алертов |
| `ops/metrics/loki/` | конфиг Loki |
| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети |
| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth |
| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД |
| `ops/metrics/grafana/` | источники данных и дашборды |
| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` |
| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток |
## Первый запуск
```bash
# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши.
ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh'
# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и
# caddy/metrics-ingest.caddy.snippet, закоммитить.
# 3. Деплой.
# Forgejo → Actions → Deploy Metrics → Run workflow
```
Пароль витрины смотреть на хосте, в переписку не копировать:
```bash
ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime"
```
## Два контура доступа, две учётки
`metrics.gendsgn.ru/ingest/*` — машинный, для агента. Пароль этого контура по
построению лежит в открытом виде на продуктовом хосте: агенту нужно им
авторизоваться. Значит, компрометация Poincare раскрывает его автоматически.
Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к
Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью.
Всё остальное — витрина Grafana под отдельным паролем. Это **второй слой**: у
Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры
и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи.
Если два запроса пароля подряд неудобны — убрать одну строку `import
caddy/metrics-ui.caddy.snippet` из `infra.caddy`; вход Grafana останется.
## GlitchTip читается прямым SQL, а не плагином
Плагин `grafana/sentry-datasource` GlitchTip официально документирует, но на
нашей 6.1.6 половина путей нерабочая: `stats_v2` с фильтром по проекту отдаёт
500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416),
Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datasource` слово
«glitchtip» не встречается ни разу — апстрим связку не тестирует.
Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only
пользователь (`scripts/setup-metrics-grafana-role.sh`, прав на запись нет).
Схема проверена на живой базе 26.08:
- `issue_events_issue``count`, `first_seen`, `last_seen`, `status`, `level`,
`project_id`, `title`, `culprit`
- `issue_events_issueaggregate``(issue_id, organization_id, date, count)`,
партиционирована по неделям с почасовыми под-партициями
- `issue_events_issueevent` — колонка времени называется **`timestamp`**
(плюс `created`); колонки `received` в этой версии нет
Отдельной таблицы `IssueIndex` **не существует** — все агрегаты лежат на самой
`issue_events_issue`. В более ранних описаниях этой задачи она упоминалась;
это была ошибка, проверено глазами.
**Граница:** доступ read-only. Тренды — в Grafana, а assign / resolve / ignore,
стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и
рабочее место. Это осознанно, а не недоделка.
## Алерты
Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что
у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик
работает и без них, но **при срабатывании правила никто не будет уведомлён**
деплой пишет об этом предупреждением, чтобы это не стало сюрпризом.
Включение: задать `METRICS_TELEGRAM_BOT_TOKEN` и `METRICS_TELEGRAM_CHAT_ID` в
окружении инфраструктурного хоста и перезапустить деплой. Профиль `alerts`
включится сам.
Alertmanager шлёт в Telegram **напрямую с Beget**, а не через бэкенд МЕРЫ, хотя
рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг
Poincare, не должно идти через сервис на Poincare.
Отдельно живёт правило `Watchdog` — оно горит всегда и раз в 12 часов
подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела.
Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый
частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного
сообщения с 30 мая, и выяснилось это случайно (#2673).
## Известные грабли
- **Alloy запущен от root.** Штатный пользователь `alloy` требует членства в
группах `adm` и `systemd-journal`; внутри контейнера этих групп с нужными gid
нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов
просто нет».
- **Путь к журналу задан явно** (`/var/log/journal`). Без него libsystemd
применяет `SD_JOURNAL_LOCAL_ONLY`, и хостовый журнал из контейнера не виден —
тоже без ошибки.
- **`retention_period` у Loki не работает без `retention_enabled` у компактора.**
Loki примет конфиг и будет копить вечно.
- **Ретенция Prometheus задана и по времени, и по размеру.** Только по времени —
значит, объём в байтах зависит от числа рядов, которое растёт само.
- **Caddy проверяется до перезагрузки.** На Beget тот же Caddy обслуживает
`git.`, `errors.` и `obsidian.`; ошибка в `infra.caddy` положила бы их все,
включая Forgejo, из которого идёт деплой.
## Что ещё не сделано
- `/metrics` в бэкендах (часть 3) — единственная часть, трогающая прод-код
- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация,
счётчик `cancelled` (часть 4)
- алерты и синтетический heartbeat (часть 5)
- `pg_stat_statements` для кластера Птицы — требует рестарта прод-БД, отдельно
- честные `started_at` / `finished_at` у прогонов (#2702) — предусловие для
графиков пропускной способности: пока start/finish врут, врут и графики

View file

@ -0,0 +1,74 @@
# Шаблон конфигурации Alertmanager. Боевой файл собирается на хосте при деплое
# (`envsubst` в deploy-metrics.yml) и в репозиторий не попадает — токен бота и
# идентификатор чата живут в окружении хоста, а не в git.
#
# ПОЧЕМУ TELEGRAM НАПРЯМУЮ, А НЕ ЧЕРЕЗ БЭКЕНД МЕРЫ. У МЕРЫ уже есть рабочий путь
# доставки (вебхук GlitchTip → бот), и соблазн переиспользовать его велик. Но тогда
# сообщение о том, что лёг Poincare, шло бы через сервис НА Poincare. Алерт обязан
# уметь уйти без участия хоста, про который он написан.
#
# Telegram с Beget работает — проверено серией замеров 25.08: TLS-рукопожатие
# 18 из 20, getMe 4 из 4. Ломается только длинный long-poll (30 с), а отправка
# сообщения — короткий запрос.
global:
resolve_timeout: 5m
route:
receiver: telegram
# Группируем по алерту и хосту: пятнадцать контейнеров одного хоста, упавших
# разом, — это одно событие, а не пятнадцать сообщений.
group_by: ["alertname", "host"]
group_wait: 45s
group_interval: 5m
# Повтор раз в 6 часов. Чаще — приучает игнорировать, реже — можно проспать.
repeat_interval: 6h
routes:
# Watchdog не должен смешиваться с настоящими алертами и не должен молчать:
# это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив.
- receiver: telegram-heartbeat
matchers:
- alertname = "Watchdog"
group_wait: 0s
group_interval: 12h
repeat_interval: 12h
# Критичное — без задержки на группировку.
- receiver: telegram
matchers:
- severity = "critical"
group_wait: 10s
repeat_interval: 3h
inhibit_rules:
# Если хост целиком недоступен, не сыпать отдельно про каждый его сервис.
- source_matchers: [alertname = "HostAgentDown"]
target_matchers: [severity =~ "warning|critical"]
equal: ["host"]
receivers:
- name: telegram
telegram_configs:
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
api_url: "https://api.telegram.org"
parse_mode: HTML
send_resolved: true
message: |
{{ if eq .Status "firing" }}🔴{{ else }}🟢{{ end }} <b>{{ .CommonLabels.alertname }}</b>{{ if .CommonLabels.host }} · {{ .CommonLabels.host }}{{ end }}
{{ range .Alerts }}
{{ .Annotations.summary }}
{{ if .Annotations.description }}{{ .Annotations.description }}{{ end }}
{{ end }}
- name: telegram-heartbeat
telegram_configs:
- bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}"
chat_id: ${METRICS_TELEGRAM_CHAT_ID}
api_url: "https://api.telegram.org"
parse_mode: HTML
send_resolved: false
message: |
⚪ <b>Мониторинг жив</b> — сторож отчитался, канал доставки работает.
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.

View file

@ -0,0 +1,172 @@
// Alloy — агент на ПРОДУКТОВОМ хосте (Poincare). Приёмник стоит у другого провайдера,
// поэтому доставка идёт исходящим HTTPS через metrics.gendsgn.ru под basic_auth.
//
// ПОЧЕМУ PUSH. Альтернатива — открыть на Poincare входящие порты под скрейп из центра.
// ufw там держит только 22/80/443, и расширять периметр ради мониторинга — плохой
// размен. Исходящий HTTPS уже разрешён, ничего открывать не нужно.
//
// ПРИ ОБРЫВЕ КАНАЛА агент копит в WAL и досылает. Pull-скрейп в этой ситуации просто
// потерял бы точки — а обрыв между площадками это ровно тот случай, ради которого
// мониторинг и заводится.
logging {
level = "warn"
format = "logfmt"
}
// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════
prometheus.remote_write "central" {
endpoint {
url = "https://metrics.gendsgn.ru/ingest/prometheus/api/v1/write"
basic_auth {
username = sys.env("METRICS_INGEST_USER")
password = sys.env("METRICS_INGEST_PASSWORD")
}
// Очередь на случай недоступности приёмника. Дефолты рассчитаны на облако с
// быстрым каналом; здесь между площадками 17 мс и приёмник может уехать в
// рестарт при деплое — даём запас, чтобы не терять точки на ровном месте.
queue_config {
capacity = 10000
max_shards = 5
min_shards = 1
max_samples_per_send = 2000
batch_send_deadline = "10s"
retry_on_http_429 = true
}
}
// Досыл после обрыва. Держим сутки: суточного окна хватает и на ночной рестарт
// приёмника, и на разбор утром.
wal {
truncate_frequency = "2h"
max_keepalive_time = "24h"
}
external_labels = {
host = sys.env("METRICS_HOST_LABEL"),
}
}
loki.write "central" {
endpoint {
url = "https://metrics.gendsgn.ru/ingest/loki/loki/api/v1/push"
basic_auth {
username = sys.env("METRICS_INGEST_USER")
password = sys.env("METRICS_INGEST_PASSWORD")
}
}
external_labels = {
host = sys.env("METRICS_HOST_LABEL"),
}
}
// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════
prometheus.scrape "node" {
targets = [
{ __address__ = "node-exporter:9100", job = "node" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
prometheus.scrape "cadvisor" {
targets = [
{ __address__ = "cadvisor:8080", job = "cadvisor" },
]
forward_to = [prometheus.relabel.cadvisor_trim.receiver]
scrape_interval = "30s"
}
prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver]
rule {
source_labels = ["__name__"]
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
action = "keep"
}
rule {
source_labels = ["name"]
regex = ""
action = "drop"
}
}
// ═══ МЕТРИКИ ОБЕИХ БОЕВЫХ БД ═══════════════════════════════════════════════════
// Именно здесь живут ряды, из-за отсутствия которых раздутие `listings` копилось
// 91 день незамеченным: WAL в сутки, n_tup_upd против n_tup_hot_upd, рост TOAST.
prometheus.scrape "postgres" {
targets = [
{ __address__ = "gendesign-pg-exporter-gendesign:9187", job = "postgres", db = "gendesign" },
{ __address__ = "gendesign-pg-exporter-tradein:9187", job = "postgres", db = "tradein" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "60s"
}
// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════
// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это
// правильно: цель видна как недоступная, а не отсутствует молча.
prometheus.scrape "apps" {
targets = [
{ __address__ = "gendesign-backend-1:8000", job = "app", app = "sitefinder" },
{ __address__ = "tradein-backend:8000", job = "app", app = "mera" },
]
metrics_path = "/metrics"
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
scrape_timeout = "20s"
}
// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════
loki.source.journal "host" {
path = "/var/log/journal"
max_age = "12h"
format_as_json = false
labels = {
job = "journal",
}
relabel_rules = loki.relabel.journal.rules
forward_to = [loki.write.central.receiver]
}
loki.relabel "journal" {
forward_to = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal__hostname"]
target_label = "node"
}
rule {
source_labels = ["__journal_container_name"]
target_label = "container"
}
rule {
source_labels = ["__journal_priority_keyword"]
target_label = "level"
}
}
// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════
prometheus.exporter.self "alloy" {}
prometheus.scrape "alloy_self" {
targets = prometheus.exporter.self.alloy.targets
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "60s"
}

View file

@ -0,0 +1,136 @@
// Alloy — агент на ИНФРАСТРУКТУРНОМ хосте (Beget). Приёмник стоит рядом, на этом же
// хосте, поэтому пишем напрямую по docker-сети: без TLS, без basic_auth, без интернета.
//
// Вариант для продуктового хоста — alloy-apps.alloy: там тот же набор источников, но
// доставка идёт исходящим HTTPS через metrics.gendsgn.ru.
//
// Метка host проставляется здесь, один раз, в external_labels — если ставить её ещё и
// на стороне Prometheus, результат зависит от honor_labels и перестаёт быть очевидным.
logging {
level = "warn"
format = "logfmt"
}
// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════
prometheus.remote_write "central" {
endpoint {
url = "http://prometheus:9090/api/v1/write"
}
external_labels = {
host = sys.env("METRICS_HOST_LABEL"),
}
}
loki.write "central" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
external_labels = {
host = sys.env("METRICS_HOST_LABEL"),
}
}
// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════
prometheus.scrape "node" {
targets = [
{ __address__ = "node-exporter:9100", job = "node" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "30s"
}
prometheus.scrape "cadvisor" {
targets = [
{ __address__ = "cadvisor:8080", job = "cadvisor" },
]
forward_to = [prometheus.relabel.cadvisor_trim.receiver]
scrape_interval = "30s"
}
// cAdvisor отдаёт ряды на каждый слой файловой системы и каждое устройство.
// Без прополки TSDB растёт на порядок ради данных, которые никто не открывает.
prometheus.relabel "cadvisor_trim" {
forward_to = [prometheus.remote_write.central.receiver]
rule {
source_labels = ["__name__"]
regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)"
action = "keep"
}
// Служебные контейнеры docker без имени только зашумляют графики.
rule {
source_labels = ["name"]
regex = ""
action = "drop"
}
}
// ═══ МЕТРИКИ ИНФРАСТРУКТУРНОЙ БД ═══════════════════════════════════════════════
// Профиль infra в docker-compose.metrics-agent.yml поднимает ровно один экспортер —
// по кластеру с базами forgejo и glitchtip.
prometheus.scrape "postgres_infra" {
targets = [
{ __address__ = "gendesign-pg-exporter-infra:9187", job = "postgres", db = "infra" },
]
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "60s"
}
// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════
loki.source.journal "host" {
// Путь задан ЯВНО. Без него libsystemd включает SD_JOURNAL_LOCAL_ONLY, и из
// контейнера хостовый журнал не виден — при этом ошибки нет, просто пустой поток.
path = "/var/log/journal"
// При рестарте агента не тянем всю историю заново: journald держит ~13 суток,
// а повторная заливка создала бы дубликаты и упёрлась в reject_old_samples.
max_age = "12h"
format_as_json = false
labels = {
job = "journal",
}
relabel_rules = loki.relabel.journal.rules
forward_to = [loki.write.central.receiver]
}
loki.relabel "journal" {
forward_to = []
// Внутренние поля journald приходят с префиксом __ и без переименования
// отбрасываются. Оставляем ровно те, по которым потом фильтруют.
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal__hostname"]
target_label = "node"
}
rule {
source_labels = ["__journal_container_name"]
target_label = "container"
}
rule {
source_labels = ["__journal_priority_keyword"]
target_label = "level"
}
}
// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════
// Метрики самого агента. Нужны для алерта «агент замолчал»: без них молчание
// источника неотличимо от «всё хорошо, событий нет».
prometheus.exporter.self "alloy" {}
prometheus.scrape "alloy_self" {
targets = prometheus.exporter.self.alloy.targets
forward_to = [prometheus.remote_write.central.receiver]
scrape_interval = "60s"
}

View file

@ -0,0 +1,332 @@
{
"uid": "gendesign-host",
"title": "Хост и контейнеры",
"description": "CPU, память, диск и контейнеры обоих хостов. Метка host: infra = Beget (Forgejo, GlitchTip, мониторинг), apps = Poincare (Птица и МЕРА).",
"tags": ["gendesign", "infra"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-6h", "to": "now" },
"templating": {
"list": [
{
"name": "host",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(node_uname_info, host)",
"refresh": 1,
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{
"type": "row",
"title": "Сводка",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
},
{
"type": "stat",
"title": "Свободно на корневом разделе",
"description": "Место, оставшееся на /. При заполнении диска Postgres останавливается — это не «медленно», а полная остановка записи.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "node_filesystem_avail_bytes{mountpoint=\"/\", host=~\"$host\"}",
"legendFormat": "{{host}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "orange", "value": 10737418240 },
{ "color": "green", "value": 32212254720 }
]
}
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
},
{
"type": "stat",
"title": "Доступно памяти",
"description": "MemAvailable — то, что ядро реально может отдать под новую нагрузку. Отличается от «free»: кэш страниц отдаётся по требованию и в нехватку не считается.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "node_memory_MemAvailable_bytes{host=~\"$host\"}",
"legendFormat": "{{host}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "orange", "value": 1073741824 },
{ "color": "green", "value": 3221225472 }
]
}
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
},
{
"type": "stat",
"title": "Загрузка (load1 на ядро)",
"description": "Нормировано на число ядер: 1.0 означает «занято ровно столько, сколько есть». Без нормировки число несравнимо между хостами с разным CPU.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "node_load1{host=~\"$host\"} / count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})",
"legendFormat": "{{host}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.8 },
{ "color": "red", "value": 1.5 }
]
}
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" }
},
{
"type": "stat",
"title": "Аптайм",
"description": "Время с последней загрузки. Внезапное обнуление — перезагрузка, о которой стоит знать.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
"targets": [
{
"refId": "A",
"expr": "time() - node_boot_time_seconds{host=~\"$host\"}",
"legendFormat": "{{host}}"
}
],
"fieldConfig": {
"defaults": { "unit": "s", "decimals": 0 },
"overrides": []
},
"options": { "colorMode": "none", "graphMode": "none", "textMode": "auto" }
},
{
"type": "row",
"title": "Ресурсы во времени",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }
},
{
"type": "timeseries",
"title": "CPU по режимам",
"description": "iowait отдельно от user/system: высокий iowait означает, что процессор ждёт диск, и добавлять ядер бесполезно.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 },
"targets": [
{
"refId": "A",
"expr": "sum by (host, mode) (rate(node_cpu_seconds_total{mode!=\"idle\", host=~\"$host\"}[5m])) / on (host) group_left count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})",
"legendFormat": "{{host}} · {{mode}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"custom": { "fillOpacity": 20, "stacking": { "mode": "normal" }, "showPoints": "never" }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Память",
"description": "Занято = всего минус доступно. Своп показан отдельно: его рост означает, что рабочий набор перестал помещаться.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 },
"targets": [
{
"refId": "A",
"expr": "node_memory_MemTotal_bytes{host=~\"$host\"} - node_memory_MemAvailable_bytes{host=~\"$host\"}",
"legendFormat": "{{host}} · занято"
},
{
"refId": "B",
"expr": "node_memory_SwapTotal_bytes{host=~\"$host\"} - node_memory_SwapFree_bytes{host=~\"$host\"}",
"legendFormat": "{{host}} · своп"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"min": 0,
"custom": { "fillOpacity": 15, "showPoints": "never" }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Занятость дисков",
"description": "По точкам монтирования. Порог алерта — 85 %, критический — 93 %.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"targets": [
{
"refId": "A",
"expr": "1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"}",
"legendFormat": "{{host}} · {{mountpoint}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"custom": { "fillOpacity": 10, "showPoints": "never", "thresholdsStyle": { "mode": "dashed" } },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.85 },
{ "color": "red", "value": 0.93 }
]
}
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Дисковый ввод-вывод",
"description": "Байты чтения и записи по устройствам. Всплеск записи по ночам — это бэкапы (00:30 и 01:30 UTC), так и должно быть.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"targets": [
{
"refId": "A",
"expr": "sum by (host) (rate(node_disk_read_bytes_total{host=~\"$host\"}[5m]))",
"legendFormat": "{{host}} · чтение"
},
{
"refId": "B",
"expr": "sum by (host) (rate(node_disk_written_bytes_total{host=~\"$host\"}[5m]))",
"legendFormat": "{{host}} · запись"
}
],
"fieldConfig": {
"defaults": {
"unit": "Bps",
"custom": { "fillOpacity": 10, "showPoints": "never" }
},
"overrides": []
}
},
{
"type": "row",
"title": "Контейнеры",
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }
},
{
"type": "timeseries",
"title": "Память по контейнерам (топ-15)",
"description": "working set, а не usage: usage включает переиспользуемый кэш и завышает картину. Именно по этой метрике сравнивается фактическое потребление с mem_limit.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 10, "w": 14, "x": 0, "y": 24 },
"targets": [
{
"refId": "A",
"expr": "topk(15, container_memory_working_set_bytes{name!=\"\", host=~\"$host\"})",
"legendFormat": "{{host}} · {{name}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"custom": { "fillOpacity": 10, "showPoints": "never" }
},
"overrides": []
}
},
{
"type": "table",
"title": "Приближение к mem_limit",
"description": "Доля от заданного лимита. Пустая строка означает, что лимит не выставлен вовсе — тогда потребление сверху не ограничено ничем, кроме памяти хоста.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 10, "w": 10, "x": 14, "y": 24 },
"targets": [
{
"refId": "A",
"instant": true,
"format": "table",
"expr": "sort_desc(container_memory_working_set_bytes{name!=\"\", host=~\"$host\"} / (container_spec_memory_limit_bytes{name!=\"\", host=~\"$host\"} > 0))",
"legendFormat": "{{name}}"
}
],
"transformations": [
{ "id": "organize", "options": { "excludeByName": { "Time": true, "job": true, "instance": true, "id": true, "image": true } } }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"custom": { "align": "auto", "cellOptions": { "type": "gauge" } },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 0.8 },
{ "color": "red", "value": 0.9 }
]
}
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Перезапуски контейнеров",
"description": "Число стартов за полчаса. Больше трёх — цикл перезапусков: снаружи такой контейнер выглядит поднятым, а деплой при этом зелёный.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 34 },
"targets": [
{
"refId": "A",
"expr": "changes(container_start_time_seconds{name!=\"\", host=~\"$host\"}[30m]) > 0",
"legendFormat": "{{host}} · {{name}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "drawStyle": "bars", "fillOpacity": 60, "showPoints": "never" }
},
"overrides": []
}
}
]
}

View file

@ -0,0 +1,300 @@
{
"uid": "gendesign-postgres",
"title": "Базы данных",
"description": "Три кластера под одним взглядом: Птица и МЕРА на Poincare, инфраструктурная база (Forgejo, GlitchTip) на Beget. Панели подобраны по разборам постфактум — каждая отвечает на вопрос, который однажды уже задавали задним числом.",
"tags": ["gendesign", "postgres"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"templating": {
"list": [
{
"name": "db",
"label": "Кластер",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(pg_up, db)",
"refresh": 1,
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{ "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "stat",
"title": "Экспортер отвечает",
"description": "Ноль означает, что метрик по этому кластеру нет вовсе. Пустой график и упавшая база выглядят одинаково — эта панель их различает.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
"targets": [
{ "refId": "A", "expr": "pg_up{db=~\"$db\"}", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "нет связи", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } }
],
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
},
"overrides": []
},
"options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Занято соединений",
"description": "Доля от max_connections. Упереться в потолок означает, что новые запросы получают отказ на подключении — со стороны приложения это выглядит как недоступность базы, а не как нагрузка.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (pg_stat_database_numbackends{db=~\"$db\"}) / on (db) group_left max by (db) (pg_settings_max_connections{db=~\"$db\"})", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.7 }, { "color": "red", "value": 0.9 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Самая старая транзакция",
"description": "Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция отравляет весь кластер, а снаружи это выглядит просто как «база пухнет».",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
"targets": [
{ "refId": "A", "expr": "max by (db) (pg_activity_horizon_oldest_xact_age_s{db=~\"$db\"})", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 900 }, { "color": "red", "value": 3600 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "WAL за сутки",
"description": "Замер 20.08 по Птице: 7,02 ГБ журнала в сутки при примерно четырёх пользовательских расчётах за тот же срок. Диспропорция такого масштаба и есть симптом — но увидеть её можно только имея ряд.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
"targets": [
{ "refId": "A", "expr": "rate(pg_wal_bytes_wal_bytes_total{db=~\"$db\"}[1h]) * 86400", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 5368709120 }, { "color": "red", "value": 21474836480 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{ "type": "row", "title": "Раздутие: почему база растёт быстрее данных", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } },
{
"type": "timeseries",
"title": "Доля апдейтов мимо HOT (топ-10)",
"description": "HOT-апдейт переписывает только строку. Всё остальное переписывает её ещё и во всех индексах, а длинные поля заново тостит. У listings доля HOT была 0,43 % при 198 апдейтах на строку — отсюда 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989). Копилось 91 день, потому что смотреть было не на что. Устойчивое значение выше 0,8 у часто обновляемой таблицы — повод править fillfactor или сам паттерн записи.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 14, "x": 0, "y": 7 },
"targets": [
{ "refId": "A", "expr": "topk(10, 1 - (rate(pg_table_write_amplification_tup_hot_upd{db=~\"$db\"}[1h]) / (rate(pg_table_write_amplification_tup_upd{db=~\"$db\"}[1h]) > 0.01)))", "legendFormat": "{{db}} · {{table}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.8 } ] }
},
"overrides": []
}
},
{
"type": "table",
"title": "Мёртвые строки и давность уборки",
"description": "Мёртвых строк много само по себе не страшно — страшно, когда autovacuum до них давно не доходил. Значение -1 в возрасте означает, что уборки не было ни разу с момента запуска: такая таблица растёт без ограничения.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 10, "x": 14, "y": 7 },
"targets": [
{ "refId": "A", "instant": true, "format": "table", "expr": "topk(15, pg_table_write_amplification_dead_tup{db=~\"$db\"} > 10000)" },
{ "refId": "B", "instant": true, "format": "table", "expr": "pg_table_write_amplification_last_autovacuum_age_s{db=~\"$db\"}" }
],
"transformations": [
{ "id": "joinByField", "options": { "byField": "table", "mode": "outer" } },
{
"id": "organize",
"options": {
"excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true, "host": true, "host 1": true, "host 2": true, "schema": true, "schema 1": true, "schema 2": true, "db 2": true, "cluster": true, "cluster 1": true, "cluster 2": true },
"renameByName": { "db 1": "Кластер", "table": "Таблица", "Value #A": "Мёртвых строк", "Value #B": "Уборка была, сек назад" }
}
},
{ "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "Мёртвых строк", "desc": true } ] } }
],
"fieldConfig": {
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Уборка была, сек назад" },
"properties": [
{ "id": "unit", "value": "s" },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 86400 }, { "color": "red", "value": 604800 } ] } }
]
},
{
"matcher": { "id": "byName", "options": "Мёртвых строк" },
"properties": [ { "id": "unit", "value": "short" } ]
}
]
}
},
{
"type": "timeseries",
"title": "Куда уходит диск: TOAST отдельно от строк",
"description": "Общий размер таблицы этого не показывает, а именно разделение объясняло, почему listings весила 19 ГБ. TOAST — вынесенные длинные значения: он растёт при каждом не-HOT апдейте текстового поля, даже если сам текст не изменился.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 14, "x": 0, "y": 16 },
"targets": [
{ "refId": "A", "expr": "topk(5, pg_table_size_detail_toast_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · TOAST" },
{ "refId": "B", "expr": "topk(5, pg_table_size_detail_index_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · индексы" },
{ "refId": "C", "expr": "topk(5, pg_table_size_detail_heap_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · строки" }
],
"fieldConfig": {
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Размер баз",
"description": "У GlitchTip нет политики ретенции вообще — проверено, grep по retention даёт ноль попаданий. База растёт без ограничения, и нужен ряд, чтобы поймать это до того, как кончится диск.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 10, "x": 14, "y": 16 },
"targets": [
{ "refId": "A", "expr": "pg_database_size_bytes_detail_bytes{db=~\"$db\"}", "legendFormat": "{{db}} · {{database}}" }
],
"fieldConfig": {
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{ "type": "row", "title": "Нагрузка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{
"type": "timeseries",
"title": "Транзакции в секунду",
"description": "Откаты отдельно от фиксаций. Ровный фон откатов — это не «иногда бывает», а поток ошибок, который в логах может не отражаться вовсе.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_xact_commit{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · зафиксировано" },
{ "refId": "B", "expr": "sum by (db) (rate(pg_stat_database_xact_rollback{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · откачено" }
],
"fieldConfig": {
"defaults": { "unit": "ops", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*откачено.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Попадание в кэш",
"description": "Доля чтений, обслуженных из shared_buffers. Провал означает, что рабочий набор перестал помещаться в память — обычно это следствие раздутия, а не роста самих данных.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m])) / clamp_min(sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m]) + rate(pg_stat_database_blks_read{db=~\"$db\"}[5m])), 1)", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "orange", "value": 0.9 }, { "color": "green", "value": 0.98 } ] }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Временные файлы",
"description": "Постгрес пишет на диск, когда сортировка или хеш не помещаются в work_mem. Всплеск здесь объясняет запросы, которые «вдруг стали медленными» без единого изменения в коде.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_temp_bytes{db=~\"$db\"}[5m]))", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": { "unit": "Bps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Состояние соединений",
"description": "idle in transaction — открытая транзакция, которая ничего не делает: именно она держит горизонт и мешает уборке. Ожидание блокировки — запросы, которые стоят друг за другом; растущая линия здесь читается снаружи как «сайт подвис».",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
"targets": [
{ "refId": "A", "expr": "pg_activity_horizon_client_backends{db=~\"$db\"}", "legendFormat": "{{db}} · клиентских" },
{ "refId": "B", "expr": "pg_activity_horizon_idle_in_transaction{db=~\"$db\"}", "legendFormat": "{{db}} · idle in transaction" },
{ "refId": "C", "expr": "pg_activity_horizon_waiting_on_lock{db=~\"$db\"}", "legendFormat": "{{db}} · ждут блокировку" }
],
"fieldConfig": {
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*idle in transaction.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*ждут блокировку.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Взаимоблокировки",
"description": "Взаимоблокировка снимается сервером принудительно: одна из транзакций получает ошибку. Для пользователя это неудавшееся действие, для логов приложения — исключение без внятной причины.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (increase(pg_stat_database_deadlocks{db=~\"$db\"}[1h]))", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 40, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" },
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] }
},
"overrides": []
}
}
]
}

View file

@ -0,0 +1,16 @@
apiVersion: 1
providers:
- name: gendesign
orgId: 1
folder: GenDesign
type: file
disableDeletion: false
# Правки через интерфейс не переживают пересоздание контейнера: файл на диске
# перетрёт их обратно. Это намеренно — дашборд должен лежать в git, иначе
# через полгода никто не скажет, почему панель считает именно так.
allowUiUpdates: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View file

@ -0,0 +1,75 @@
apiVersion: 1
# Источники данных задаются файлом, а не руками в интерфейсе: настройка, сделанная
# кликами, живёт только в томе и теряется при пересоздании контейнера — ровно та
# ловушка, из-за которой при переезде «healthy» ничего не значил.
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData:
# Совпадает со scrape_interval: иначе Grafana подбирает шаг сама и на
# длинных окнах рисует пилу там, где данные ровные.
timeInterval: 30s
httpMethod: POST
manageAlerts: false
editable: false
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
jsonData:
maxLines: 2000
# Связка логов с метриками по общей метке host.
derivedFields: []
editable: false
- name: Alertmanager
uid: alertmanager
type: alertmanager
access: proxy
url: http://alertmanager:9093
jsonData:
implementation: prometheus
handleGrafanaManagedAlerts: false
editable: false
# ── GlitchTip через ПРЯМОЙ SQL, а не через Sentry-плагин ────────────────────
# Плагин grafana/sentry-datasource официально документирован GlitchTip'ом, но
# на нашей 6.1.6 половина его путей нерабочая: stats_v2 с фильтром по проекту
# отдаёт 500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404
# (#416), Metrics/Spans/Tags вообще Sentry-only. В самом grafana/sentry-datasource
# слова «glitchtip» не встречается ни разу — апстрим эту связку не тестирует.
#
# Прямой SQL от этого свободен и правок в GlitchTip не требует вовсе, нужен
# только read-only пользователь. Схема проверена на живой базе 26.08:
# issue_events_issue несёт count / first_seen / last_seen / status / level,
# а issue_events_issueaggregate (issue_id, organization_id, date, count)
# партиционирована по неделям — ряды и топ-N без сканов сырья.
#
# ГРАНИЦА: доступ read-only. Тренды — здесь, а assign/resolve, стектрейсы и
# breadcrumbs — только в самом GlitchTip. Окна остаётся два: витрина и рабочее
# место, и это осознанно, а не недоделка.
- name: GlitchTip DB
uid: glitchtip-db
type: postgres
access: proxy
url: gendesign-infra-postgres:5432
database: glitchtip
user: grafana_ro
secureJsonData:
password: ${GLITCHTIP_RO_PASSWORD}
jsonData:
sslmode: disable
postgresVersion: 1600
timescaledb: false
maxOpenConns: 4
maxIdleConns: 2
connMaxLifetime: 14400
editable: false

View file

@ -0,0 +1,78 @@
# Loki — monolithic (all-in-one). Рекомендованный режим до ~20 ГБ/сутки.
# Наш объём — ~39 МБ/сутки (замер 24.08), то есть запас в 500 раз. Микросервисная
# раскладка здесь была бы сложностью без единого выигрыша.
auth_enabled: false # один арендатор; разграничение снаружи, на Caddy basic_auth
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: warn
# Логи Alloy шлёт пачками; дефолтные 4 МБ на gRPC-сообщение при всплеске
# (например, рестарт с досылом из WAL) дают 'grpc: received message larger than max'.
grpc_server_max_recv_msg_size: 16777216
grpc_server_max_send_msg_size: 16777216
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2026-08-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
filesystem:
directory: /loki/chunks
limits_config:
# Приём «из прошлого». Агент после обрыва досылает накопленное из WAL —
# с дефолтным окном (1 неделя приёма, но reject_old_samples_max_age) часть
# догоняемых строк молча отбрасывается с 'entry too far behind'.
reject_old_samples: true
reject_old_samples_max_age: 168h
# Потолок на арендатора. 39 МБ/сутки = ~0.5 МБ/с в пике; 8 МБ/с — щедрый запас,
# но не «безлимит», чтобы взбесившийся источник не съел диск за ночь.
ingestion_rate_mb: 8
ingestion_burst_size_mb: 16
max_streams_per_user: 5000
max_label_names_per_series: 20
# Ретенция. Journald на хостах держит ~13 дней при потолке 500 МБ; в Loki
# смысл хранить дольше — ради разбора инцидентов постфактум.
retention_period: 720h
volume_enabled: true
compactor:
working_directory: /loki/compactor
delete_request_store: filesystem
# Без retention_enabled параметр retention_period выше НЕ работает: Loki
# примет его в конфиг и будет копить вечно. Классическая тихая настройка.
retention_enabled: true
retention_delete_delay: 2h
compaction_interval: 10m
ruler:
storage:
type: local
local:
directory: /loki/rules
analytics:
reporting_enabled: false

View file

@ -0,0 +1,116 @@
# Дополнительные запросы для postgres_exporter (PG_EXPORTER_EXTEND_QUERY_PATH).
#
# Здесь только то, отсутствие чего уже стоило времени. Каждый блок — ответ на
# конкретный разбор постфактум, а не «полезно иметь».
#
# cache_seconds стоит у всех: экспортер скрейпится раз в 60 с, а часть запросов
# трогает pg_class по всей базе. Кэш держит нагрузку на уровне шума.
# ── Раздутие: обновления, идущие МИМО HOT ────────────────────────────────────
# Разбор #2992/#2989: у `listings` 198 апдейтов на строку при доле HOT 0,43 %.
# Каждый не-HOT апдейт переписывает строку во все индексы и заново тостит
# описание — отсюда TOAST 15 ГБ при ~230 МБ живого содержимого. Копилось 91 день,
# потому что смотреть было не на что.
pg_table_write_amplification:
query: |
SELECT
schemaname AS schema,
relname AS table,
n_tup_ins AS tup_ins,
n_tup_upd AS tup_upd,
n_tup_del AS tup_del,
n_tup_hot_upd AS tup_hot_upd,
n_live_tup AS live_tup,
n_dead_tup AS dead_tup,
COALESCE(EXTRACT(EPOCH FROM (now() - last_autovacuum)), -1) AS last_autovacuum_age_s,
COALESCE(EXTRACT(EPOCH FROM (now() - last_autoanalyze)), -1) AS last_autoanalyze_age_s
FROM pg_stat_user_tables
WHERE n_tup_upd > 0 OR n_live_tup > 10000
cache_seconds: 60
metrics:
- schema: { usage: "LABEL", description: "Схема" }
- table: { usage: "LABEL", description: "Таблица" }
- tup_ins: { usage: "COUNTER", description: "Вставлено строк" }
- tup_upd: { usage: "COUNTER", description: "Обновлено строк" }
- tup_del: { usage: "COUNTER", description: "Удалено строк" }
- tup_hot_upd: { usage: "COUNTER", description: "Из них HOT — не трогают индексы" }
- live_tup: { usage: "GAUGE", description: "Живых строк" }
- dead_tup: { usage: "GAUGE", description: "Мёртвых строк — работа для vacuum" }
- last_autovacuum_age_s: { usage: "GAUGE", description: "Секунд с последнего autovacuum, -1 если не было" }
- last_autoanalyze_age_s: { usage: "GAUGE", description: "Секунд с последнего autoanalyze, -1 если не было" }
# ── Размеры: куда именно уходит диск ─────────────────────────────────────────
# Отдельно heap, индексы и TOAST. Суммарный размер таблицы этого не показывает,
# а именно разделение объясняло, почему `listings` весила 19 ГБ при 230 МБ данных.
pg_table_size_detail:
query: |
SELECT
n.nspname AS schema,
c.relname AS table,
pg_relation_size(c.oid) AS heap_bytes,
pg_indexes_size(c.oid) AS index_bytes,
COALESCE(pg_total_relation_size(c.reltoastrelid), 0) AS toast_bytes,
pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
AND pg_total_relation_size(c.oid) > 10485760
cache_seconds: 300
metrics:
- schema: { usage: "LABEL", description: "Схема" }
- table: { usage: "LABEL", description: "Таблица" }
- heap_bytes: { usage: "GAUGE", description: "Сами строки" }
- index_bytes: { usage: "GAUGE", description: "Индексы" }
- toast_bytes: { usage: "GAUGE", description: "TOAST — вынесенные длинные значения" }
- total_bytes: { usage: "GAUGE", description: "Итого с учётом всего" }
# ── WAL: сколько журнала генерится ───────────────────────────────────────────
# Замер 20.08: 7,02 ГБ/сутки при примерно четырёх пользовательских расчётах в
# сутки. Диспропорция такого масштаба и есть симптом — но заметить её можно
# только имея ряд.
pg_wal_bytes:
query: |
SELECT
CAST(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS BIGINT) AS wal_bytes_total,
(SELECT count(*) FROM pg_ls_waldir()) AS wal_segments
cache_seconds: 60
metrics:
- wal_bytes_total: { usage: "COUNTER", description: "Позиция WAL от начала — рост даёт байт/сек" }
- wal_segments: { usage: "GAUGE", description: "Сегментов в pg_wal сейчас" }
# ── Горизонт vacuum и долгие транзакции ──────────────────────────────────────
# Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт, из-за
# чего vacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция
# отравляет весь кластер, и снаружи это выглядит просто как «база пухнет».
pg_activity_horizon:
query: |
SELECT
COALESCE(MAX(EXTRACT(EPOCH FROM (now() - xact_start))), 0) AS oldest_xact_age_s,
COALESCE(MAX(EXTRACT(EPOCH FROM (now() - query_start))), 0) AS oldest_query_age_s,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_on_lock,
count(*) FILTER (WHERE backend_type = 'client backend') AS client_backends
FROM pg_stat_activity
WHERE backend_type = 'client backend'
cache_seconds: 30
metrics:
- oldest_xact_age_s: { usage: "GAUGE", description: "Возраст самой старой транзакции, сек" }
- oldest_query_age_s: { usage: "GAUGE", description: "Возраст самого старого запроса, сек" }
- idle_in_transaction: { usage: "GAUGE", description: "Открыта транзакция и ничего не делает — держит горизонт" }
- waiting_on_lock: { usage: "GAUGE", description: "Ждут блокировку" }
- client_backends: { usage: "GAUGE", description: "Клиентских соединений" }
# ── Размер баз ───────────────────────────────────────────────────────────────
# У GlitchTip нет политики ретенции вообще (проверено: grep по retention даёт
# ноль попаданий), база растёт без ограничения. Нужен ряд, чтобы поймать это
# до того, как кончится диск.
pg_database_size_bytes_detail:
query: |
SELECT datname AS database, pg_database_size(datname) AS bytes
FROM pg_database
WHERE datistemplate = false AND datallowconn = true
cache_seconds: 300
metrics:
- database: { usage: "LABEL", description: "База" }
- bytes: { usage: "GAUGE", description: "Размер, байт" }

View file

@ -0,0 +1,58 @@
# Prometheus — серверная сторона, живёт на Beget.
#
# СКРЕЙПА ЧУЖИХ ХОСТОВ ЗДЕСЬ НЕТ. Метрики с Poincare приходят push'ем через
# remote-write receiver (--web.enable-remote-write-receiver), потому что открывать
# входящие порты на продуктовом хосте ради мониторинга — плохой размен.
# Локально скрейпится только то, что стоит на этом же хосте.
#
# Метка `host` проставляется агентом Alloy на своей стороне (external_labels),
# поэтому здесь её нет: если задать и там и тут, honor_labels-семантика сделает
# результат неочевидным.
global:
scrape_interval: 30s
scrape_timeout: 10s
evaluation_interval: 30s
external_labels:
cluster: gendesign
rule_files:
- /etc/prometheus/rules/*.yml
# Пока профиль alerts выключен, этой цели не существует и Prometheus раз в
# интервал пишет в лог, что не смог её разрешить. Это шум, а не отказ: правила
# считаются и видны в интерфейсе, просто уведомлять некому. Молча выключать
# alerting не стали — тогда включение алертов потребовало бы правки конфига,
# а не одной переменной.
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
# Сам Prometheus. Нужен не для красоты: по нему строится алерт на здоровье
# приёмника — пустая панель неотличима от «ошибок нет», и это надо различать.
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
labels:
host: infra
- job_name: alertmanager
static_configs:
- targets: ["alertmanager:9093"]
labels:
host: infra
- job_name: loki
static_configs:
- targets: ["loki:3100"]
labels:
host: infra
- job_name: grafana
static_configs:
- targets: ["grafana:3000"]
labels:
host: infra
metrics_path: /metrics

View file

@ -0,0 +1,208 @@
# Правила алертов: инфраструктура и здоровье самого мониторинга.
#
# Принцип отбора — тот же, что у задачи #3078: сюда попадает только то, что уже
# ломалось молча. Правила «на всякий случай» не заводим: лишний алерт, который
# никто не разбирает, обесценивает остальные.
groups:
# ── Здоровье самого наблюдателя ─────────────────────────────────────────────
# Пустая панель неотличима от «всё хорошо». Эти правила закрывают именно это.
- name: monitoring-self
interval: 60s
rules:
# Всегда горит. Существует ради того, чтобы его ОТСУТСТВИЕ было заметно:
# раз в 12 часов приходит подтверждение, что цепочка правило → Alertmanager
# → Telegram → человек цела. Молчащий канал — самый частый способ узнать
# об аварии последним (в проекте МЕРЫ наружу не ушло ни одного сообщения
# с 30 мая, и это выяснилось случайно).
- alert: Watchdog
expr: vector(1)
labels:
severity: none
annotations:
summary: "Сторож мониторинга"
# Агент замолчал. На Poincare это единственный источник всех метрик:
# если он умер, графики просто перестанут обновляться, оставаясь зелёными.
- alert: HostAgentDown
expr: up{job="node"} == 0 or absent(up{job="node", host="apps"})
for: 5m
labels:
severity: critical
annotations:
summary: "Агент метрик не отвечает"
description: "Хост {{ $labels.host }}: node-exporter недоступен более 5 минут. Метрики этого хоста больше не поступают."
# Приёмник перестал принимать push. Симптом со стороны центра.
- alert: RemoteWriteStalled
expr: |
absent_over_time(up{job="node", host="apps"}[15m])
for: 5m
labels:
severity: critical
annotations:
summary: "С продуктового хоста 15 минут не приходят метрики"
description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write."
# ── Хост ────────────────────────────────────────────────────────────────────
- name: host
interval: 60s
rules:
# Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел;
# порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте.
- alert: DiskSpaceLow
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85
for: 15m
labels:
severity: warning
annotations:
summary: "Диск занят больше 85 %"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}."
- alert: DiskSpaceCritical
expr: |
(1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93
for: 5m
labels:
severity: critical
annotations:
summary: "Диск почти кончился"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается."
# Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену.
- alert: DiskWillFillIn24h
expr: |
predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
for: 30m
labels:
severity: warning
annotations:
summary: "По текущему темпу диск кончится за сутки"
description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам."
- alert: MemoryPressure
expr: |
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
for: 15m
labels:
severity: warning
annotations:
summary: "Памяти доступно меньше 10 %"
description: "{{ $labels.host }}: свободной памяти {{ $value | humanizePercentage }} от общей."
# Своп сам по себе не беда, но резкий рост означает, что что-то перестало
# помещаться. На Beget своп в 1,78 ГБ был симптомом сосуществования прода
# и инфраструктуры — и рассосался ровно в момент переезда.
- alert: SwapGrowing
expr: |
node_memory_SwapTotal_bytes > 0
and (1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes) > 0.50
for: 30m
labels:
severity: warning
annotations:
summary: "Своп занят больше половины"
description: "{{ $labels.host }}: обычно означает, что рабочий набор перестал помещаться в память."
# ── Контейнеры ──────────────────────────────────────────────────────────────
- name: containers
interval: 60s
rules:
# Цикл перезапусков. Контейнер в restart-loop снаружи выглядит «поднятым»,
# а деплой при этом зелёный — именно так чуть не уехал в прод пустой пароль
# у infra-postgres (#3061).
- alert: ContainerRestartLoop
expr: |
changes(container_start_time_seconds{name!=""}[30m]) > 3
for: 5m
labels:
severity: warning
annotations:
summary: "Контейнер перезапускается по кругу"
description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса."
# Подошёл к своему mem_limit — следующий шаг OOM-kill.
- alert: ContainerNearMemoryLimit
expr: |
container_spec_memory_limit_bytes{name!=""} > 0
and container_memory_working_set_bytes{name!=""}
/ container_spec_memory_limit_bytes{name!=""} > 0.90
for: 15m
labels:
severity: warning
annotations:
summary: "Контейнер у своего потолка памяти"
description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill."
# ── Postgres ────────────────────────────────────────────────────────────────
- name: postgres
interval: 60s
rules:
# Забытая транзакция держит горизонт vacuum и отравляет весь кластер.
# Разбор #2607: осиротевшие запросы висели 46 часов.
- alert: PostgresLongTransaction
expr: pg_activity_horizon_oldest_xact_age_s > 3600
for: 10m
labels:
severity: warning
annotations:
summary: "Транзакция открыта больше часа"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Пока она жива, vacuum не может убрать мёртвые строки во ВСЕЙ базе."
- alert: PostgresLongTransactionCritical
expr: pg_activity_horizon_oldest_xact_age_s > 21600
for: 10m
labels:
severity: critical
annotations:
summary: "Транзакция открыта больше шести часов"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Это уже влияет на размер базы."
- alert: PostgresIdleInTransaction
expr: pg_activity_horizon_idle_in_transaction > 3
for: 15m
labels:
severity: warning
annotations:
summary: "Соединения висят в открытой транзакции"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value }} шт. Обычно это незакрытая сессия в коде."
# Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие
# позволило 91 день не замечать 198 апдейтов на строку.
- alert: PostgresLowHotUpdateRatio
expr: |
rate(pg_table_write_amplification_tup_upd[6h]) > 0.5
and
rate(pg_table_write_amplification_tup_hot_upd[6h])
/ rate(pg_table_write_amplification_tup_upd[6h]) < 0.2
for: 6h
labels:
severity: warning
annotations:
summary: "Обновления идут мимо HOT"
description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие."
- alert: PostgresDeadTuplesHigh
expr: |
pg_table_write_amplification_dead_tup > 1000000
and pg_table_write_amplification_dead_tup
/ (pg_table_write_amplification_live_tup + 1) > 0.5
for: 1h
labels:
severity: warning
annotations:
summary: "Мёртвых строк больше половины от живых"
description: "{{ $labels.host }} / {{ $labels.table }}: {{ $value }} мёртвых. Autovacuum не справляется либо заблокирован долгой транзакцией."
# WAL. Замер 20.08: 7 ГБ/сутки при четырёх пользовательских расчётах.
- alert: PostgresWalRateHigh
expr: rate(pg_wal_bytes_wal_bytes_total[1h]) > 104857600 / 3600
for: 2h
labels:
severity: warning
annotations:
summary: "WAL пишется быстрее 100 МБ/час"
description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."

View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Собирает строки подключения для postgres_exporter на продуктовом хосте из
# паролей, которые там уже есть. Идемпотентен: заданные значения не трогает.
#
# ПОЧЕМУ НЕ ЗАВОДИМ ОТДЕЛЬНЫЕ СЕКРЕТЫ. Каждая новая копия пароля — ещё одно
# место, откуда он может утечь, и ещё одно, которое забудут повернуть при
# ротации. Экспортеру нужны те же учётки, что уже лежат в окружении хоста.
#
# Запускать на продуктовом хосте. Вызывается из deploy-metrics.yml.
set -euo pipefail
ENVF=/opt/gendesign/backend/.env.runtime
if [ ! -f "$ENVF" ]; then
echo " ОШИБКА: не найден файл окружения бэкенда." >&2
exit 1
fi
set -a
# shellcheck source=/dev/null
. "$ENVF"
set +a
add_key() {
local key="$1" value="$2"
if grep -qE "^${key}=" "$ENVF" 2>/dev/null; then
echo " $key — уже задан, не трогаю"
return 0
fi
if [ -z "$value" ]; then
echo " $key — нечем заполнить, пропускаю (метрики этой базы не поедут)"
return 0
fi
printf '%s=%s\n' "$key" "$value" >> "$ENVF"
echo " $key — записан"
}
# Экспортер ходит по сетевому алиасу, а не по IP: адреса контейнеров меняются
# при каждом пересоздании, и DSN с IP протух бы на первом же деплое.
add_key GENDESIGN_EXPORTER_DSN \
"${GENDESIGN_EXPORTER_DSN:-${DATABASE_URL:-}}"
add_key TRADEIN_EXPORTER_DSN \
"${TRADEIN_EXPORTER_DSN:-${TRADEIN_DATABASE_URL:-}}"
# postgres_exporter не понимает схему postgresql+psycopg:// из SQLAlchemy —
# ему нужна чистая postgresql://. Приводим, если досталась приложенческая форма.
python3 - "$ENVF" <<'PY'
import io, re, sys
path = sys.argv[1]
with io.open(path, encoding="utf-8") as fh:
lines = fh.readlines()
changed = False
out = []
for line in lines:
m = re.match(r'^((?:GENDESIGN|TRADEIN)_EXPORTER_DSN)=(.*)$', line.rstrip('\n'))
if not m:
out.append(line)
continue
key, dsn = m.group(1), m.group(2)
fixed = re.sub(r'^postgresql\+\w+://', 'postgresql://', dsn)
fixed = re.sub(r'^postgres\+\w+://', 'postgresql://', fixed)
if 'sslmode=' not in fixed:
fixed += ('&' if '?' in fixed else '?') + 'sslmode=disable'
if fixed != dsn:
changed = True
print(" %s — схема приведена к postgresql:// для экспортера" % key)
out.append("%s=%s\n" % (key, fixed))
if changed:
with io.open(path, "w", encoding="utf-8", newline="\n") as fh:
fh.writelines(out)
PY
echo " строки подключения экспортеров готовы"

View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Заводит read-only роль grafana_ro в базе glitchtip — под датасорс Grafana.
# Идемпотентен: повторный запуск только досогласует права.
#
# ПОЧЕМУ ОТДЕЛЬНАЯ РОЛЬ, А НЕ ВЛАДЕЛЕЦ БАЗЫ. Датасорс Grafana доступен всякому,
# кто вошёл в интерфейс, и позволяет выполнять произвольный SQL в панелях.
# Владельческая роль сделала бы витрину способом уронить трекер ошибок. Здесь
# прав на запись нет вовсе, поэтому худшее, что можно сделать через панель, —
# медленный SELECT.
#
# Запускать на инфраструктурном хосте. Вызывается из deploy-metrics.yml.
set -euo pipefail
CONT=gendesign-infra-postgres
DB=glitchtip
ROLE=grafana_ro
: "${GLITCHTIP_RO_PASSWORD:?GLITCHTIP_RO_PASSWORD не задан — запусти scripts/setup-metrics-secrets.sh}"
if ! docker inspect "$CONT" >/dev/null 2>&1; then
echo " $CONT не найден — пропускаю создание роли."
exit 0
fi
# Ищем роль с правом заводить других. Имя суперпользователя в этом кластере
# нигде не зафиксировано, а угадывать «postgres» неверно: в образе оно задаётся
# переменной POSTGRES_USER и здесь ею не является.
SUPER=""
for candidate in glitchtip forgejo postgres; do
if docker exec "$CONT" psql -U "$candidate" -d postgres -tAc \
"SELECT 1 FROM pg_roles WHERE rolname = CURRENT_USER AND (rolsuper OR rolcreaterole)" 2>/dev/null \
| grep -q 1; then
SUPER="$candidate"
break
fi
done
if [ -z "$SUPER" ]; then
echo " ОШИБКА: не нашёл роль с правом CREATE ROLE в $CONT." >&2
echo " Проверь вручную: docker exec $CONT psql -U <роль> -c '\\du'" >&2
exit 1
fi
echo " привилегированная роль: $SUPER"
# Пароль передаётся через переменную окружения psql, а не в тексте запроса —
# иначе он осел бы в pg_stat_statements и в логе запросов.
docker exec -e RO_PASS="$GLITCHTIP_RO_PASSWORD" -i "$CONT" \
psql -U "$SUPER" -d "$DB" -v ON_ERROR_STOP=1 <<'SQL'
\set ro_pass `echo "$RO_PASS"`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
CREATE ROLE grafana_ro LOGIN;
RAISE NOTICE 'роль grafana_ro создана';
ELSE
RAISE NOTICE 'роль grafana_ro уже есть';
END IF;
END
$$;
ALTER ROLE grafana_ro WITH PASSWORD :'ro_pass';
-- Ограничение на число соединений: панель с автообновлением способна открыть
-- их десятками, а это тот же кластер, где живёт Forgejo.
ALTER ROLE grafana_ro CONNECTION LIMIT 8;
GRANT CONNECT ON DATABASE glitchtip TO grafana_ro;
GRANT USAGE ON SCHEMA public TO grafana_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro;
-- Таблицы событий партиционированы по неделям: новые партиции появляются сами,
-- и без этой строки датасорс начал бы отдавать пустоту на свежих данных,
-- оставаясь при этом «рабочим». Тихий отказ ровно того сорта, который мы ловим.
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro;
SQL
echo " grafana_ro: права выданы на $DB"

View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Разовая подготовка учётных данных стека наблюдаемости (#3078).
#
# Запускать НА ИНФРАСТРУКТУРНОМ ХОСТЕ (Beget), один раз. Идемпотентен: уже
# заданные значения переиспользуются, ничего не перезаписывается.
#
# ЧТО ДЕЛАЕТ:
# 1. Генерирует пароли приёмника, витрины, администратора Grafana и read-only
# роли к базе GlitchTip; дописывает их в окружение хоста.
# 2. Кладёт пароль приёмника на продуктовый хост — агенту нужно им
# авторизоваться при отправке метрик и логов.
# 3. Печатает bcrypt-хеши для caddy/metrics-*.caddy.snippet.
#
# ЧЕГО НЕ ДЕЛАЕТ: не печатает сами пароли. Хеш публиковать безопасно, пароль —
# нет, а вывод скрипта попадает в журнал деплоя и в историю терминала.
#
# Токен Telegram скрипт НЕ генерирует — его нужно задать руками:
# METRICS_TELEGRAM_BOT_TOKEN, METRICS_TELEGRAM_CHAT_ID
set -euo pipefail
ENVF=/opt/gendesign/backend/.env.runtime
REMOTE_ENVF=/opt/gendesign/backend/.env.runtime
RSSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 selectel)
STAMP=$(date -u +%Y%m%d%H%M%S)
if [ ! -f "$ENVF" ]; then
echo "ОШИБКА: не найден файл окружения бэкенда на этом хосте." >&2
exit 1
fi
# Копия перед первой правкой — одна на запуск, а не на каждый ключ.
backup_once() {
[ -f "${ENVF}.bak-metrics-${STAMP}" ] && return 0
cp -p "$ENVF" "${ENVF}.bak-metrics-${STAMP}"
}
have() { grep -qE "^${1}=" "$ENVF" 2>/dev/null; }
value_of() { grep -m1 -E "^${1}=" "$ENVF" | cut -d= -f2-; }
# 32 символа из [A-Za-z0-9]: помещается в basic_auth без экранирования и не
# ломает разбор .env, где кавычки и знак равенства создают сюрпризы.
gen_pass() { tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32; }
ensure() {
local key="$1" val="$2"
if have "$key"; then
echo " $key — уже есть, переиспользую"
else
backup_once
printf '%s=%s\n' "$key" "$val" >> "$ENVF"
echo " $key — сгенерирован"
fi
}
echo "=== учётки на инфраструктурном хосте ==="
ensure METRICS_INGEST_USER "alloy"
ensure METRICS_INGEST_PASSWORD "$(gen_pass)"
ensure METRICS_UI_USER "metrics"
ensure METRICS_UI_PASSWORD "$(gen_pass)"
ensure GRAFANA_ADMIN_USER "admin"
ensure GRAFANA_ADMIN_PASSWORD "$(gen_pass)"
ensure GLITCHTIP_RO_PASSWORD "$(gen_pass)"
INGEST_PASS=$(value_of METRICS_INGEST_PASSWORD)
UI_PASS=$(value_of METRICS_UI_PASSWORD)
GT_RO=$(value_of GLITCHTIP_RO_PASSWORD)
ensure INFRA_EXPORTER_DSN \
"postgresql://grafana_ro:${GT_RO}@gendesign-infra-postgres:5432/glitchtip?sslmode=disable"
echo
echo "=== пароль приёмника на продуктовый хост ==="
if "${RSSH[@]}" "grep -qE '^METRICS_INGEST_PASSWORD=' ${REMOTE_ENVF}" 2>/dev/null; then
echo " уже задан, не трогаю"
else
"${RSSH[@]}" "cp -p ${REMOTE_ENVF} ${REMOTE_ENVF}.bak-metrics-${STAMP}"
# Значение идёт по stdin, а не аргументом команды: аргументы видны в
# `ps` на обеих машинах и оседают в истории оболочки.
printf 'METRICS_INGEST_USER=alloy\nMETRICS_INGEST_PASSWORD=%s\n' "$INGEST_PASS" \
| "${RSSH[@]}" "cat >> ${REMOTE_ENVF}"
"${RSSH[@]}" "chown --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}; \
chmod --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}"
echo " записан, права сохранены по резервной копии"
fi
echo
echo "=== bcrypt-хеши для caddy/metrics-*.caddy.snippet ==="
echo " (пароли не печатаются; хеши безопасны для git)"
echo
for pair in "alloy:${INGEST_PASS}:metrics-ingest" "metrics:${UI_PASS}:metrics-ui"; do
user="${pair%%:*}"; rest="${pair#*:}"
pass="${rest%%:*}"; file="${rest#*:}"
hash=$(docker run --rm caddy:2 caddy hash-password --plaintext "$pass" 2>/dev/null | tr -d '\r\n')
b64=$(printf '%s' "$hash" | base64 -w 0)
printf ' caddy/%s.caddy.snippet\n %-8s %s\n' "$file" "$user" "$b64"
done
echo
echo "=== что осталось сделать руками ==="
have METRICS_TELEGRAM_BOT_TOKEN \
&& echo " METRICS_TELEGRAM_BOT_TOKEN — задан" \
|| echo " METRICS_TELEGRAM_BOT_TOKEN — НЕ задан, алерты никуда не уйдут"
have METRICS_TELEGRAM_CHAT_ID \
&& echo " METRICS_TELEGRAM_CHAT_ID — задан" \
|| echo " METRICS_TELEGRAM_CHAT_ID — НЕ задан, алерты никуда не уйдут"
echo
echo " Пароль витрины смотреть так (в переписку не копировать):"
echo " grep '^METRICS_UI_PASSWORD=' $ENVF"