SLO и error budget
Правила, как определять Service Level Objectives (SLO) для сервиса,
считать error budget и превращать его в конкретные alerting-правила.
Инструментация (как писать метрики) — в
observability. Эта страница отвечает на другой
вопрос: какие SLO выбрать, что делать, когда бюджет сжигается, и как
перевести это в Prometheus alert rules.
Содержание
- Зачем SLO
- SLI vs SLO vs SLA
- Какие SLI выбрать
- Availability SLI
- Latency SLI
- Freshness SLI
- SLO для фоновых pipeline’ов
- Error budget
- Multi-window multi-burn-rate alerts
- Multi-window burn-rate и spike handling
- Fast burn и slow burn
- Документирование SLO
- Ревью SLO
- Что не делать
- См. также
Зачем SLO
Без SLO команда спорит, что считать «упавшим» сервисом. SLO даёт цифровое определение — 99.9% запросов за 30 дней должны ответить успешно и быстрее 500ms. Всё, что хуже — бюджет сгорает, команда останавливает feature-work и разбирается.
Правила:
- SLO ставятся для user-facing endpoint’ов сервиса, которые видит клиент через gateway. Для internal endpoint’ов — отдельно или не ставятся, зависит от того, есть ли у них клиенты с SLA.
- SLO устанавливает owner сервиса (см.
../onboarding/04-who-owns-what), не SRE извне и не кто-то снаружи. - SLO < 100%. Цель «ноль ошибок» бессмысленна: каждый 9-ка в SLO удорожает его экспоненциально, а user’ская сеть и так добавляет свои ошибки. Начинай с 99.9%, повышай только если есть явная бизнес-необходимость.
SLI vs SLO vs SLA
Три слова постоянно путают. Различия:
| Термин | Что | Пример |
|---|---|---|
| SLI (Service Level Indicator) | Метрика — что измеряем | «доля успешных HTTP-запросов к /v1/reviews» |
| SLO (Service Level Objective) | Целевое значение SLI за окно | «99.9% успешных за 30 дней» |
| SLA (Service Level Agreement) | Контрактное обязательство с внешними последствиями | «99.5% в месяц; штраф за нарушение» |
У нас внешних SLA нет (мы не продаём API клиентам с контрактами). Мы оперируем SLI и SLO. SLA — термин, который приходит извне, не используй его без контекста.
Какие SLI выбрать
На один endpoint обычно два SLI:
- Availability — доля успешных запросов.
- Latency — доля запросов, уложившихся в целевой таргет по времени.
Для async-пайплайнов (Kafka consumer, background worker) добавляется третий:
- Freshness — насколько свежи обработанные данные.
Не плоди 10 SLI на endpoint: чем больше, тем менее каждый actionable. Один availability + один latency = 95% нужной сигнализации.
Availability SLI
Формула: successful_requests / total_requests за окно.
«Успешным» считаются ответы с HTTP-кодом не-5xx (4xx — это ошибка клиента, а не сервиса):
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code!~"5.."}[30d]))
) /
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[30d]))Важные детали:
code!~"5.."— неstatus="success". Тебе важно автоматическое попадание любого 5xx, не руками размеченного.- Группировка по
routeна паттерне (/v1/reviews/{id}), не по реальному URL — см.observability. rate(...[30d])— окно SLO. Неincrease—rateстабильнее на границах scrape.
Стандартные таргеты:
| Тип endpoint’а | Availability SLO |
|---|---|
| Критичный user-facing (auth, feed) | 99.95% |
| Обычный user-facing (profile, reviews) | 99.9% |
| Non-critical feature (search-suggest) | 99.5% |
| Internal endpoint с несколькими клиентами | 99.9% |
| Background worker (outbox forwarder, consumer) | свой SLI, см. §Freshness |
«Стандартный» — default для нового endpoint’а. Если команда считает, что бизнес требует жёстче/мягче — обоснуй в документе SLO (см. §Документирование).
Latency SLI
Формула: доля запросов, уложившихся в T.
(
sum(rate(http_request_duration_seconds_bucket{service="review",route="/v1/reviews",le="0.5"}[30d]))
) /
sum(rate(http_request_duration_seconds_count{service="review",route="/v1/reviews"}[30d]))Это уже корректная формула на histogram’е — считает, сколько попало
в bucket le=0.5 (≤ 500ms).
Стандартные таргеты:
| Тип endpoint’а | Latency SLO |
|---|---|
| Read: GET single resource | 99% < 200ms |
| Read: list с простым фильтром | 99% < 500ms |
| Read: list с агрегациями | 99% < 1s |
| Write: create/update | 99% < 800ms |
| Upload (media) | 99% < 5s |
«99%» — не «p99». p99 < 500ms и «99% запросов < 500ms» — это одно
и то же, но SLO-форма стандартная: доля, уложившаяся в таргет, не
«95-й перцентиль такой-то».
Не делай SLO «p99 < 500ms and p50 < 100ms» — две цели → два бюджета → две причины для страницы. Один SLI, одно число.
Freshness SLI
Для async-пайплайнов (Kafka consumer, outbox forwarder) доля «быстрых» запросов не описывает реальность — пользователь не видит end-to-end задержку, он видит, что данные до сих пор не обновились.
Формула для outbox: доля событий, опубликованных в Kafka < N секунд после записи в outbox.
(
sum(rate(outbox_forwarder_publish_duration_seconds_bucket{service="review",le="5"}[30d]))
) /
sum(rate(outbox_forwarder_publish_duration_seconds_count{service="review"}[30d]))Для consumer — доля событий, обработанных < N секунд после публикации в Kafka:
(
sum(rate(consumer_lag_seconds_bucket{service="notification",consumer_group="...",le="10"}[30d]))
) /
sum(rate(consumer_lag_seconds_count{service="notification",consumer_group="..."}[30d]))Типовые таргеты:
| Пайплайн | Freshness SLO |
|---|---|
| Outbox → Kafka (publish) | 99% < 5s |
| Kafka consumer → side effect (notification, search-index update) | 99% < 10s |
| Batch aggregation (daily stats) | 99% < 1 час после cutoff |
См. также ../patterns/outbox —
метрика outbox_forwarder_lag_seconds как alert source.
SLO для фоновых pipeline’ов
HTTP-запросы видит пользователь, но часть критичного поведения системы — фоновая: outbox forwarder публикует события в Kafka, saga orchestrator догоняет зависшие шаги, consumer применяет изменения. У каждого из них — собственный SLO, иначе «все HTTP зелёные, а downstream не получает событий час» — не ловится вообще.
Outbox forwarder
Forwarder — единственная точка, превращающая запись в outbox в
Kafka-сообщение. Если он отстал, cross-service eventual consistency
превращается из миллисекунд в часы. SLO на forwarder — не
опциональный, он обязателен для каждого сервиса, у которого есть
outbox.
| SLI | Формула | Целевое |
|---|---|---|
| Publish freshness | доля событий, опубликованных < 5s после INSERT INTO outbox | 99% за 30 дней |
| Unpublished backlog | outbox_unpublished_rows (gauge, текущее число строк с offset_acked IS NULL) | < 100 устойчиво; < 1000 spike-допустимо |
| Forwarder liveness | heartbeat metric outbox_forwarder_last_tick_timestamp_seconds | time() - ... < 30s |
Alerting — три правила одновременно:
- alert: OutboxForwarderLagHigh
expr: |
histogram_quantile(0.99,
sum by (le, service)
(rate(outbox_forwarder_publish_duration_seconds_bucket[5m]))
) > 5
for: 5m
labels: { severity: page, slo: outbox-freshness }
annotations:
summary: "Outbox p99 publish latency > 5s"
runbook: "https://backend-handbook.kazmaps.dev/how-to/debug-outbox-lag"
- alert: OutboxBacklogGrowing
expr: outbox_unpublished_rows > 1000
for: 10m
labels: { severity: page, slo: outbox-backlog }
annotations:
summary: "Outbox backlog > 1000 rows for 10m"
runbook: "https://backend-handbook.kazmaps.dev/how-to/debug-outbox-lag"
- alert: OutboxForwarderStalled
expr: time() - outbox_forwarder_last_tick_timestamp_seconds > 120
for: 2m
labels: { severity: page, slo: outbox-liveness }
annotations:
summary: "Forwarder hasn't ticked in 2m"Связь с runbook’ом — ../how-to/debug-outbox-lag.
SLO на forwarder’е записывается в docs/slo.md сервиса наравне с
HTTP-SLO; при нарушении бюджет сгорает так же, как у HTTP-endpoint’а.
Saga orchestrator
Для сервисов, координирующих саги (см.
../patterns/saga), три SLO:
| SLI | Формула | Целевое |
|---|---|---|
| Saga completion freshness | доля саг, завершённых (status succeeded / compensated) < N часов от старта | 99% < 1ч для обычных саг, 99% < 24ч для cross-service delete |
| Stuck-saga rate | gauge saga_stuck_total{type} — саги в running или compensating дольше deadline | 0 устойчиво |
| Failed-compensation rate | counter saga_failed_compensation_total{type} — саги, застрявшие в failed_compensation | 0 (любое срабатывание — инцидент) |
Watchdog, подметающий зависшие саги, — отдельный фоновый компонент; у него свой liveness-heartbeat наравне с forwarder’ом.
- alert: SagaFailedCompensation
expr: increase(saga_failed_compensation_total[1h]) > 0
for: 0m
labels: { severity: page, slo: saga-integrity }
annotations:
summary: "Saga compensation failed — требуется ручной разбор"
runbook: "https://backend-handbook.kazmaps.dev/troubleshooting/saga-failed-compensation"
- alert: SagaStuck
expr: saga_stuck_total > 0
for: 15m
labels: { severity: ticket, slo: saga-freshness }saga_failed_compensation_total > 0 — всегда page. В этом
состоянии данные, возможно, уже в inconsistent-состоянии между
сервисами, и каждый час без разбора ухудшает ситуацию.
Kafka consumer
Consumer SLO — доля событий, обработанных в окне, плюс отдельно лаг и DLQ-прирост:
| SLI | Формула | Целевое |
|---|---|---|
| Consumer freshness | доля событий с end-to-end latency (от publish до ack) < N | 99% < 10s для notifications; SLO per consumer-group |
| Consumer lag | kafka_consumer_lag_messages{group} | < 1000 устойчиво |
| DLQ growth | increase(messages_poisoned_total[1h]) | 0 устойчиво (любой прирост = ticket) |
Alert на messages_poisoned_total любого прироста — даже одно
сообщение в DLQ говорит, что handler не справляется и кто-то должен
посмотреть. Не откладывай разбор DLQ.
Error budget
Error budget = 1 - SLO за окно. Это сколько можно облажаться, не
нарушив SLO.
Для SLO 99.9% за 30 дней:
budget = 1 - 0.999 = 0.001 = 0.1%Что это значит в абсолютных числах:
- При 100 RPS: 30 дней = 259,200,000 запросов. 0.1% = 259,200 ошибок разрешено за окно.
- При 10 RPS: 25,920,000 запросов. 0.1% = 25,920 ошибок.
- При 1 RPS: 2,592,000 запросов. 0.1% = 2,592 ошибок.
Переводы в «сколько времени можно лежать на 100%»:
| SLO | Допустимое downtime за 30 дней |
|---|---|
| 99% | 7h 12m |
| 99.5% | 3h 36m |
| 99.9% | 43m 12s |
| 99.95% | 21m 36s |
| 99.99% | 4m 19s |
Правила обращения с бюджетом:
- Бюджет сгорел быстрее положенного → останавливаем feature-work. Сервис-owner выкатывает только hotfix’ы, пока бюджет не восстановится. Это не формальная санкция — это напоминание, что стабильность сейчас важнее нового функционала.
- Бюджет восстанавливается sliding window’ом. За 30-дневное окно старые ошибки «выпадают» каждую секунду; новые ошибки съедают бюджет. Не «обнуляется 1-го числа».
- Не трать остаток бюджета специально. «У нас бюджет на этой неделе ещё 0.05%, можно не бояться плохого релиза» — мышление ведёт к нестабильности. SLO — потолок, не обязательство потратить.
Multi-window multi-burn-rate alerts
Один alert на «бюджет сгорел» бесполезен — к моменту срабатывания
инцидент уже час как длится. Рабочая схема — два alert’а с разными
окнами и порогами, комбинированные AND.
Формула burn rate
burn_rate = (error_rate_в_окне) / (1 - SLO)Если SLO 99.9%, budget = 0.001. При текущем error rate 0.01:
burn_rate = 0.01 / 0.001 = 10Это значит: при таком темпе бюджет сгорит за окно / 10. Окно 30
дней → за 3 дня бюджет на ноль.
Таблица действий:
| Burn rate | Что значит | Реакция |
|---|---|---|
| 14.4 | Сгорит за 2 часа | Page немедленно |
| 6 | Сгорит за 5 часов | Page, если длится > короткого окна |
| 1 | Ровно по бюджету | Штатный режим |
| < 1 | Бюджет растёт | Ничего не делай |
14.4 — стандартный порог «page сразу» из SRE Workbook: даже короткий всплеск за 5 минут при этом rate сожрёт 2% бюджета.
Шаблон Prometheus alert rule
Fast-burn alert (высокий rate на коротком окне → page):
- alert: ReviewAvailabilityFastBurn
expr: |
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[5m]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[5m]))
) > (14.4 * 0.001)
and
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[1h]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[1h]))
) > (14.4 * 0.001)
for: 2m
labels:
severity: page
service: review
slo: availability
annotations:
summary: "Review availability SLO fast burn"
description: "Error rate {{ $value | humanizePercentage }}, burning budget 14.4x normal rate."
runbook: "https://backend-handbook.kazmaps.dev/troubleshooting/..."Slow-burn alert (низкий rate на длинном окне → ticket):
- alert: ReviewAvailabilitySlowBurn
expr: |
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[30m]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[30m]))
) > (6 * 0.001)
and
(
sum(rate(http_requests_total{service="review",route="/v1/reviews",code=~"5.."}[6h]))
/
sum(rate(http_requests_total{service="review",route="/v1/reviews"}[6h]))
) > (6 * 0.001)
for: 15m
labels:
severity: ticket
service: review
slo: availability
annotations:
summary: "Review availability SLO slow burn"
description: "Error rate {{ $value | humanizePercentage }}, burning budget 6x normal rate."Почему два окна в AND:
- Короткое окно — быстро реагирует на реальные проблемы.
- Длинное окно — отсекает одноразовые спайки (CI deploy, 30 секунд 502 из-за rollout).
Обе условия вместе → стабильный ускоренный burn, а не шум.
Multi-window burn-rate и spike handling
Проблема стационарной формулы. Single-window alert
err_rate[1h] > 14.4 * budget срабатывает либо поздно (длинный
инцидент уже идёт час, пока окно заполнится), либо ложно (30-секундный
spike при малом RPS раздувает rate на коротком окне). Один порог на
одном окне — компромисс, проигрывающий в обоих направлениях.
Решение — multi-window multi-burn-rate (Google SRE Workbook, «The Site Reliability Workbook», глава Alerting on SLOs). Несколько окон одновременно; alert срабатывает только если все окна подтвердили burn.
Две группы alerts:
- Fast burn (долгий инцидент, быстрое сгорание бюджета):
- Short window
5m, long window1h, burn-rate threshold14.4(2% бюджета за час). - Логика:
err_rate[5m] > 14.4 * budget AND err_rate[1h] > 14.4 * budget. - Реакция: page on-call, severity SEV-2.
- Short window
- Slow burn (постепенная деградация):
- Short window
30m, long window6h, burn-rate threshold6(10% бюджета за сутки). - Логика:
err_rate[30m] > 6 * budget AND err_rate[6h] > 6 * budget. - Реакция: ticket / warn, severity SEV-3.
- Short window
Пример fast-burn alert’а для review-service (SLO 99.9% → budget 0.001):
- alert: ErrorBudgetFastBurn
expr: |
(
sum(rate(http_requests_total{code=~"5..",job="review-service"}[5m]))
/ sum(rate(http_requests_total{job="review-service"}[5m]))
> (14.4 * 0.001)
)
and
(
sum(rate(http_requests_total{code=~"5..",job="review-service"}[1h]))
/ sum(rate(http_requests_total{job="review-service"}[1h]))
> (14.4 * 0.001)
)
for: 2m
labels: { severity: page, team: backend, slo: review-availability }
annotations:
summary: "Fast error budget burn on review-service"Короткое окно как reset-защита. Alert автоматически reset’ится,
когда свежее окно очистилось (инцидент закончился — err_rate[5m]
падает ниже порога за ~5 минут). Long-window alert без
short-window компаньона — антипаттерн: он висит часами после того, как
проблема уже решена, потому что err_rate[1h] помнит всё окно.
Cooldown в Alertmanager:
# alertmanager.yml route
- match: { severity: page }
repeat_interval: 30m
- match: { severity: ticket }
repeat_interval: 4hЭталонная таблица (Google-style)
| Consumed budget | Short window | Long window | Burn rate | Severity |
|---|---|---|---|---|
| 2% за 1ч | 5 мин | 1ч | 14.4 | page |
| 5% за 6ч | 30 мин | 6ч | 6 | ticket |
| 10% за 3д | 2ч | 3д | 1 | review |
Третья строка — опционально для сервисов с жёстким SLO: отдельный low-severity alert для отслеживания стабильного, но заметного burn’а на недельной дистанции.
Fast burn и slow burn
Стандартная пара для одного SLI:
| Alert | Burn rate | Окна (short/long) | Severity | for: |
|---|---|---|---|---|
| Fast burn | 14.4 | 5m / 1h | page | 2m |
| Slow burn | 6 | 30m / 6h | ticket | 15m |
- Page — немедленное внимание, on-call отвечает <15 мин.
- Ticket — в рабочее время следующего дня.
Для latency SLI формула та же, только числитель считает «запросы медленнее таргета»:
sum(rate(http_request_duration_seconds_count{service="review",route="/v1/reviews"}[5m]))
-
sum(rate(http_request_duration_seconds_bucket{service="review",route="/v1/reviews",le="0.5"}[5m]))Делишь на total — получаешь долю «медленных» запросов. Дальше та же формула burn rate.
Документирование SLO
Каждый сервис держит файл docs/slo.md в своём репо. Формат:
# Review Service — SLOs
Window: 30 days, rolling.
## Availability
- **Endpoint:** `/v1/reviews` (GET, POST, PUT, DELETE)
- **SLI:** non-5xx responses / total
- **SLO:** 99.9%
- **Alerts:** fast burn (14.4x, 5m/1h), slow burn (6x, 30m/6h)
- **Dashboard:** <grafana-link>
## Latency
- **Endpoint:** `/v1/reviews` (GET list)
- **SLI:** requests ≤ 500ms / total
- **SLO:** 99%
- **Alerts:** fast burn, slow burn
## Freshness (outbox)
- **Pipeline:** review-db.outbox → kazmaps.review.review
- **SLI:** publishes < 5s from row insert
- **SLO:** 99%
- **Alert:** `outbox_forwarder_lag_seconds > 60 for 5m`Живёт в сервис-репо, не в handbook. Handbook описывает формат SLO, значения per-сервис — нет.
Ревью SLO
Пересматривай SLO раз в квартал или после крупных изменений архитектуры (migration на новую БД, переключение с sync на async pipeline):
- SLO выполняется с запасом (error rate << бюджета) третий квартал подряд → подними SLO на 0.05% (например, 99.9% → 99.95%). Это делает alert’ы острее.
- SLO постоянно нарушается → либо у команды реальная проблема со стабильностью, либо SLO нереалистичный. Разбираться, какой из двух. Снижение SLO «чтобы не гореть» без root-cause фикса — плохо.
- Бизнес-требования изменились (critical endpoint стал less-critical) → SLO пересматривается.
Commit в slo.md + короткая запись в changelog: «что изменилось,
почему».
Что не делать
- SLO на каждый внутренний endpoint.
/metrics,/healthz,/internal/debug— не user-facing, SLO не нужны. Alert на факт доступности — да, SLO — нет. - SLI «количество ошибок». Абсолютное число не нормализовано на трафик: 100 ошибок при 10k запросов — норма, при 100 запросах — провал. Всегда доля.
- SLO 100%. Недостижимо и бесполезно: сетевой RTT и сбой на клиентской стороне добавят ошибок, ты получишь constant burn.
- SLO на неверной метрике.
http_requests_total{code=~"5.."}не включает таймауты, где connection reset — считай отдельно или уверься, что middleware превращает их в 503. - Alert на
> 0 errors. Любая сетка даст вспышку на одном запросе ночью. Burn rate на окне — единственный масштабируемый подход. - Смотреть только p50 / p99 вместо bucket-based SLI. p99 нельзя
надёжно усреднить между инстансами — PromQL
avg(histogram_quantile(...))даёт неверный результат. Правильно —histogram_quantile(...)сsum(rate(bucket))глобально, либо SLI через bucket’ы. - Добавлять каждый edge-case в SLO. Один SLO per endpoint + один per critical pipeline. 20 SLO на сервис = ни одного actionable.
См. также
observability— инструментовка, метрики, labels, histogram vs summary.../how-to/add-metric-and-alert— пошаговый рецепт добавления метрики и alert rule.../patterns/outbox— метрики outbox forwarder’а, freshness SLI на них.../patterns/retry-and-circuit-breaker— как считать SLI при graceful degradation (stale cache — этоsuccessfulилиerror?).