SLO, error budget и алертинг без шума

Как превратить метрики в решения: SLI/SLO/SLA, error budget и burn rate, multi-window multi-burn-rate алерты, symptom- vs cause-based подход и борьба с алерт-шумом через Alertmanager. Практика на стеке khorost.tech.

Дашборд с метриками отвечает на вопрос «как дела». SLO отвечает на вопрос, который важнее: «пора ли что-то делать и будить ли человека ночью». Без SLO алертинг скатывается либо в паранойю (пищит на каждый скачок), либо в глухоту (молчит до реального инцидента). Error budget переводит абстрактную «надёжность» в конкретный бюджет, который можно тратить и за который можно отвечать.

Это пятая статья серии «Наблюдаемость на практике». Метрики дали числа — здесь они превращаются в пороги, бюджеты и алерты, на которые не стыдно реагировать.

Всё, что ниже, проверено на стенде: Prometheus 3.13.2, Alertmanager 0.33.1, приёмник уведомлений на Go, сценарий из деградации и последующего покоя. Две вещи из этой работы оказались важнее теории. Первая: правило burn rate прошло promtool и выдавало неверное число, причём такое, при котором алерт всё равно срабатывал — дефект бы не нашёлся при проверке «алерт зажёгся». Вторая: алерт гаснет заметно дольше, чем зажигается, и об этом лучше узнать не в момент инцидента.

Вытянутый резервуар, наполненный слева и утекающий справа; ниже два окна наблюдения разной ширины со стрелками-индикаторами, линии от обоих сходятся в одну и ведут к звонку

В статье

SLI, SLO, SLA: кто кому что обещает

Три буквы, которые постоянно путают.

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

SLO — целевое значение SLI на окне: «99,9 % запросов без ошибки за 30 дней». Внутренняя цель.

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

На стенде я задал два SLI, и оба в конфиге, а не на словах:

- record: slo:availability:ratio_rate5m
  expr: |
    1 - (
      job:http_server_requests_errors:rate5m
        /
      job:http_server_requests:rate5m
    )

Четырёхсотые в бюджет не входят: 404 на несуществующий артикул — правильная работа системы. Если включить их в SLI, бюджет надёжности начнут тратить пользователи, ошибающиеся в вводе.

Знаменатель намеренно не занулён. При отсутствии трафика правильный ответ — «данных нет», а не «доступность 100 %»: подставленная единица показала бы идеальное здоровье у остановленного сервиса. Что с этим делать — ниже, в разделе про пропажу трафика.

- record: slo:latency:ratio_rate5m
  expr: |
    sum by (service_name) (
      rate(http_server_request_duration_seconds_bucket{le="0.1"}[5m])
    )
      /
    sum by (service_name) (
      rate(http_server_request_duration_seconds_count[5m])
    )

Порог 0,1 с здесь не круглое число ради красоты — он совпадает с границей бакета. Иначе доля «быстрее порога» считалась бы интерполяцией внутри бакета, то есть оценкой с точностью в его ширину. В статье про метрики есть замер этого эффекта: два p95 на одних и тех же запросах разошлись только из-за сетки границ. Если нужного значения в сетке нет — добавьте границу, а не считайте между.

Бюджет ошибок и burn rate

Бюджет ошибок — это 100 % − SLO. При цели 99 % допустимо 1 % запросов с ошибкой за окно. Смысл не в арифметике, а в том, что надёжность становится ресурсом: бюджет есть — можно катить релизы и рисковать; бюджет сгорел — останавливаемся и стабилизируем. Это переводит спор «фичи против надёжности» из вкусового в счётный.

Burn rate — во сколько раз быстрее бюджет тратится по сравнению с темпом, при котором его хватило бы ровно на весь период.

- record: slo:error_budget_burn:rate5m
  expr: |
    (
      job:http_server_requests_errors:rate5m
        /
      job:http_server_requests:rate5m
    ) / 0.01

Число 0.01 в знаменателе — это 1 − цель SLO. Для 99,9 % там будет 0.001, и пороги алертов при этом не меняются: они выражены в burn rate, а не в долях ошибок, именно чтобы не пересчитывать их при смене цели.

Что означают значения:

Burn rate Смысл
1 бюджет истратится ровно к концу периода — норма
6 5 % месячного бюджета за 6 часов — посмотреть в рабочее время
14,4 2 % месячного бюджета за час; весь бюджет за 50 часов — будить человека

Откуда 14,4: месяц (30 дней) делённый на 50 часов. При таком темпе весь месячный бюджет уходит за 50 часов, а за час — 2 % бюджета. Это порог из практики SRE, а не свойство природы; он оправдан там, где месячное окно и цель в районе трёх девяток.

Почему нельзя обойтись абсолютным порогом ошибок («алерт, если ошибок больше пятидесяти в минуту»): такой порог не учитывает объём. Пятьдесят ошибок при миллионе запросов — норма, при трёхсот запросах — катастрофа. Burn rate нормирует на объём по построению.

Обратную сторону этого свойства я получил на стенде случайно, и она стоит отдельного абзаца. Проверка алертов, запущенная сразу после другого прогона (4000 запросов, 3595 успешных), объявила отказ: алерт не сработал при 400 ответах 502 из 400. Наиболее вероятное объяснение — успешный трафик предыдущего прогона, ещё лежавший в окнах rate: он разбавил долю ошибок ниже порога 14,4 %. Но точного числа здесь нет и быть не может. Предыдущая нагрузка была смешанной и сама содержала 5xx, поэтому её ошибки из числителя не исключить, а суммы запросов за два прогона не говорят, какая доля попала в конкретное окно на момент проверки. Фактические значения обоих окон в этот момент не сохранены — так что это гипотеза, согласующаяся с наблюдением, а не доказанная причина. Дальнейший опыт с ожиданием опустошения окон её поддерживает: после него та же деградация алерт зажигала.

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

В самой проверке это лечится ожиданием: перед фазой деградации скрипт ждёт не только затухания алерта, но и опустошения окна rate. Ожидания «алерт погас» недостаточно — он гаснет раньше, чем из окна уходит успешный трафик.

И одного окна недостаточно тоже. Условие быстрого алерта — конъюнкция по 5m и 10m, а длинное окно опустошается позже. На прогоне это выглядело так: короткое окно опустело, деградация подана, 400 ответов 502 получены — и зажёгся только SLOBurnRateSlow с порогом 6, а Fast остался в pending. Правило работало верно, в окне 10m ещё лежал прежний успешный трафик.

Мелкая деталь на ту же тему, стоившая отдельного прогона: максимум из двух окон наивно не посчитать. max(A or B) вернёт A, если A существует: оператор or не объединяет значения, а для серий с одинаковыми лейблами берёт левую сторону. С таким выражением длинное окно осталось бы непроверенным, причём молча. Внутри PromQL максимум вычислить можно, если сначала различить серии дополнительным лейблом — тогда or вернёт обе, и max выберет большую:

max without (window) (
    label_replace(A, "window", "5m",  "", "")
  or
    label_replace(B, "window", "10m", "", "")
)

Стенд считает этот максимум в скрипте проверки, вне Prometheus; оба способа дают одно и то же.

Правило, которое прошло promtool и врало

Это самая полезная находка Task’а, и она не про SLO, а про PromQL.

В статье 4 описан приём: числитель доли ошибок нужно занулять, иначе сервис, ни разу не отдавший 5xx, выпадает из правила целиком. Я применил тот же приём к burn rate на длинном окне:

(
  sum by (service_name) (rate(...{status=~"5.."}[10m]))
  or on (service_name)
  (0 * sum by (service_name) (rate(...[10m])))
    /
  sum by (service_name) (rate(...[10m]))
) / 0.01

promtool check rules — SUCCESS. Синтаксис верен. Семантика нет: у оператора or приоритет ниже, чем у деления, поэтому выражение читается как

числитель  ИЛИ  (нуль / знаменатель)

То есть основная ветка на знаменатель не делится вовсе. Получается абсолютный rate ошибок, делённый на 0,01.

Замер при нагрузке из одних 502, то есть при доле ошибок ровно 100 %:

без скобок : 16,79
со скобками: 100,00

Верно второе: доля 100 % при цели 99 % даёт burn rate 1 / 0.01 = 100.

И вот чем это особенно коварно. Значение 16,79 тоже больше порога 14,4, так что алерт сработал бы. Проверка «алерт зажёгся на деградации» дефект бы не нашла. А зависит это число от абсолютного RPS: при слабом трафике полностью сломанный сервис остался бы ниже порога, и алерт молчал бы именно тогда, когда нужен.

Лечится внутренними скобками вокруг всего or:

- record: slo:error_budget_burn:rate10m
  expr: |
    (
      (
        sum by (service_name) (
          rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[10m])
        )
        or on (service_name)
        (0 * sum by (service_name) (rate(http_server_request_duration_seconds_count[10m])))
      )
        /
      sum by (service_name) (rate(http_server_request_duration_seconds_count[10m]))
    ) / 0.01

Вывод шире одного правила: promtool проверяет синтаксис, а не смысл. Числа правил надо сверять с ручным расчётом хотя бы один раз — на известной нагрузке, где ответ можно посчитать в голове. Нагрузка из одних ошибок для этого идеальна: ожидаемый burn rate равен 1 / (1 − цель), и любое другое число — дефект.

Multi-window: зачем два окна вместо одного

Условие алерта требует превышения порога одновременно на коротком и на длинном окне:

- alert: SLOBurnRateFast
  expr: |
    slo:error_budget_burn:rate5m > 14.4
      and
    slo:error_budget_burn:rate10m > 14.4
  for: 30s
  labels:
    severity: critical
    slo: availability
  annotations:
    summary: "бюджет ошибок тратится быстро: {{ $labels.service_name }}"

Смысл конъюнкции — не в строгости, а в шуме. Короткое окно реагирует быстро, но дёргается на всплесках. Длинное подтверждает, что деградация не мгновенная, но само по себе опаздывает. Одно короткое окно даёт ложные срабатывания, одно длинное — пропуски и опоздания.

Медленный алерт устроен так же, но с порогом 6 и severity: warning: 5 % бюджета за 6 часов не требует ночного звонка, а требует, чтобы кто-то посмотрел в рабочее время.

Про окна важная оговорка. У меня в конфиге 5m и 10m, а for: 30s. Это стендовые значения, сжатые, чтобы алерт успел сработать за время прогона проверки. В проде окна для быстрого алерта — 5m и 1h при окне SLO 28–30 дней, а for обычно 2m. Копировать стендовые числа нельзя: на сжатых окнах алерт реагирует на случайный всплеск в несколько запросов.

Что менять при переносе, у меня записано прямо в конфиге у каждого правила — иначе через месяц это знание теряется, а числа остаются.

Сколько на самом деле длится «сработал»

Теория говорит про окна. Практика интересуется, сколько пройдёт от начала деградации до звонка. Замерил: два прогона по 400 запросов, все с отказом сервера.

прогон 1: 84 с ожидания, 93 с от начала подачи нагрузки
прогон 2: 70 с ожидания, 77 с от начала подачи нагрузки
прогон 3: 73 с ожидания, 83 с от начала подачи нагрузки

Разброс 14 с при значениях около 76 — примерно 18 %, то есть заметно меньше самой величины, и вывод делать можно: при окнах 5m/10m и for: 30s задержка составляет 70–85 секунд.

Из чего она складывается:

  • 30 с даёт сам for — это гарантированная часть, её видно в конфиге;
  • остальные 40–55 с стенд по фазам не раскладывал. Туда входят и рост доли ошибок в окне rate[5m] до порога, и интервалы конвейера — экспорт метрик, скрейп, вычисление правил. Окно, вероятно, даёт заметную часть — оно усредняет, и мгновенная деградация видна не мгновенно, — но сколько именно, этим замером не установлено.

Вклад окна — то, что обычно недооценивают. Пятиминутное окно не значит «узнаю через пять минут»; оно значит «узнаю, когда доля в окне вырастет достаточно», а это зависит от того, насколько резко всё сломалось.

Алерт гаснет дольше, чем зажигается

Замер той же проверки, вторая фаза:

прогон 1: 259 с
прогон 2: 272 с
прогон 3: 292 с

Против 70–85 секунд на срабатывание — втрое-вчетверо дольше. Причина симметричная и очевидная задним числом: алерт гаснет, когда ошибки вытесняются из окна rate[5m], то есть примерно через длину окна после того, как деградация прекратилась.

Практическое следствие стоит знать заранее: после починки алерт горит ещё около длины окна, и это не поломка. Люди, не знающие этого, начинают чинить уже починенное — а хуже, начинают править порог, потому что «алерт врёт».

Обратная половина проверки

Проверка «алерт сработал на деградации» ничего не стоит в одиночку. Алерт, который горит всегда, тоже «сработал». Пропущенными остаются и слишком низкий порог, и правило, вычисляющее мусор — как то самое с приоритетом or.

Поэтому сценарий проверки двухфазный, и обе фазы обязательны:

./scripts/verify-alerts.sh 400 300
ГОДЕН   состояние на деградации                  firing (ожидалось firing)
ГОДЕН   алертов в Alertmanager                   1 (нужно не меньше 1)
ГОДЕН   уведомлений firing в приёмнике           1 (нужно не меньше 1)
ГОДЕН   недоставленных уведомлений               0 (ожидалось 0)
ГОДЕН   алерт погас после деградации             inactive (ожидалось inactive)
ГОДЕН   уведомлений resolved                     2 (нужно не меньше 1)
ГОДЕН   состояние на нормальной нагрузке         inactive (ожидалось inactive)
ГОДЕН   ложных уведомлений firing                0 (ожидалось 0)
ГОДЕН   фаза деградации воспроизвелась           400 (ожидалось 400)

Три вещи в этом вердикте стоит отметить.

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

Деградация подтверждается отдельно. Прежде чем ждать алерт, скрипт сверяет, что получено ровно 400 ответов 502 из 400 запросов. Ждать срабатывания, не убедившись, что ломать удалось, — это способ получить ложный вывод «алерт не работает».

Режим нагрузки только 5xx, и это не случайно. В правилах RED четырёхсотые из бюджета ошибок исключены намеренно, поэтому план из 404 алерт бы не зажёг, и проверка молча превратилась бы в проверку ничего. Генератор в режиме -errors-only шлёт только тот артикул, который валит бэкенд:

func BuildErrorPlan(n int, _ int64) []Step {
	if n <= 0 {
		return nil
	}
	plan := make([]Step, 0, n)
	for i := 0; i < n; i++ {
		plan = append(plan, Step{SKU: "SKU-BOOM", Quantity: 1, WantStatus: http.StatusBadGateway})
	}
	return plan
}

До «не меньше» в вердикте ослаблены три критерия: число алертов в Alertmanager и число уведомлений firing и resolved. Причина одна: на стенде деградируют оба сервиса сразу (go-frontend отдаёт 502 именно потому, что java-backend отдал 500), и алертов бывает один или два — в зависимости от того, успели ли оба пересечь порог к моменту опроса; число уведомлений следует за числом алертов (в этом прогоне resolved пришло два). Остальные проверки — точные равенства.

Дыра, которую видно только замером

В статье 4 обнаружилось: histogram_quantile от нулевого rate возвращает NaN, а NaN не проходит ни одно сравнение. Проверено запросами — не срабатывает ни p95 > порог, ни обратное p95 < порог. Доля ошибок при нулевом трафике ведёт себя так же: её не «ноль», её просто нет.

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

Ловится это только отдельным условием на сам факт наличия трафика. Первая версия у меня была такой — и она не работала так, как я написал:

sum(job:http_server_requests:rate5m) == 0
  or
absent(job:http_server_requests:rate5m)

sum без by схлопывает всё в одну серию без лейблов, и отсюда два следствия, каждое из которых убивает замысел.

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

У сработавшего алерта нет лейбла service_name, поэтому подавление с equal: [service_name] не совпадёт никогда. Конфиг выглядит настроенным, а подавления нет — дефект проявится только в инциденте, когда дежурный получит лишние уведомления.

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

Ошибка вторая: «нет трафика» и «сервис не работает» — разные утверждения

Я завёл инвентарь из двух сервисов и потребовал от каждого положительного HTTP-rate:

slo:service:expected unless (job:http_server_requests:rate5m > 0)

На стенде это неправда. java-backend получает запросы производно — только когда кто-то оформляет заказ. Стенд стоит без нагрузки, Java совершенно исправна, а алерт горит critical. В проде так выглядит любой внутренний сервис ночью.

Значит для каждого сервиса надо явно сказать, какой сигнал от него ожидается. Контрактов получилось два.

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

- record: slo:service:alive
  expr: |
    max by (service_name) (go_memory_used_bytes) * 0 + 1
      or
    max by (service_name) (jvm_memory_used_bytes) * 0 + 1

Имена метрик взяты по факту, а не по памяти: у Go это go_memory_used_bytes — именно она, потому что go_goroutines приходит без service_name и для разреза по сервису не годится. Умножение на нуль плюс единица превращает любое значение в признак «жив», сохраняя лейбл.

- alert: ServiceDown
  expr: |
    slo:service:expected
      unless
    slo:service:alive
  for: 1m

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

- alert: NoTrafficAtEntrypoint
  expr: |
    label_replace(vector(1), "service_name", "go-frontend", "", "")
      unless
    (job:http_server_requests:rate5m{service_name="go-frontend"} > 0)
  for: 1m
  labels:
    severity: warning

warning, а не critical, и это осознанно: отсутствие заказов ночью — нормальное состояние, повод посмотреть, а не будить. Для внутреннего сервиса с собственным контрактом (обработка очереди, cron, black-box probe) заводят отдельный алерт по его своему счётчику — числу обработанных сообщений, например, — а не по входящему HTTP.

Оговорка про стенд, чтобы severity не выглядел декорацией. На стенде оба уровня уходят в один и тот же webhook — проверке нужен единственный приёмник, по которому можно точно посчитать доставленные уведомления. Само по себе слово warning никого не будит и никуда не направляет: направляет маршрут. В проде разница между «звонок» и «тикет» делается ровно здесь:

route:
  receiver: tickets          # адресат по умолчанию: очередь задач
  group_by: [alertname, service_name]
  routes:
    - matchers:
        - severity = "critical"
      receiver: pager        # только это будит человека
      group_wait: 0s
    - matchers:
        - severity = "warning"
      receiver: tickets      # заводится тикет, дежурный не просыпается

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

Проверено двумя опытами на простаивающем стенде. Первый: пока оба сервиса живы и заказов нет, ServiceDown молчит, а NoTrafficAtEntrypoint зажигается по go-frontend — то есть Java больше не даёт ложную тревогу. Второй: останавливаю java-backend.

+6с   alive: go-frontend,java-backend  |  ServiceDown: —
 +59с  alive: go-frontend,java-backend  |  ServiceDown: —
 +85с  alive: go-frontend               |  ServiceDown: java-backend:pending
+137с  alive: go-frontend               |  ServiceDown: java-backend:firing

Сто тридцать семь секунд до firing, и минуту из них даёт for. Остальное — время, за которое метрика мёртвого сервиса перестала существовать: наблюдение с шагом опроса застало её исчезнувшей к 85-й секунде. Она не пропадает мгновенно: в push-модели Prometheus скрейпит не сервис, а prometheus exporter внутри Collector, и тот продолжает отдавать последние известные значения ещё какое-то время после того, как сервис замолчал.

Соблазнительно объяснить это одним параметром — metric_expiration у экспортёра. Я так и сделал, а потом проверил, и объяснение не подтвердилось. В той конфигурации параметр вообще не был задан явно; я выставил его в 2m и перемерил тот же сценарий несколько раз:

exporters:
  prometheus:
    endpoint: 0.0.0.0:8889
    resource_to_telemetry_conversion:
      enabled: true
    # От этого параметра зависит, как быстро исчезнет метрика мёртвого сервиса.
    metric_expiration: 2m

И вот что получилось. Четыре замера времени, за которое пропадает признак жизни:

Конфигурация Признак жизни исчез через
metric_expiration не задан 85 с
metric_expiration: 2m 216 с
metric_expiration: 2m (повтор) 80 с
metric_expiration: 2m (повтор) 90 с

Смотреть тут надо не на среднее, а на разброс: три прогона одной и той же конфигурации дали 216, 80 и 90 секунд. Расхождение между повторами больше, чем разница между конфигурациями, — значит влияние metric_expiration в этом диапазоне мои замеры просто не разрешают, и любой вывод вида «параметр даёт столько-то секунд» был бы выдуманным. Ожидаемых «примерно 120» не вышло ни разу.

Соблазн объявить 216 промахом измерения велик, и он, возможно, и есть промах. Но выбрасывать неудобное значение только за неудобность нельзя: причину я не нашёл, а значит это часть наблюдаемого поведения, и планировать надо по худшему.

Слагаемых больше одного: сама экспирация, периодичность, с которой экспортёр её применяет, staleness-логика Prometheus, интервал вычисления правила (15 с) и for. Разложение по слагаемым я не измерял.

Практический вывод от этого не страдает, а становится честнее: в push-модели нижняя граница обнаружения смерти сервиса задаётся конвейером экспорта, а не вашим for, она составляет минуты, а не секунды, и величину эту надо мерить у себя — и не по одному прогону. Уменьшать metric_expiration можно, но рассчитывать по нему время обнаружения нельзя, а риск ложных «сервис умер» при обычных паузах в экспорте растёт вполне надёжно.

Глобальный случай — когда молчит вообще всё — остаётся отдельным вопросом («сломался сервис или лежит весь стенд»), и в подавлении по сервису такой алерт не участвует.

Symptom против cause и критерий существования алерта

Правило простое: будить человека — на симптом, причину расследовать инструментами.

Симптом — то, что чувствует пользователь: выросла доля ошибок, выросла латентность, запросы не обслуживаются. Причина — заполнился диск, перезапустился под, вырос пул соединений. Алерт на причину шумит: диск, заполнившийся на 85 % в три часа ночи, сам по себе никого не беспокоит, если пользователи не заметили.

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

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

Метрики USE из статьи 4 — типичный пример того, что должно жить на дашборде, а не в алертах: утилизация пула соединений сама по себе не повод для звонка, она повод для объяснения, когда позвонил симптом.

Alertmanager: группировка, подавление, доставка

Prometheus только вычисляет правила. Группировкой, подавлением и доставкой занимается Alertmanager.

route:
  receiver: webhook
  group_by: [alertname, service_name]
  group_wait: 5s
  group_interval: 10s
  repeat_interval: 30s

  routes:
    - matchers:
        - severity = "critical"
      receiver: webhook
      group_wait: 0s

group_by определяет, что считается одним инцидентом. Здесь в него входит service_name, и это не косметика: без него два деградировавших сервиса дали бы одно уведомление, и проверка «пришло два» не прошла бы — при том что алерты сработали правильно.

Таймеры в этом конфиге тоже стендовые. В проде group_wait около 30 с, group_interval 5 м, а repeat_interval — 4 часа: более частые напоминания превращаются в шум, от которого перестают реагировать. У критических group_wait: 0s — смысл быстрого алерта в скорости, ждать ради красивой группы нечего.

Подавление производных алертов

Когда сервис не обслуживает запросы, алерты про его долю ошибок и латентность бессмысленны — у них NaN или пустота. Без подавления дежурный получит несколько уведомлений об одной остановке.

inhibit_rules:
  - source_matchers:
      - alertname = "ServiceDown"
    target_matchers:
      - slo = "availability"
    equal: [service_name]

  - source_matchers:
      - alertname = "ServiceDown"
    target_matchers:
      - alertname = "NoTrafficAtEntrypoint"
    equal: [service_name]

Источником стоит именно per-service алерт, и это исправление той же ошибки, что описана выше: в первой версии источником был глобальный алерт без лейбла service_name, поэтому equal: [service_name] не мог совпасть никогда, и подавление молча не работало.

Второе правило закрывает соседний случай: если сервис мёртв, сообщать отдельно, что он не принимает запросы, — это второе уведомление об одной причине.

Подавление проверяется отдельным критерием, и синтетическими алертами через API, а не сценарием нагрузки. Причина простая: чтобы столкнуть «сервис не обслуживает запросы» и «burn rate высокий» по одному сервису естественным путём, нужен сервис, который одновременно молчит и генерирует ошибки, — состояния взаимоисключающие. Через API оба алерта подаются напрямую:

ГОДЕН   подавление: цель того же сервиса     suppressed (ожидалось suppressed)
ГОДЕН   подавление: цель другого сервиса     active (ожидалось active)
ГОДЕН   подавление: источник не подавлен     active (ожидалось active)

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

Но синтетические алерты доказывают только правило подавления: PromQL в них не участвует вовсе. Отдельный вопрос — работает ли сам ServiceDown на настоящем исчезновении сервиса. Это разные отказы, и второй молчаливее: если признак жизни взят так, что не пропадает никогда — например, по метрике без service_name или по метрике, которую отдаёт не сам сервис, — подавление будет исправно, а гасить окажется нечего. Поэтому отдельной фазой останавливается настоящий java-backend и проверяется вся цепочка:

ГОДЕН   ServiceDown: признак жизни исчез     да (ожидалось да)
ГОДЕН   ServiceDown: состояние алерта        firing (ожидалось firing)
ГОДЕН   ServiceDown: сервис вернулся         да (ожидалось да)

Третий критерий тут не формальность: если сервис не поднялся обратно, все последующие прогоны стенда будут врать, и узнать об этом лучше сразу. Фаза долгая — по измеренной выше причине она занимает несколько минут, — и отключается переменной SKIP_SERVICEDOWN=1, когда нужен быстрый прогон.

Проверка, которая ломает стенд

Любая проверка, которая что-то ломает намеренно, обязана уметь чинить это не только на нормальном пути. Прерывание на долгом ожидании или ошибка посреди фазы оставили бы java-backend лежать — и следующий прогон объявил бы отказ, причём диагноз выглядел бы как дефект стенда, а не как след прерванной проверки. Отсюда обработчик:

JAVA_STOPPED=0

restore_java_backend() {
  local rc=$?                      # код возврата сохраняем до всего остального
  if [ "$JAVA_STOPPED" = "1" ]; then
    docker compose start java-backend >/dev/null 2>&1 || echo "поднять не удалось" >&2
    JAVA_STOPPED=0                 # идемпотентность: EXIT после INT ничего не повторит
  fi
  return $rc
}

trap restore_java_backend EXIT
trap 'restore_java_backend; exit 130' INT
trap 'restore_java_backend; exit 143' TERM

Флаг ставится до остановки, а не после: между командой и присваиванием тоже можно получить сигнал. Проверял тремя опытами с настоящей остановкой контейнера: ошибка под set -e — сервис поднят, код возврата 1 сохранён; SIGTERM в цикле ожидания — поднят; SIGINT на переднем плане — поднят, код 130.

И две оговорки, которые эта проверка вскрыла — обе не про алерты, а про bash:

Trap выполняется после текущей команды, а не в момент сигнала. С одним длинным sleep 600 восстановление ждало бы все 600 секунд: сигнал доставлен, обработчик стоит в очереди. Формально работает, практически нет. Поэтому ожидания фазы 4 — циклы с коротким sleep 10, и заменять их одним длинным ожиданием нельзя.

У фонового запуска SIGINT перехватить невозможно. Оболочка выставляет его в игнорирование ещё до старта скрипта, а сигнал, уже игнорируемый на входе, trap не устанавливает вовсе — молча. Мой первый тест это и показал: фоновый процесс SIGINT попросту не заметил. SIGTERM и путь через EXIT работают в обоих режимах, так что фоновый прогон прерывают через kill -TERM.

Проверка обвинила исправный алерт

Первый же прогон новой фазы дал отказ — и оба его повода оказались не в алертах.

Первый: критерий «признак жизни исчез» сравнивал значение с 0.000000, а при пустом ответе Prometheus скрипт печатал 0 — форматирование применялось только к найденному значению. Исчезновение серии не распозналось ни разу, проверка выбрала весь предел ожидания и объявила, что выражение опирается не на сам сервис. Правило было исправно; неисправен был критерий. Урок общий и переносимый: сравнение форматированных строк обязано покрывать и случай отсутствия данных, иначе «данных нет» неотличимо от «условие не выполнено».

Второй интереснее — и тем, как я его сначала объяснил. В фазе «алерт гореть не должен» он загорелся, и в выводе стояло: успешных 271 из 300. Диагноз напрашивался сам — стенд не прогрелся, часть запросов получила 502, доля ошибок дала честный firing, а проверка обвинила алерт. Я так и написал.

Разбивка ответов этот диагноз не подтвердила:

"expected_by_status": { "201": 271, "404": 16, "502": 13 },
"actual_by_status":   { "201": 271, "404": 16, "502": 13 },
"mismatch": 0

Нормальная нагрузка намеренно содержит 16 ответов 404 и 13 ответов 502: это правдоподобный трафик, а не идеальный. Ожидаемое совпало с фактическим до единицы. Никакого непрогретого стенда не было — я взял верное число и приписал ему неверный смысл, что ровно тот случай, от которого не спасает проверка чисел на подделку.

Что именно зажгло алерт в том прогоне, я так и не установил. Правдоподобное объяснение — хвост ошибок в длинном окне: незадолго до прогона я вручную останавливал java-backend для замера, go-frontend отдавал на это 502, и они могли сложиться с 13 собственными отказами нормальной нагрузки. Проверить не вышло: на трёх последующих прогонах ложное срабатывание не воспроизвелось ни разу.

Отсюда честный статус — гипотеза, а не диагноз. Но она указала на реальную дыру в самой проверке: та переходила к нормальной нагрузке, дождавшись inactive, а погасший алерт не означает, что окно очистилось — он гаснет, как только доля падает ниже порога, а ошибки живут в окне ещё минуты. Теперь перед нормальной фазой ожидается обнуление rate ошибок. На последнем прогоне это ожидание заняло 0 секунд: окно к тому моменту уже было пустым. Страховка, которая пока ни разу не понадобилась, — и это ровно то, что о ней известно.

Фаза 0.5 при этом осталась: она отвечает за другое — подтверждает, что система вообще исправна до начала сценария. И с первого раза она тоже не заработала: успешным считала код 200, а создание заказа отвечает 201. На полностью исправном стенде критерий не находил ни одного успешного запроса и ждал здоровья до предела. Тот же класс, что и в первом случае: критерий написан по ожиданию, а не по факту. Заодно добавлено требование нулевых транспортных ошибок — запрос, не дошедший до сервиса, в разбивку по кодам не попадает вовсе и молча не учитывается ни в успехах, ни в отказах.

Приёмник обязан отвечать

Это выяснилось ещё на разведке, и стоит отдельного упоминания. Заглушка на netcat получает POST с полным телом уведомления — и не отвечает HTTP-ответом. Alertmanager считает доставку неудачной:

level=WARN  "Notify attempt failed, will retry later" attempts=1 err="EOF"
... attempts=2 "connection reset by peer" ... attempts=3 "connection refused"
level=ERROR "Notify for alerts failed" err="notify retry canceled after 6 attempts"

Шесть попыток и явный отказ. Поэтому приёмник на стенде — настоящий сервис на Go, и он же даёт проверке точный критерий: GET /alerts возвращает число уведомлений и список алертов со статусами, POST /reset обнуляет состояние перед сценарием.

Ответ 400 на некорректное тело, а не 500, — тоже осознанно: при 5xx Alertmanager уходит в повторы, и в логах это выглядит как проблема доставки, хотя дело в теле запроса.

// Ответ обязателен. Приёмник, который не отвечает HTTP-ответом, Alertmanager
	// считает недоступным: проверено заглушкой на netcat — шесть попыток и
	// «notify retry canceled after 6 attempts» в логах.
	w.WriteHeader(http.StatusOK)

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

Недоставленное уведомление надо ловить метрикой

sum(alertmanager_notifications_failed_total)

Дельта за сценарий должна быть ровно нулём. Без этого критерия недоставленное уведомление остаётся незамеченным: Prometheus считает, что алерт горит, Alertmanager — что он его отправил, а человек ничего не получил.

И деталь, которая экономит время: Alertmanager вырезает url приёмника из /api/v2/status, считая его секретом. В отдаваемом конфиге есть webhook_configs с send_resolved и настройками HTTP, а поля url нет вовсе. Проверить «доставка настроена туда, куда нужно» через API нельзя — только по факту получения на стороне приёмника.

Путь от SLI до человека

flowchart LR metric["Метрика
http_server_request_duration_seconds"] subgraph prom["Prometheus"] red["recording rule RED
job:...errors:rate5m"] sli["recording rule SLI
slo:availability:ratio_rate5m"] burn["recording rule burn rate
делить на (1 - цель SLO)"] alert["alerting rule
два окна И for: 30s"] metric --> red --> sli --> burn --> alert end subgraph am["Alertmanager"] group["группировка
alertname + service_name"] inhibit["подавление
ServiceDown гасит производные"] notify["доставка
6 попыток при отказе"] group --> inhibit --> notify end alert -->|"firing"| group notify --> human["Приёмник
точный критерий проверки"] failed["alertmanager_notifications_failed_total
дельта должна быть 0"] notify -.-> failed

flowchart LR
    metric["Метрика
http_server_request_duration_seconds"] subgraph prom["Prometheus"] red["recording rule RED
job:...errors:rate5m"] sli["recording rule SLI
slo:availability:ratio_rate5m"] burn["recording rule burn rate
делить на (1 - цель SLO)"] alert["alerting rule
два окна И for: 30s"] metric --> red --> sli --> burn --> alert end subgraph am["Alertmanager"] group["группировка
alertname + service_name"] inhibit["подавление
ServiceDown гасит производные"] notify["доставка
6 попыток при отказе"] group --> inhibit --> notify end alert -->|"firing"| group notify --> human["Приёмник
точный критерий проверки"] failed["alertmanager_notifications_failed_total
дельта должна быть 0"] notify -.-> failed

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

Итог

Что стоит забрать:

  1. SLI меряют со стороны пользователя. Загрузка процессора и длина очередей в SLI не входят — они объясняют, почему плохо, а не то, плохо ли.
  2. 404 не входит в бюджет ошибок. Иначе бюджет надёжности тратят пользователи, ошибающиеся в вводе.
  3. Порог латентности обязан совпадать с границей бакета. Иначе доля «быстрее порога» — интерполяция с точностью в ширину бакета.
  4. promtool проверяет синтаксис, а не смысл. Правило burn rate прошло проверку и давало 16,79 вместо 100: у or приоритет ниже деления. И это тот сорт дефекта, который проверка «алерт зажёгся» не находит — 16,79 тоже выше порога, но зависит от абсолютного RPS.
  5. Числа правил сверяют с ручным расчётом хотя бы раз. Нагрузка из одних ошибок для этого идеальна: ожидаемый burn rate равен 1 / (1 − цель).
  6. Два окна вместо одного — не строгость, а борьба с шумом: короткое реагирует, длинное подтверждает.
  7. Задержка до срабатывания 70–85 с при окнах 5m/10m и for: 30s, из них 30 с даёт сам for. Окно усредняет, поэтому мгновенная деградация видна не мгновенно.
  8. Алерт гаснет дольше, чем зажигается: 259–272 с против 70–85. После починки он горит ещё около длины окна, и это не поломка.
  9. Проверка «не горит в покое» обязательна. Алерт, который горит всегда, тоже «сработал».
  10. При нулевом трафике алерты по латентности и доле ошибок молчат — NaN и пустота не проходят сравнений. Нужно отдельное условие на факт наличия запросов, и оно должно быть per-service: sum(...) без by видит только остановку всего стенда и лишает алерт лейбла, из-за чего подавление по сервису не совпадает никогда.
  11. Инвентарь ожидаемых сервисов задаётся явно. По метрикам его не построить: у сервиса без запросов серии нет, и «умер» от «никогда не было» метрики не отличают.
  12. Подавление надо проверять, а не настраивать. Оно молча не работает при несовпадении лейблов, и естественным сценарием его не проверить — нужны синтетические алерты через API и три критерия: цель того же сервиса подавлена, другого — нет, источник себя не подавил.
  13. Будить на симптом, причину расследовать. У алерта есть действие для дежурного прямо сейчас — или это не алерт.
  14. Приёмник обязан отвечать HTTP-ответом, а недоставленные уведомления надо ловить метрикой: иначе всё работает, а человек ничего не получил.
  15. severity ничего не делает сам по себе — направляет маршрут. Разделение «звонок против тикета» живёт в route, и приёмником по умолчанию разумно ставить самый безобидный, чтобы алерт с забытым маршрутом попадал в тикеты, а не в paging-канал.
  16. Смерть сервиса в push-модели обнаруживается медленнее, чем кажется по конфигу — минуты, не секунды. Арифметика «metric_expiration плюс for» не сходится, а два повтора одной конфигурации дали 216 и 80 секунд: разброс больше разницы между конфигурациями, поэтому влияние параметра этими замерами не установлено. Мерить у себя и не по одному прогону.
  17. Проверка обвиняет не тот компонент чаще, чем хочется. Отказы этого стенда оказались дефектами самой проверки: сравнение с 0.000000 при пустом ответе 0 и ожидание кода 200 там, где сервис отвечает 201. А ещё один отказ я сначала объяснил непрогретым стендом — и ошибся: числа были верные, неверным был смысл, который я им приписал.
  18. Проверка, которая ломает намеренно, обязана чинить на всех путях выхода — через trap на EXIT, INT и TERM, а не только в конце удачного сценария. Иначе прерванный прогон оставляет стенд в аварийном состоянии, и следующая проверка поставит диагноз не тому.
  19. inactive не значит «окно очистилось». Алерт гаснет, как только доля падает ниже порога, а ошибки живут в окне ещё минуты — следующая фаза проверки складывается с их хвостом. Ждать надо опустошения окна, а не погасания алерта.

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

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии