Compare commits
No commits in common. "afd881d6b1890ca0238c11183a2bfa91e2d70d62" and "2116c3774e2b9cd11f115e21301108e4d05f3b63" have entirely different histories.
afd881d6b1
...
2116c3774e
22 changed files with 0 additions and 2763 deletions
|
|
@ -1,267 +0,0 @@
|
||||||
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,7 +98,3 @@ ds-bundle/
|
||||||
.design-sync/.cache/
|
.design-sync/.cache/
|
||||||
.design-sync/learnings/
|
.design-sync/learnings/
|
||||||
.design-sync/node_modules
|
.design-sync/node_modules
|
||||||
|
|
||||||
# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml):
|
|
||||||
# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает.
|
|
||||||
ops/metrics/alertmanager/alertmanager.yml
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# Приём метрик и логов от агентов — машинный контур 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
# Витрина мониторинга — внешний контур доступа к 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,44 +62,3 @@ git.gendsgn.ru {
|
||||||
output file /var/log/caddy/git.gendsgn.ru.log
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на 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
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (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
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
# Наблюдаемость: метрики, логи, алерты
|
|
||||||
|
|
||||||
Задача #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 врут, врут и графики
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
# Шаблон конфигурации 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> — сторож отчитался, канал доставки работает.
|
|
||||||
Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг.
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,300 +0,0 @@
|
||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
# Дополнительные запросы для 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: "Размер, байт" }
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
# Правила алертов: инфраструктура и здоровье самого мониторинга.
|
|
||||||
#
|
|
||||||
# Принцип отбора — тот же, что у задачи #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/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи."
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
#!/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 " строки подключения экспортеров готовы"
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
#!/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"
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
#!/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