← назад к разделу

Когда сервис начинает тормозить или падать, нужно знать об этом до того, как это заметят пользователи. В этой статье разберём: что такое 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 /orders99.9%< 500ms
POST /payments99.95%< 1s
GET /orders/{orderID}99.95%< 200ms
GET /products99.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 (устойчиво)искать утечку горутин
pgxpoolrate(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проверить внешний сервис
Kafkakafka_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.