Наблюдение: метрики, показ, оповещения
Документ доводит установку от «сервис выкачен и закрыт шлюзом» до «числа снимаются, графики открываются, оповещение приходит на проверенный отказ». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.
31 минутаДокумент доводит установку от «сервис выкачен и закрыт шлюзом» до «числа снимаются, графики открываются, оповещение приходит на проверенный отказ». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.
Журналы вынесены в отдельный документ — Журналы. Они ставятся в тот же каталог и тем же файлом compose, но у них другой набор инструментов, другой срок хранения и другие проверки. Метрики показывают, что не так и когда началось; журналы объясняют, что именно произошло. Один без другого разбор не закрывает, поэтому документы читаются подряд.
Что нужно до начала: сервер с Docker, приложение и его шлюз запущены, домен с TLS, у сервисов есть ручки «жив» и «готов», в ответе видна версия.
docker compose version # версия Compose v2
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/health # 200Ожидается: версия из двух слов (docker compose, не docker-compose) и код 200. Без рабочей
ручки готовности половина проверок ниже не имеет смысла: нечего опрашивать.
Место в цепочке
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
| приложение выкачено за шлюзом, резервные копии настроены | набор инструментов, ручка метрик, правила оповещений, проверки | сбор журналов; разбор конкретного отказа по слоям контура |
Что предыдущее звено обязано обеспечить: у сервисов есть ручки «жив» и «готов», а в ответе видна версия — без этого невозможно отличить «сервис не запущен» от «запущен, но не готов», и непонятно, какая версия сейчас отвечает. Отсюда же требование к выкату: он оставляет отметку времени, иначе всплеск ошибок не с чем сопоставить (см. Релиз и выкат и раздел «Отметки о выкатах»).
Что этот документ оставляет следующему: работающий Prometheus и Grafana в отдельном проекте Compose. Документ о журналах добавляет в этот же проект два сервиса и второй источник данных в Grafana, не переделывая ничего из настроенного здесь.
Три вида данных
| Вид | Отвечает на вопрос | Хранится | Где описан |
|---|---|---|---|
| метрики | сколько и как быстро — числа во времени | долго, дёшево | этот документ |
| журналы (логи) | что именно произошло в конкретном случае | недолго, дороже | Журналы |
| трассировки | где именно ушло время в цепочке сервисов | выборочно | не описаны |
Метрики показывают, что что-то не так, и когда началось. Журналы объясняют, что произошло. Трассировки показывают, какой участок цепочки виноват, когда сервисов несколько.
Начинать стоит с метрик и журналов: трассировки нужны, когда цепочка сервисов длинная и непонятно, какое звено тормозит.
Что измерять
Для любого сервиса, принимающего запросы, достаточно четырёх величин:
| Величина | Что показывает | На что смотреть |
|---|---|---|
| частота запросов | нагрузка | резкие провалы — признак отказа выше по цепочке |
| доля ошибок | качество ответов | рост доли 5xx; 4xx отдельно — это чаще про клиента |
| время ответа | скорость | распределение, не среднее (см. ниже) |
| насыщение | запас ресурсов | процессор, память, место на диске, занятость пула соединений |
Среднее время ответа скрывает проблему. Если 95 % запросов укладываются в 50 мс, а 5 % занимают 5 секунд, среднее покажет приемлемые 300 мс, хотя каждый двадцатый пользователь ждёт пять секунд. Смотреть надо на процентили: срединное значение и «медленный хвост» (95-й, 99-й).
Дополнительно — величины, специфичные для системы: длина очереди необработанных сообщений, возраст последней резервной копии, срок до истечения сертификата. Они предупреждают об отказе заранее.
Набор инструментов
Роли и один рабочий набор под них. Это не единственный возможный набор: те же роли закрывают и другие инструменты, в том числе управляемые службы. Критерий выбора здесь один — всё ставится контейнерами рядом с приложением, не требует внешней службы и не тянет за собой отдельную эксплуатацию.
| Роль | Инструмент | Образ | Почему он |
|---|---|---|---|
| сбор метрик | Prometheus | prom/prometheus:v3.5.0 | опрашивает сервисы сам по HTTP: сервису не нужно знать про приёмник и уметь отправлять |
| хранение метрик | он же | — | своя база рядов внутри Prometheus; отдельное хранилище нужно, когда одного сервера мало |
| показ | Grafana | grafana/grafana-oss:12.0.0 | графики и таблицы поверх Prometheus; тот же экран потом покажет журналы |
| оповещения | Alertmanager | prom/alertmanager:v0.28.1 | группировка, подавление и повтор — то, что отличает оповещение от потока писем |
| показатели хоста | node_exporter | prom/node-exporter:v1.9.1 | диск, память, процессор машины; приложение их не отдаёт |
| проверка снаружи | blackbox_exporter | prom/blackbox-exporter:v0.25.0 | запрашивает домен как обычный клиент и заодно отдаёт срок сертификата |
| сбор журналов | Grafana Alloy | см. Журналы | — |
| хранение журналов | Loki | см. Журналы | — |
Версии образов зафиксированы: тег latest делает установку невоспроизводимой — на двух машинах
окажутся разные версии, а причина расхождения не видна ни в одном файле.
Проверка, что теги существуют (список выпусков меняется; если тега нет — возьмите ближайший выпущенный и запишите его в файл compose):
for i in prom/prometheus:v3.5.0 grafana/grafana-oss:12.0.0 prom/alertmanager:v0.28.1 \
prom/node-exporter:v1.9.1 prom/blackbox-exporter:v0.25.0; do
docker manifest inspect "$i" >/dev/null 2>&1 && echo "есть $i" || echo "НЕТ $i"
doneОжидается: пять строк есть.
Шаг 1. Ручка метрик в сервисе
Сервис отдаёт текущие значения по HTTP, Prometheus их забирает. Ручка объявляется в группе
internal — /internal/metrics: по BMBP это группа отладочных и
административных маршрутов, которые не проходят через шлюзы. Путь по умолчанию у большинства
клиентских библиотек — /metrics; несовпадение снимается параметром metrics_path в настройке
Prometheus (шаг 3), менять раскладку маршрутов под инструмент не требуется.
Что отдавать
| Показатель | Имя и тип | Метки | Зачем |
|---|---|---|---|
| частота запросов и доля ошибок | http_requests_total, счётчик | service, method, route, status | обе величины считаются из одного счётчика |
| время ответа | http_request_duration_seconds, гистограмма | service, method, route | процентили считаются из корзин |
| пул соединений с базой | db_pool_connections, db_pool_size, датчики | service, state (in_use, idle) | исчерпание пула превращается в очередь на входе |
| очередь сообщений | queue_messages_pending, датчик | service, topic | потребитель не справляется с отправителем |
| версия | app_build_info со значением 1, датчик | service, version | сопоставление всплеска с выкатом |
Границы корзин гистограммы задаются от обещанного времени ответа, например
0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 секунд. Процентиль вычисляется по корзинам, поэтому его
точность ограничена сеткой: если все значения попадают в одну корзину, различить их нельзя.
Где это объявляется
- счётчик и гистограмма заполняются посредником (middleware) в
api/_shared— там же, где конверт ответа и обработка ошибок. В обработчиках доменов их нет: иначе каждый домен считает по-своему и часть маршрутов остаётся неучтённой; db_pool_connectionsиdb_pool_sizeберутся изinfrastructure/db/connection— состояние пула знает только он;queue_messages_pending— из клиента событий;coreметрик не касается: инвариант «coreне знает о транспорте» действует и здесь.
Кардинальность: чего в метках не бывает
Метка — это то, у чего конечное и небольшое множество значений. Каждое сочетание значений меток — отдельный ряд в базе, и ряд, переставший обновляться, занимает место до конца срока хранения.
route— шаблон маршрута, а не фактический путь:/api/front/orders/{id}, не/api/front/orders/8f21…. Идентификатор в метке превращает один ряд в столько рядов, сколько было заказов;- идентификаторы пользователя, запроса, документа в метки не попадают вовсе. Их место — в журнале (см. Журналы): там они стоят дёшево, а искать по ним удобнее.
Проверка
Ручка не опубликована наружу, поэтому запрос идёт изнутри сети приложения. Узнайте имя сети и обратитесь к сервису по имени:
docker network ls --format '{{.Name}}' | grep -vE '^(bridge|host|none)$'
APP_NET=<имя сети приложения>
docker run --rm --network "$APP_NET" curlimages/curl:8.7.1 \
-sf http://backend:8000/internal/metrics | head -20Ожидается: строки вида http_requests_total{...} 123 и # HELP/# TYPE перед ними. Отказ
соединения означает, что имя сервиса или порт другие; пустой ответ — что ручка объявлена, но
посредник ничего не считает.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/internal/metricsОжидается: 404 (маршрут /internal на шлюз не заведён). Ответ 200 означает, что внутренние
ручки видны из интернета — уберите /internal из маршрутов шлюза, см.
Сетевой контур.
Шаг 2. Каталог, файлы, сеть
Наблюдение живёт отдельным проектом Compose в своём каталоге. Так его снятие не задевает приложение, а тома с историей метрик не путаются с томами данных.
/opt/app/observability/
├── compose.yml
├── .env # параметры запуска (не секреты)
├── .secrets/
│ ├── .env # пароль входа в Grafana
│ ├── .env.example
│ └── smtp_password # пароль почтового ящика оповещений
├── prometheus/
│ ├── prometheus.yml
│ └── alerts.yml
├── alertmanager/
│ └── alertmanager.yml
├── blackbox/
│ └── blackbox.yml
└── grafana/provisioning/datasources/
└── datasources.ymlРаскладка .secrets/ и права на неё — Секреты. Файл .env рядом с compose.yml —
это подстановка на стороне инструмента, она годится для параметров запуска и не годится для
значений: docker compose config печатает их в открытом виде.
Создайте каталоги и узнайте имя сети приложения — по нему Prometheus обращается к сервисам по именам:
sudo install -d -o "$USER" -m 755 /opt/app/observability
cd /opt/app/observability
mkdir -p prometheus alertmanager blackbox grafana/provisioning/datasources
install -d -m 700 .secrets
docker network ls --format '{{.Name}}' | grep -vE '^(bridge|host|none)$'
echo "APP_NETWORK=<имя сети приложения>" > .envCompose добавляет к имени сети имя проекта, поэтому в списке она выглядит как <проект>_host_net,
а не host_net.
compose.yml
name: observability
networks:
obs_net:
driver: bridge # внутренняя сеть набора
app_net:
external: true # сеть приложения: создана его проектом, здесь не создаётся
name: ${APP_NETWORK}
volumes: # именованные тома: данные переживают пересоздание контейнеров
prometheus_data:
grafana_data:
alertmanager_data:
services:
prometheus:
image: prom/prometheus:v3.5.0
restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=90d" # срок хранения рядов
- "--storage.tsdb.retention.size=8GB" # и предел по месту: что наступит раньше
- "--web.enable-lifecycle" # перечитывание настроек без перезапуска
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
ports: ["127.0.0.1:9090:9090"] # только с этой машины
networks: [obs_net, app_net]
alertmanager:
image: prom/alertmanager:v0.28.1
restart: unless-stopped
command: ["--config.file=/etc/alertmanager/alertmanager.yml"]
volumes:
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- ./.secrets:/etc/alertmanager/secrets:ro # пароль почты — файлом, не окружением
- alertmanager_data:/alertmanager
ports: ["127.0.0.1:9093:9093"]
networks: [obs_net]
grafana:
image: grafana/grafana-oss:12.0.0
restart: unless-stopped
env_file: ["./.secrets/.env"] # GF_SECURITY_ADMIN_PASSWORD
environment:
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=false
volumes:
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
ports: ["127.0.0.1:3000:3000"]
networks: [obs_net]
depends_on: [prometheus]
node_exporter:
image: prom/node-exporter:v1.9.1
restart: unless-stopped
command:
- "--path.rootfs=/host"
- "--collector.textfile.directory=/textfile"
- "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|run)($$|/)"
pid: host
volumes:
- /:/host:ro,rslave
- /var/lib/node-exporter/textfile:/textfile:ro
expose: ["9100"] # порт объявлен, но не опубликован
networks: [obs_net]
blackbox:
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
command: ["--config.file=/etc/blackbox/blackbox.yml"]
volumes:
- ./blackbox/blackbox.yml:/etc/blackbox/blackbox.yml:ro
expose: ["9115"]
networks: [obs_net]Почему наружу ничего не публикуется. Правило контура одно: единственный вход из интернета — порты 80 и 443 на сервере шлюза, всё остальное закрывается тем же способом, что прочие внутренние службы (см. Сетевой контур). У наблюдения это правило имеет ещё одно основание: метрики показывают внутреннее устройство системы — имена сервисов, маршруты, версии, нагрузку, — а Grafana и Alertmanager принимают вход по паролю, то есть открытый наружу порт становится ещё одной дверью с подбором пароля.
Три порта опубликованы на loopback: они нужны для проверок из этого документа и для доступа через SSH-туннель. Опубликованный на loopback порт доступен только тому, кто уже вошёл на машину. Экспортеры не публикуются вовсе: к ним обращается только Prometheus изнутри сети.
$$ в строке --collector.filesystem.mount-points-exclude — это экранированный $: Compose
подставляет переменные в текст файла, и одиночный $ он попытается развернуть.
Тома именованные, а не привязанные каталоги хоста: образы работают не от root (Prometheus —
от nobody, Grafana — от своего пользователя), и созданный вами каталог оказался бы им недоступен
на запись.
Каталог для показателей, которые пишут скрипты (используется в шаге 5):
sudo install -d -m 755 /var/lib/node-exporter/textfileФайл значений
umask 077
cat > .secrets/.env.example <<'EOF'
# Шаблон. Скопировать в .secrets/.env и заполнить.
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=CHANGE_ME
EOF
cp .secrets/.env.example .secrets/.env
openssl rand -hex 24 # значение для GF_SECURITY_ADMIN_PASSWORD
printf '%s' '<пароль почтового ящика>' > .secrets/smtp_password # без перевода строки
chmod 700 .secrets && chmod 600 .secrets/.env .secrets/smtp_password
chmod 644 .secrets/.env.exampleПароль входа в Grafana генерируется, а не придумывается, и заменяется до первого запуска: Grafana с
паролем по умолчанию и открытым туннелем — это доступ к внутренним показателям для любого, кто вошёл
на машину. Файл smtp_password записывается без завершающего перевода строки: он передаётся как
значение целиком, и лишний символ выглядит как неверный пароль.
Шаг 3. Prometheus: кого опрашивать
# prometheus/prometheus.yml
global:
scrape_interval: 15s # шаг опроса: чаще — точнее и дороже по месту
evaluation_interval: 15s # шаг проверки правил оповещений
external_labels:
installation: main # видно в письме, когда установок несколько
rule_files:
- /etc/prometheus/alerts.yml
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["127.0.0.1:9090"]
- job_name: app
metrics_path: /internal/metrics
static_configs:
- targets: ["backend:8000"]
labels: { service: backend }
- job_name: node
static_configs:
- targets: ["node_exporter:9100"]
- job_name: probe # запрос к домену снаружи, как у обычного клиента
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets: ["https://example.com/health"]
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox:9115# blackbox/blackbox.yml
modules:
http_2xx:
prober: http
timeout: 5s
http:
method: GET
valid_status_codes: [200]
preferred_ip_protocol: ip4Три строки relabel_configs в задании probe меняют местами цель и адрес опроса: Prometheus
обращается к blackbox_exporter, а проверяемый адрес передаёт ему параметром. Без них Prometheus
попытается забрать метрики прямо с домена.
Правила оповещений пишутся на шаге 5, но файл должен существовать до первого запуска: на месте отсутствующего файла Docker создаёт каталог, и Prometheus не стартует, наткнувшись на него.
echo 'groups: []' > prometheus/alerts.yml
docker compose up -ddocker compose ps # все сервисы running
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/-/healthy # 200
curl -s 'http://127.0.0.1:9090/api/v1/targets?state=active' \
| grep -o '"health":"[a-z]*"' | sort | uniq -cОжидается: одна строка вида 4 "health":"up" — по числу целей. Любое "down" означает, что
цель недоступна: см. «Типичные отказы».
Запрос в хранилище — те же данные, что потом лягут в панели:
curl -sG http://127.0.0.1:9090/api/v1/query \
--data-urlencode 'query=sum by (service) (rate(http_requests_total[5m]))' \
| head -c 400; echoОжидается: "status":"success" и хотя бы одно значение в "result". Пустой "result":[]
означает, что ряд ещё не появился: подождите два шага опроса и подайте на сервис нагрузку.
Шаг 4. Показ
Источник данных подключается файлом, а не руками в интерфейсе: настройка, сделанная руками, живёт в томе и теряется при пересоздании установки.
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: truedocker compose up -d grafanaДоступ — SSH-туннелем с рабочей машины (порт наружу не публикуется):
ssh -N -L 3000:127.0.0.1:3000 example.com
# в браузере: http://127.0.0.1:3000Проверка (выполняется на сервере):
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/api/health # 200
curl -s http://127.0.0.1:3000/api/health # "database":"ok"Запросы для панелей — четыре величины из раздела «Что измерять» плюс показатели, специфичные для системы:
| Панель | Запрос |
|---|---|
| частота запросов | sum by (service) (rate(http_requests_total[5m])) |
| доля ошибок | sum by (service) (rate(http_requests_total{status=~"5.."}[5m])) / sum by (service) (rate(http_requests_total[5m])) |
| время ответа, 95-й процентиль | histogram_quantile(0.95, sum by (service, le) (rate(http_request_duration_seconds_bucket[5m]))) |
| время ответа, срединное | histogram_quantile(0.5, sum by (service, le) (rate(http_request_duration_seconds_bucket[5m]))) |
| занятость пула соединений | db_pool_connections{state="in_use"} / db_pool_size |
| очередь сообщений | sum by (topic) (queue_messages_pending) |
| свободное место на диске | node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} |
Один экран с этими панелями закрывает вопрос «работает ли система сейчас». Разбор конкретного случая идёт дальше — в журналы.
Шаг 5. Правила оповещений
Правило одно: оповещение должно требовать действия. Если на сообщение никто ничего не делает, его отключают — иначе оно приучает игнорировать и остальные.
Оповещать по симптому, а не по причине. «Доля ошибок выше 5 % пять минут подряд» — симптом, его замечает пользователь. «Загрузка процессора 90 %» — причина, и сама по себе она может быть нормальной. Из причин в оповещения попадают только те, что неизбежно приводят к отказу: место на диске, срок сертификата, возраст резервной копии.
У порога есть время удержания. Условие должно держаться несколько минут, иначе одиночный всплеск поднимает тревогу ночью. Кратковременный скачок при выкате — норма.
# prometheus/alerts.yml
groups:
- name: service
rules:
- alert: HighErrorRate
expr: |
sum by (service) (rate(http_requests_total{status=~"5.."}[5m]))
/ sum by (service) (rate(http_requests_total[5m])) > 0.05
for: 5m
labels: { severity: page }
annotations:
summary: "доля ошибок {{ $value | humanizePercentage }} у {{ $labels.service }}"
action: "найти записи журнала за время начала, сверить со временем последнего выката"
- alert: SlowResponses
expr: |
histogram_quantile(0.95,
sum by (service, le) (rate(http_request_duration_seconds_bucket[5m]))) > 1
for: 10m
labels: { severity: ticket }
annotations:
summary: "95-й процентиль времени ответа {{ $value }} с у {{ $labels.service }}"
action: "проверить занятость пула соединений и очередь; смотреть медленные запросы"
- alert: AppDown
expr: up{job="app"} == 0
for: 2m
labels: { severity: page }
annotations:
summary: "сервис не отвечает на опрос метрик"
action: "docker compose ps и журнал контейнера: не запустился или не готов"
- alert: ProbeFailed
expr: probe_success == 0
for: 3m
labels: { severity: page }
annotations:
summary: "домен не отвечает снаружи: {{ $labels.instance }}"
action: "проверка контура сверху вниз: домен → шлюз → сервис → база"
- name: capacity
rules:
- alert: DiskFillingUp
expr: |
node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} < 0.15
for: 30m
labels: { severity: ticket }
annotations:
summary: "свободно {{ $value | humanizePercentage }} на {{ $labels.mountpoint }}"
action: "docker system df, срок хранения журналов и метрик, старые образы"
- alert: CertExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
for: 1h
labels: { severity: ticket }
annotations:
summary: "сертификат истекает через {{ $value | humanize }} суток"
action: "certbot renew --dry-run: автопродление не сработало"
- alert: BackupStale
expr: time() - backup_last_success_timestamp_seconds > 26 * 3600
for: 10m
labels: { severity: ticket }
annotations:
summary: "последняя резервная копия старше суток"
action: "проверить задание копирования; без свежей копии выкат с миграциями откатывать нечем"
- alert: QueueGrowing
expr: min_over_time(queue_messages_pending[30m]) > 1000
for: 30m
labels: { severity: ticket }
annotations:
summary: "очередь {{ $labels.topic }} не опускалась ниже 1000 за полчаса"
action: "потребитель медленнее отправителя: см. документ об обмене сообщениями"Почему такие пороги
| Правило | Порог и удержание | Основание |
|---|---|---|
HighErrorRate | 5 % за 5 мин | единичные 5xx есть всегда; 5 % — уже заметная доля пользователей. Пять минут отсекают всплеск при переключении копий на выкате |
SlowResponses | 95-й процентиль > 1 с за 10 мин | порог берётся от того, что обещано пользователю, а не от текущего значения. Десять минут отличают медленный хвост от одиночного тяжёлого запроса |
AppDown | 2 мин | штатный перезапуск контейнера укладывается в это время, отказ — нет |
ProbeFailed | 3 мин | проверка идёт по всей цепочке, включая домен и TLS; три минуты покрывают перечитывание конфигурации входа |
DiskFillingUp | 15 % за 30 мин | остатка хватает, чтобы разобраться в рабочее время, а не ночью. Тридцать минут отсекают временные файлы сборки |
CertExpiringSoon | 21 сутки | автопродление начинается за 30 суток; тревога через девять дней после первой неудачной попытки оставляет три недели на разбор |
BackupStale | 26 часов | суточное расписание плюс запас на длительность самого копирования |
QueueGrowing | 1000 за 30 мин, по минимуму окна | минимум за окно, а не мгновенное значение: всплеск, который разобрали, минимум не поднимает |
Пороги — начальные значения, а не измеренные. После двух-трёх недель работы их сверяют с накопленными рядами: правило, которое ни разу не сработало на настоящем отказе, и правило, которое срабатывает без действий, одинаково бесполезны.
Чего здесь намеренно нет — правила на падение частоты запросов. Порог зависит от суточного и недельного профиля нагрузки; без накопленной истории он даёт ложные срабатывания каждую ночь. Правило вводится, когда профиль виден на графике.
Значение из скрипта (backup_last_success_timestamp_seconds) попадает в метрики через файл,
который читает node_exporter. В скрипт резервного копирования после успешного снимка добавляется:
D=/var/lib/node-exporter/textfile
printf 'backup_last_success_timestamp_seconds %s\n' "$(date +%s)" > "$D/backup.prom.tmp"
mv "$D/backup.prom.tmp" "$D/backup.prom" # замена одним действием: без .tmp читается половина файлаdocker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
curl -s -X POST http://127.0.0.1:9090/-/reload
curl -s http://127.0.0.1:9090/api/v1/rules | grep -o '"name":"[A-Za-z]*"' | sort -uОжидается: SUCCESS: 8 rules found, пустой ответ на перезагрузку и десять имён в последнем
выводе — восемь правил и две группы (service, capacity). Ошибка разбора означает, что правила
не загружены, а Prometheus продолжает работать со старым набором.
Шаг 6. Куда уходит оповещение
# alertmanager/alertmanager.yml
global:
resolve_timeout: 5m
smtp_smarthost: "smtp.example.com:587"
smtp_from: "admin@example.com"
smtp_auth_username: "admin@example.com"
smtp_auth_password_file: /etc/alertmanager/secrets/smtp_password
route:
receiver: mail-day
group_by: [alertname, service] # одно письмо на правило и сервис, а не на каждый ряд
group_wait: 30s # ждём соседние срабатывания, чтобы собрать их в одно письмо
group_interval: 5m # как часто досылать изменения по той же группе
repeat_interval: 12h # повтор, пока условие держится
routes:
- matchers: ['severity="page"']
receiver: mail-now
repeat_interval: 2h
receivers:
- name: mail-day
email_configs:
- to: "admin@example.com"
send_resolved: true
- name: mail-now
email_configs:
- to: "admin@example.com"
send_resolved: true
inhibit_rules: # сервис лежит — не слать вдобавок про долю ошибок
- source_matchers: ['alertname="AppDown"']
target_matchers: ['alertname=~"HighErrorRate|SlowResponses"']
equal: [service]Почта выбрана как канал, не требующий внешней службы сверх уже имеющегося ящика. Alertmanager
поддерживает и доставку запросом на произвольный адрес (webhook_configs) — этим подключается любой
мессенджер или система дежурств, форма настройки та же.
Два получателя различаются не адресом, а частотой повтора: severity: page — то, что требует
действия немедленно; severity: ticket — то, что разбирается в рабочее время. Метка проставлена в
каждом правиле шага 5.
docker compose up -d alertmanager
docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.ymlОжидается: SUCCESS и перечень найденного — маршрут, одно правило подавления, два получателя.
Ошибка означает, что Alertmanager продолжает работать с прежним файлом: он не применяет
конфигурацию, которую не смог разобрать.
Шаг 7. Проверка оповещения искусственным отказом
Проверяются две разные вещи, поэтому проверок две.
7.1. Путь доставки — доходит ли письмо вообще. Правило-пустышка срабатывает всегда:
cat >> prometheus/alerts.yml <<'EOF'
- name: selftest
rules:
- alert: DeliveryTest
expr: vector(1)
for: 0m
labels: { severity: ticket }
annotations: { summary: "проверка доставки оповещений" }
EOF
curl -s -X POST http://127.0.0.1:9090/-/reloadЧерез минуту:
docker compose exec alertmanager amtool --alertmanager.url=http://127.0.0.1:9093 alert queryОжидается: строка с DeliveryTest и письмо на указанном адресе. Тревога есть в Alertmanager, а
письма нет — причина в почте: смотрите docker compose logs alertmanager, там видна ошибка
отправки.
Уберите временное правило и перезагрузите настройки — блок добавлялся в конец файла, поэтому удаляется всё от его заголовка до конца:
sed -i '/^ - name: selftest$/,$d' prometheus/alerts.yml
docker compose exec prometheus promtool check rules /etc/prometheus/alerts.yml
curl -s -X POST http://127.0.0.1:9090/-/reload7.2. Измерение — замечает ли система настоящий отказ. Остановите сервис и дождитесь удержания
AppDown (2 минуты):
cd /opt/app/backend && docker compose stop backend
sleep 180
curl -s http://127.0.0.1:9090/api/v1/alerts | grep -o '"alertname":"[A-Za-z]*"'
docker compose start backendОжидается: AppDown в списке, письмо, и через несколько минут после запуска — письмо о
снятии (send_resolved).
Остановка боевого сервиса — это простой. Выполняйте проверку на стенде или в окно обслуживания; если ни того, ни другого нет, ограничьтесь проверкой 7.1: она подтверждает доставку, а условие правила подтверждается первым же настоящим отказом.
Шаг 8. Ничего не слушается снаружи
sudo ss -ltnp | grep -E ':(3000|9090|9093)\b'Ожидается: во всех строках адрес 127.0.0.1. Значение 0.0.0.0 или * означает, что порт
открыт на всех интерфейсах.
С другой машины:
curl -m 5 -I http://example.com:3000 # ожидается таймаут или отказ соединения
curl -m 5 -I http://example.com:9090 # то жеОтвет на любой из этих запросов означает, что порт опубликован без адреса. Правила ufw при этом не
помогут: Docker добавляет свои правила раньше (см. Docker) — исправлять надо
публикацию в compose.yml.
Отметки о выкатах
Всплеск ошибок сопоставляется с обновлением по времени. Чтобы сопоставление занимало секунды, выкат оставляет след в двух местах.
Метрика версии. Сервис отдаёт app_build_info{service, version} со значением 1. Запрос
показывает, когда версия сменилась:
curl -sG http://127.0.0.1:9090/api/v1/query \
--data-urlencode 'query=changes(count by (service) (app_build_info)[1h:1m]) > 0'Отметка на графике. Скрипт выката добавляет аннотацию в Grafana — вертикальная линия на всех панелях:
curl -s -X POST http://127.0.0.1:3000/api/annotations \
-H 'Content-Type: application/json' \
-u "admin:$GF_SECURITY_ADMIN_PASSWORD" \
-d '{"text":"выкат <метка релиза>","tags":["deploy"]}'Строка добавляется в скрипт выката после переключения входа (см. Релиз и выкат, шаг 6 выката). Значение пароля подставляется из файла окружения, а не набирается в оболочке: набранное остаётся в её истории. Без отметок вопрос «это из-за обновления или нет» решается сверкой по журналу выкатов вручную, и в этот момент его обычно нет под рукой.
Дежурство
Оповещение существует ради действия, поэтому у каждого правила есть адресат и ожидаемое действие —
оно записано в аннотацию action (шаг 5). Правило без адресата и действия не заводится.
Что делать с правилом, на которое не реагируют. Раз в месяц смотрите, какие правила срабатывали:
curl -sG http://127.0.0.1:9090/api/v1/query \
--data-urlencode 'query=sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d]))'Для каждого правила из списка ответ один из трёх:
- срабатывало, по нему что-то делали — оставить;
- срабатывало, ничего не делали — починить: поднять порог, увеличить удержание, заменить причину на симптом;
- чинить нечего, действие не появится — удалить.
Третий вариант — рабочий, а не признак поражения. Игнорируемое оповещение хуже отсутствующего: оно приучает не смотреть на почту, и вместе с ним игнорируются остальные.
Ночью будят только правила с меткой severity: page. Перевод правила в page означает, что кто-то
встанет и будет что-то делать; если действия ночью нет, метка ticket.
Микросервисы на разных серверах
Когда сервисы стоят на разных машинах, Prometheus не может обращаться к ним по имени контейнера. Он ходит по частной сети через nginx микросервиса — тот самый вход, который описан в Сетевом контуре. Публиковать порт сервиса ради опроса нельзя: это создаёт вход без единой проверки.
В конфигурации nginx микросервиса открывается только путь метрик и только для адреса сборщика:
location /internal/metrics {
allow 10.0.0.20; # адрес сервера наблюдения в частной сети
deny all;
proxy_pass http://backend:8000;
}В настройке Prometheus вместо имени контейнера указывается адрес nginx микросервиса в частной сети:
- job_name: app
metrics_path: /internal/metrics
static_configs:
- targets: ["10.0.0.10:8000"]
labels: { service: backend }Проверка — с сервера наблюдения:
curl -sf http://10.0.0.10:8000/internal/metrics | head -3 # ожидается: строки метрикС машины, не входящей в частную сеть, тот же запрос должен завершаться отказом соединения.
Разбор отказа
Порядок: симптом → слой → причина.
- Что видит пользователь: ошибка, медленно, недоступно. Отсюда определяется симптом.
- Найти слой — проверкой контура сверху вниз (домен → шлюз → сервис → база), как описано в Сетевом контуре. Первый неотвечающий слой и есть место отказа.
- В журналах этого слоя найти записи по времени начала, дальше — по идентификатору запроса (см. Журналы).
- Сверить со временем последнего выката: совпадение по времени указывает на новую версию как на причину.
Запросы, отвечающие на шаг 1 и 2 без открывания интерфейса:
# когда началось: доля ошибок за последний час
curl -sG http://127.0.0.1:9090/api/v1/query_range \
--data-urlencode 'query=sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))' \
--data-urlencode "start=$(date -u -d '1 hour ago' +%s)" \
--data-urlencode "end=$(date -u +%s)" --data-urlencode 'step=60'
# какие цели не отвечают
curl -sG http://127.0.0.1:9090/api/v1/query --data-urlencode 'query=up == 0' | head -c 400Полезно фиксировать по итогам: что произошло, как обнаружили, что помогло. Это дешевле, чем проходить тот же путь второй раз, и показывает, какого оповещения не хватало.
Порядок внедрения
Если ставить не всё сразу, порядок по соотношению пользы к трудозатратам:
- проверка домена снаружи (
blackbox+ правилоProbeFailed) — шаги 2–3, 5; - сбор журналов в одно место с ротацией;
- ручка метрик и четыре величины по каждому сервису — шаг 1;
- оповещения на диск, сертификат, возраст резервной копии — шаг 5;
- сквозной идентификатор запроса в журналах;
- отметки о выкатах;
- трассировки — когда предыдущего перестанет хватать.
Первым идёт внешняя проверка: она одна отвечает на вопрос «работает ли система для пользователя» и не требует правок в приложении.
Типичные отказы
| Признак | Причина | Что делать |
|---|---|---|
цель в состоянии down, хотя сервис работает | Prometheus не в сети приложения либо имя сервиса другое | сверить APP_NETWORK в .env с docker network ls; проверить запросом из контейнера |
цель down с ошибкой 404 | путь ручки не совпадает с metrics_path | привести metrics_path к пути ручки сервиса |
Prometheus или Grafana не стартует: permission denied | вместо именованного тома подставлен каталог хоста | вернуть именованный том; образы работают не от root |
| после пересоздания контейнеров графики пусты | данные лежали в контейнере, а не в томе | проверить volumes в compose.yml, том prometheus_data |
| Prometheus занимает всё больше памяти, рядов миллионы | идентификатор попал в метку | убрать метку, оставить шаблон маршрута; ряды исчезнут после срока хранения |
| в Grafana пусто, а в Prometheus данные есть | в панели другой источник данных или другой диапазон времени | сверить uid источника и период; повторить тот же запрос через API |
| оповещения приходят постоянно, на них не реагируют | пороги без времени удержания либо оповещения по причинам | оставить симптомы, добавить удержание, лишнее удалить (раздел «Дежурство») |
| тревога есть в Prometheus, письма нет | Alertmanager недоступен или ошибка отправки почты | amtool alert query, docker compose logs alertmanager |
| на один отказ приходит десяток писем | не задана группировка | group_by по alertname и service, правила подавления |
| отказ заметил пользователь, а не система | нет проверки снаружи | задание probe и правило ProbeFailed |
| метрики есть, но по ним ничего не понять | смотрят на средние значения | перейти к процентилям (histogram_quantile) |
| после выката всё сломалось, причину ищут долго | нет отметки о выкате и версии в метриках | app_build_info и аннотация из скрипта выката |
| правила не применились после правки файла | ошибка разбора: Prometheus оставил прежний набор | promtool check rules, затем перезагрузка настроек |
| место на диске кончилось из-за метрик | не задан предел по размеру | --storage.tsdb.retention.size, снизить срок хранения |
Откат и снятие
cd /opt/app/observability
docker compose down # контейнеры и сеть набора; тома остаются
docker compose down -v # плюс тома: история метрик и настройки Grafana удаляютсяЧто при этом не затрагивается: приложение, его тома и его сеть. Наблюдение — отдельный проект
Compose со своими томами, а сеть приложения объявлена как внешняя, поэтому down её не удаляет.
Данные приложения находятся вне этого каталога.
Снять одно правило, не трогая остальное: убрать его из alerts.yml и перезагрузить настройки
(curl -s -X POST http://127.0.0.1:9090/-/reload).
Приостановить оповещения на время работ — не удаляя правил:
docker compose exec alertmanager amtool --alertmanager.url=http://127.0.0.1:9093 \
silence add alertname=HighErrorRate --duration=2h --author=ops --comment="выкат"--author обязателен: в контейнере нет учётной записи, из которой amtool взял бы имя сам.
Ручка /internal/metrics в сервисе после снятия остаётся: она не нагружает сервис, пока её никто
не опрашивает, и потребуется при следующей установке. Если её нужно закрыть — это правка nginx
микросервиса, а не приложения.
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.