gendesign/ops/metrics/alloy/alloy-infra.alloy
bot-backend c093eaafe5
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m33s
fix(observability): пароли не уезжают в Loki — скруббер учётных данных (#3114)
postgres_exporter при неудачном скрейпе печатает полный DSN вместе с паролем.
Замер по Loki за сутки: 104 такие строки на инфра-хосте и 106 на продуктовом.
При ретенции 30 дней это порядка 6000 строк с паролями БД в хранилище, доступ
к которому даёт вход в Grafana - причём внешний basic_auth с витрины сегодня
же снят (#3113).

Проверено экспериментом, а не предположено. Напрашивалось передать пароль
отдельно от строки подключения (DATA_SOURCE_URI + DATA_SOURCE_USER +
DATA_SOURCE_PASS_FILE). Прогон на скретч-контейнерах с настоящим файлом
запросов показал: НЕ помогает - экспортер собирает строку сам и логирует её
целиком. Первые три попытки воспроизведения были неинформативны (контейнер
падал сразу; скрейпа не было; не подключён файл кастомных запросов) - утечка
воспроизводится только при неудачном скрейпе с нашим queries.yml.

Стало: ступень loki.process между источником журнала и loki.write, маскирует
пароль в любом URL вида scheme://user:pass@host. Пользователь и адрес
остаются - без них строка ошибки перестаёт годиться для диагностики. Ровно
одна группа захвата: Alloy заменяет содержимое групп, вторая затёрла бы имя
пользователя.

Это защита в глубину, а не замена причине. Конкретно эта ошибка уходит грантом
pg_monitor (запрос pg_wal_bytes в нашем queries.yml требует pg_ls_waldir) -
это боевая БД и остаётся за владельцем. Скруббер же ловит любой пароль в URL,
включая компоненты, о которых мы ещё не знаем.

Регулярка без экранирования намеренно: Alloy не принимает \s в строке
(unknown escape sequence), поэтому класс задан явным пробелом. Оба конфига
прогнаны через `alloy fmt` образом grafana/alloy:v1.6.1 - синтаксис ok.

Тесты (8) берут выражение ИЗ КОНФИГА и применяют к настоящей строке из прода:
пароль исчезает; пользователь и адрес остаются; обычные URL и почтовые адреса
не портятся; журнал направлен в скруббер, а не мимо него. Фальсификация: на
исходных конфигах краснеют все 8. tests/ops целиком - 43 passed.
2026-08-26 15:50:57 +03:00

169 lines
8.2 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
}
// Служебные ряды без имени (корневой cgroup, id="/") только зашумляют графики.
// keep .+ вместо drop с пустым regex — та же мысль, но без зависимости от
// трактовки пустого regex: в Alloy он неотличим от незаданного, а незаданный
// дефолтится в (.*).
//
// NB: на пустые панели это правило НЕ влияло. Причина была в cAdvisor 0.52 на
// Docker 29 — до сюда доезжал ровно один ряд, корневой. Лечится версией 0.55.1.
rule {
source_labels = ["name"]
regex = ".+"
action = "keep"
}
}
// ═══ МЕТРИКИ ИНФРАСТРУКТУРНОЙ БД ═══════════════════════════════════════════════
// Профиль 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.process.scrub_credentials.receiver]
}
// ── Скруббер учётных данных (#3114) ──────────────────────────────────────────
// Прод-факт: postgres_exporter при неудачном скрейпе печатает ПОЛНЫЙ DSN вместе
// с паролем. Замер по Loki за сутки — 104 строки на инфра-хосте и 106 на
// продуктовом, то есть при ретенции 30 дней в хранилище копится порядка 6000
// строк с паролями БД. Доступ к ним даёт вход в Grafana.
//
// Проверено на скретч-контейнерах: передача пароля отдельно
// (DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS_FILE) НЕ помогает —
// экспортер собирает строку подключения сам и логирует её целиком. Поэтому
// чистим на нашей стороне, до отправки в Loki.
//
// Это защита в глубину, а не замена причине: конкретно эта ошибка уходит
// грантом pg_monitor (#3114). Скруббер же ловит ЛЮБОЙ пароль в URL — в том
// числе из компонентов, о которых мы ещё не знаем.
//
// Маскируется только пароль: пользователь и адрес остаются, без них строка
// ошибки перестала бы годиться для диагностики. Одна группа захвата — Alloy
// заменяет содержимое групп, и вторая группа затёрла бы имя пользователя.
loki.process "scrub_credentials" {
forward_to = [loki.write.central.receiver]
stage.replace {
expression = "://[^:@/ ]+:([^@ ]+)@"
replace = "***"
}
}
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"
}