Merge pull request 'feat(observability): стек метрик и логов — Prometheus, Loki, Grafana на Beget, агенты на обоих хостах' (#3099) from feat/observability-metrics-stack into main
Some checks failed
Deploy Infra Host / sync-infra-host (push) Failing after 4s
Deploy / changes (push) Successful in 8s
Deploy Metrics / server (push) Failing after 8s
Deploy Metrics / agent-apps (push) Has been skipped
Deploy Metrics / agent-infra (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 34s
Deploy / build-worker (push) Successful in 36s
Deploy / build-backend (push) Successful in 37s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 2m40s
Some checks failed
Deploy Infra Host / sync-infra-host (push) Failing after 4s
Deploy / changes (push) Successful in 8s
Deploy Metrics / server (push) Failing after 8s
Deploy Metrics / agent-apps (push) Has been skipped
Deploy Metrics / agent-infra (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-frontend (push) Successful in 34s
Deploy / build-worker (push) Successful in 36s
Deploy / build-backend (push) Successful in 37s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Failing after 2m40s
This commit is contained in:
commit
afd881d6b1
22 changed files with 2763 additions and 0 deletions
267
.forgejo/workflows/deploy-metrics.yml
Normal file
267
.forgejo/workflows/deploy-metrics.yml
Normal 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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
15
caddy/metrics-ingest.caddy.snippet
Normal file
15
caddy/metrics-ingest.caddy.snippet
Normal 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
|
||||
}
|
||||
19
caddy/metrics-ui.caddy.snippet
Normal file
19
caddy/metrics-ui.caddy.snippet
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
223
docker-compose.metrics-agent.yml
Normal file
223
docker-compose.metrics-agent.yml
Normal 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
202
docker-compose.metrics.yml
Normal 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
164
docs/observability.md
Normal 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 врут, врут и графики
|
||||
74
ops/metrics/alertmanager/alertmanager.yml.tmpl
Normal file
74
ops/metrics/alertmanager/alertmanager.yml.tmpl
Normal 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> — сторож отчитался, канал доставки работает.
|
||||
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
|
||||
172
ops/metrics/alloy/alloy-apps.alloy
Normal file
172
ops/metrics/alloy/alloy-apps.alloy
Normal 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"
|
||||
}
|
||||
136
ops/metrics/alloy/alloy-infra.alloy
Normal file
136
ops/metrics/alloy/alloy-infra.alloy
Normal 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"
|
||||
}
|
||||
332
ops/metrics/grafana/dashboards/host.json
Normal file
332
ops/metrics/grafana/dashboards/host.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
300
ops/metrics/grafana/dashboards/postgres.json
Normal file
300
ops/metrics/grafana/dashboards/postgres.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
16
ops/metrics/grafana/provisioning/dashboards/dashboards.yml
Normal file
16
ops/metrics/grafana/provisioning/dashboards/dashboards.yml
Normal 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
|
||||
75
ops/metrics/grafana/provisioning/datasources/datasources.yml
Normal file
75
ops/metrics/grafana/provisioning/datasources/datasources.yml
Normal 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
|
||||
78
ops/metrics/loki/loki-config.yml
Normal file
78
ops/metrics/loki/loki-config.yml
Normal 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
|
||||
116
ops/metrics/postgres/queries.yml
Normal file
116
ops/metrics/postgres/queries.yml
Normal 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: "Размер, байт" }
|
||||
58
ops/metrics/prometheus/prometheus.yml
Normal file
58
ops/metrics/prometheus/prometheus.yml
Normal 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
|
||||
208
ops/metrics/prometheus/rules/infra.yml
Normal file
208
ops/metrics/prometheus/rules/infra.yml
Normal 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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
||||
77
scripts/setup-metrics-exporter-dsn.sh
Normal file
77
scripts/setup-metrics-exporter-dsn.sh
Normal 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 " строки подключения экспортеров готовы"
|
||||
78
scripts/setup-metrics-grafana-role.sh
Normal file
78
scripts/setup-metrics-grafana-role.sh
Normal 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"
|
||||
108
scripts/setup-metrics-secrets.sh
Normal file
108
scripts/setup-metrics-secrets.sh
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue