Команда настроила мониторинг, алерты сыплются постоянно — и в какой-то момент никто на них уже не реагирует. А когда случается настоящий инцидент, его замечают спустя час. Это классическая проблема «слепого мониторинга»: много шума, мало сигнала.
SLO — это способ навести порядок: договориться, что именно считается нормальной работой, и алертировать только тогда, когда норма нарушается.
Дальше по статье будут пороги и формулы — вот заранее вся картина на одной шкале: одни и те же ошибки, но решение принимается не по их доле, а по скорости, с которой они съедают бюджет.
Всплеск 30% за две минуты — самая заметная доля ошибок и самая дешёвая: 36 секунд бюджета, оба окна ниже порога. Тихие 0.9% шесть часов подряд съедают 3.2 минуты из 43 и поднимают slow burn. Кого будить, решает не доля ошибок, а то, за какое окно и с какой скоростью уходит бюджет.
Что такое SLO и зачем он нужен
Сначала нужно договориться, сколько мы готовы терять: ни один сервис не работает всегда, и без числа спор «нормально или уже нет» бесконечен. Это число и есть SLO (Service Level Objective) — численная цель уровня сервиса: не «всё работает», а «99.9% запросов успешны за последние 30 дней».
Откуда берётся цифра: бизнес вместе с командой решает, сколько допустимо терять. 99.9% означает, что за 30 дней сервис может «потратить» 43 минуты на сбои — это и есть error budget (бюджет ошибок). Пока бюджет не исчерпан, команда может выпускать релизы, в том числе рискованные. Исчерпался — пауза на стабилизацию.
SLI (Service Level Indicator) — это то, что измеряется. Обычно два вида:
- Availability (доступность): доля запросов без 5xx-ошибок.
- Latency (задержка): p95 или p99 времени ответа.
Почему именно p95, а не среднее? Среднее легко «размазывает» проблемы: если 5% запросов отвечают за 10 секунд, среднее может быть вполне приемлемым. Процентиль показывает, что происходит с реальными пользователями.
Пример SLO для нескольких эндпоинтов:
| Endpoint | Availability | Latency |
|---|---|---|
POST /orders | 99.9% non-5xx | p95 < 500ms |
POST /payments | 99.95% non-5xx | p95 < 1s |
GET /orders/{id} | 99.95% non-5xx | p95 < 200ms |
Выбирать SLO реалистично: 99.99% (52 минуты на весь год) требует совсем другой инфраструктуры — несколько регионов, мгновенное переключение на резерв. Это на порядок дороже 99.9% (8.7 часа в год).
Как считать SLI в Prometheus
Spring Boot с Micrometer автоматически публикует метрику http_server_requests_seconds. Из неё считается SLI:
# Availability SLI: доля успешных запросов за 30 дней
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST",status!~"5.."}[30d]))
/
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[30d]))
# Latency SLI: p95 за 30 дней
histogram_quantile(0.95,
sum by (le) (rate(http_server_requests_seconds_bucket{uri="/orders",method="POST"}[30d]))
)
Результат — число от 0 до 1. Если availability SLI = 0.999, значит 99.9% запросов успешны — SLO выполняется.
Что считать успешным запросом
В запросе выше стоит status!~"5..", и за этой короткой строчкой прячется решение, вокруг которого при внедрении SLO спорят дольше всего. От определения «успеха» зависит вообще всё: и цифра SLI, и то, будят ли дежурного.
Ответы 4xx. По умолчанию их считают успехом сервиса: 400 на неверный запрос и 404 на несуществующий заказ — это правильная работа, а не отказ. Два исключения. Первое: 429 и 503 при исчерпании лимитов — формально клиент виноват, фактически сервис отказал в обслуживании, и пользователь видит отказ; такие коды берут в ошибки. Второе: всплеск 400 после выката почти всегда означает, что сломались вы (изменили проверку, поехал разбор запроса) — SLI этого не покажет, поэтому на резкий рост доли 4xx ставят отдельную тревогу, отдельно от SLO.
Оборванные соединения. Пользователь закрыл страницу, не дождавшись ответа. В метриках это либо отсутствие записи, либо нестандартный код — и это не отказ сервиса. Но оборвавшихся становится много, когда сервис медленный: люди не ждут. Поэтому обрывы не считают ошибками, а следят за их долей отдельно — она хороший ранний признак деградации.
Служебные пути. Проверки здоровья, /actuator/*, метрики, синтетические пробы исключают из SLI: они многочисленны, всегда успешны и разбавляют картину — при десяти проверках в секунду и одном настоящем запросе SLI покажет отличную доступность на полностью сломанном сервисе. Исключают по метке пути, а синтетические пробы — по заголовку, которым их помечают.
Что ещё берут в ошибки, хотя код 200. Ответ, отданный из запасного пути с пустым результатом («поиск недоступен, показали ничего»), — формально 200, фактически пользователь не получил того, за чем пришёл. Если такие ответы важны, их помечают в метрике отдельно и включают в числитель ошибок. Это приближает SLI к тому, что чувствует человек, и одновременно делает его сложнее — поэтому начинают с простого определения и уточняют после первого спора «почему SLI зелёный, а жалобы есть».
Правило на будущее: определение успеха записывают рядом с SLO одной фразой («успех — это ответ не 5xx и не 429 на путях, кроме служебных, за исключением ответов из запасного пути»). Незаписанное определение переизобретается каждым, кто открывает дашборд.
Одна оговорка про [30d]. Посчитать так разово для отчёта можно, но на живой базе этот запрос перебирает месяц точек и считается десятки секунд — дашборд с ним будет отваливаться по таймауту. Штатный способ — записывающие правила: Prometheus раз в минуту сам считает пятиминутные скорости и складывает их в отдельные ряды, а месячный SLI считается уже по ним.
groups:
- name: orders-sli
rules:
- record: orders_post:requests:rate5m
expr: sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[5m]))
- record: orders_post:requests_ok:rate5m
expr: sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST",status!~"5.."}[5m]))
Тогда SLI за месяц — это avg_over_time(orders_post:requests_ok:rate5m[30d]) / avg_over_time(orders_post:requests:rate5m[30d]), и считается он мгновенно.
Multi-window burn rate: как алертировать правильно
Считать SLI за 30 дней хорошо для отчётов, но для алертов — слишком медленно. Если прямо сейчас сервис падает, узнать об этом через неделю бесполезно.
Другая крайность: алертировать на каждую ошибку. Это приводит к постоянному шуму — команда перестаёт обращать внимание.
Решение из Google SRE Workbook — burn rate (скорость сжигания бюджета).
Как это работает. SLO 99.9% даёт бюджет 0.1% за 30 дней. Если за 1 час ошибок столько, что при таком темпе месячный бюджет кончится за пару суток — это экстренная ситуация. Число, которое это выражает:
burn rate = (текущая доля ошибок) / (1 - SLO_target)
Для SLO 99.9%:
- burn rate > 14.4 за 1 час — бюджет тридцати дней уходит за 30 / 14.4 ≈ 2 суток, а за сам этот час сгорает 2% бюджета. Нужно реагировать немедленно.
- burn rate > 6 за 6 часов — бюджет закончится за ~5 дней. Нужно разобраться до конца рабочего дня.
- burn rate около 1 — нормальный расход, всё в порядке.
Fast burn и slow burn — это два отдельных алерта, у каждого своё окно и свой порог: 14.4 за 1 час и 6 за 6 часов. Событию не нужно пройти оба порога сразу: сорокаминутный сбой на 6% ошибок даёт burn rate 40 за час и всего 6.7 за шесть часов — будит дежурного первый алерт.
Одно и то же событие в двух окнах: сорокаминутный сбой в часовом окне даёт скорость 40 и пробивает порог 14,4, в шестичасовом размывается до 6,7, а ровный фон 0,9 процента размывать нечем, и его ловит только длинное окно с порогом 6.
В полном рецепте Google к каждому алерту добавляют ещё короткое окно-подтверждение (5 минут для fast burn, 30 минут для slow burn), чтобы алерт погас сразу после того, как всплеск закончился, а не висел ещё час. В примере ниже эту роль играет for.
Про эту замену стоит сказать точнее, потому что for и короткое окно делают разные вещи. for задерживает срабатывание: условие должно держаться заданное время, после чего тревога зажигается — и горит, пока выполняется длинное окно, то есть ещё час после того, как всплеск закончился. Короткое окно работает на другом конце: пятиминутная доля падает сразу, условие перестаёт выполняться, и тревога гаснет по факту починки. Поэтому в полном виде условие требует превышения в обоих окнах одновременно, а for остаётся защитой от единичного всплеска:
(
sum(rate(...{status=~"5.."}[1h])) / sum(rate(...[1h])) > (14.4 * 0.001)
)
and
(
sum(rate(...{status=~"5.."}[5m])) / sum(rate(...[5m])) > (14.4 * 0.001)
)
И порогов в полном рецепте не два, а четыре: 14,4 за час (будит немедленно), 6 за 6 часов (разобраться в рабочее время), 3 за сутки и 1 за три суток — так сгорает бюджет при вялотекущей деградации, которую короткие окна не видят вовсе. Начать разумно с двух, а третий и четвёртый добавить, когда появится привычка смотреть на бюджет; без них медленная утечка бюджета не обнаруживается, пока он не кончится.
- alert: OrdersSloFastBurn
expr: |
(
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST",status=~"5.."}[1h]))
/
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[1h]))
) > (14.4 * (1 - 0.999))
for: 2m
annotations:
runbook_url: https://runbooks.internal/orders-slo-fast-burn
- alert: OrdersSloSlowBurn
expr: |
(
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST",status=~"5.."}[6h]))
/
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[6h]))
) > (6 * (1 - 0.999))
for: 15m
annotations:
runbook_url: https://runbooks.internal/orders-slo-slow-burn
Fast burn будит дежурного немедленно — потенциальный серьёзный сбой. Slow burn создаёт задачу в трекере — деградация не критичная, но требует разбора.
Алерт на задержку, а не только на ошибки
В таблице целей стоят две строки — доступность и задержка, — а весь рецепт выше посчитан только для ошибок. Задержку тоже считают бюджетом, и способ немного другой, потому что перцентиль для этого не годится.
Почему не перцентиль. «p95 больше 500 мс» — плохое условие тревоги: перцентили нельзя усреднять по времени и складывать между копиями, а главное — из p95 не получается бюджет. Бюджет требует счёта событий: сколько запросов были хорошими, а сколько плохими.
Как правильно. Цель формулируют как долю: «99 % запросов быстрее 500 мс за 30 дней». Тогда хорошие запросы — это те, что попали в бакет гистограммы до границы 500 мс, и их число даёт сам Prometheus:
# доля запросов быстрее 500 мс
sum(rate(http_server_requests_seconds_bucket{uri="/orders",method="POST",le="0.5"}[5m]))
/
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[5m]))
Здесь важна одна деталь, которая связывает эту статью с настройкой: граница le="0.5" должна существовать. Бакеты берутся не из воздуха — их задаёт настройка целевых границ (management.metrics.distribution.slo), и если в ней нет ровно 500 мс, запрос вернёт пустоту или посчитает по соседней границе, то есть неправильно. Поэтому порядок такой: сначала договорились о цели, потом прописали её границу в настройках сервиса, потом написали правило. Об этом — статья про конфигурацию.
Дальше всё как с ошибками: доля плохих — это 1 - <доля быстрых>, и она подставляется в тот же расчёт скорости сжигания с теми же порогами:
- alert: OrdersLatencySloFastBurn
expr: |
(
1 -
sum(rate(http_server_requests_seconds_bucket{uri="/orders",method="POST",le="0.5"}[1h]))
/
sum(rate(http_server_requests_seconds_count{uri="/orders",method="POST"}[1h]))
) > (14.4 * (1 - 0.99))
for: 2m
annotations:
runbook_url: https://runbooks.internal/orders-latency-slo
Практическое замечание: у задержки обычно две границы, а не одна. «99 % быстрее 500 мс» отвечает за обычный опыт, «99,9 % быстрее 2 с» — за то, что совсем медленных ответов почти нет. Два бюджета считаются независимо, и первым обычно сгорает второй: редкие очень медленные запросы (холодный кеш, тяжёлый отчёт, повтор после сбоя) заметны именно там.
Бюджет 43 минуты живёт в скользящем окне 30 дней: каждый сбой его уменьшает, а ровно через тридцать дней после сбоя эти минуты возвращаются в остаток.
Алерт на исчерпание бюджета
Когда от error budget остаётся меньше 10%, это сигнал команде: следующий месяц — не время для рискованных релизов, надо заняться устойчивостью.
Как посчитать остаток бюджета:
# Сколько бюджета осталось: 1 = весь свободен, 0 = весь израсходован
1 - (
(1 - avg_over_time(orders_post:requests_ok:rate5m[30d])
/ avg_over_time(orders_post:requests:rate5m[30d]))
/ (1 - 0.999)
)
Алерт если осталось меньше 10%:
- alert: OrdersErrorBudgetExhausted
expr: |
1 - (
(1 - avg_over_time(orders_post:requests_ok:rate5m[30d])
/ avg_over_time(orders_post:requests:rate5m[30d]))
/ (1 - 0.999)
) < 0.1
for: 1h
annotations:
summary: "Только 10% error budget осталось"
description: |
Команда переключается с новых возможностей на надёжность.
Релизы рискованных изменений приостановлены до восстановления бюджета.
Это не «нужно срочно чинить ночью» — это плановый сигнал для планирования следующего спринта.
Кто назначает цифру
Техника выше отвечает «как считать», и не отвечает на первый вопрос, который задаёт любой, кто пытается завести SLO: откуда берётся 99,9 % и кто это решает. Ответ разочаровывающе организационный, и без него SLO остаётся упражнением.
Цифру предлагает команда, соглашается владелец продукта. Механика такая: команда смотрит, какая доступность уже есть сейчас (SLI за последние три месяца), и предлагает цель чуть ниже достигнутого — а не круглое число из презентации. Если сервис фактически держит 99,95 %, цель 99,9 % честна: она достижима и оставляет запас. Цель 99,99 % при фактических 99,95 % означает, что цель нарушена с первого дня и её перестанут замечать через неделю.
Разговор ведут не в процентах, а в минутах и в последствиях. «99,9 % — это 43 минуты недоступности в месяц; при 99,99 % — 4 минуты». И встречный вопрос к продукту: сколько стоит час простоя и что вы готовы за это заплатить. Потому что каждая дополнительная девятка — это не старание, а деньги и отказ от скорости: резерв в другом регионе, дублирование, длинные проверки перед выкатом, дежурство круглосуточно. Обычно на этом месте выясняется, что 99,99 % никому не нужны.
Если бизнес всё равно хочет четыре девятки — это не спор, а расчёт. Показывают, что для этого требуется (обычно: второй контур, автоматическое переключение, выкат только через проверку на канарейке, круглосуточное дежурство с двумя людьми), называют цену и сроки, и решение принимает тот, кто платит. Второй аргумент, который часто убеждает: доступность вашего сервиса не может быть выше доступности того, на чём он стоит, — если у платёжного провайдера в договоре 99,9 %, обещать 99,99 % на операциях с оплатой нельзя физически. Сумма зависимостей считается умножением, и эта арифметика заканчивает разговор быстрее любых слов.
Несколько эндпоинтов. Одна цифра на весь сервис искажает картину: оформление заказа и просмотр справки не равны по важности, но справку запрашивают в сто раз чаще, и она своей массой утопит отказы в оформлении. Поэтому цели ставят на пользовательские операции (два-четыре на сервис, не двадцать), каждая со своим бюджетом, и считают их независимо. Общую цифру «доступность сервиса» оставляют для отчёта, а тревоги вешают на операции.
Что записывают. Одна страница на сервис: операции, для каждой — определение успеха, цель, окно (обычно 30 дней), кто владелец, что делает команда при исчерпании бюджета. Последний пункт — самый важный и самый пропускаемый: договорённость «при бюджете ниже 10 % мы останавливаем рискованные изменения и занимаемся устойчивостью» должна быть согласована до того, как бюджет кончится. Иначе разговор об остановке выката произойдёт в момент, когда все раздражены, и закончится тем, что цель просто поднимут.
Частые ошибки
Алерт на каждую ошибку в логах
Один клиент пытается отправить невалидный запрос 1000 раз за минуту — 1000 log.error(...) превращаются в 1000 алертов. Команда начинает их игнорировать. Потом происходит настоящий инцидент, и его замечают слишком поздно.
Правильный подход — алертировать на скорость ошибок конкретного типа, не на каждый отдельный случай:
- alert: HighErrorRate
expr: sum by (exception) (rate(app_errors_total[5m])) > 1
for: 5m
annotations:
runbook_url: https://runbooks.internal/high-error-rate
Единичный ValidationException — это нормально для плохих входных данных, не алерт. Порог > 1 в примере — это одна ошибка в секунду, то есть шестьдесят в минуту: вот с такого потока уже стоит разобраться.
SLO в 100%
100% означает «мы не можем ошибиться никогда». На практике любой релиз становится источником тревоги — любая регрессия сразу нарушение SLO, срочный инцидент. Error budget равен нулю — нечем маневрировать.
99.9% даёт 43 минуты в месяц «законного» времени на сбои. Команда может выпустить рискованное изменение, убедиться в проблеме и откатить — при этом SLO не нарушается.
Алерт без инструкции
PagerDuty в три ночи будит дежурного. Алерт: «p95 latency на /orders > 1s». Без инструкции дежурный открывает Grafana, не знает что смотреть, не знает кому звонить. В итоге — звонок тимлиду в три ночи без какой-либо подготовки.
Хорошая инструкция (runbook) по тому же алерту:
- Проверить
hikaricp_connections_pending— если больше нуля, нужно масштабировать приложение или базу данных. - Проверить
external_calls_duration_seconds{system="payment-provider"}— если больше 2 секунд, инцидент на стороне payment, можно подтвердить получение и ждать. - Если ни первое, ни второе — эскалировать в
#order-service-oncall.
Runbook — обязательная часть каждого алерта. Без него алерт неполный.
Глубже: низкий трафик ломает этот рецептрасширенное
Всё, что посчитано выше, держится на предположении, что запросов много. Как только их мало, скорость сжигания начинает врать, и это самая частая причина, по которой команды выключают такие тревоги через месяц.
Арифметика простая. Сервис получает десять запросов в час. Один из них ответил ошибкой — доля ошибок за час равна 10 %, скорость сжигания при цели 99,9 % получается 100, то есть в семь раз выше порога немедленной тревоги. Дежурного будят ночью из-за одного запроса, который, возможно, был повтором сканера. Обратная беда тоже есть: за пять минут при таком трафике может не прийти ни одного запроса, и правило вернёт пустоту вместо нуля.
Что с этим делают, по возрастанию усилий:
Порог по абсолютному числу. К условию добавляют «и запросов было не меньше N»: тогда одна ошибка на десяти запросах тревогу не создаёт.
(
sum(rate(...{status=~"5.."}[1h])) / sum(rate(...[1h])) > (14.4 * 0.001)
)
and
sum(increase(...[1h])) > 100
Длинные окна. Для редкого трафика короткое окно не набирает выборки: берут 6 часов и сутки вместо часа и шести, соглашаясь узнавать о проблеме позже — при десяти запросах в час быстрее и не получится.
Другой вид цели. Иногда честнее признать, что доступность запросов — не тот показатель. Для сервиса с редкими, но важными операциями лучше работает цель на успешность обработки («все заявки за сутки обработаны») и тревога на факт: «последняя успешная обработка была больше двух часов назад». Это не про долю, а про срок — и при малом трафике это единственная надёжная форма.
Объединение. Один эндпоинт с редким трафиком не имеет своего SLO: его берут в общую цель сервиса, где запросов достаточно.
И тревога на пропажу самих метрик
У всех правил выше есть общее слепое пятно: они считают долю. Если метрики перестали приходить вовсе — сервис умер целиком, упал сборщик, сломалась метка после переименования — доля не вычисляется, условие не выполняется, и ни одна тревога не срабатывает. Мониторинг молчит именно тогда, когда всё плохо.
Закрывается это отдельным правилом, которое смотрит не на значение, а на наличие ряда:
- alert: OrdersMetricsMissing
expr: absent(http_server_requests_seconds_count{job="order-service"}) == 1
for: 10m
annotations:
summary: "Метрики order-service не приходят 10 минут"
runbook_url: https://runbooks.internal/metrics-missing
Пара замечаний по применению. Срок в for берут больше обычного (пять-десять минут), иначе правило будет срабатывать при каждом выкате, когда старые копии уже погашены, а новые ещё не опрошены. Проверяют наличие самого частого ряда сервиса, а не редкого бизнес-счётчика, который законно молчит ночами. И к этому же семейству относится тревога на состояние опроса (up == 0) и на возраст последней точки: вместе они отвечают на вопрос «мониторинг вообще работает?», который иначе никто не задаёт, пока не понадобится.
Глубже: алерты за пределами SLOрасширенное
SLO измеряет то, что видит пользователь. Но есть сигналы, которые предупреждают о проблеме раньше, чем она повлияет на пользователей:
| Что мониторить | Метрика | Что означает |
|---|---|---|
| Память JVM | jvm_memory_used / max > 0.85 | Скоро GC-паузы или OutOfMemory |
| Пул соединений к БД | hikaricp_connections_pending > 0 | Запросы ждут соединения — задержки растут |
| Бизнес-ошибки | order_failed_total rate > 100/мин | Что-то изменилось в данных или логике |
| Circuit Breaker | resilience4j_circuitbreaker_state{state="open"} | Внешний сервис недоступен, идёт на fallback |
| Попадания в кэш | sum(rate(cache_gets_total{result="hit"}[5m])) / sum(rate(cache_gets_total[5m])) < 0.7 | Кэш почти не помогает — нагрузка на БД растёт |
| Отставание Kafka | kafka_consumer_fetch_manager_records_lag_max > 10000 | Потребители не успевают за продюсерами |
Например, Circuit Breaker в состоянии open означает, что запросы к внешнему сервису уходят на запасной путь — SLO ещё в норме, но проблема уже есть и может усугубиться.
Коротко
- SLO — численная цель (например, 99.9% запросов успешны). SLI — то, что реально измеряется в Prometheus. Error budget — «разрешённый» объём сбоев. 99.9% даёт 43 минуты в месяц. Пока бюджет не исчерпан, команда может рисковать.
- Burn rate показывает, как быстро расходуется бюджет. Fast burn (1 час, порог 14.4) — немедленная реакция. Slow burn (6 часов, порог 6) — плановый разбор. Используй два окна на один алерт: если только короткое окно превышает порог, возможно это всплеск, а не тренд.
- Алерт на исчерпание бюджета (< 10%) — это сигнал для планирования, а не для дежурного. Алерты на инфраструктуру (память JVM, пул БД, Circuit Breaker) — отдельно от SLO, предупреждают раньше.
- Не алертируй на каждую ошибку — алертируй на скорость ошибок конкретного типа. 100% SLO — это ловушка: любой релиз становится угрозой инцидента.
- Каждый алерт должен иметь runbook: что проверить, что сделать, кому звонить.
- Определение успеха записывают рядом с целью: 4xx обычно успех, кроме 429 и отказов по лимитам, служебные пути и пробы исключают, ответы из запасного пути стоит считать отказом.
- Задержку тоже считают бюджетом, но не перцентилем, а долей запросов быстрее границы; граница должна существовать в настройке бакетов, иначе запрос молча считает не то.
- При малом трафике одна ошибка даёт скорость сжигания в сотни: нужен порог по абсолютному числу запросов, длинные окна или цель на срок вместо доли.
- Правила на долю молчат, когда метрики пропали совсем: отдельная тревога на
absent()и на состояние опроса с запасом в несколько минут на время выката. - Цифру предлагает команда по факту достигнутого, а соглашается владелец продукта в минутах и деньгах; доступность не бывает выше доступности зависимостей, а цели ставят на две-четыре пользовательские операции.
Что почитать дальше
- Метрики в Spring Boot — настройка
http_server_requests_secondsдля расчёта SLI. - Трассировка в Spring Boot — детальный разбор проблемных запросов через traces.
- Health checks — почему kubernetes-пробы не заменяют SLO.
- Google SRE Workbook — Alerting on SLOs — первоисточник multi-window burn rate.