diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml new file mode 100644 index 00000000..64bfbb5e --- /dev/null +++ b/.forgejo/workflows/deploy-metrics.yml @@ -0,0 +1,267 @@ +name: Deploy Metrics + +# Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно: +# +# server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana, +# Alertmanager. Наблюдатель намеренно живёт у другого провайдера, +# чем наблюдаемое. +# agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy. +# Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается +# ни одного входящего порта. +# +# Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы, +# deploy.yml остаётся в стороне. + +on: + push: + branches: [main] + paths: + - "docker-compose.metrics.yml" + - "docker-compose.metrics-agent.yml" + - "ops/metrics/**" + - "caddy/sites/infra.caddy" + - "caddy/metrics-ui.caddy.snippet" + - "caddy/metrics-ingest.caddy.snippet" + - "scripts/setup-metrics-secrets.sh" + - ".forgejo/workflows/deploy-metrics.yml" + workflow_dispatch: + +concurrency: + group: deploy-metrics + cancel-in-progress: false + +jobs: + # ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════ + server: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + # Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток + # берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком + # Poincare — гарантированный отказ. + - name: Адресат и подлинность инфраструктурного хоста + id: target + env: + INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} + INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${INFRA_HOST:-}" ]; then + HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}" + SRC="INFRA_DEPLOY_SSH_FINGERPRINT" + else + HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}" + SRC="DEPLOY_SSH_FINGERPRINT" + fi + case "${HOST_FINGERPRINT}" in + *[![:print:]]*) + echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2 + exit 1 + ;; + esac + echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT" + if [ -z "${HOST_FINGERPRINT:-}" ]; then + echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)." + fi + + - name: Поднять серверный стек + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ steps.target.outputs.fingerprint }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + git fetch origin main + git reset --hard origin/main + + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + # Окружение нужно в shell, а не только в env_file: проверки вида + # ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА. + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + # ── Проверка ДО подъёма, а не после ──────────────────────────── + # Пустой токен даёт Alertmanager, который стартует зелёным и молча + # ничего не шлёт. Это ровно тот класс тихого отказа, ради которого + # весь стек и заводится, — ловим на пороге. + missing="" + for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \ + METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do + eval "val=\${$v:-}" + [ -z "$val" ] && missing="$missing $v" + done + if [ -n "$missing" ]; then + echo "ОШИБКА: в окружении хоста не заданы:$missing" + echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh" + exit 1 + fi + + # Алерты включаются, только когда канал доставки реально задан. + # Поднимать Alertmanager с пустым токеном нельзя: он стартует + # зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради + # которого весь стек и заводится. + PROFILES="" + if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then + PROFILES="alerts" + mkdir -p ops/metrics/alertmanager + METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \ + METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ + envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID}' \ + < ops/metrics/alertmanager/alertmanager.yml.tmpl \ + > ops/metrics/alertmanager/alertmanager.yml + chmod 600 ops/metrics/alertmanager/alertmanager.yml + echo "Алерты: канал задан, Alertmanager поднимается." + else + echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078." + fi + + # ── read-only роль для датасорса GlitchTip ───────────────────── + # Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana + # обязан быть безопасен даже при полном доступе к дашбордам. + bash scripts/setup-metrics-grafana-role.sh + + COMPOSE_PROFILES="$PROFILES" \ + docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet + COMPOSE_PROFILES="$PROFILES" \ + docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans + + # ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── + # На этом хосте тот же Caddy обслуживает git., errors. и obsidian. + # Синтаксическая ошибка в infra.caddy положила бы их все, включая + # сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен, + # и reload делается только после успешной проверки. + if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then + if docker compose -p gendesign -f docker-compose.prod.yml \ + exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then + docker compose -p gendesign -f docker-compose.prod.yml \ + exec -T caddy caddy reload --config /etc/caddy/Caddyfile + echo "Caddy: конфиг проверен и перезагружен." + else + echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен." + echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять." + exit 1 + fi + fi + + # ── Приёмка ──────────────────────────────────────────────────── + for i in $(seq 1 30); do + if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then + break + fi + sleep 3 + done + docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps + + # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ + agent-apps: + runs-on: ubuntu-latest + needs: server + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Агент на продуктовом хосте + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + git fetch origin main + git reset --hard origin/main + + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then + echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться." + echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh" + exit 1 + fi + + # DSN экспортеров собираются из уже имеющихся паролей БД, если их + # ещё нет. Отдельных секретов не заводим — лишняя копия пароля это + # лишнее место, откуда он может утечь. + bash scripts/setup-metrics-exporter-dsn.sh + + set -a; . backend/.env.runtime; set +a + + METRICS_ROLE=apps \ + METRICS_ALLOY_CONFIG=alloy-apps.alloy \ + COMPOSE_PROFILES=apps \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml pull --quiet + + METRICS_ROLE=apps \ + METRICS_ALLOY_CONFIG=alloy-apps.alloy \ + COMPOSE_PROFILES=apps \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml up -d + + sleep 10 + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES=apps \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml ps + + agent-infra: + runs-on: ubuntu-latest + needs: server + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Агент на инфраструктурном хосте + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + METRICS_ROLE=infra \ + METRICS_ALLOY_CONFIG=alloy-infra.alloy \ + COMPOSE_PROFILES=infra \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml pull --quiet + + METRICS_ROLE=infra \ + METRICS_ALLOY_CONFIG=alloy-infra.alloy \ + COMPOSE_PROFILES=infra \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml up -d + + sleep 10 + METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES=infra \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml ps diff --git a/.gitignore b/.gitignore index 53ceef6e..2ece89ee 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ ds-bundle/ .design-sync/.cache/ .design-sync/learnings/ .design-sync/node_modules + +# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml): +# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает. +ops/metrics/alertmanager/alertmanager.yml diff --git a/caddy/metrics-ingest.caddy.snippet b/caddy/metrics-ingest.caddy.snippet new file mode 100644 index 00000000..b99d098f --- /dev/null +++ b/caddy/metrics-ingest.caddy.snippet @@ -0,0 +1,15 @@ +# Приём метрик и логов от агентов — машинный контур metrics.gendsgn.ru/ingest/*. +# +# ОТДЕЛЬНАЯ УЧЁТКА, А НЕ ТА ЖЕ, ЧТО У ВИТРИНЫ. Пароль приёмника лежит в открытом +# виде в окружении продуктового хоста (агенту нужно им авторизоваться), то есть +# компрометация Poincare раскрывает его автоматически. Если бы это была учётка +# витрины, вместе с ней утёк бы и доступ к самим дашбордам и к Prometheus, где +# видна вся картина инфраструктуры. Разделение ограничивает ущерб записью. +# +# Пароль — METRICS_INGEST_PASSWORD, задан на ОБОИХ хостах (на Beget как источник +# истины, на Poincare для агента). Сюда попадает только bcrypt-хеш. +# Сгенерирован scripts/setup-metrics-secrets.sh 2026-08-26. + +basic_auth bcrypt "GenDesign Metrics Ingest" { + alloy JDJhJDE0JGMvTmxIenZUbW00cEtJVm01OGl0dmVRL0VSNk1mNjIwbDFvQWl1VTRwaEVaa1FHWXFUdEVl # агент Alloy, пароль в METRICS_INGEST_PASSWORD +} diff --git a/caddy/metrics-ui.caddy.snippet b/caddy/metrics-ui.caddy.snippet new file mode 100644 index 00000000..7652bd8c --- /dev/null +++ b/caddy/metrics-ui.caddy.snippet @@ -0,0 +1,19 @@ +# Витрина мониторинга — внешний контур доступа к metrics.gendsgn.ru. +# +# Пароль живёт в окружении хоста как METRICS_UI_PASSWORD (backend/.env.runtime), +# сюда попадает только bcrypt-хеш — его публикация ничего не раскрывает. +# Сгенерирован ops/../scripts/setup-metrics-secrets.sh 2026-08-26. +# +# ЭТО ВТОРОЙ СЛОЙ, А НЕ ЕДИНСТВЕННЫЙ. У Grafana есть собственный вход с ролями +# (GF_USERS_ALLOW_SIGN_UP=false), и он остаётся включённым. Внешний basic_auth +# нужен потому, что дашборд смотрит в интернет: он отсекает сканеры и любую +# будущую дыру в самой Grafana до того, как она станет доступной снаружи. +# Если два запроса пароля подряд окажутся неудобны — убрать ОДНУ строку +# `import caddy/metrics-ui.caddy.snippet` из infra.caddy, вход Grafana останется. +# +# Формат: username base64(bcrypt_hash) # комментарий +# Caddy 2.11+ требует именно base64 от bcrypt-хеша. + +basic_auth bcrypt "GenDesign Metrics" { + metrics JDJhJDE0JFpObUNJUzVGZnd3blRKQXE3cDIxVWVEaG1udjY0cHVyVmY3OE9Ga2xubjE5RC90elZkL0dD # витрина, пароль в METRICS_UI_PASSWORD на Beget +} diff --git a/caddy/sites/infra.caddy b/caddy/sites/infra.caddy index e8ecc929..b39f3de7 100644 --- a/caddy/sites/infra.caddy +++ b/caddy/sites/infra.caddy @@ -62,3 +62,44 @@ git.gendsgn.ru { output file /var/log/caddy/git.gendsgn.ru.log } } + +# Мониторинг — Grafana + приёмник метрик и логов (задача #3078). +# DNS: A-record metrics.gendsgn.ru → 46.173.16.127 (заведена 2026-08-26). +# +# ПОЧЕМУ ЗДЕСЬ, А НЕ В apps.caddy. Наблюдатель сознательно поставлен у ДРУГОГО +# провайдера, чем наблюдаемое: продукт живёт на Selectel Poincare, и если ляжет +# он, мониторинг обязан выжить и сказать об этом. Стек поднимается отдельным +# compose-проектом gendesign-metrics и виден Caddy через сеть gendesign_shared. +# +# ДВА КОНТУРА С РАЗНЫМИ УЧЁТКАМИ: +# /ingest/* — машинный. Агент Alloy с Poincare шлёт сюда remote_write и логи +# исходящим HTTPS. Благодаря этому на Poincare не открыт НИ ОДИН +# входящий порт сверх 22/80/443. +# всё прочее — витрина Grafana под отдельным паролем. +# Пароль приёмника по построению лежит в открытом виде на продуктовом хосте; +# разделив учётки, мы не отдаём вместе с ним доступ к дашбордам. +metrics.gendsgn.ru { + encode zstd gzip + + # handle_path срезает префикс: Prometheus получает /api/v1/write, + # как если бы обращались к нему напрямую. + handle_path /ingest/prometheus/* { + import ../metrics-ingest.caddy.snippet + reverse_proxy prometheus:9090 + } + + # Loki ожидает путь /loki/api/v1/push — он и остаётся после среза /ingest/loki. + handle_path /ingest/loki/* { + import ../metrics-ingest.caddy.snippet + reverse_proxy loki:3100 + } + + handle { + import ../metrics-ui.caddy.snippet + reverse_proxy grafana:3000 + } + + log { + output file /var/log/caddy/metrics.gendsgn.ru.log + } +} diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml new file mode 100644 index 00000000..e079e638 --- /dev/null +++ b/docker-compose.metrics-agent.yml @@ -0,0 +1,223 @@ +# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на Beget +# (рядом с сервером), и на Poincare (рядом с продуктом). +# +# docker compose -p gendesign-metrics-agent -f docker-compose.metrics-agent.yml up -d +# +# Что делает: собирает метрики хоста (node-exporter), контейнеров (cAdvisor), баз +# (postgres-exporter) и логи journald, после чего Alloy отправляет всё на приёмник. +# +# ПОЧЕМУ АГЕНТ, А НЕ СКРЕЙП ИЗ ЦЕНТРА. Prometheus на Beget не может дотянуться до +# экспортеров на Poincare, не открыв там входящие порты — а ufw на Poincare намеренно +# держит только 22/80/443. Агент решает это push'ем: исходящий HTTPS уже разрешён. +# Побочный выигрыш — при обрыве канала Alloy копит в WAL и досылает, тогда как +# pull-скрейп просто теряет точки. +# +# КОНФИГУРАЦИЯ ЗАВИСИТ ОТ ХОСТА — задаётся переменными окружения, файл один: +# +# На Beget (инфраструктура, приёмник рядом): +# METRICS_ROLE=infra +# METRICS_ALLOY_CONFIG=alloy-infra.alloy # пишет напрямую в prometheus:9090 / loki:3100 +# COMPOSE_PROFILES=infra +# +# На Poincare (продукт, приёмник за интернетом): +# METRICS_ROLE=apps +# METRICS_ALLOY_CONFIG=alloy-apps.alloy # пишет в https://metrics.gendsgn.ru под basic_auth +# COMPOSE_PROFILES=apps +# METRICS_INGEST_USER / METRICS_INGEST_PASSWORD — учётка приёмника +# +# Профили решают, какие postgres-exporter'ы поднимать: на Poincare две базы +# (Птица + МЕРА), на Beget одна (infra-postgres с forgejo и glitchtip). + +x-logging: &default-logging + driver: journald + +services: + # ── Alloy: единый агент метрик и логов ─────────────────────────────────────── + # Почему Alloy, а не Promtail: у Promtail EOL 2 марта 2026. Alloy читает journald + # нативно и умеет одновременно скрейпить Prometheus-эндпоинты. + alloy: + image: grafana/alloy:v1.6.1 + container_name: gendesign-alloy + restart: unless-stopped + # root нужен для чтения /var/log/journal. Штатный пользователь `alloy` требует + # членства в группах adm и systemd-journal — внутри контейнера этих групп с + # правильными gid хоста нет, и агент молча читает НОЛЬ записей. Проверено как + # известные грабли: отказ выглядит как «логов просто нет». + user: root + command: + - "run" + - "--server.http.listen-addr=0.0.0.0:12345" + - "--storage.path=/var/lib/alloy/data" + - "/etc/alloy/config.alloy" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Метка хоста попадает во все ряды и логи — без неё графики двух машин + # сливаются в один и разобрать, где что, невозможно. + METRICS_HOST_LABEL: ${METRICS_ROLE:?zadaj METRICS_ROLE=infra ili apps} + METRICS_INGEST_USER: ${METRICS_INGEST_USER:-} + METRICS_INGEST_PASSWORD: ${METRICS_INGEST_PASSWORD:-} + volumes: + - ./ops/metrics/alloy/${METRICS_ALLOY_CONFIG:?zadaj METRICS_ALLOY_CONFIG}:/etc/alloy/config.alloy:ro + # Явный путь к журналу обязателен. Без него libsystemd применяет + # SD_JOURNAL_LOCAL_ONLY и хостовые логи из контейнера не видны — при этом + # ошибки нет, просто пустой поток. + - /var/log/journal:/var/log/journal:ro + - /etc/machine-id:/etc/machine-id:ro + - alloy_data:/var/lib/alloy/data + expose: + - "12345" + networks: + - shared + mem_limit: 512m + logging: *default-logging + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:12345/-/ready || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + # ── node-exporter: CPU, память, диск, сеть, IO хоста ───────────────────────── + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: gendesign-node-exporter + restart: unless-stopped + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/host/root" + # Без исключения оверлеев метрика файловой системы засоряется десятками + # слоёв docker, и «свободное место на диске» перестаёт читаться глазами. + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/)" + - "--collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$$" + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro,rslave + expose: + - "9100" + networks: + - shared + mem_limit: 128m + logging: *default-logging + + # ── cAdvisor: память и CPU по контейнерам ──────────────────────────────────── + # Читает докер-сокет, поэтому видит ВСЕ контейнеры хоста независимо от сетей — + # отдельно подключать его к gendesign_default не нужно. + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.52.1 + container_name: gendesign-cadvisor + restart: unless-stopped + privileged: true + devices: + - /dev/kmsg:/dev/kmsg + command: + # Урезаем набор метрик: полный cAdvisor выдаёт тысячи рядов на контейнер и + # раздувает TSDB на порядок ради данных, которые никто не смотрит. + - "--docker_only=true" + - "--housekeeping_interval=30s" + - "--disable_metrics=percpu,sched,tcp,udp,advtcp,process,hugetlb,referenced_memory,cpu_topology,resctrl" + - "--store_container_labels=false" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + expose: + - "8080" + networks: + - shared + mem_limit: 384m + logging: *default-logging + + # ── postgres-exporter: Птица (только на Poincare) ──────────────────────────── + # WAL/сутки, n_tup_upd против n_tup_hot_upd, рост TOAST, коннекты, горизонт + # vacuum — то, из-за отсутствия чего раздутие копилось 91 день незамеченным. + postgres-exporter-gendesign: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-gendesign + restart: unless-stopped + profiles: ["apps"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:?nyzhen v backend/.env.runtime} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + - product + mem_limit: 128m + logging: *default-logging + + # ── postgres-exporter: МЕРА (только на Poincare) ───────────────────────────── + postgres-exporter-tradein: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-tradein + restart: unless-stopped + profiles: ["apps"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:?nyzhen v backend/.env.runtime} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + mem_limit: 128m + logging: *default-logging + + # ── postgres-exporter: инфраструктурная БД (только на Beget) ───────────────── + # forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip + # ничем не ограничен — политики ретенции у GlitchTip нет вообще. + postgres-exporter-infra: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-infra + restart: unless-stopped + profiles: ["infra"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:?nyzhen v backend/.env.runtime} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + - infra + mem_limit: 128m + logging: *default-logging + +volumes: + alloy_data: + +networks: + shared: + external: true + name: gendesign_shared + # Сеть продуктового стека Птицы — в ней gendesign-postgres-1 на Poincare. + product: + external: true + name: gendesign_default + # На Beget это та же по имени сеть, но с инфраструктурными контейнерами. + # Разные алиасы в файле нужны, чтобы профили не тянули лишнюю сеть на чужом хосте. + infra: + external: true + name: gendesign_default diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml new file mode 100644 index 00000000..4b70b18d --- /dev/null +++ b/docker-compose.metrics.yml @@ -0,0 +1,202 @@ +# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (Beget). +# +# Деплоится ОТДЕЛЬНО от продуктового стека, своим project-name: +# docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d +# +# ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ПРОДОМ. После переезда 25.08 продукт живёт на Selectel +# Poincare, инфраструктура (Forgejo, GlitchTip, CouchDB) осталась на Beget. Наблюдатель +# сознательно поставлен у ДРУГОГО провайдера, чем наблюдаемое: если ляжет Poincare, +# мониторинг должен об этом сказать, а не лечь вместе с ним. +# +# ТРАНСПОРТ — PUSH, А НЕ PULL. Prometheus не ходит на Poincare за метриками: там агент +# Alloy сам шлёт remote_write и loki.write исходящим HTTPS через metrics.gendsgn.ru. +# Поэтому на Poincare не открывается НИ ОДНОГО входящего порта — ufw там остаётся +# 22/80/443. Приёмник включён флагом --web.enable-remote-write-receiver ниже. +# +# СЕТИ. Обе внешние, обе уже существуют на Beget: +# gendesign_shared — через неё Caddy ходит на grafana:3000 (и couchdb, garmin-mcp) +# gendesign_default — в ней infra-postgres, glitchtip, forgejo: их скрейпит агент +# Тот же приём, что у Caddy — он подключён к обеим. +# +# ПОРТЫ НАРУЖУ НЕ ПУБЛИКУЮТСЯ. Только expose. Наружу выходит один хост +# metrics.gendsgn.ru через Caddy под basic_auth (caddy/sites/infra.caddy). +# 3000/9090/3100/9093 на 0.0.0.0 не биндятся сознательно: на Beget уже был случай, +# когда redis уехал в интернет из-за лишней записи ports в базовом compose-файле. + +x-logging: &default-logging + driver: journald + +services: + # ── Prometheus: хранилище временных рядов + движок правил ──────────────────── + prometheus: + image: prom/prometheus:v3.1.0 + container_name: gendesign-prometheus + restart: unless-stopped + user: "65534:65534" # nobody — том создаётся снаружи, см. deploy-metrics.yml + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + # Ретенция задана ЯВНО и по обоим измерениям. Без --storage.tsdb.retention.size + # диск съедается молча: по умолчанию ограничение только по времени, а сколько + # это в байтах — зависит от числа рядов, которое растёт само. + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.retention.size=8GB" + # Приёмник push-метрик от агентов. Без флага remote_write отвечает 404, + # и агент на Poincare будет молча копить в WAL, а графики останутся пустыми. + - "--web.enable-remote-write-receiver" + # Нужен для перезагрузки правил без рестарта (deploy-metrics.yml дёргает). + - "--web.enable-lifecycle" + - "--web.external-url=https://metrics.gendsgn.ru/prometheus" + - "--web.route-prefix=/" + volumes: + - ./ops/metrics/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./ops/metrics/prometheus/rules:/etc/prometheus/rules:ro + - prometheus_data:/prometheus + expose: + - "9090" + networks: + - shared + - infra + mem_limit: 2g + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + # ── Loki: логи. Monolithic-режим — рекомендован до ~20 ГБ/сутки ────────────── + # У нас ~39 МБ/сутки (замер 24.08), запас в 500 раз. Микросервисный режим здесь + # был бы чистой сложностью без выигрыша. + loki: + image: grafana/loki:3.3.2 + container_name: gendesign-loki + restart: unless-stopped + user: "10001:10001" + command: ["-config.file=/etc/loki/loki-config.yml"] + volumes: + - ./ops/metrics/loki/loki-config.yml:/etc/loki/loki-config.yml:ro + - loki_data:/loki + expose: + - "3100" + networks: + - shared + mem_limit: 1g + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + # ── Alertmanager: маршрутизация и дедупликация алертов ─────────────────────── + # Шлёт в Telegram НАПРЯМУЮ с Beget, а не через бэкенд МЕРЫ. Это осознанно: + # доставка алерта не должна зависеть от хоста, про который алерт. Если Poincare + # лёг, сообщение об этом обязано уйти без его участия. + alertmanager: + image: prom/alertmanager:v0.28.0 + container_name: gendesign-alertmanager + restart: unless-stopped + user: "65534:65534" + # За профилем: канал доставки алертов — открытый вопрос #3078 (тот же чат, + # что у вебхука GlitchTip, или отдельный; порог ночной побудки). Пока он не + # решён, стек метрик поднимается и работает без Alertmanager, а не ждёт его. + # Включается добавлением `alerts` в COMPOSE_PROFILES — см. часть 5. + profiles: ["alerts"] + command: + - "--config.file=/etc/alertmanager/alertmanager.yml" + - "--storage.path=/alertmanager" + - "--web.external-url=https://metrics.gendsgn.ru/alertmanager" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Токен и чат берутся из окружения — в репозиторий не попадают. + # Отсутствие проверяется в deploy-metrics.yml ДО подъёма: пустой токен + # даёт Alertmanager, который стартует зелёным и молча ничего не шлёт. + METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} + METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} + volumes: + - ./ops/metrics/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_data:/alertmanager + expose: + - "9093" + networks: + - shared + mem_limit: 256m + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"] + interval: 30s + timeout: 10s + retries: 5 + + # ── Grafana: витрина ───────────────────────────────────────────────────────── + grafana: + image: grafana/grafana:11.5.1 + container_name: gendesign-grafana + restart: unless-stopped + user: "472:472" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Доступ закрыт Caddy basic_auth снаружи. Внутренний вход Grafana оставлен + # включённым намеренно: анонимный Viewer + отключённый логин лишили бы + # возможности что-то настроить, а один общий пароль в Caddy не даёт + # разделения ролей внутри. + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} + GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/ + GF_SERVER_SERVE_FROM_SUB_PATH: "false" + # Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не + # хочется, чтобы наблюдатель сам ходил в интернет без нужды. + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false" + GF_NEWS_NEWS_FEED_ENABLED: "false" + GF_USERS_ALLOW_SIGN_UP: "false" + # Датасорс к БД GlitchTip: пароль read-only роли из окружения. + GLITCHTIP_RO_PASSWORD: ${GLITCHTIP_RO_PASSWORD:-} + TRADEIN_RO_PASSWORD: ${TRADEIN_RO_PASSWORD:-} + GENDESIGN_RO_PASSWORD: ${GENDESIGN_RO_PASSWORD:-} + volumes: + - ./ops/metrics/grafana/provisioning:/etc/grafana/provisioning:ro + - ./ops/metrics/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + expose: + - "3000" + networks: + - shared + - infra + mem_limit: 512m + logging: *default-logging + depends_on: + - prometheus + - loki + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + prometheus_data: + loki_data: + grafana_data: + alertmanager_data: + +networks: + shared: + external: true + name: gendesign_shared + infra: + external: true + name: gendesign_default diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..590c426c --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,164 @@ +# Наблюдаемость: метрики, логи, алерты + +Задача #3078. Стек живёт по адресу `https://metrics.gendsgn.ru/`. + +## Зачем это заведено + +Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни `/metrics` +в бэкендах. Единственным каналом наблюдения оставался journald, а единственным +сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был +невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики +нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв +`house_imv_backfill` (#2698), так 8 суток писал ноль записей `newbuilding_enrich` +(#2767), так 91 день копилось раздутие `listings` (#2992). + +Grafana **не заменяет** GlitchTip. Ошибки остаются там: Grafana OSS не принимает +Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это +требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных — +числовые ряды и алерты по трендам вместо алертов по событиям. + +## Топология + +``` + Poincare (188.246.224.93) — продукт Beget (46.173.16.127) — инфраструктура + ┌──────────────────────────────┐ ┌────────────────────────────────────┐ + │ node-exporter │ │ Prometheus ← приёмник remote_write │ + │ cAdvisor │ HTTPS │ Loki ← приёмник логов │ + │ postgres-exporter × 2 │ ──────────► │ Grafana ← витрина │ + │ Alloy ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts) │ + └──────────────────────────────┘ │ node-exporter, cAdvisor, Alloy │ + │ postgres-exporter (infra) │ + └────────────────────────────────────┘ +``` + +**Наблюдатель стоит у другого провайдера, чем наблюдаемое.** Если ляжет Poincare, +мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним. + +**Транспорт — push.** Prometheus не ходит на Poincare: там агент сам шлёт +исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего +порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и +досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть +именно в аварии, ради которой мониторинг и заводится. + +## Что где лежит + +| Файл | Назначение | +|---|---| +| `docker-compose.metrics.yml` | серверная сторона, только Beget | +| `docker-compose.metrics-agent.yml` | агенты, оба хоста; профили `apps` / `infra` | +| `ops/metrics/prometheus/` | конфиг и правила алертов | +| `ops/metrics/loki/` | конфиг Loki | +| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети | +| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth | +| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД | +| `ops/metrics/grafana/` | источники данных и дашборды | +| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` | +| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток | + +## Первый запуск + +```bash +# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши. +ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh' + +# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и +# caddy/metrics-ingest.caddy.snippet, закоммитить. + +# 3. Деплой. +# Forgejo → Actions → Deploy Metrics → Run workflow +``` + +Пароль витрины смотреть на хосте, в переписку не копировать: + +```bash +ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime" +``` + +## Два контура доступа, две учётки + +`metrics.gendsgn.ru/ingest/*` — машинный, для агента. Пароль этого контура по +построению лежит в открытом виде на продуктовом хосте: агенту нужно им +авторизоваться. Значит, компрометация Poincare раскрывает его автоматически. +Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к +Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью. + +Всё остальное — витрина Grafana под отдельным паролем. Это **второй слой**: у +Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры +и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи. +Если два запроса пароля подряд неудобны — убрать одну строку `import +caddy/metrics-ui.caddy.snippet` из `infra.caddy`; вход Grafana останется. + +## GlitchTip читается прямым SQL, а не плагином + +Плагин `grafana/sentry-datasource` GlitchTip официально документирует, но на +нашей 6.1.6 половина путей нерабочая: `stats_v2` с фильтром по проекту отдаёт +500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416), +Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datasource` слово +«glitchtip» не встречается ни разу — апстрим связку не тестирует. + +Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only +пользователь (`scripts/setup-metrics-grafana-role.sh`, прав на запись нет). +Схема проверена на живой базе 26.08: + +- `issue_events_issue` — `count`, `first_seen`, `last_seen`, `status`, `level`, + `project_id`, `title`, `culprit` +- `issue_events_issueaggregate` — `(issue_id, organization_id, date, count)`, + партиционирована по неделям с почасовыми под-партициями +- `issue_events_issueevent` — колонка времени называется **`timestamp`** + (плюс `created`); колонки `received` в этой версии нет + +Отдельной таблицы `IssueIndex` **не существует** — все агрегаты лежат на самой +`issue_events_issue`. В более ранних описаниях этой задачи она упоминалась; +это была ошибка, проверено глазами. + +**Граница:** доступ read-only. Тренды — в Grafana, а assign / resolve / ignore, +стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и +рабочее место. Это осознанно, а не недоделка. + +## Алерты + +Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что +у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик +работает и без них, но **при срабатывании правила никто не будет уведомлён** — +деплой пишет об этом предупреждением, чтобы это не стало сюрпризом. + +Включение: задать `METRICS_TELEGRAM_BOT_TOKEN` и `METRICS_TELEGRAM_CHAT_ID` в +окружении инфраструктурного хоста и перезапустить деплой. Профиль `alerts` +включится сам. + +Alertmanager шлёт в Telegram **напрямую с Beget**, а не через бэкенд МЕРЫ, хотя +рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг +Poincare, не должно идти через сервис на Poincare. + +Отдельно живёт правило `Watchdog` — оно горит всегда и раз в 12 часов +подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела. +Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый +частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного +сообщения с 30 мая, и выяснилось это случайно (#2673). + +## Известные грабли + +- **Alloy запущен от root.** Штатный пользователь `alloy` требует членства в + группах `adm` и `systemd-journal`; внутри контейнера этих групп с нужными gid + нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов + просто нет». +- **Путь к журналу задан явно** (`/var/log/journal`). Без него libsystemd + применяет `SD_JOURNAL_LOCAL_ONLY`, и хостовый журнал из контейнера не виден — + тоже без ошибки. +- **`retention_period` у Loki не работает без `retention_enabled` у компактора.** + Loki примет конфиг и будет копить вечно. +- **Ретенция Prometheus задана и по времени, и по размеру.** Только по времени — + значит, объём в байтах зависит от числа рядов, которое растёт само. +- **Caddy проверяется до перезагрузки.** На Beget тот же Caddy обслуживает + `git.`, `errors.` и `obsidian.`; ошибка в `infra.caddy` положила бы их все, + включая Forgejo, из которого идёт деплой. + +## Что ещё не сделано + +- `/metrics` в бэкендах (часть 3) — единственная часть, трогающая прод-код +- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация, + счётчик `cancelled` (часть 4) +- алерты и синтетический heartbeat (часть 5) +- `pg_stat_statements` для кластера Птицы — требует рестарта прод-БД, отдельно +- честные `started_at` / `finished_at` у прогонов (#2702) — предусловие для + графиков пропускной способности: пока start/finish врут, врут и графики diff --git a/ops/metrics/alertmanager/alertmanager.yml.tmpl b/ops/metrics/alertmanager/alertmanager.yml.tmpl new file mode 100644 index 00000000..4e807845 --- /dev/null +++ b/ops/metrics/alertmanager/alertmanager.yml.tmpl @@ -0,0 +1,74 @@ +# Шаблон конфигурации Alertmanager. Боевой файл собирается на хосте при деплое +# (`envsubst` в deploy-metrics.yml) и в репозиторий не попадает — токен бота и +# идентификатор чата живут в окружении хоста, а не в git. +# +# ПОЧЕМУ TELEGRAM НАПРЯМУЮ, А НЕ ЧЕРЕЗ БЭКЕНД МЕРЫ. У МЕРЫ уже есть рабочий путь +# доставки (вебхук GlitchTip → бот), и соблазн переиспользовать его велик. Но тогда +# сообщение о том, что лёг Poincare, шло бы через сервис НА Poincare. Алерт обязан +# уметь уйти без участия хоста, про который он написан. +# +# Telegram с Beget работает — проверено серией замеров 25.08: TLS-рукопожатие +# 18 из 20, getMe 4 из 4. Ломается только длинный long-poll (30 с), а отправка +# сообщения — короткий запрос. + +global: + resolve_timeout: 5m + +route: + receiver: telegram + # Группируем по алерту и хосту: пятнадцать контейнеров одного хоста, упавших + # разом, — это одно событие, а не пятнадцать сообщений. + group_by: ["alertname", "host"] + group_wait: 45s + group_interval: 5m + # Повтор раз в 6 часов. Чаще — приучает игнорировать, реже — можно проспать. + repeat_interval: 6h + + routes: + # Watchdog не должен смешиваться с настоящими алертами и не должен молчать: + # это «сторож сторожа», он горит всегда и подтверждает, что канал доставки жив. + - receiver: telegram-heartbeat + matchers: + - alertname = "Watchdog" + group_wait: 0s + group_interval: 12h + repeat_interval: 12h + + # Критичное — без задержки на группировку. + - receiver: telegram + matchers: + - severity = "critical" + group_wait: 10s + repeat_interval: 3h + +inhibit_rules: + # Если хост целиком недоступен, не сыпать отдельно про каждый его сервис. + - source_matchers: [alertname = "HostAgentDown"] + target_matchers: [severity =~ "warning|critical"] + equal: ["host"] + +receivers: + - name: telegram + telegram_configs: + - bot_token: "${METRICS_TELEGRAM_BOT_TOKEN}" + chat_id: ${METRICS_TELEGRAM_CHAT_ID} + api_url: "https://api.telegram.org" + parse_mode: HTML + send_resolved: true + message: | + {{ if eq .Status "firing" }}🔴{{ else }}🟢{{ end }} {{ .CommonLabels.alertname }}{{ 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: | + ⚪ Мониторинг жив — сторож отчитался, канал доставки работает. + Если это сообщение перестало приходить дважды подряд, замолчал сам мониторинг. diff --git a/ops/metrics/alloy/alloy-apps.alloy b/ops/metrics/alloy/alloy-apps.alloy new file mode 100644 index 00000000..638e3268 --- /dev/null +++ b/ops/metrics/alloy/alloy-apps.alloy @@ -0,0 +1,172 @@ +// Alloy — агент на ПРОДУКТОВОМ хосте (Poincare). Приёмник стоит у другого провайдера, +// поэтому доставка идёт исходящим HTTPS через metrics.gendsgn.ru под basic_auth. +// +// ПОЧЕМУ PUSH. Альтернатива — открыть на Poincare входящие порты под скрейп из центра. +// ufw там держит только 22/80/443, и расширять периметр ради мониторинга — плохой +// размен. Исходящий HTTPS уже разрешён, ничего открывать не нужно. +// +// ПРИ ОБРЫВЕ КАНАЛА агент копит в WAL и досылает. Pull-скрейп в этой ситуации просто +// потерял бы точки — а обрыв между площадками это ровно тот случай, ради которого +// мониторинг и заводится. + +logging { + level = "warn" + format = "logfmt" +} + +// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════ + +prometheus.remote_write "central" { + endpoint { + url = "https://metrics.gendsgn.ru/ingest/prometheus/api/v1/write" + + basic_auth { + username = sys.env("METRICS_INGEST_USER") + password = sys.env("METRICS_INGEST_PASSWORD") + } + + // Очередь на случай недоступности приёмника. Дефолты рассчитаны на облако с + // быстрым каналом; здесь между площадками 17 мс и приёмник может уехать в + // рестарт при деплое — даём запас, чтобы не терять точки на ровном месте. + queue_config { + capacity = 10000 + max_shards = 5 + min_shards = 1 + max_samples_per_send = 2000 + batch_send_deadline = "10s" + retry_on_http_429 = true + } + } + + // Досыл после обрыва. Держим сутки: суточного окна хватает и на ночной рестарт + // приёмника, и на разбор утром. + wal { + truncate_frequency = "2h" + max_keepalive_time = "24h" + } + + external_labels = { + host = sys.env("METRICS_HOST_LABEL"), + } +} + +loki.write "central" { + endpoint { + url = "https://metrics.gendsgn.ru/ingest/loki/loki/api/v1/push" + + basic_auth { + username = sys.env("METRICS_INGEST_USER") + password = sys.env("METRICS_INGEST_PASSWORD") + } + } + + external_labels = { + host = sys.env("METRICS_HOST_LABEL"), + } +} + +// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════ + +prometheus.scrape "node" { + targets = [ + { __address__ = "node-exporter:9100", job = "node" }, + ] + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "30s" +} + +prometheus.scrape "cadvisor" { + targets = [ + { __address__ = "cadvisor:8080", job = "cadvisor" }, + ] + forward_to = [prometheus.relabel.cadvisor_trim.receiver] + scrape_interval = "30s" +} + +prometheus.relabel "cadvisor_trim" { + forward_to = [prometheus.remote_write.central.receiver] + + rule { + source_labels = ["__name__"] + regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)" + action = "keep" + } + + rule { + source_labels = ["name"] + regex = "" + action = "drop" + } +} + +// ═══ МЕТРИКИ ОБЕИХ БОЕВЫХ БД ═══════════════════════════════════════════════════ +// Именно здесь живут ряды, из-за отсутствия которых раздутие `listings` копилось +// 91 день незамеченным: WAL в сутки, n_tup_upd против n_tup_hot_upd, рост TOAST. + +prometheus.scrape "postgres" { + targets = [ + { __address__ = "gendesign-pg-exporter-gendesign:9187", job = "postgres", db = "gendesign" }, + { __address__ = "gendesign-pg-exporter-tradein:9187", job = "postgres", db = "tradein" }, + ] + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "60s" +} + +// ═══ МЕТРИКИ ПРИЛОЖЕНИЙ ════════════════════════════════════════════════════════ +// Эндпоинты появляются в части 3. До этого скрейп просто отдаёт `up 0` — и это +// правильно: цель видна как недоступная, а не отсутствует молча. + +prometheus.scrape "apps" { + targets = [ + { __address__ = "gendesign-backend-1:8000", job = "app", app = "sitefinder" }, + { __address__ = "tradein-backend:8000", job = "app", app = "mera" }, + ] + metrics_path = "/metrics" + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "30s" + scrape_timeout = "20s" +} + +// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════ + +loki.source.journal "host" { + path = "/var/log/journal" + max_age = "12h" + format_as_json = false + labels = { + job = "journal", + } + relabel_rules = loki.relabel.journal.rules + forward_to = [loki.write.central.receiver] +} + +loki.relabel "journal" { + forward_to = [] + + rule { + source_labels = ["__journal__systemd_unit"] + target_label = "unit" + } + rule { + source_labels = ["__journal__hostname"] + target_label = "node" + } + rule { + source_labels = ["__journal_container_name"] + target_label = "container" + } + rule { + source_labels = ["__journal_priority_keyword"] + target_label = "level" + } +} + +// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════ + +prometheus.exporter.self "alloy" {} + +prometheus.scrape "alloy_self" { + targets = prometheus.exporter.self.alloy.targets + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "60s" +} diff --git a/ops/metrics/alloy/alloy-infra.alloy b/ops/metrics/alloy/alloy-infra.alloy new file mode 100644 index 00000000..17c00c47 --- /dev/null +++ b/ops/metrics/alloy/alloy-infra.alloy @@ -0,0 +1,136 @@ +// Alloy — агент на ИНФРАСТРУКТУРНОМ хосте (Beget). Приёмник стоит рядом, на этом же +// хосте, поэтому пишем напрямую по docker-сети: без TLS, без basic_auth, без интернета. +// +// Вариант для продуктового хоста — alloy-apps.alloy: там тот же набор источников, но +// доставка идёт исходящим HTTPS через metrics.gendsgn.ru. +// +// Метка host проставляется здесь, один раз, в external_labels — если ставить её ещё и +// на стороне Prometheus, результат зависит от honor_labels и перестаёт быть очевидным. + +logging { + level = "warn" + format = "logfmt" +} + +// ═══ КУДА ОТПРАВЛЯЕМ ═══════════════════════════════════════════════════════════ + +prometheus.remote_write "central" { + endpoint { + url = "http://prometheus:9090/api/v1/write" + } + + external_labels = { + host = sys.env("METRICS_HOST_LABEL"), + } +} + +loki.write "central" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } + + external_labels = { + host = sys.env("METRICS_HOST_LABEL"), + } +} + +// ═══ МЕТРИКИ ХОСТА И КОНТЕЙНЕРОВ ═══════════════════════════════════════════════ + +prometheus.scrape "node" { + targets = [ + { __address__ = "node-exporter:9100", job = "node" }, + ] + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "30s" +} + +prometheus.scrape "cadvisor" { + targets = [ + { __address__ = "cadvisor:8080", job = "cadvisor" }, + ] + forward_to = [prometheus.relabel.cadvisor_trim.receiver] + scrape_interval = "30s" +} + +// cAdvisor отдаёт ряды на каждый слой файловой системы и каждое устройство. +// Без прополки TSDB растёт на порядок ради данных, которые никто не открывает. +prometheus.relabel "cadvisor_trim" { + forward_to = [prometheus.remote_write.central.receiver] + + rule { + source_labels = ["__name__"] + regex = "container_(memory_(usage_bytes|working_set_bytes|rss)|cpu_(usage_seconds_total|cfs_throttled_seconds_total)|network_(receive|transmit)_bytes_total|fs_(usage|limit)_bytes|last_seen|spec_memory_limit_bytes|start_time_seconds|processes)" + action = "keep" + } + + // Служебные контейнеры docker без имени только зашумляют графики. + rule { + source_labels = ["name"] + regex = "" + action = "drop" + } +} + +// ═══ МЕТРИКИ ИНФРАСТРУКТУРНОЙ БД ═══════════════════════════════════════════════ +// Профиль infra в docker-compose.metrics-agent.yml поднимает ровно один экспортер — +// по кластеру с базами forgejo и glitchtip. + +prometheus.scrape "postgres_infra" { + targets = [ + { __address__ = "gendesign-pg-exporter-infra:9187", job = "postgres", db = "infra" }, + ] + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "60s" +} + +// ═══ ЛОГИ ══════════════════════════════════════════════════════════════════════ + +loki.source.journal "host" { + // Путь задан ЯВНО. Без него libsystemd включает SD_JOURNAL_LOCAL_ONLY, и из + // контейнера хостовый журнал не виден — при этом ошибки нет, просто пустой поток. + path = "/var/log/journal" + // При рестарте агента не тянем всю историю заново: journald держит ~13 суток, + // а повторная заливка создала бы дубликаты и упёрлась в reject_old_samples. + max_age = "12h" + format_as_json = false + labels = { + job = "journal", + } + relabel_rules = loki.relabel.journal.rules + forward_to = [loki.write.central.receiver] +} + +loki.relabel "journal" { + forward_to = [] + + // Внутренние поля journald приходят с префиксом __ и без переименования + // отбрасываются. Оставляем ровно те, по которым потом фильтруют. + rule { + source_labels = ["__journal__systemd_unit"] + target_label = "unit" + } + rule { + source_labels = ["__journal__hostname"] + target_label = "node" + } + rule { + source_labels = ["__journal_container_name"] + target_label = "container" + } + rule { + source_labels = ["__journal_priority_keyword"] + target_label = "level" + } +} + +// ═══ САМОКОНТРОЛЬ ══════════════════════════════════════════════════════════════ +// Метрики самого агента. Нужны для алерта «агент замолчал»: без них молчание +// источника неотличимо от «всё хорошо, событий нет». + +prometheus.exporter.self "alloy" {} + +prometheus.scrape "alloy_self" { + targets = prometheus.exporter.self.alloy.targets + forward_to = [prometheus.remote_write.central.receiver] + scrape_interval = "60s" +} diff --git a/ops/metrics/grafana/dashboards/host.json b/ops/metrics/grafana/dashboards/host.json new file mode 100644 index 00000000..1a2d7c66 --- /dev/null +++ b/ops/metrics/grafana/dashboards/host.json @@ -0,0 +1,332 @@ +{ + "uid": "gendesign-host", + "title": "Хост и контейнеры", + "description": "CPU, память, диск и контейнеры обоих хостов. Метка host: infra = Beget (Forgejo, GlitchTip, мониторинг), apps = Poincare (Птица и МЕРА).", + "tags": ["gendesign", "infra"], + "timezone": "browser", + "editable": false, + "schemaVersion": 39, + "refresh": "1m", + "time": { "from": "now-6h", "to": "now" }, + "templating": { + "list": [ + { + "name": "host", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(node_uname_info, host)", + "refresh": 1, + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "type": "row", + "title": "Сводка", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } + }, + { + "type": "stat", + "title": "Свободно на корневом разделе", + "description": "Место, оставшееся на /. При заполнении диска Postgres останавливается — это не «медленно», а полная остановка записи.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "node_filesystem_avail_bytes{mountpoint=\"/\", host=~\"$host\"}", + "legendFormat": "{{host}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 10737418240 }, + { "color": "green", "value": 32212254720 } + ] + } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" } + }, + { + "type": "stat", + "title": "Доступно памяти", + "description": "MemAvailable — то, что ядро реально может отдать под новую нагрузку. Отличается от «free»: кэш страниц отдаётся по требованию и в нехватку не считается.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "node_memory_MemAvailable_bytes{host=~\"$host\"}", + "legendFormat": "{{host}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "orange", "value": 1073741824 }, + { "color": "green", "value": 3221225472 } + ] + } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" } + }, + { + "type": "stat", + "title": "Загрузка (load1 на ядро)", + "description": "Нормировано на число ядер: 1.0 означает «занято ровно столько, сколько есть». Без нормировки число несравнимо между хостами с разным CPU.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "node_load1{host=~\"$host\"} / count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})", + "legendFormat": "{{host}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.8 }, + { "color": "red", "value": 1.5 } + ] + } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "auto" } + }, + { + "type": "stat", + "title": "Аптайм", + "description": "Время с последней загрузки. Внезапное обнуление — перезагрузка, о которой стоит знать.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 }, + "targets": [ + { + "refId": "A", + "expr": "time() - node_boot_time_seconds{host=~\"$host\"}", + "legendFormat": "{{host}}" + } + ], + "fieldConfig": { + "defaults": { "unit": "s", "decimals": 0 }, + "overrides": [] + }, + "options": { "colorMode": "none", "graphMode": "none", "textMode": "auto" } + }, + { + "type": "row", + "title": "Ресурсы во времени", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } + }, + { + "type": "timeseries", + "title": "CPU по режимам", + "description": "iowait отдельно от user/system: высокий iowait означает, что процессор ждёт диск, и добавлять ядер бесполезно.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 7 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (host, mode) (rate(node_cpu_seconds_total{mode!=\"idle\", host=~\"$host\"}[5m])) / on (host) group_left count by (host) (node_cpu_seconds_total{mode=\"idle\", host=~\"$host\"})", + "legendFormat": "{{host}} · {{mode}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "custom": { "fillOpacity": 20, "stacking": { "mode": "normal" }, "showPoints": "never" } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Память", + "description": "Занято = всего минус доступно. Своп показан отдельно: его рост означает, что рабочий набор перестал помещаться.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 7 }, + "targets": [ + { + "refId": "A", + "expr": "node_memory_MemTotal_bytes{host=~\"$host\"} - node_memory_MemAvailable_bytes{host=~\"$host\"}", + "legendFormat": "{{host}} · занято" + }, + { + "refId": "B", + "expr": "node_memory_SwapTotal_bytes{host=~\"$host\"} - node_memory_SwapFree_bytes{host=~\"$host\"}", + "legendFormat": "{{host}} · своп" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "min": 0, + "custom": { "fillOpacity": 15, "showPoints": "never" } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Занятость дисков", + "description": "По точкам монтирования. Порог алерта — 85 %, критический — 93 %.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 }, + "targets": [ + { + "refId": "A", + "expr": "1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay\", host=~\"$host\"}", + "legendFormat": "{{host}} · {{mountpoint}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "custom": { "fillOpacity": 10, "showPoints": "never", "thresholdsStyle": { "mode": "dashed" } }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.85 }, + { "color": "red", "value": 0.93 } + ] + } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Дисковый ввод-вывод", + "description": "Байты чтения и записи по устройствам. Всплеск записи по ночам — это бэкапы (00:30 и 01:30 UTC), так и должно быть.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 }, + "targets": [ + { + "refId": "A", + "expr": "sum by (host) (rate(node_disk_read_bytes_total{host=~\"$host\"}[5m]))", + "legendFormat": "{{host}} · чтение" + }, + { + "refId": "B", + "expr": "sum by (host) (rate(node_disk_written_bytes_total{host=~\"$host\"}[5m]))", + "legendFormat": "{{host}} · запись" + } + ], + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { "fillOpacity": 10, "showPoints": "never" } + }, + "overrides": [] + } + }, + { + "type": "row", + "title": "Контейнеры", + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 } + }, + { + "type": "timeseries", + "title": "Память по контейнерам (топ-15)", + "description": "working set, а не usage: usage включает переиспользуемый кэш и завышает картину. Именно по этой метрике сравнивается фактическое потребление с mem_limit.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 10, "w": 14, "x": 0, "y": 24 }, + "targets": [ + { + "refId": "A", + "expr": "topk(15, container_memory_working_set_bytes{name!=\"\", host=~\"$host\"})", + "legendFormat": "{{host}} · {{name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { "fillOpacity": 10, "showPoints": "never" } + }, + "overrides": [] + } + }, + { + "type": "table", + "title": "Приближение к mem_limit", + "description": "Доля от заданного лимита. Пустая строка означает, что лимит не выставлен вовсе — тогда потребление сверху не ограничено ничем, кроме памяти хоста.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 10, "w": 10, "x": 14, "y": 24 }, + "targets": [ + { + "refId": "A", + "instant": true, + "format": "table", + "expr": "sort_desc(container_memory_working_set_bytes{name!=\"\", host=~\"$host\"} / (container_spec_memory_limit_bytes{name!=\"\", host=~\"$host\"} > 0))", + "legendFormat": "{{name}}" + } + ], + "transformations": [ + { "id": "organize", "options": { "excludeByName": { "Time": true, "job": true, "instance": true, "id": true, "image": true } } } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "custom": { "align": "auto", "cellOptions": { "type": "gauge" } }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 0.8 }, + { "color": "red", "value": 0.9 } + ] + } + }, + "overrides": [] + } + }, + { + "type": "timeseries", + "title": "Перезапуски контейнеров", + "description": "Число стартов за полчаса. Больше трёх — цикл перезапусков: снаружи такой контейнер выглядит поднятым, а деплой при этом зелёный.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 34 }, + "targets": [ + { + "refId": "A", + "expr": "changes(container_start_time_seconds{name!=\"\", host=~\"$host\"}[30m]) > 0", + "legendFormat": "{{host}} · {{name}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "drawStyle": "bars", "fillOpacity": 60, "showPoints": "never" } + }, + "overrides": [] + } + } + ] +} diff --git a/ops/metrics/grafana/dashboards/postgres.json b/ops/metrics/grafana/dashboards/postgres.json new file mode 100644 index 00000000..7f3b343b --- /dev/null +++ b/ops/metrics/grafana/dashboards/postgres.json @@ -0,0 +1,300 @@ +{ + "uid": "gendesign-postgres", + "title": "Базы данных", + "description": "Три кластера под одним взглядом: Птица и МЕРА на Poincare, инфраструктурная база (Forgejo, GlitchTip) на Beget. Панели подобраны по разборам постфактум — каждая отвечает на вопрос, который однажды уже задавали задним числом.", + "tags": ["gendesign", "postgres"], + "timezone": "browser", + "editable": false, + "schemaVersion": 39, + "refresh": "1m", + "time": { "from": "now-24h", "to": "now" }, + "templating": { + "list": [ + { + "name": "db", + "label": "Кластер", + "type": "query", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "query": "label_values(pg_up, db)", + "refresh": 1, + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } }, + + { + "type": "stat", + "title": "Экспортер отвечает", + "description": "Ноль означает, что метрик по этому кластеру нет вовсе. Пустой график и упавшая база выглядят одинаково — эта панель их различает.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "pg_up{db=~\"$db\"}", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "нет связи", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } } + ], + "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "Занято соединений", + "description": "Доля от max_connections. Упереться в потолок означает, что новые запросы получают отказ на подключении — со стороны приложения это выглядит как недоступность базы, а не как нагрузка.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "sum by (db) (pg_stat_database_numbackends{db=~\"$db\"}) / on (db) group_left max by (db) (pg_settings_max_connections{db=~\"$db\"})", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.7 }, { "color": "red", "value": 0.9 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "Самая старая транзакция", + "description": "Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция отравляет весь кластер, а снаружи это выглядит просто как «база пухнет».", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "max by (db) (pg_activity_horizon_oldest_xact_age_s{db=~\"$db\"})", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 900 }, { "color": "red", "value": 3600 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" } + }, + + { + "type": "stat", + "title": "WAL за сутки", + "description": "Замер 20.08 по Птице: 7,02 ГБ журнала в сутки при примерно четырёх пользовательских расчётах за тот же срок. Диспропорция такого масштаба и есть симптом — но увидеть её можно только имея ряд.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 }, + "targets": [ + { "refId": "A", "expr": "rate(pg_wal_bytes_wal_bytes_total{db=~\"$db\"}[1h]) * 86400", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 5368709120 }, { "color": "red", "value": 21474836480 } ] } + }, + "overrides": [] + }, + "options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" } + }, + + { "type": "row", "title": "Раздутие: почему база растёт быстрее данных", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } }, + + { + "type": "timeseries", + "title": "Доля апдейтов мимо HOT (топ-10)", + "description": "HOT-апдейт переписывает только строку. Всё остальное переписывает её ещё и во всех индексах, а длинные поля заново тостит. У listings доля HOT была 0,43 % при 198 апдейтах на строку — отсюда 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989). Копилось 91 день, потому что смотреть было не на что. Устойчивое значение выше 0,8 у часто обновляемой таблицы — повод править fillfactor или сам паттерн записи.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 14, "x": 0, "y": 7 }, + "targets": [ + { "refId": "A", "expr": "topk(10, 1 - (rate(pg_table_write_amplification_tup_hot_upd{db=~\"$db\"}[1h]) / (rate(pg_table_write_amplification_tup_upd{db=~\"$db\"}[1h]) > 0.01)))", "legendFormat": "{{db}} · {{table}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.8 } ] } + }, + "overrides": [] + } + }, + + { + "type": "table", + "title": "Мёртвые строки и давность уборки", + "description": "Мёртвых строк много само по себе не страшно — страшно, когда autovacuum до них давно не доходил. Значение -1 в возрасте означает, что уборки не было ни разу с момента запуска: такая таблица растёт без ограничения.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 10, "x": 14, "y": 7 }, + "targets": [ + { "refId": "A", "instant": true, "format": "table", "expr": "topk(15, pg_table_write_amplification_dead_tup{db=~\"$db\"} > 10000)" }, + { "refId": "B", "instant": true, "format": "table", "expr": "pg_table_write_amplification_last_autovacuum_age_s{db=~\"$db\"}" } + ], + "transformations": [ + { "id": "joinByField", "options": { "byField": "table", "mode": "outer" } }, + { + "id": "organize", + "options": { + "excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true, "host": true, "host 1": true, "host 2": true, "schema": true, "schema 1": true, "schema 2": true, "db 2": true, "cluster": true, "cluster 1": true, "cluster 2": true }, + "renameByName": { "db 1": "Кластер", "table": "Таблица", "Value #A": "Мёртвых строк", "Value #B": "Уборка была, сек назад" } + } + }, + { "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "Мёртвых строк", "desc": true } ] } } + ], + "fieldConfig": { + "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Уборка была, сек назад" }, + "properties": [ + { "id": "unit", "value": "s" }, + { "id": "custom.cellOptions", "value": { "type": "color-text" } }, + { "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 86400 }, { "color": "red", "value": 604800 } ] } } + ] + }, + { + "matcher": { "id": "byName", "options": "Мёртвых строк" }, + "properties": [ { "id": "unit", "value": "short" } ] + } + ] + } + }, + + { + "type": "timeseries", + "title": "Куда уходит диск: TOAST отдельно от строк", + "description": "Общий размер таблицы этого не показывает, а именно разделение объясняло, почему listings весила 19 ГБ. TOAST — вынесенные длинные значения: он растёт при каждом не-HOT апдейте текстового поля, даже если сам текст не изменился.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 14, "x": 0, "y": 16 }, + "targets": [ + { "refId": "A", "expr": "topk(5, pg_table_size_detail_toast_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · TOAST" }, + { "refId": "B", "expr": "topk(5, pg_table_size_detail_index_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · индексы" }, + { "refId": "C", "expr": "topk(5, pg_table_size_detail_heap_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · строки" } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Размер баз", + "description": "У GlitchTip нет политики ретенции вообще — проверено, grep по retention даёт ноль попаданий. База растёт без ограничения, и нужен ряд, чтобы поймать это до того, как кончится диск.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 9, "w": 10, "x": 14, "y": 16 }, + "targets": [ + { "refId": "A", "expr": "pg_database_size_bytes_detail_bytes{db=~\"$db\"}", "legendFormat": "{{db}} · {{database}}" } + ], + "fieldConfig": { + "defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [] + } + }, + + { "type": "row", "title": "Нагрузка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } }, + + { + "type": "timeseries", + "title": "Транзакции в секунду", + "description": "Откаты отдельно от фиксаций. Ровный фон откатов — это не «иногда бывает», а поток ошибок, который в логах может не отражаться вовсе.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 26 }, + "targets": [ + { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_xact_commit{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · зафиксировано" }, + { "refId": "B", "expr": "sum by (db) (rate(pg_stat_database_xact_rollback{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · откачено" } + ], + "fieldConfig": { + "defaults": { "unit": "ops", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*откачено.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Попадание в кэш", + "description": "Доля чтений, обслуженных из shared_buffers. Провал означает, что рабочий набор перестал помещаться в память — обычно это следствие раздутия, а не роста самих данных.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 26 }, + "targets": [ + { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m])) / clamp_min(sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m]) + rate(pg_stat_database_blks_read{db=~\"$db\"}[5m])), 1)", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "orange", "value": 0.9 }, { "color": "green", "value": 0.98 } ] } + }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Временные файлы", + "description": "Постгрес пишет на диск, когда сортировка или хеш не помещаются в work_mem. Всплеск здесь объясняет запросы, которые «вдруг стали медленными» без единого изменения в коде.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 26 }, + "targets": [ + { "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_temp_bytes{db=~\"$db\"}[5m]))", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { "unit": "Bps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1 } }, + "overrides": [] + } + }, + + { + "type": "timeseries", + "title": "Состояние соединений", + "description": "idle in transaction — открытая транзакция, которая ничего не делает: именно она держит горизонт и мешает уборке. Ожидание блокировки — запросы, которые стоят друг за другом; растущая линия здесь читается снаружи как «сайт подвис».", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 }, + "targets": [ + { "refId": "A", "expr": "pg_activity_horizon_client_backends{db=~\"$db\"}", "legendFormat": "{{db}} · клиентских" }, + { "refId": "B", "expr": "pg_activity_horizon_idle_in_transaction{db=~\"$db\"}", "legendFormat": "{{db}} · idle in transaction" }, + { "refId": "C", "expr": "pg_activity_horizon_waiting_on_lock{db=~\"$db\"}", "legendFormat": "{{db}} · ждут блокировку" } + ], + "fieldConfig": { + "defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } }, + "overrides": [ + { "matcher": { "id": "byRegexp", "options": ".*idle in transaction.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }, + { "matcher": { "id": "byRegexp", "options": ".*ждут блокировку.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] } + ] + } + }, + + { + "type": "timeseries", + "title": "Взаимоблокировки", + "description": "Взаимоблокировка снимается сервером принудительно: одна из транзакций получает ошибку. Для пользователя это неудавшееся действие, для логов приложения — исключение без внятной причины.", + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 }, + "targets": [ + { "refId": "A", "expr": "sum by (db) (increase(pg_stat_database_deadlocks{db=~\"$db\"}[1h]))", "legendFormat": "{{db}}" } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "min": 0, + "custom": { "fillOpacity": 40, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" }, + "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] } + }, + "overrides": [] + } + } + ] +} diff --git a/ops/metrics/grafana/provisioning/dashboards/dashboards.yml b/ops/metrics/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..bc47c477 --- /dev/null +++ b/ops/metrics/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,16 @@ +apiVersion: 1 + +providers: + - name: gendesign + orgId: 1 + folder: GenDesign + type: file + disableDeletion: false + # Правки через интерфейс не переживают пересоздание контейнера: файл на диске + # перетрёт их обратно. Это намеренно — дашборд должен лежать в git, иначе + # через полгода никто не скажет, почему панель считает именно так. + allowUiUpdates: false + updateIntervalSeconds: 30 + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/ops/metrics/grafana/provisioning/datasources/datasources.yml b/ops/metrics/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 00000000..f76904a5 --- /dev/null +++ b/ops/metrics/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,75 @@ +apiVersion: 1 + +# Источники данных задаются файлом, а не руками в интерфейсе: настройка, сделанная +# кликами, живёт только в томе и теряется при пересоздании контейнера — ровно та +# ловушка, из-за которой при переезде «healthy» ничего не значил. + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + # Совпадает со scrape_interval: иначе Grafana подбирает шаг сама и на + # длинных окнах рисует пилу там, где данные ровные. + timeInterval: 30s + httpMethod: POST + manageAlerts: false + editable: false + + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:3100 + jsonData: + maxLines: 2000 + # Связка логов с метриками по общей метке host. + derivedFields: [] + editable: false + + - name: Alertmanager + uid: alertmanager + type: alertmanager + access: proxy + url: http://alertmanager:9093 + jsonData: + implementation: prometheus + handleGrafanaManagedAlerts: false + editable: false + + # ── GlitchTip через ПРЯМОЙ SQL, а не через Sentry-плагин ──────────────────── + # Плагин grafana/sentry-datasource официально документирован GlitchTip'ом, но + # на нашей 6.1.6 половина его путей нерабочая: stats_v2 с фильтром по проекту + # отдаёт 500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 + # (#416), Metrics/Spans/Tags вообще Sentry-only. В самом grafana/sentry-datasource + # слова «glitchtip» не встречается ни разу — апстрим эту связку не тестирует. + # + # Прямой SQL от этого свободен и правок в GlitchTip не требует вовсе, нужен + # только read-only пользователь. Схема проверена на живой базе 26.08: + # issue_events_issue несёт count / first_seen / last_seen / status / level, + # а issue_events_issueaggregate (issue_id, organization_id, date, count) + # партиционирована по неделям — ряды и топ-N без сканов сырья. + # + # ГРАНИЦА: доступ read-only. Тренды — здесь, а assign/resolve, стектрейсы и + # breadcrumbs — только в самом GlitchTip. Окна остаётся два: витрина и рабочее + # место, и это осознанно, а не недоделка. + - name: GlitchTip DB + uid: glitchtip-db + type: postgres + access: proxy + url: gendesign-infra-postgres:5432 + database: glitchtip + user: grafana_ro + secureJsonData: + password: ${GLITCHTIP_RO_PASSWORD} + jsonData: + sslmode: disable + postgresVersion: 1600 + timescaledb: false + maxOpenConns: 4 + maxIdleConns: 2 + connMaxLifetime: 14400 + editable: false diff --git a/ops/metrics/loki/loki-config.yml b/ops/metrics/loki/loki-config.yml new file mode 100644 index 00000000..7919be2f --- /dev/null +++ b/ops/metrics/loki/loki-config.yml @@ -0,0 +1,78 @@ +# Loki — monolithic (all-in-one). Рекомендованный режим до ~20 ГБ/сутки. +# Наш объём — ~39 МБ/сутки (замер 24.08), то есть запас в 500 раз. Микросервисная +# раскладка здесь была бы сложностью без единого выигрыша. + +auth_enabled: false # один арендатор; разграничение снаружи, на Caddy basic_auth + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: warn + # Логи Alloy шлёт пачками; дефолтные 4 МБ на gRPC-сообщение при всплеске + # (например, рестарт с досылом из WAL) дают 'grpc: received message larger than max'. + grpc_server_max_recv_msg_size: 16777216 + grpc_server_max_send_msg_size: 16777216 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2026-08-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + # Приём «из прошлого». Агент после обрыва досылает накопленное из WAL — + # с дефолтным окном (1 неделя приёма, но reject_old_samples_max_age) часть + # догоняемых строк молча отбрасывается с 'entry too far behind'. + reject_old_samples: true + reject_old_samples_max_age: 168h + # Потолок на арендатора. 39 МБ/сутки = ~0.5 МБ/с в пике; 8 МБ/с — щедрый запас, + # но не «безлимит», чтобы взбесившийся источник не съел диск за ночь. + ingestion_rate_mb: 8 + ingestion_burst_size_mb: 16 + max_streams_per_user: 5000 + max_label_names_per_series: 20 + # Ретенция. Journald на хостах держит ~13 дней при потолке 500 МБ; в Loki + # смысл хранить дольше — ради разбора инцидентов постфактум. + retention_period: 720h + volume_enabled: true + +compactor: + working_directory: /loki/compactor + delete_request_store: filesystem + # Без retention_enabled параметр retention_period выше НЕ работает: Loki + # примет его в конфиг и будет копить вечно. Классическая тихая настройка. + retention_enabled: true + retention_delete_delay: 2h + compaction_interval: 10m + +ruler: + storage: + type: local + local: + directory: /loki/rules + +analytics: + reporting_enabled: false diff --git a/ops/metrics/postgres/queries.yml b/ops/metrics/postgres/queries.yml new file mode 100644 index 00000000..8e76e960 --- /dev/null +++ b/ops/metrics/postgres/queries.yml @@ -0,0 +1,116 @@ +# Дополнительные запросы для postgres_exporter (PG_EXPORTER_EXTEND_QUERY_PATH). +# +# Здесь только то, отсутствие чего уже стоило времени. Каждый блок — ответ на +# конкретный разбор постфактум, а не «полезно иметь». +# +# cache_seconds стоит у всех: экспортер скрейпится раз в 60 с, а часть запросов +# трогает pg_class по всей базе. Кэш держит нагрузку на уровне шума. + +# ── Раздутие: обновления, идущие МИМО HOT ──────────────────────────────────── +# Разбор #2992/#2989: у `listings` 198 апдейтов на строку при доле HOT 0,43 %. +# Каждый не-HOT апдейт переписывает строку во все индексы и заново тостит +# описание — отсюда TOAST 15 ГБ при ~230 МБ живого содержимого. Копилось 91 день, +# потому что смотреть было не на что. +pg_table_write_amplification: + query: | + SELECT + schemaname AS schema, + relname AS table, + n_tup_ins AS tup_ins, + n_tup_upd AS tup_upd, + n_tup_del AS tup_del, + n_tup_hot_upd AS tup_hot_upd, + n_live_tup AS live_tup, + n_dead_tup AS dead_tup, + COALESCE(EXTRACT(EPOCH FROM (now() - last_autovacuum)), -1) AS last_autovacuum_age_s, + COALESCE(EXTRACT(EPOCH FROM (now() - last_autoanalyze)), -1) AS last_autoanalyze_age_s + FROM pg_stat_user_tables + WHERE n_tup_upd > 0 OR n_live_tup > 10000 + cache_seconds: 60 + metrics: + - schema: { usage: "LABEL", description: "Схема" } + - table: { usage: "LABEL", description: "Таблица" } + - tup_ins: { usage: "COUNTER", description: "Вставлено строк" } + - tup_upd: { usage: "COUNTER", description: "Обновлено строк" } + - tup_del: { usage: "COUNTER", description: "Удалено строк" } + - tup_hot_upd: { usage: "COUNTER", description: "Из них HOT — не трогают индексы" } + - live_tup: { usage: "GAUGE", description: "Живых строк" } + - dead_tup: { usage: "GAUGE", description: "Мёртвых строк — работа для vacuum" } + - last_autovacuum_age_s: { usage: "GAUGE", description: "Секунд с последнего autovacuum, -1 если не было" } + - last_autoanalyze_age_s: { usage: "GAUGE", description: "Секунд с последнего autoanalyze, -1 если не было" } + +# ── Размеры: куда именно уходит диск ───────────────────────────────────────── +# Отдельно heap, индексы и TOAST. Суммарный размер таблицы этого не показывает, +# а именно разделение объясняло, почему `listings` весила 19 ГБ при 230 МБ данных. +pg_table_size_detail: + query: | + SELECT + n.nspname AS schema, + c.relname AS table, + pg_relation_size(c.oid) AS heap_bytes, + pg_indexes_size(c.oid) AS index_bytes, + COALESCE(pg_total_relation_size(c.reltoastrelid), 0) AS toast_bytes, + pg_total_relation_size(c.oid) AS total_bytes + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND pg_total_relation_size(c.oid) > 10485760 + cache_seconds: 300 + metrics: + - schema: { usage: "LABEL", description: "Схема" } + - table: { usage: "LABEL", description: "Таблица" } + - heap_bytes: { usage: "GAUGE", description: "Сами строки" } + - index_bytes: { usage: "GAUGE", description: "Индексы" } + - toast_bytes: { usage: "GAUGE", description: "TOAST — вынесенные длинные значения" } + - total_bytes: { usage: "GAUGE", description: "Итого с учётом всего" } + +# ── WAL: сколько журнала генерится ─────────────────────────────────────────── +# Замер 20.08: 7,02 ГБ/сутки при примерно четырёх пользовательских расчётах в +# сутки. Диспропорция такого масштаба и есть симптом — но заметить её можно +# только имея ряд. +pg_wal_bytes: + query: | + SELECT + CAST(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') AS BIGINT) AS wal_bytes_total, + (SELECT count(*) FROM pg_ls_waldir()) AS wal_segments + cache_seconds: 60 + metrics: + - wal_bytes_total: { usage: "COUNTER", description: "Позиция WAL от начала — рост даёт байт/сек" } + - wal_segments: { usage: "GAUGE", description: "Сегментов в pg_wal сейчас" } + +# ── Горизонт vacuum и долгие транзакции ────────────────────────────────────── +# Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт, из-за +# чего vacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция +# отравляет весь кластер, и снаружи это выглядит просто как «база пухнет». +pg_activity_horizon: + query: | + SELECT + COALESCE(MAX(EXTRACT(EPOCH FROM (now() - xact_start))), 0) AS oldest_xact_age_s, + COALESCE(MAX(EXTRACT(EPOCH FROM (now() - query_start))), 0) AS oldest_query_age_s, + count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction, + count(*) FILTER (WHERE wait_event_type = 'Lock') AS waiting_on_lock, + count(*) FILTER (WHERE backend_type = 'client backend') AS client_backends + FROM pg_stat_activity + WHERE backend_type = 'client backend' + cache_seconds: 30 + metrics: + - oldest_xact_age_s: { usage: "GAUGE", description: "Возраст самой старой транзакции, сек" } + - oldest_query_age_s: { usage: "GAUGE", description: "Возраст самого старого запроса, сек" } + - idle_in_transaction: { usage: "GAUGE", description: "Открыта транзакция и ничего не делает — держит горизонт" } + - waiting_on_lock: { usage: "GAUGE", description: "Ждут блокировку" } + - client_backends: { usage: "GAUGE", description: "Клиентских соединений" } + +# ── Размер баз ─────────────────────────────────────────────────────────────── +# У GlitchTip нет политики ретенции вообще (проверено: grep по retention даёт +# ноль попаданий), база растёт без ограничения. Нужен ряд, чтобы поймать это +# до того, как кончится диск. +pg_database_size_bytes_detail: + query: | + SELECT datname AS database, pg_database_size(datname) AS bytes + FROM pg_database + WHERE datistemplate = false AND datallowconn = true + cache_seconds: 300 + metrics: + - database: { usage: "LABEL", description: "База" } + - bytes: { usage: "GAUGE", description: "Размер, байт" } diff --git a/ops/metrics/prometheus/prometheus.yml b/ops/metrics/prometheus/prometheus.yml new file mode 100644 index 00000000..35c52b97 --- /dev/null +++ b/ops/metrics/prometheus/prometheus.yml @@ -0,0 +1,58 @@ +# Prometheus — серверная сторона, живёт на Beget. +# +# СКРЕЙПА ЧУЖИХ ХОСТОВ ЗДЕСЬ НЕТ. Метрики с Poincare приходят push'ем через +# remote-write receiver (--web.enable-remote-write-receiver), потому что открывать +# входящие порты на продуктовом хосте ради мониторинга — плохой размен. +# Локально скрейпится только то, что стоит на этом же хосте. +# +# Метка `host` проставляется агентом Alloy на своей стороне (external_labels), +# поэтому здесь её нет: если задать и там и тут, honor_labels-семантика сделает +# результат неочевидным. + +global: + scrape_interval: 30s + scrape_timeout: 10s + evaluation_interval: 30s + external_labels: + cluster: gendesign + +rule_files: + - /etc/prometheus/rules/*.yml + +# Пока профиль alerts выключен, этой цели не существует и Prometheus раз в +# интервал пишет в лог, что не смог её разрешить. Это шум, а не отказ: правила +# считаются и видны в интерфейсе, просто уведомлять некому. Молча выключать +# alerting не стали — тогда включение алертов потребовало бы правки конфига, +# а не одной переменной. +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +scrape_configs: + # Сам Prometheus. Нужен не для красоты: по нему строится алерт на здоровье + # приёмника — пустая панель неотличима от «ошибок нет», и это надо различать. + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + labels: + host: infra + + - job_name: alertmanager + static_configs: + - targets: ["alertmanager:9093"] + labels: + host: infra + + - job_name: loki + static_configs: + - targets: ["loki:3100"] + labels: + host: infra + + - job_name: grafana + static_configs: + - targets: ["grafana:3000"] + labels: + host: infra + metrics_path: /metrics diff --git a/ops/metrics/prometheus/rules/infra.yml b/ops/metrics/prometheus/rules/infra.yml new file mode 100644 index 00000000..8b24f2b4 --- /dev/null +++ b/ops/metrics/prometheus/rules/infra.yml @@ -0,0 +1,208 @@ +# Правила алертов: инфраструктура и здоровье самого мониторинга. +# +# Принцип отбора — тот же, что у задачи #3078: сюда попадает только то, что уже +# ломалось молча. Правила «на всякий случай» не заводим: лишний алерт, который +# никто не разбирает, обесценивает остальные. + +groups: + # ── Здоровье самого наблюдателя ───────────────────────────────────────────── + # Пустая панель неотличима от «всё хорошо». Эти правила закрывают именно это. + - name: monitoring-self + interval: 60s + rules: + # Всегда горит. Существует ради того, чтобы его ОТСУТСТВИЕ было заметно: + # раз в 12 часов приходит подтверждение, что цепочка правило → Alertmanager + # → Telegram → человек цела. Молчащий канал — самый частый способ узнать + # об аварии последним (в проекте МЕРЫ наружу не ушло ни одного сообщения + # с 30 мая, и это выяснилось случайно). + - alert: Watchdog + expr: vector(1) + labels: + severity: none + annotations: + summary: "Сторож мониторинга" + + # Агент замолчал. На Poincare это единственный источник всех метрик: + # если он умер, графики просто перестанут обновляться, оставаясь зелёными. + - alert: HostAgentDown + expr: up{job="node"} == 0 or absent(up{job="node", host="apps"}) + for: 5m + labels: + severity: critical + annotations: + summary: "Агент метрик не отвечает" + description: "Хост {{ $labels.host }}: node-exporter недоступен более 5 минут. Метрики этого хоста больше не поступают." + + # Приёмник перестал принимать push. Симптом со стороны центра. + - alert: RemoteWriteStalled + expr: | + absent_over_time(up{job="node", host="apps"}[15m]) + for: 5m + labels: + severity: critical + annotations: + summary: "С продуктового хоста 15 минут не приходят метрики" + description: "Либо лёг агент на Poincare, либо оборван канал до metrics.gendsgn.ru, либо приёмник не принимает remote-write." + + # ── Хост ──────────────────────────────────────────────────────────────────── + - name: host + interval: 60s + rules: + # Диск. На Beget уже был случай, когда занято 79 % и никто не смотрел; + # порог 85 % даёт запас на реакцию, а не сообщает о свершившемся факте. + - alert: DiskSpaceLow + expr: | + (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} + / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.85 + for: 15m + labels: + severity: warning + annotations: + summary: "Диск занят больше 85 %" + description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}." + + - alert: DiskSpaceCritical + expr: | + (1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} + / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) > 0.93 + for: 5m + labels: + severity: critical + annotations: + summary: "Диск почти кончился" + description: "{{ $labels.host }} {{ $labels.mountpoint }}: занято {{ $value | humanizePercentage }}. Postgres при заполнении диска останавливается." + + # Прогноз важнее порога: он ловит утечку до того, как она упрётся в стену. + - alert: DiskWillFillIn24h + expr: | + predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0 + for: 30m + labels: + severity: warning + annotations: + summary: "По текущему темпу диск кончится за сутки" + description: "{{ $labels.host }} {{ $labels.mountpoint }}: экстраполяция по последним 6 часам." + + - alert: MemoryPressure + expr: | + (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90 + for: 15m + labels: + severity: warning + annotations: + summary: "Памяти доступно меньше 10 %" + description: "{{ $labels.host }}: свободной памяти {{ $value | humanizePercentage }} от общей." + + # Своп сам по себе не беда, но резкий рост означает, что что-то перестало + # помещаться. На Beget своп в 1,78 ГБ был симптомом сосуществования прода + # и инфраструктуры — и рассосался ровно в момент переезда. + - alert: SwapGrowing + expr: | + node_memory_SwapTotal_bytes > 0 + and (1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes) > 0.50 + for: 30m + labels: + severity: warning + annotations: + summary: "Своп занят больше половины" + description: "{{ $labels.host }}: обычно означает, что рабочий набор перестал помещаться в память." + + # ── Контейнеры ────────────────────────────────────────────────────────────── + - name: containers + interval: 60s + rules: + # Цикл перезапусков. Контейнер в restart-loop снаружи выглядит «поднятым», + # а деплой при этом зелёный — именно так чуть не уехал в прод пустой пароль + # у infra-postgres (#3061). + - alert: ContainerRestartLoop + expr: | + changes(container_start_time_seconds{name!=""}[30m]) > 3 + for: 5m + labels: + severity: warning + annotations: + summary: "Контейнер перезапускается по кругу" + description: "{{ $labels.host }} / {{ $labels.name }}: больше трёх стартов за полчаса." + + # Подошёл к своему mem_limit — следующий шаг OOM-kill. + - alert: ContainerNearMemoryLimit + expr: | + container_spec_memory_limit_bytes{name!=""} > 0 + and container_memory_working_set_bytes{name!=""} + / container_spec_memory_limit_bytes{name!=""} > 0.90 + for: 15m + labels: + severity: warning + annotations: + summary: "Контейнер у своего потолка памяти" + description: "{{ $labels.host }} / {{ $labels.name }}: {{ $value | humanizePercentage }} от mem_limit. Дальше OOM-kill." + + # ── Postgres ──────────────────────────────────────────────────────────────── + - name: postgres + interval: 60s + rules: + # Забытая транзакция держит горизонт vacuum и отравляет весь кластер. + # Разбор #2607: осиротевшие запросы висели 46 часов. + - alert: PostgresLongTransaction + expr: pg_activity_horizon_oldest_xact_age_s > 3600 + for: 10m + labels: + severity: warning + annotations: + summary: "Транзакция открыта больше часа" + description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Пока она жива, vacuum не может убрать мёртвые строки во ВСЕЙ базе." + + - alert: PostgresLongTransactionCritical + expr: pg_activity_horizon_oldest_xact_age_s > 21600 + for: 10m + labels: + severity: critical + annotations: + summary: "Транзакция открыта больше шести часов" + description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanizeDuration }}. Это уже влияет на размер базы." + + - alert: PostgresIdleInTransaction + expr: pg_activity_horizon_idle_in_transaction > 3 + for: 15m + labels: + severity: warning + annotations: + summary: "Соединения висят в открытой транзакции" + description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value }} шт. Обычно это незакрытая сессия в коде." + + # Раздутие. Не мгновенный сигнал, а тренд — но именно его отсутствие + # позволило 91 день не замечать 198 апдейтов на строку. + - alert: PostgresLowHotUpdateRatio + expr: | + rate(pg_table_write_amplification_tup_upd[6h]) > 0.5 + and + rate(pg_table_write_amplification_tup_hot_upd[6h]) + / rate(pg_table_write_amplification_tup_upd[6h]) < 0.2 + for: 6h + labels: + severity: warning + annotations: + summary: "Обновления идут мимо HOT" + description: "{{ $labels.host }} / {{ $labels.table }}: доля HOT {{ $value | humanizePercentage }}. Каждый такой апдейт переписывает строку во все индексы и заново тостит длинные поля — так набегает раздутие." + + - alert: PostgresDeadTuplesHigh + expr: | + pg_table_write_amplification_dead_tup > 1000000 + and pg_table_write_amplification_dead_tup + / (pg_table_write_amplification_live_tup + 1) > 0.5 + for: 1h + labels: + severity: warning + annotations: + summary: "Мёртвых строк больше половины от живых" + description: "{{ $labels.host }} / {{ $labels.table }}: {{ $value }} мёртвых. Autovacuum не справляется либо заблокирован долгой транзакцией." + + # WAL. Замер 20.08: 7 ГБ/сутки при четырёх пользовательских расчётах. + - alert: PostgresWalRateHigh + expr: rate(pg_wal_bytes_wal_bytes_total[1h]) > 104857600 / 3600 + for: 2h + labels: + severity: warning + annotations: + summary: "WAL пишется быстрее 100 МБ/час" + description: "{{ $labels.host }} / {{ $labels.db }}: {{ $value | humanize1024 }}B/с. Стоит сверить с реальной пользовательской нагрузкой — расхождение означает лишние записи." diff --git a/scripts/setup-metrics-exporter-dsn.sh b/scripts/setup-metrics-exporter-dsn.sh new file mode 100644 index 00000000..ff618cab --- /dev/null +++ b/scripts/setup-metrics-exporter-dsn.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Собирает строки подключения для postgres_exporter на продуктовом хосте из +# паролей, которые там уже есть. Идемпотентен: заданные значения не трогает. +# +# ПОЧЕМУ НЕ ЗАВОДИМ ОТДЕЛЬНЫЕ СЕКРЕТЫ. Каждая новая копия пароля — ещё одно +# место, откуда он может утечь, и ещё одно, которое забудут повернуть при +# ротации. Экспортеру нужны те же учётки, что уже лежат в окружении хоста. +# +# Запускать на продуктовом хосте. Вызывается из deploy-metrics.yml. +set -euo pipefail + +ENVF=/opt/gendesign/backend/.env.runtime + +if [ ! -f "$ENVF" ]; then + echo " ОШИБКА: не найден файл окружения бэкенда." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +. "$ENVF" +set +a + +add_key() { + local key="$1" value="$2" + if grep -qE "^${key}=" "$ENVF" 2>/dev/null; then + echo " $key — уже задан, не трогаю" + return 0 + fi + if [ -z "$value" ]; then + echo " $key — нечем заполнить, пропускаю (метрики этой базы не поедут)" + return 0 + fi + printf '%s=%s\n' "$key" "$value" >> "$ENVF" + echo " $key — записан" +} + +# Экспортер ходит по сетевому алиасу, а не по IP: адреса контейнеров меняются +# при каждом пересоздании, и DSN с IP протух бы на первом же деплое. +add_key GENDESIGN_EXPORTER_DSN \ + "${GENDESIGN_EXPORTER_DSN:-${DATABASE_URL:-}}" + +add_key TRADEIN_EXPORTER_DSN \ + "${TRADEIN_EXPORTER_DSN:-${TRADEIN_DATABASE_URL:-}}" + +# postgres_exporter не понимает схему postgresql+psycopg:// из SQLAlchemy — +# ему нужна чистая postgresql://. Приводим, если досталась приложенческая форма. +python3 - "$ENVF" <<'PY' +import io, re, sys + +path = sys.argv[1] +with io.open(path, encoding="utf-8") as fh: + lines = fh.readlines() + +changed = False +out = [] +for line in lines: + m = re.match(r'^((?:GENDESIGN|TRADEIN)_EXPORTER_DSN)=(.*)$', line.rstrip('\n')) + if not m: + out.append(line) + continue + key, dsn = m.group(1), m.group(2) + fixed = re.sub(r'^postgresql\+\w+://', 'postgresql://', dsn) + fixed = re.sub(r'^postgres\+\w+://', 'postgresql://', fixed) + if 'sslmode=' not in fixed: + fixed += ('&' if '?' in fixed else '?') + 'sslmode=disable' + if fixed != dsn: + changed = True + print(" %s — схема приведена к postgresql:// для экспортера" % key) + out.append("%s=%s\n" % (key, fixed)) + +if changed: + with io.open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.writelines(out) +PY + +echo " строки подключения экспортеров готовы" diff --git a/scripts/setup-metrics-grafana-role.sh b/scripts/setup-metrics-grafana-role.sh new file mode 100644 index 00000000..3da10af8 --- /dev/null +++ b/scripts/setup-metrics-grafana-role.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Заводит read-only роль grafana_ro в базе glitchtip — под датасорс Grafana. +# Идемпотентен: повторный запуск только досогласует права. +# +# ПОЧЕМУ ОТДЕЛЬНАЯ РОЛЬ, А НЕ ВЛАДЕЛЕЦ БАЗЫ. Датасорс Grafana доступен всякому, +# кто вошёл в интерфейс, и позволяет выполнять произвольный SQL в панелях. +# Владельческая роль сделала бы витрину способом уронить трекер ошибок. Здесь +# прав на запись нет вовсе, поэтому худшее, что можно сделать через панель, — +# медленный SELECT. +# +# Запускать на инфраструктурном хосте. Вызывается из deploy-metrics.yml. +set -euo pipefail + +CONT=gendesign-infra-postgres +DB=glitchtip +ROLE=grafana_ro + +: "${GLITCHTIP_RO_PASSWORD:?GLITCHTIP_RO_PASSWORD не задан — запусти scripts/setup-metrics-secrets.sh}" + +if ! docker inspect "$CONT" >/dev/null 2>&1; then + echo " $CONT не найден — пропускаю создание роли." + exit 0 +fi + +# Ищем роль с правом заводить других. Имя суперпользователя в этом кластере +# нигде не зафиксировано, а угадывать «postgres» неверно: в образе оно задаётся +# переменной POSTGRES_USER и здесь ею не является. +SUPER="" +for candidate in glitchtip forgejo postgres; do + if docker exec "$CONT" psql -U "$candidate" -d postgres -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname = CURRENT_USER AND (rolsuper OR rolcreaterole)" 2>/dev/null \ + | grep -q 1; then + SUPER="$candidate" + break + fi +done + +if [ -z "$SUPER" ]; then + echo " ОШИБКА: не нашёл роль с правом CREATE ROLE в $CONT." >&2 + echo " Проверь вручную: docker exec $CONT psql -U <роль> -c '\\du'" >&2 + exit 1 +fi +echo " привилегированная роль: $SUPER" + +# Пароль передаётся через переменную окружения psql, а не в тексте запроса — +# иначе он осел бы в pg_stat_statements и в логе запросов. +docker exec -e RO_PASS="$GLITCHTIP_RO_PASSWORD" -i "$CONT" \ + psql -U "$SUPER" -d "$DB" -v ON_ERROR_STOP=1 <<'SQL' +\set ro_pass `echo "$RO_PASS"` + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + CREATE ROLE grafana_ro LOGIN; + RAISE NOTICE 'роль grafana_ro создана'; + ELSE + RAISE NOTICE 'роль grafana_ro уже есть'; + END IF; +END +$$; + +ALTER ROLE grafana_ro WITH PASSWORD :'ro_pass'; + +-- Ограничение на число соединений: панель с автообновлением способна открыть +-- их десятками, а это тот же кластер, где живёт Forgejo. +ALTER ROLE grafana_ro CONNECTION LIMIT 8; + +GRANT CONNECT ON DATABASE glitchtip TO grafana_ro; +GRANT USAGE ON SCHEMA public TO grafana_ro; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro; + +-- Таблицы событий партиционированы по неделям: новые партиции появляются сами, +-- и без этой строки датасорс начал бы отдавать пустоту на свежих данных, +-- оставаясь при этом «рабочим». Тихий отказ ровно того сорта, который мы ловим. +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro; +SQL + +echo " grafana_ro: права выданы на $DB" diff --git a/scripts/setup-metrics-secrets.sh b/scripts/setup-metrics-secrets.sh new file mode 100644 index 00000000..f1fba79f --- /dev/null +++ b/scripts/setup-metrics-secrets.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Разовая подготовка учётных данных стека наблюдаемости (#3078). +# +# Запускать НА ИНФРАСТРУКТУРНОМ ХОСТЕ (Beget), один раз. Идемпотентен: уже +# заданные значения переиспользуются, ничего не перезаписывается. +# +# ЧТО ДЕЛАЕТ: +# 1. Генерирует пароли приёмника, витрины, администратора Grafana и read-only +# роли к базе GlitchTip; дописывает их в окружение хоста. +# 2. Кладёт пароль приёмника на продуктовый хост — агенту нужно им +# авторизоваться при отправке метрик и логов. +# 3. Печатает bcrypt-хеши для caddy/metrics-*.caddy.snippet. +# +# ЧЕГО НЕ ДЕЛАЕТ: не печатает сами пароли. Хеш публиковать безопасно, пароль — +# нет, а вывод скрипта попадает в журнал деплоя и в историю терминала. +# +# Токен Telegram скрипт НЕ генерирует — его нужно задать руками: +# METRICS_TELEGRAM_BOT_TOKEN, METRICS_TELEGRAM_CHAT_ID +set -euo pipefail + +ENVF=/opt/gendesign/backend/.env.runtime +REMOTE_ENVF=/opt/gendesign/backend/.env.runtime +RSSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 selectel) +STAMP=$(date -u +%Y%m%d%H%M%S) + +if [ ! -f "$ENVF" ]; then + echo "ОШИБКА: не найден файл окружения бэкенда на этом хосте." >&2 + exit 1 +fi + +# Копия перед первой правкой — одна на запуск, а не на каждый ключ. +backup_once() { + [ -f "${ENVF}.bak-metrics-${STAMP}" ] && return 0 + cp -p "$ENVF" "${ENVF}.bak-metrics-${STAMP}" +} + +have() { grep -qE "^${1}=" "$ENVF" 2>/dev/null; } +value_of() { grep -m1 -E "^${1}=" "$ENVF" | cut -d= -f2-; } + +# 32 символа из [A-Za-z0-9]: помещается в basic_auth без экранирования и не +# ломает разбор .env, где кавычки и знак равенства создают сюрпризы. +gen_pass() { tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32; } + +ensure() { + local key="$1" val="$2" + if have "$key"; then + echo " $key — уже есть, переиспользую" + else + backup_once + printf '%s=%s\n' "$key" "$val" >> "$ENVF" + echo " $key — сгенерирован" + fi +} + +echo "=== учётки на инфраструктурном хосте ===" +ensure METRICS_INGEST_USER "alloy" +ensure METRICS_INGEST_PASSWORD "$(gen_pass)" +ensure METRICS_UI_USER "metrics" +ensure METRICS_UI_PASSWORD "$(gen_pass)" +ensure GRAFANA_ADMIN_USER "admin" +ensure GRAFANA_ADMIN_PASSWORD "$(gen_pass)" +ensure GLITCHTIP_RO_PASSWORD "$(gen_pass)" + +INGEST_PASS=$(value_of METRICS_INGEST_PASSWORD) +UI_PASS=$(value_of METRICS_UI_PASSWORD) +GT_RO=$(value_of GLITCHTIP_RO_PASSWORD) + +ensure INFRA_EXPORTER_DSN \ + "postgresql://grafana_ro:${GT_RO}@gendesign-infra-postgres:5432/glitchtip?sslmode=disable" + +echo +echo "=== пароль приёмника на продуктовый хост ===" +if "${RSSH[@]}" "grep -qE '^METRICS_INGEST_PASSWORD=' ${REMOTE_ENVF}" 2>/dev/null; then + echo " уже задан, не трогаю" +else + "${RSSH[@]}" "cp -p ${REMOTE_ENVF} ${REMOTE_ENVF}.bak-metrics-${STAMP}" + # Значение идёт по stdin, а не аргументом команды: аргументы видны в + # `ps` на обеих машинах и оседают в истории оболочки. + printf 'METRICS_INGEST_USER=alloy\nMETRICS_INGEST_PASSWORD=%s\n' "$INGEST_PASS" \ + | "${RSSH[@]}" "cat >> ${REMOTE_ENVF}" + "${RSSH[@]}" "chown --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}; \ + chmod --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}" + echo " записан, права сохранены по резервной копии" +fi + +echo +echo "=== bcrypt-хеши для caddy/metrics-*.caddy.snippet ===" +echo " (пароли не печатаются; хеши безопасны для git)" +echo +for pair in "alloy:${INGEST_PASS}:metrics-ingest" "metrics:${UI_PASS}:metrics-ui"; do + user="${pair%%:*}"; rest="${pair#*:}" + pass="${rest%%:*}"; file="${rest#*:}" + hash=$(docker run --rm caddy:2 caddy hash-password --plaintext "$pass" 2>/dev/null | tr -d '\r\n') + b64=$(printf '%s' "$hash" | base64 -w 0) + printf ' caddy/%s.caddy.snippet\n %-8s %s\n' "$file" "$user" "$b64" +done + +echo +echo "=== что осталось сделать руками ===" +have METRICS_TELEGRAM_BOT_TOKEN \ + && echo " METRICS_TELEGRAM_BOT_TOKEN — задан" \ + || echo " METRICS_TELEGRAM_BOT_TOKEN — НЕ задан, алерты никуда не уйдут" +have METRICS_TELEGRAM_CHAT_ID \ + && echo " METRICS_TELEGRAM_CHAT_ID — задан" \ + || echo " METRICS_TELEGRAM_CHAT_ID — НЕ задан, алерты никуда не уйдут" +echo +echo " Пароль витрины смотреть так (в переписку не копировать):" +echo " grep '^METRICS_UI_PASSWORD=' $ENVF"