От метрики до дежурного: путь алерта через Prometheus, Alertmanager и nxs-anomaly

—

от автора

Критичный сервис лег, Prometheus показывает firing, в общем чате уже бухтят менеджеры, но к исправлению проблемы никто не приступил: один инженер решил, что проблему уже разбирает коллега, а коллега уже не дежурит.

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

Для того, чтобы разобраться, мы рассмотрим путь одной HTTP 500 ошибки: от счётчика приложения и PromQL до webhook в систему реагирования. По дороге соберём конфигурации, разберём ошибки маршрутизации и проверим, почему повторная отправка не заменяет эскалацию. Для участка с дежурствами используем наш открытый проект nxs-anomaly.

Сначала определяем, о чём будить человека

Для начала, представим сервис оформления заказов checkout. Он обрабатывает около 20 запросов в секунду, но после изменения конфигурации часть запросов начинает завершаться 5xx кодом ответа. Сигнал для дежурного здесь — код ошибки, с которыми сталкиваются пользователи.

В Google SRE Workbook глава Alerting on SLOs связывает вызов дежурного с угрозой целевому уровню надёжности — SLO. И для разных сервисов определение SLO происходит по разному, например, для сервиса запросов это может быть доля успешных операций. Поэтому, при выборе правила приходится учитывать пропущенные проблемы, ложные срабатывания, скорость обнаружения и время до снятия алерта после восстановления.

Мы возьмём простое условие для примера: более 5% HTTP 5xx за пятиминутное окно, сохраняющиеся две минуты. Это удобный способ разобрать механику. Для рабочего сервиса порог нужно обосновать его требованиями и трафиком; в той же главе Workbook разобраны правила по скорости расходования бюджета ошибок (burn rate) — с несколькими окнами наблюдения.

Вся цепочка выглядит так:

  • Приложение:

    • Запрос пользователя →

    • Счётчик в приложении →

  • Prometheus:

    • Сбор метрик Prometheus →

    • Вычисление PromQL →

    • pending →

    • firing →

  • Alertmanager:

    • Отправка сработваших алертов по HTTP API Alertmanager →

    • маршрут и группировка →

    • webhook nxs-anomaly →

  • nxs-anomaly

    • маршрут к цепочке эскалации →

    • расписание →

    • уведомление →

    • переход алерта в статус ACK

Алертом дальше называем обнаруженное условие проблемы, уведомлением — сообщение об этом условии. Один алерт может вызвать несколько уведомлений, а одно уведомление может содержать несколько алертов.

Упрощенно, в описанной схеме есть несколько элементов:

Компонент

Определение

На какой вопрос отвечает

Prometheus

Система мониторинга, которая собирает метрики, проверяет правила и отправляет сработавшие алерты в Alertmanager

Выполняется ли условие для фиксирования проблемы и достаточно ли долго?

Alertmanager

Инструмент обработки алертов: группирует их, подавляет лишние уведомления и направляет события нужным получателям

Какие алерты объединить, какие отключить и в какую интеграцию отправить?

nxs-anomaly

Система управления дежурствами и эскалациями: определяет ответственного, уведомляет его и подключает следующих участников при отсутствии подтверждения.

Кто сейчас отвечает за событие и кому позвонить при отсутствии обратной связи по алерту?

Такое разделение вычисления правил и уведомлений описано в обзоре архитектуры алертинга Prometheus. Дальше важно не терять границы: успешная работа одного компонента не подтверждает результат всей цепочки.

Как ошибка становится временным рядом

Допустим, наши разработчики молодцы и добавили метрики в наше приложение. Оно считает запросы к себе, увеличивает счётчик завершённых запросов и отдаёт его через /metrics:

```text# HELP checkout_http_requests_total Total completed HTTP requests.# TYPE checkout_http_requests_total countercheckout_http_requests_total{status="200"} 120000checkout_http_requests_total{status="500"} 0

counter хранит накопленное число событий. Само по себе значение 120000 особо ничего не говорит о текущем состоянии сервиса, например счётчик мог расти несколько дней. Для алертинга понадобится скорость его изменения — сколько запросов и ошибок появляется за единицу времени.

Далее, для нашего примера зададим конфиг prometheus.yml:

global:  scrape_interval: 15s  evaluation_interval: 15srule_files:  - checkout.rules.ymlscrape_configs:  - job_name: checkout    metrics_path: /metrics    static_configs:      - targets: ["checkout:8080"]        labels:          service: checkout          environment: production          team: checkoutalerting:  alertmanagers:    - static_configs:        - targets: ["alertmanager:9093"]

Здесь checkout:8080 и alertmanager:9093 — условные адреса нашего сервиса и Alertmanager во внутренней сети. Следующим шагом разберемся, как алерты вообще зараждаются.

Модель данных Prometheus устроена следующим образом: Prometheus периодически запрашивает наш endpoint /metrics и сохраняет полученные значения с временными отметками. Имя метрики и набор меток (labels) — определяют временной ряд. Значения для status="200" и status="500" образуют разные временные ряды.

Еще у конфигурации два независимых интервала:

  • scrape_interval задаёт период получения метрик;

  • evaluation_interval задаёт период вычисления правил.

Ошибка запроса не заставляет Prometheus немедленно выполнить правило. Сначала изменившееся значение должно попасть в очередной сбор, затем — в очередное вычисление. Метки из static_configs добавятся к рядам вместе с job и instance. Параметры описаны в конфигурации Prometheus.

Как временные ряды превращаются в условие алерта

Prometheus предоставляет язык запросов, называемый PromQL (Prometheus Query), который позволяет пользователю выбирать и агрегировать данные временных рядов в реальном времени. Например, Скорость поступления всех запросов можно получить через запрос:

sum by (environment, service, team) (  rate(checkout_http_requests_total{job="checkout"}[5m]))

А скорость поступления ответов 5xx:

sum by (environment, service, team) (  rate(checkout_http_requests_total{    job="checkout",    status=~"5.."  }[5m]))

Таким образом, долю ошибок можно получить таким выражением:

sum by (environment, service, team) (  rate(checkout_http_requests_total{job="checkout",status=~"5.."}[5m]))/sum by (environment, service, team) (  rate(checkout_http_requests_total{job="checkout"}[5m]))

Числитель в этом выражении — это суммарная скорость ответов 5xx, знаменатель — скорость всех запросов. Например, 4 ошибки в секунду при 20 запросах в секунду дадут 0.2, или 20%.

Функция rate() учитывает сбросы счётчика, например после перезапуска приложения, и оценивает среднюю скорость увеличения на выбранном интервале. Порядок действий принципиален: сначала rate() для отдельных счётчиков, затем агрегирование. Если сначала сложить счётчики разных процессов, сброс одного из них может некорректно повлиять на результат.

Почему не посчитать процент ошибок на каждом экземпляре и затем взять среднее?

Экземпляр

Запросы

Ошибки

Доля ошибок

A

1000

10

1%

B

1

1

100%

Среднее арифметическое процентов — 50,5%.

Реальная доля ошибок сервиса:

(10 + 1) / (1000 + 1) ≈ 1,10%

Разница возникает из-за неодинакового трафика. Для общей доли ошибок нужно делить суммарное количество ошибок на суммарное количество запросов.

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

Для ограниченного и заранее известного набора значений удобно инициализировать счётчики нулями в приложении. Другой вариант — осознанно подставлять ноль средствами PromQL. Но подстановка не должна скрывать исчезновение самой метрики или отказ сбора. В примере предполагаем, что приложение заранее создаёт используемые серии, включая 500, с нулевыми значениями.

Теперь запишем правило алерта: чтобы не повторять длинные выражения, добавим recording rules и одно правило алертингаcheckout.rules.yml:

groups:  - name: checkout    rules:      - alert: CheckoutHighErrorRatio        expr: |          (            sum by (environment, service, team) (              rate(checkout_http_requests_total{                job="checkout",environment="production",status=~"5.."              }[5m])            )            /            sum by (environment, service, team) (              rate(checkout_http_requests_total{                job="checkout",environment="production"              }[5m])            )          ) > 0.05        for: 2m        labels:          severity: critical        annotations:          summary: "Высокая доля HTTP 5xx в {{ $labels.service }}"          description: "Более 5% ответов за окно 5m завершаются HTTP 5xx."

Recording rules сохраняют результаты выражений в виде новых временных рядов. Мы поместили зависимые вычисления в одну группу в нужном порядке: правила внутри группы выполняются последовательно. Перенос зависимых вычислений в разные группы требует учитывать порядок и время получения данных отдельно.

У выражения два условия: доля ошибок выше 5% и общий трафик больше одного запроса в секунду. Второе условие защищает этот конкретный алерт от ситуации «одна ошибка из одного запроса». Это осознанный компромисс: при низком трафике правило может не сработать даже при полной недоступности сервиса для редких пользователей. Для такого режима нужны отдельная проверка доступности или другая политика оценки ошибок.

Ещё одна важная деталь — обычное сравнение >, без модификатора bool.

service:checkout_http_errors:ratio5m > 0.05

отфильтровывает серии, не прошедшие порог. А выражение:

service:checkout_http_errors:ratio5m > bool 0.05

возвращает значения 0 и 1, сохраняя соответствующие элементы вектора. Для алертинга наличие элемента имеет значение само по себе: ноль не означает автоматически «алерт выключен». Поэтому добавление bool способно радикально изменить поведение правила.

Pending, firing и передача в Alertmanager

Теперь у проблемы появляется жизненный цикл. Когда выражение впервые возвращает элемент с определённым набором labels, экземпляр алерта становится pending. Если элемент продолжает присутствовать при последующих вычислениях в течение времени, заданного в for: 2m, состояние меняется на firing. Если условие перестаёт выполняться до истечения for, ожидание прерывается. Следующее появление условия начинает отсчёт заново.

keep_firing_for: 1m действует на другой стороне: уже сработавший алерт некоторое время остаётся активным после исчезновения условия. Этот параметр помогает сгладить краткие колебания, но увеличивает задержку восстановления. Состояния можно увидеть в интерфейсе Prometheus и в серии ALERTS.

Например:

ALERTS{  alertname="CheckoutHighErrorRatio",  alertstate="pending"}

Или:

ALERTS{  alertname="CheckoutHighErrorRatio",  alertstate="firing"}

У экземпляра нашего алерта будет примерно такой набор labels:

alertname="CheckoutHighErrorRatio"environment="production"service="checkout"team="checkout"severity="critical"

Именно устойчивость этого набора позволяет рассматривать последовательные вычисления как продолжение одной проблемы.

Если записывать текущее значение процента ошибок в label, оно будет меняться вместе с измерением. Система начнёт видеть разные экземпляры алерта. Меняющиеся показатели следует помещать в annotations.

По той же причине добавление instance в результат агрегации — довольно содержательное решение. Без него мы обнаруживаем ухудшение сервиса в целом. С ним получаем отдельные алерты по экземплярам. Второй вариант полезен для локализации, но может создать десятки параллельных событий при одной общей причине.

Посчитаем задержку до firing. Окно [5m] часто воспринимают как команду «подождать пять минут». На практике это интервал данных, по которому считается скорость. После начала инцидента новые ошибки постепенно меняют результат. Предположим:

  • до инцидента ошибок не было;

  • трафик оставался постоянным;

  • после начала инцидента доля ошибок сразу выросла до 20%;

  • пятиминутное окно уже заполнено данными;

  • сборами и вычислениями пока пренебрегаем.

При таких допущениях доля ошибок в окне приблизительно равна:

Доля ошибок в окне ≈ 20% × t / 300 секунд

Порог 5% достигается примерно через:

t ≈ 300 × 5% / 20% = 75 секунд

Далее начинает действовать for: 2m. Получаем около 195 секунд от начала инцидента до готовности алерта перейти в firing, плюс дискретность сбора и вычисления. Точное время будет зависеть от реальных выборок, фаз таймеров и поведения rate() на границах окна.

Из этого расчёта следует полезный вывод: одинаковый порог обнаруживает разные по тяжести инциденты с разной скоростью. Если ошибки сразу достигают 100%, пятиминутное окно пересечёт порог 5% гораздо быстрее. Если доля ошибок выросла лишь немного выше 5%, пересечение займёт существенно больше времени.

Поэтому, длительность окна, порог и for нужно выбирать совместно. Иначе правило, которое выглядит «быстрым» из-за небольшого for, будет долго реагировать из-за сглаживания.

Отправка в Alertmanager

Когда алерт переходит в firing, Prometheus передаёт информацию о нём в Alertmanager. Здесь начинается другой процесс: решение о том, когда и куда отправить уведомление. В рамках статьи используем следующую конфигурацию Alertmanager:

route:  receiver: nxs-anomaly-checkout  group_by:    - environment    - service    - alertname    - severity  group_wait: 30s  group_interval: 1m  repeat_interval: 4hreceivers:  - name: nxs-anomaly-checkout    webhook_configs:      - url_file: /etc/alertmanager/secrets/nxs-anomaly-url        send_resolved: true        max_alerts: 0

Файл /etc/alertmanager/secrets/nxs-anomaly-url должен содержать полный адрес интеграции:

https://anomaly.example.com/integrations/v1/alertmanager/REPLACE_WITH_INTEGRATION_KEY

Корневой маршрут в примере принимает все алерты этого Alertmanager. Для общего сервера нескольких команд понадобятся дочерние маршруты с matchers и осмысленный резервный получатель. Порядок дочерних маршрутов имеет значение; продолжение поиска после совпадения определяется continue.

send_resolved: true позволяет передавать восстановление. max_alerts: 0 отключает ограничение количества алертов в webhook, чтобы пример не терял отдельные элементы при усечении.

Три таймера выполняют разные задачи:

Параметр

Значение в примере

Назначение

group_wait

30 секунд

Начальное ожидание перед первым уведомлением новой группы

group_interval

1 минута

Период обработки изменений уже уведомлённой группы

repeat_interval

4 часа

Интервал повторного уведомления о продолжающейся проблеме без изменений

Если первая проблема попала в новую группу в 12:00:00, первая отправка ожидается примерно в 12:00:30. Если новый алерт той же группы появился в 12:00:40, он не обязательно немедленно вызовет отдельный webhook: обновление будет обработано с учётом group_interval.

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

Группировка помогает сократить поток уведомлений, но не устанавливает причину инцидента: если у двадцати экземпляров сервиса возникла одна проблема, Alertmanager может отправить их одним сообщением. Из этого не следует, что он доказал общую первопричину.

Отдельно работают два механизма заглущении:

  • Silence временно подавляет уведомления по совпадающим labels, например на время обслуживания.

  • Inhibition подавляет одни уведомления при наличии других алертов, например вторичные симптомы при известном отказе зависимости.

Оба механизма влияют на уведомления. Наличие активной проблемы и факт отправки сообщения остаются разными состояниями. Для нашей цепочки это имеет практическое последствие: если Alertmanager не отправил webhook из-за silence, nxs-anomaly может вообще не узнать о начале этой проблемы.

Но silence, созданный после уже доставленного webhook, сам по себе не отменяет начавшуюся эскалацию в nxs-anomaly. Это разные системы с разными состояниями. Такой сценарий нужно учитывать в регламенте обслуживания.

На границе Alertmanager и nxs-anomaly передаётся JSON с массивом alerts. Упрощённый фрагмент выглядит так:

{  "version": "4",  "receiver": "nxs-anomaly-checkout",  "status": "firing",  "groupKey": "opaque-alertmanager-group-key",  "groupLabels": {    "environment": "production",    "service": "checkout",    "alertname": "CheckoutHighErrorRatio",    "severity": "critical"  },  "alerts": [    {      "status": "firing",      "labels": {        "alertname": "CheckoutHighErrorRatio",        "environment": "production",        "service": "checkout",        "team": "checkout",        "severity": "critical"      },      "annotations": {        "summary": "Высокая доля HTTP 5xx в checkout",        "description": "Доля HTTP 5xx выше 5% при трафике более 1 запроса в секунду."      },      "startsAt": "2026-09-23T09:03:30Z",      "fingerprint": "0123456789abcdef"    }  ]}

Значения времени и fingerprint здесь иллюстративные — служебные поля сокращены. В одном webhook могут находиться несколько алертов с разными индивидуальными состояниями. Общий status: firing не означает, что каждый элемент массива продолжает срабатывать: достаточно наличия активных элементов в группе. Для обработки отдельных событий нужно смотреть на их собственные поля.

Особенно важно различать несколько сущностей:

Сущность

Что она представляет

Временной ряд

Измерения с определённым набором labels

Экземпляр алерта Prometheus

Результат правила для конкретного набора labels

Группа Alertmanager

Набор алертов, объединяемых для уведомления

Группа алертов nxs-anomaly

Состояние проблемы внутри конкретной интеграции

Попытка доставки

Отдельное обращение к Telegram, SMTP или другому каналу

Отправка в алерта в nxs-anomaly

В nxs-anomaly webhook принимается по адресу:

POST /integrations/v1/alertmanager/{key}

{key} — ключ конкретной интеграции. Он отличается от ключа административного API.

Обработка алерта на уровне nxs-anomaly включает:

  • нормализацию входных данных,

  • выбор маршрута,

  • pipeline преобразования алерта с целью его обогощения данными,

  • поиск подходящей открытой группы,

  • запуск предусмотренной обработки.

У порядка есть значимая особенность: маршрут выбирается по исходным labels до преобразований pipeline. Если добавить team=checkout только внутри pipeline, это не заставит уже выполненный выбор маршрута сработать так, как будто label присутствовал изначально.

Поэтому признаки, необходимые для маршрутизации, лучше формировать на стороне источника. В нашем примере team, service и environment приходят из Prometheus.

Для Alertmanager заголовок строится с учётом annotations.summary, описание — с учётом annotations.description, а уровень серьёзности проблемы берётся из labels.severity и нормализуется.

Самая важная деталь касается идентификации: при наличии fingerprint именно он используется как ключ дедупликации. Если fingerprint отсутствует, реализация может вычислить его из labels; для других неполных вариантов входных данных предусмотрены дополнительные варианты получения ключа.

Поэтому:

Один webhook Alertmanager не обязательно создаёт одну группу в nxs-anomaly.

Если в alerts[] находятся десять элементов с разными fingerprints, внутри nxs-anomaly они могут соответствовать десяти отдельным группам. group_by в Alertmanager управляет упаковкой уведомления, а не принудительным объединением всех событий в одну проблему downstream.

Подробности этих этапов описаны в документации обработки алертов nxs-anomaly. Для проектирования интеграции это означает следующее: сначала нужно определить желаемую гранулярность проблемы. Например, если нужен один алерт на сервис, соответствующую агрегацию обычно стоит выполнить в PromQL. Группировка множества экземпляров в одном webhook сама по себе не превращает их в один экземпляр алерта.

Обработка алерта в nxs-anomaly

После приёма события система должна определить, кому оно адресовано. Для этого в nxs-anomaly нужны пользователи с настроенными каналами, команда, расписание, цепочка эскалации и интеграция, использующая эту цепочку. Сначала зададим человеческий сценарий:

За checkout отвечает команда Checkout. Первое уведомление получает текущий дежурный. Если проблема остаётся без подтверждения, через пять минут подключается резервный инженер. Ещё через десять минут уведомляется вся команда.

Пример тела запроса для создания расписания через административный API:

{  "name": "Checkout primary",  "team_id": "REPLACE_WITH_TEAM_ID",  "timezone": "Europe/Moscow",  "enabled": true,  "rotation": {    "enabled": true,    "start_at": "2026-09-21T10:00:00+03:00",    "handoff_interval": 1,    "handoff_unit": "weeks",    "participant_ids": [      "REPLACE_WITH_USER_A_ID",      "REPLACE_WITH_USER_B_ID"    ]  }}

Идентификаторы заменяются значениями реально созданных объектов.

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

Кроме обычной ротации, при выборе дежурного учитываются замены. Поэтому проверять нужно результат расписания на интересующую дату, а не только список участников.

Теперь зададим цепочку:

{  "name": "Checkout critical",  "steps": [    {      "kind": "NOTIFY_SCHEDULE",      "schedule_id": "REPLACE_WITH_SCHEDULE_ID"    },    {      "kind": "WAIT",      "delay_minutes": 5    },    {      "kind": "NOTIFY_USER",      "user_ids": [        "REPLACE_WITH_BACKUP_USER_ID"      ]    },    {      "kind": "WAIT",      "delay_minutes": 10    },    {      "kind": "NOTIFY_TEAM",      "team_id": "REPLACE_WITH_TEAM_ID"    }  ]}

Здесь есть несколько деталей, которые стоит проговорить до первого инцидента.

  • NOTIFY_SCHEDULE выбирает дежурного на момент исполнения шага. Если шаг выполняется после смены дежурства, получатель может отличаться от человека, дежурившего при поступлении исходного события.

  • NOTIFY_TEAM обращается ко всей команде. Это широкая эскалация, поэтому она поставлена последней.

  • WAIT означает ожидание внутри цепочки. Его нельзя интерпретировать как «пять минут после того, как человек прочитал сообщение». Прочтение и выполнение шага — разные события.

Наконец, в расписании не должно быть непреднамеренных пробелов. Ограничения рабочих часов могут оставлять интервалы без дежурного. В рассматриваемой версии предусмотрена проверка покрытия для шагов с расписаниями; обход такой проверки должен быть осознанным решением.

Свяжем цепочку с интеграцией:

{  "name": "Prometheus checkout production",  "type": "alertmanager",  "team_id": "REPLACE_WITH_TEAM_ID",  "default_chain_id": "REPLACE_WITH_CHAIN_ID"}

После создания интеграции её ключ используется в адресе webhook Alertmanager. Форматы объектов и порядок настройки приведены в руководстве по конфигурации nxs-anomaly.


На этом мы заканчиваем верхнеурвневый обзор связки Prometheus, Alertmanager и nxs-anomaly. В следующей части статьи мы рассмотрим настройку всех элементов nxs-anomaly подробно, заверенем их в Terraform провайдер и получим наш первый алерт.

Для ознакомления вам могут быть полезны следующие ссылки:

ссылка на оригинал статьи https://habr.com/ru/articles/1086794/