gitaspen docs

Наблюдение: метрики, показ, оповещения

Документ доводит установку от «сервис выкачен и закрыт шлюзом» до «числа снимаются, графики открываются, оповещение приходит на проверенный отказ». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.

31 минута

Документ доводит установку от «сервис выкачен и закрыт шлюзом» до «числа снимаются, графики открываются, оповещение приходит на проверенный отказ». Каждый шаг заканчивается проверкой с однозначным ожидаемым результатом: если результат другой — переходите к разделу «Типичные отказы», не выполняя следующий шаг.

Журналы вынесены в отдельный документ — Журналы. Они ставятся в тот же каталог и тем же файлом compose, но у них другой набор инструментов, другой срок хранения и другие проверки. Метрики показывают, что не так и когда началось; журналы объясняют, что именно произошло. Один без другого разбор не закрывает, поэтому документы читаются подряд.

Что нужно до начала: сервер с Docker, приложение и его шлюз запущены, домен с TLS, у сервисов есть ручки «жив» и «готов», в ответе видна версия.

bash
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-й).

Дополнительно — величины, специфичные для системы: длина очереди необработанных сообщений, возраст последней резервной копии, срок до истечения сертификата. Они предупреждают об отказе заранее.


Набор инструментов

Роли и один рабочий набор под них. Это не единственный возможный набор: те же роли закрывают и другие инструменты, в том числе управляемые службы. Критерий выбора здесь один — всё ставится контейнерами рядом с приложением, не требует внешней службы и не тянет за собой отдельную эксплуатацию.

РольИнструментОбразПочему он
сбор метрикPrometheusprom/prometheus:v3.5.0опрашивает сервисы сам по HTTP: сервису не нужно знать про приёмник и уметь отправлять
хранение метрикон жесвоя база рядов внутри Prometheus; отдельное хранилище нужно, когда одного сервера мало
показGrafanagrafana/grafana-oss:12.0.0графики и таблицы поверх Prometheus; тот же экран потом покажет журналы
оповещенияAlertmanagerprom/alertmanager:v0.28.1группировка, подавление и повтор — то, что отличает оповещение от потока писем
показатели хостаnode_exporterprom/node-exporter:v1.9.1диск, память, процессор машины; приложение их не отдаёт
проверка снаружиblackbox_exporterprom/blackbox-exporter:v0.25.0запрашивает домен как обычный клиент и заодно отдаёт срок сертификата
сбор журналовGrafana Alloyсм. Журналы
хранение журналовLokiсм. Журналы

Версии образов зафиксированы: тег latest делает установку невоспроизводимой — на двух машинах окажутся разные версии, а причина расхождения не видна ни в одном файле.

Проверка, что теги существуют (список выпусков меняется; если тега нет — возьмите ближайший выпущенный и запишите его в файл compose):

bash
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…. Идентификатор в метке превращает один ряд в столько рядов, сколько было заказов;
  • идентификаторы пользователя, запроса, документа в метки не попадают вовсе. Их место — в журнале (см. Журналы): там они стоят дёшево, а искать по ним удобнее.

Проверка

Ручка не опубликована наружу, поэтому запрос идёт изнутри сети приложения. Узнайте имя сети и обратитесь к сервису по имени:

bash
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 обращается к сервисам по именам:

bash
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=<имя сети приложения>" > .env

Compose добавляет к имени сети имя проекта, поэтому в списке она выглядит как <проект>_host_net, а не host_net.

compose.yml

yaml
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):

bash
sudo install -d -m 755 /var/lib/node-exporter/textfile

Файл значений

bash
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: кого опрашивать

yaml
# 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
yaml
# 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 не стартует, наткнувшись на него.

bash
echo 'groups: []' > prometheus/alerts.yml
docker compose up -d
Проверка:
docker 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" означает, что цель недоступна: см. «Типичные отказы».

Запрос в хранилище — те же данные, что потом лягут в панели:

bash
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. Показ

Источник данных подключается файлом, а не руками в интерфейсе: настройка, сделанная руками, живёт в томе и теряется при пересоздании установки.

yaml
# grafana/provisioning/datasources/datasources.yml
apiVersion: 1
datasources:
  - name: Prometheus
    uid: prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
bash
docker compose up -d grafana

Доступ — SSH-туннелем с рабочей машины (порт наружу не публикуется):

bash
ssh -N -L 3000:127.0.0.1:3000 example.com
# в браузере: http://127.0.0.1:3000

Проверка (выполняется на сервере):

bash
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 %» — причина, и сама по себе она может быть нормальной. Из причин в оповещения попадают только те, что неизбежно приводят к отказу: место на диске, срок сертификата, возраст резервной копии.

У порога есть время удержания. Условие должно держаться несколько минут, иначе одиночный всплеск поднимает тревогу ночью. Кратковременный скачок при выкате — норма.

yaml
# 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: "потребитель медленнее отправителя: см. документ об обмене сообщениями"

Почему такие пороги

ПравилоПорог и удержаниеОснование
HighErrorRate5 % за 5 минединичные 5xx есть всегда; 5 % — уже заметная доля пользователей. Пять минут отсекают всплеск при переключении копий на выкате
SlowResponses95-й процентиль > 1 с за 10 минпорог берётся от того, что обещано пользователю, а не от текущего значения. Десять минут отличают медленный хвост от одиночного тяжёлого запроса
AppDown2 минштатный перезапуск контейнера укладывается в это время, отказ — нет
ProbeFailed3 минпроверка идёт по всей цепочке, включая домен и TLS; три минуты покрывают перечитывание конфигурации входа
DiskFillingUp15 % за 30 миностатка хватает, чтобы разобраться в рабочее время, а не ночью. Тридцать минут отсекают временные файлы сборки
CertExpiringSoon21 суткиавтопродление начинается за 30 суток; тревога через девять дней после первой неудачной попытки оставляет три недели на разбор
BackupStale26 часовсуточное расписание плюс запас на длительность самого копирования
QueueGrowing1000 за 30 мин, по минимуму окнаминимум за окно, а не мгновенное значение: всплеск, который разобрали, минимум не поднимает

Пороги — начальные значения, а не измеренные. После двух-трёх недель работы их сверяют с накопленными рядами: правило, которое ни разу не сработало на настоящем отказе, и правило, которое срабатывает без действий, одинаково бесполезны.

Чего здесь намеренно нет — правила на падение частоты запросов. Порог зависит от суточного и недельного профиля нагрузки; без накопленной истории он даёт ложные срабатывания каждую ночь. Правило вводится, когда профиль виден на графике.

Значение из скрипта (backup_last_success_timestamp_seconds) попадает в метрики через файл, который читает node_exporter. В скрипт резервного копирования после успешного снимка добавляется:

bash
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. Куда уходит оповещение

yaml
# 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.

bash
docker compose up -d alertmanager
docker compose exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml

Ожидается: SUCCESS и перечень найденного — маршрут, одно правило подавления, два получателя. Ошибка означает, что Alertmanager продолжает работать с прежним файлом: он не применяет конфигурацию, которую не смог разобрать.


Шаг 7. Проверка оповещения искусственным отказом

Проверяются две разные вещи, поэтому проверок две.

7.1. Путь доставки — доходит ли письмо вообще. Правило-пустышка срабатывает всегда:

bash
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

Через минуту:

bash
docker compose exec alertmanager amtool --alertmanager.url=http://127.0.0.1:9093 alert query

Ожидается: строка с DeliveryTest и письмо на указанном адресе. Тревога есть в Alertmanager, а письма нет — причина в почте: смотрите docker compose logs alertmanager, там видна ошибка отправки.

Уберите временное правило и перезагрузите настройки — блок добавлялся в конец файла, поэтому удаляется всё от его заголовка до конца:

bash
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/-/reload

7.2. Измерение — замечает ли система настоящий отказ. Остановите сервис и дождитесь удержания AppDown (2 минуты):

bash
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. Ничего не слушается снаружи

bash
sudo ss -ltnp | grep -E ':(3000|9090|9093)\b'

Ожидается: во всех строках адрес 127.0.0.1. Значение 0.0.0.0 или * означает, что порт открыт на всех интерфейсах.

С другой машины:

bash
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. Запрос показывает, когда версия сменилась:

bash
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 — вертикальная линия на всех панелях:

bash
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). Правило без адресата и действия не заводится.

Что делать с правилом, на которое не реагируют. Раз в месяц смотрите, какие правила срабатывали:

bash
curl -sG http://127.0.0.1:9090/api/v1/query \
  --data-urlencode 'query=sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d]))'

Для каждого правила из списка ответ один из трёх:

  1. срабатывало, по нему что-то делали — оставить;
  2. срабатывало, ничего не делали — починить: поднять порог, увеличить удержание, заменить причину на симптом;
  3. чинить нечего, действие не появится — удалить.

Третий вариант — рабочий, а не признак поражения. Игнорируемое оповещение хуже отсутствующего: оно приучает не смотреть на почту, и вместе с ним игнорируются остальные.

Ночью будят только правила с меткой severity: page. Перевод правила в page означает, что кто-то встанет и будет что-то делать; если действия ночью нет, метка ticket.


Микросервисы на разных серверах

Когда сервисы стоят на разных машинах, Prometheus не может обращаться к ним по имени контейнера. Он ходит по частной сети через nginx микросервиса — тот самый вход, который описан в Сетевом контуре. Публиковать порт сервиса ради опроса нельзя: это создаёт вход без единой проверки.

В конфигурации nginx микросервиса открывается только путь метрик и только для адреса сборщика:

nginx
location /internal/metrics {
    allow 10.0.0.20;        # адрес сервера наблюдения в частной сети
    deny all;
    proxy_pass http://backend:8000;
}

В настройке Prometheus вместо имени контейнера указывается адрес nginx микросервиса в частной сети:

yaml
  - job_name: app
    metrics_path: /internal/metrics
    static_configs:
      - targets: ["10.0.0.10:8000"]
        labels: { service: backend }

Проверка — с сервера наблюдения:

bash
curl -sf http://10.0.0.10:8000/internal/metrics | head -3   # ожидается: строки метрик

С машины, не входящей в частную сеть, тот же запрос должен завершаться отказом соединения.


Разбор отказа

Порядок: симптом → слой → причина.

  1. Что видит пользователь: ошибка, медленно, недоступно. Отсюда определяется симптом.
  2. Найти слой — проверкой контура сверху вниз (домен → шлюз → сервис → база), как описано в Сетевом контуре. Первый неотвечающий слой и есть место отказа.
  3. В журналах этого слоя найти записи по времени начала, дальше — по идентификатору запроса (см. Журналы).
  4. Сверить со временем последнего выката: совпадение по времени указывает на новую версию как на причину.

Запросы, отвечающие на шаг 1 и 2 без открывания интерфейса:

bash
# когда началось: доля ошибок за последний час
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

Полезно фиксировать по итогам: что произошло, как обнаружили, что помогло. Это дешевле, чем проходить тот же путь второй раз, и показывает, какого оповещения не хватало.


Порядок внедрения

Если ставить не всё сразу, порядок по соотношению пользы к трудозатратам:

  1. проверка домена снаружи (blackbox + правило ProbeFailed) — шаги 2–3, 5;
  2. сбор журналов в одно место с ротацией;
  3. ручка метрик и четыре величины по каждому сервису — шаг 1;
  4. оповещения на диск, сертификат, возраст резервной копии — шаг 5;
  5. сквозной идентификатор запроса в журналах;
  6. отметки о выкатах;
  7. трассировки — когда предыдущего перестанет хватать.

Первым идёт внешняя проверка: она одна отвечает на вопрос «работает ли система для пользователя» и не требует правок в приложении.


Типичные отказы

ПризнакПричинаЧто делать
цель в состоянии 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, снизить срок хранения

Откат и снятие

bash
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).

Приостановить оповещения на время работ — не удаляя правил:

bash
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 микросервиса, а не приложения.

Инструкция не помогла?

Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.