Дашборд с метриками отвечает на вопрос «как дела». SLO отвечает на вопрос, который важнее: «пора ли что-то делать и будить ли человека ночью». Без SLO алертинг скатывается либо в паранойю (пищит на каждый скачок), либо в глухоту (молчит до реального инцидента). Error budget переводит абстрактную «надёжность» в конкретный бюджет, который можно тратить и за который можно отвечать.
Это пятая статья серии «Наблюдаемость на практике». Метрики дали числа — здесь они превращаются в пороги, бюджеты и алерты, на которые не стыдно реагировать.
Всё, что ниже, проверено на стенде: Prometheus 3.13.2, Alertmanager 0.33.1, приёмник уведомлений на Go, сценарий из деградации и последующего покоя. Две вещи из этой работы оказались важнее теории. Первая: правило burn rate прошло promtool и выдавало неверное число, причём такое, при котором алерт всё равно срабатывал — дефект бы не нашёлся при проверке «алерт зажёгся». Вторая: алерт гаснет заметно дольше, чем зажигается, и об этом лучше узнать не в момент инцидента.
В статье
- SLI, SLO, SLA: кто кому что обещает
- Бюджет ошибок и burn rate
- Правило, которое прошло promtool и врало
- Multi-window: зачем два окна вместо одного
- Сколько на самом деле длится «сработал»
- Алерт гаснет дольше, чем зажигается
- Обратная половина проверки
- Дыра, которую видно только замером
- Symptom против cause и критерий существования алерта
- Alertmanager: группировка, подавление, доставка
- Путь от SLI до человека
- Итог
- Документация и первоисточники
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.01promtool 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: warningwarning, а не 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: 0sgroup_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 до человека
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
Обратите внимание на пунктирную стрелку: метрика недоставленных уведомлений — часть контура, а не украшение. Всё, что левее приёмника, может работать безупречно, а человек уведомления не получит.
Итог
Что стоит забрать:
- SLI меряют со стороны пользователя. Загрузка процессора и длина очередей в SLI не входят — они объясняют, почему плохо, а не то, плохо ли.
- 404 не входит в бюджет ошибок. Иначе бюджет надёжности тратят пользователи, ошибающиеся в вводе.
- Порог латентности обязан совпадать с границей бакета. Иначе доля «быстрее порога» — интерполяция с точностью в ширину бакета.
promtoolпроверяет синтаксис, а не смысл. Правило burn rate прошло проверку и давало 16,79 вместо 100: уorприоритет ниже деления. И это тот сорт дефекта, который проверка «алерт зажёгся» не находит — 16,79 тоже выше порога, но зависит от абсолютного RPS.- Числа правил сверяют с ручным расчётом хотя бы раз. Нагрузка из одних ошибок для этого идеальна: ожидаемый burn rate равен
1 / (1 − цель). - Два окна вместо одного — не строгость, а борьба с шумом: короткое реагирует, длинное подтверждает.
- Задержка до срабатывания 70–85 с при окнах 5m/10m и
for: 30s, из них 30 с даёт самfor. Окно усредняет, поэтому мгновенная деградация видна не мгновенно. - Алерт гаснет дольше, чем зажигается: 259–272 с против 70–85. После починки он горит ещё около длины окна, и это не поломка.
- Проверка «не горит в покое» обязательна. Алерт, который горит всегда, тоже «сработал».
- При нулевом трафике алерты по латентности и доле ошибок молчат —
NaNи пустота не проходят сравнений. Нужно отдельное условие на факт наличия запросов, и оно должно быть per-service:sum(...)безbyвидит только остановку всего стенда и лишает алерт лейбла, из-за чего подавление по сервису не совпадает никогда. - Инвентарь ожидаемых сервисов задаётся явно. По метрикам его не построить: у сервиса без запросов серии нет, и «умер» от «никогда не было» метрики не отличают.
- Подавление надо проверять, а не настраивать. Оно молча не работает при несовпадении лейблов, и естественным сценарием его не проверить — нужны синтетические алерты через API и три критерия: цель того же сервиса подавлена, другого — нет, источник себя не подавил.
- Будить на симптом, причину расследовать. У алерта есть действие для дежурного прямо сейчас — или это не алерт.
- Приёмник обязан отвечать HTTP-ответом, а недоставленные уведомления надо ловить метрикой: иначе всё работает, а человек ничего не получил.
severityничего не делает сам по себе — направляет маршрут. Разделение «звонок против тикета» живёт вroute, и приёмником по умолчанию разумно ставить самый безобидный, чтобы алерт с забытым маршрутом попадал в тикеты, а не в paging-канал.- Смерть сервиса в push-модели обнаруживается медленнее, чем кажется по конфигу — минуты, не секунды. Арифметика «
metric_expirationплюсfor» не сходится, а два повтора одной конфигурации дали 216 и 80 секунд: разброс больше разницы между конфигурациями, поэтому влияние параметра этими замерами не установлено. Мерить у себя и не по одному прогону. - Проверка обвиняет не тот компонент чаще, чем хочется. Отказы этого стенда оказались дефектами самой проверки: сравнение с
0.000000при пустом ответе0и ожидание кода200там, где сервис отвечает201. А ещё один отказ я сначала объяснил непрогретым стендом — и ошибся: числа были верные, неверным был смысл, который я им приписал. - Проверка, которая ломает намеренно, обязана чинить на всех путях выхода — через
trapнаEXIT,INTиTERM, а не только в конце удачного сценария. Иначе прерванный прогон оставляет стенд в аварийном состоянии, и следующая проверка поставит диагноз не тому. inactiveне значит «окно очистилось». Алерт гаснет, как только доля падает ниже порога, а ошибки живут в окне ещё минуты — следующая фаза проверки складывается с их хвостом. Ждать надо опустошения окна, а не погасания алерта.
Дальше — непрерывное профилирование: когда алерт разбудил и симптом подтверждён, следующий вопрос — где именно в коде тратится время.
Документация и первоисточники
- Google SRE Workbook: Alerting on SLOs — откуда берутся 14,4 и 6, и почему окон два.
- Google SRE Book: Service Level Objectives — SLI, SLO, SLA и бюджет ошибок.
- Prometheus: alerting rules —
for, labels, annotations, состояния алерта. - Prometheus: приоритет операторов PromQL — почему
orтребует скобок. - Alertmanager: конфигурация — маршруты, группировка,
inhibit_rules, receivers. - Alertmanager: формат уведомления webhook — schema version 4.
- Стенд, правила, конфиги и сырые выводы замеров —
digital-cookbook/observability/practice-stack.
Комментарии