Когда сервис начинает тормозить или падать, нужно знать об этом до того, как это заметят пользователи. В этой статье разберём: что такое SLO, почему один алерт «на каждую ошибку» не работает, и как правильно настроить алертинг через Prometheus в Go-сервисе.
Почему «ошибка → алерт» не работает
Первая идея у многих такая: каждый раз когда в логах появляется ERROR — отправлять уведомление. Кажется надёжным. На практике — команда начинает игнорировать канал с алертами через неделю.
Проблема в том, что одиночные ошибки — это нормально. Пользователь ошибся в запросе, внешний сервис кратковременно недоступен, сеть дрогнула. Если на каждую такую ошибку приходит уведомление — это просто шум. А в шуме тонет сигнал о настоящей аварии.
Правильный подход: алерт срабатывает не на единичную ошибку, а на темп, с которым расходуется запас допустимых ошибок.
SLO и error budget простыми словами
SLO (Service Level Objective) — это обещание уровня сервиса. Не «сервис всегда работает», а конкретная цифра: «99.9% запросов успешны за последние 30 дней».
Разница принципиальная. Если взять 99.9% за цель, получаем error budget — бюджет ошибок:
- 99.9% успешности → 0.1% можно потратить на ошибки
- За 30 дней это ≈ 43 минуты допустимого простоя
Этот бюджет — не просто техническая цифра. Он отвечает на вопрос: «можно ли сейчас катить рискованный релиз?». Если бюджет почти не потрачен — можно. Если осталось 5% — сначала нужно разобраться с надёжностью.
Цель 99.99% (52 минуты в год) означает совсем другой уровень затрат: несколько регионов, активная репликация. 99.9% (8.7 часов в год) достижимо на одном регионе при нормальном дежурстве.
Метрики из chi-middleware
SLO считается из метрик, которые middleware пишет при каждом запросе. Два ключевых счётчика — количество запросов по статусу и их длительность:
// internal/platform/middleware/metrics.go
var (
httpRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests",
}, []string{"method", "path", "status_class"})
httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency",
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
}, []string{"method", "path", "status_class"})
)
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
status := statusClass(ww.Status())
path := chi.RouteContext(r.Context()).RoutePattern()
httpRequestsTotal.WithLabelValues(r.Method, path, status).Inc()
httpRequestDurationSeconds.WithLabelValues(r.Method, path, status).Observe(time.Since(start).Seconds())
})
}
func statusClass(code int) string {
switch {
case code < 400:
return "success"
case code < 500:
return "client_error"
default:
return "server_error"
}
}
Важная деталь: path берётся из chi.RouteContext(...).RoutePattern() — это шаблон /orders/{orderID}, а не реальный URL /orders/ord-123. Если записывать конкретные ID в метку — для каждого заказа создастся отдельный временной ряд, и Prometheus вскоре упадёт с нехваткой памяти.
SLO targets для эндпоинтов
Разные эндпоинты важны по-разному. Платёжный требует выше доступности, чем поиск:
| Эндпоинт | Доступность | Задержка p99 |
|---|---|---|
POST /orders | 99.9% | < 500ms |
POST /payments | 99.95% | < 1s |
GET /orders/{orderID} | 99.95% | < 200ms |
GET /products | 99.5% | < 800ms |
Цели выбираются совместно с product: техническая сторона говорит, что стоит достичь каждого уровня, бизнес-сторона решает, оправданы ли затраты.
PromQL для SLI
SLI (Service Level Indicator) — это фактически измеренный показатель, по которому судят о выполнении SLO:
# Доступность POST /orders за последние 30 дней
sum(rate(http_requests_total{path="/orders",method="POST",status_class="success"}[30d]))
/
sum(rate(http_requests_total{path="/orders",method="POST"}[30d]))
# p99 задержки за 30 дней
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{path="/orders",method="POST"}[30d]))
)
Для задержки используют p99 (99-й процентиль), не среднее. Среднее скрывает «хвост»: если 1% запросов тормозит 10 секунд, среднее может выглядеть вполне нормально.
Multi-window burn rate
Один алерт «SLO нарушен за 30 дней» приходит слишком поздно — бюджет уже потрачен. Нужно предупреждать раньше, пока авария только разгорается.
Идея: считаем скорость расхода бюджета (burn rate) в нескольких временных окнах.
burn_rate = частота_ошибок_в_окне / допустимая_частота_ошибок
Для SLO 99.9% допустимая частота ошибок = 0.1%. Если за час наблюдается 1.44% ошибок — это burn rate 14.4×: при таком темпе весь месячный бюджет иссякнет за 20 часов.
| Окно | Порог burn rate | Что это значит | Действие |
|---|---|---|---|
| 1 час | > 14.4× | 5% бюджета за час | немедленно будить дежурного |
| 6 часов | > 6× | 5% бюджета за 6 часов | создать задачу в рабочее время |
| 24 часа | > 3× | 10% бюджета за сутки | мониторить ситуацию |
Такой подход называется multi-window multi-burn-rate и описан в книге Google SRE Workbook. Суть: короткое окно реагирует быстро на острые аварии, длинное — на медленную деградацию.
# ops-репо/alerts/order-slo.yaml
groups:
- name: order-slo
rules:
- alert: OrdersAvailabilityFastBurn
expr: |
(
sum(rate(http_requests_total{path="/orders",method="POST",status_class="server_error"}[1h]))
/
sum(rate(http_requests_total{path="/orders",method="POST"}[1h]))
) > (14.4 * (1 - 0.999))
for: 2m
labels:
severity: critical
annotations:
summary: "Orders SLO: быстрое сжигание error budget (1h window)"
runbook_url: "https://runbooks.internal/orders-slo-fast-burn"
- alert: OrdersAvailabilitySlowBurn
expr: |
(
sum(rate(http_requests_total{path="/orders",method="POST",status_class="server_error"}[6h]))
/
sum(rate(http_requests_total{path="/orders",method="POST"}[6h]))
) > (6 * (1 - 0.999))
for: 15m
labels:
severity: warning
annotations:
summary: "Orders SLO: медленное сжигание error budget (6h window)"
runbook_url: "https://runbooks.internal/orders-slo-slow-burn"
for: 2m у быстрого алерта означает: условие должно выполняться непрерывно 2 минуты, прежде чем придёт уведомление. Это отсекает кратковременные пики. for: 15m у медленного — устойчивая деградация, не случайный всплеск.
Алерт на задержку
- alert: OrdersLatencyP99High
expr: |
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket{path="/orders",method="POST"}[5m]))
) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "Orders p99 latency > 500ms"
runbook_url: "https://runbooks.internal/orders-latency-high"
Алерт на исчерпание бюджета
Отдельный алерт когда за 30 дней осталось меньше 10% бюджета. Это не повод будить ночью — это сигнал для планирования следующего спринта.
Recording rule вычисляет остаток бюджета заранее, чтобы алерт не тратил ресурсы на тяжёлый запрос каждую минуту:
groups:
- name: order-slo-recording
rules:
- record: job:orders_availability_sli:rate30d
expr: |
sum(rate(http_requests_total{path="/orders",method="POST",status_class="success"}[30d]))
/
sum(rate(http_requests_total{path="/orders",method="POST"}[30d]))
- record: job:orders_error_budget_remaining:ratio
expr: |
1 - ((1 - job:orders_availability_sli:rate30d) / (1 - 0.999))
- name: order-slo-alerts
rules:
- alert: OrdersErrorBudgetExhausted
expr: job:orders_error_budget_remaining:ratio < 0.1
for: 1h
labels:
severity: warning
annotations:
summary: "Orders: осталось менее 10% error budget"
description: |
Рискованные релизы приостановлены до восстановления бюджета.
Следующий спринт — работа над надёжностью.
runbook_url: "https://runbooks.internal/orders-error-budget-exhausted"
Алерты вне SLO
SLO отражает опыт пользователя — успешность запросов. Но есть сигналы, которые важно отслеживать отдельно, с другими критериями реагирования.
Бизнес-метрики
Бизнес-ошибки (отказ платежа, провал валидации) не обязательно попадают в 5xx. Их стоит считать отдельно:
// internal/order/metrics.go
var (
ordersFailedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "orders_failed_total",
Help: "Orders that failed business validation or processing",
}, []string{"reason"})
paymentDeclinedTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "payment_declined_total",
Help: "Payments declined by payment provider",
}, []string{"decline_code"})
)
Значения меток — только низкокардинальные: insufficient_funds, card_expired, fraud_suspected. Никаких order_id или customer_id в метках — это взрывной рост временных рядов.
Ресурсы процесса
Горутины и пул соединений с базой:
// cmd/server/main.go
prometheus.MustRegister(collectors.NewGoCollector())
prometheus.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
Кастомный коллектор для pgxpool:
// internal/platform/metrics/pgpool.go
type pgPoolCollector struct {
pool *pgxpool.Pool
emptyAcquires *prometheus.Desc
idle *prometheus.Desc
}
func NewPgPoolCollector(pool *pgxpool.Pool) prometheus.Collector {
return &pgPoolCollector{
pool: pool,
emptyAcquires: prometheus.NewDesc("pgxpool_empty_acquires_total",
"Cumulative number of acquisitions that found the pool empty", nil, nil),
idle: prometheus.NewDesc("pgxpool_idle_connections",
"Idle connections in the pool", nil, nil),
}
}
func (c *pgPoolCollector) Collect(ch chan<- prometheus.Metric) {
stat := c.pool.Stat()
ch <- prometheus.MustNewConstMetric(c.emptyAcquires, prometheus.CounterValue,
float64(stat.EmptyAcquireCount()))
ch <- prometheus.MustNewConstMetric(c.idle, prometheus.GaugeValue,
float64(stat.IdleConns()))
}
func (c *pgPoolCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.emptyAcquires
ch <- c.idle
}
Алерт на насыщение пула — через rate() по счётчику пустых попыток занять соединение:
- alert: PgPoolEmptyAcquiresHigh
expr: rate(pgxpool_empty_acquires_total[1m]) > 5
for: 1m
labels:
severity: warning
annotations:
runbook_url: "https://runbooks.internal/pgpool-saturation"
Сводная карта алертов
| Категория | Условие | Что делать |
|---|---|---|
| Горутины | go_goroutines > 10000 (устойчиво) | искать утечку горутин |
| pgxpool | rate(pgxpool_empty_acquires_total[1m]) > 5 | увеличить размер пула |
| Заказы | rate(orders_failed_total[5m]) > 100 | разобрать данные с product |
| Платежи | rate(payment_declined_total{decline_code="fraud"}[5m]) > 10 | уведомить службу безопасности |
| Внешний сервис | circuit_breaker_state{state="open"} == 1 | проверить внешний сервис |
| Kafka | kafka_consumer_group_lag > 10000 | масштабировать потребителей |
Runbook — обязательная часть алерта
Каждый алерт должен содержать annotations.runbook_url. Уведомление в 3 ночи без инструкции — это не сигнал к действию, это загадка. Дежурный тратит время на расследование, которое уже кто-то проделал.
Хороший runbook — короткий и конкретный. Для OrdersAvailabilityFastBurn:
# Orders SLO Fast Burn — Runbook
## Симптомы
Burn rate > 14.4× за последний час.
## Диагностика (в порядке)
1. `rate(http_requests_total{path="/orders",status_class="server_error"}[5m])` — абсолютный темп 5xx.
2. `pgxpool_waiting_connections > 0` — соединений с базой не хватает.
3. `circuit_breaker_state{service="payment-provider"} == 1` — внешний сервис недоступен.
4. Grafana → трейсы с `status=ERROR` по `/orders` за последние 30 минут.
## Действия
- Если #2: увеличить число реплик или размер пула соединений.
- Если #3: подтвердить алерт, ждать восстановления; включить запасной путь если есть.
- Иначе: эскалировать в `#order-service-oncall`.
Go-код не содержит runbook — он только поставляет метрики. Runbook хранится в ops-репозитории, ссылка на него — в YAML-правиле алерта.
Частые ошибки
Алерт на каждую запись в лог с уровнем ERROR. Итог — команда отключает уведомления. Правильно: считать агрегированный темп ошибок, а не отдельные события.
SLO = 100%. Математически это означает нулевой бюджет: любая ошибка нарушает цель. Реалистичные значения — 99.9% или 99.95%.
Алерт без for:. Срабатывает на любой кратковременный пик. Даже for: 2m убирает большую часть шума.
Один алерт на всё окно 30 дней. Приходит слишком поздно. Нужны короткое окно (1h) для острых аварий и длинное (6h) для медленной деградации.
Среднее вместо процентиля для задержки. Среднее скрывает медленные запросы. Используйте p99 (или p95).
Коротко
- SLO — это обещание уровня сервиса в цифрах: «99.9% запросов успешны за 30 дней».
- Error budget — допустимый запас сбоев. Для 99.9% это ≈ 43 минуты в месяц.
- Алертить нужно не на отдельные ошибки, а на темп расхода бюджета (burn rate).
- Multi-window: короткое окно (1h, burn > 14.4×) — немедленно будить; длинное (6h, burn > 6×) — создать задачу.
- Для задержки — p99, не среднее; без
for:алерт срабатывает на шум. - Бизнес-метрики, ресурсы процесса и очереди — отдельные алерты с отдельными runbook'ами.
- Каждый алерт содержит
runbook_url. Уведомление без инструкции — загадка для дежурного. - Go-код только поставляет метрики; правила алертов и runbook — в ops-репозитории.
Что почитать дальше
- Метрики в Go — chi-middleware, RED-метрики, pgxpool Collector.
- Трассировка в Go — детальный разбор инцидента через OTel traces.
- Health checks в Go — liveness/readiness и почему они не заменяют SLO.
- Логирование в Go — slog, OTel bridge, связка лог → trace.