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

Дежурный получает двести алертов за ночь. К утру он начинает мьютить каналы и игнорировать уведомления. В какой-то момент приходит настоящий инцидент — и никто не реагирует.

Это классическая «усталость от алертов». Чтобы её избежать, нужна система, где каждый алерт означает реальную проблему и каждый алерт объясняет, что делать. Вот для этого и существуют SLO.

Что такое SLO и зачем он нужен

SLO (Service Level Objective) — это конкретное обещание уровня сервиса. Не «сервис должен работать хорошо», а «99.9% запросов к POST /orders должны завершаться успешно в течение 30-дневного окна».

Зачем это формализовывать? Потому что из SLO автоматически вытекает error budget — количество ошибок, которые сервис может «потратить» за период и всё ещё уложиться в обещание.

SLO 99.9% означает error budget 0.1% за 30 дней. Переводя в минуты — это примерно 43 минуты допустимого downtime в месяц. Именно этот бюджет становится мерой, которая связывает надёжность и скорость разработки: когда бюджет кончается, рискованные релизы приостанавливаются до его восстановления.

SLI (Service Level Indicator) — это то, чем измеряется выполнение SLO. Для веб-сервиса это обычно доля успешных запросов (availability) и задержка (latency).

Важный момент: SLO не должен быть 100%. 100% — это другая архитектура (multi-region, немедленный failover), другие затраты. Для большинства сервисов 99.9% или 99.95% — разумный выбор, который обсуждается с product owner.

Как измерить SLI в NestJS

SLI считается из метрик. В NestJS удобно собирать их через prom-client с помощью перехватчика, который замеряет каждый HTTP-запрос.

// metrics/http-metrics.interceptor.ts
@Injectable()
export class HttpMetricsInterceptor implements NestInterceptor {
  private readonly histogram: Histogram<string>;

  constructor() {
    this.histogram = new Histogram({
      name: 'http_server_requests_seconds',
      help: 'HTTP request duration',
      labelNames: ['method', 'route', 'status_class'] as const,
      buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
    });
  }

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest<Request>();
    const route = req.route?.path ?? 'unknown';
    const method = req.method;
    const end = this.histogram.startTimer();

    return next.handle().pipe(
      tap({
        next: () => {
          const status = ctx.switchToHttp().getResponse<Response>().statusCode;
          end({ method, route, status_class: statusClass(status) });
        },
        error: (err: unknown) => {
          const code = err instanceof HttpException ? err.getStatus() : 500;
          end({ method, route, status_class: statusClass(code) });
        },
      }),
    );
  }
}

function statusClass(code: number): string {
  if (code < 400) return 'success';
  if (code < 500) return 'client_error';
  return 'server_error';
}

Обратите внимание на метку route — это шаблон /orders/:id, а не сырой URL с реальным идентификатором заказа. Если использовать сырой URL, количество уникальных значений метки вырастет до миллионов и Prometheus начнёт потреблять огромное количество памяти.

Дальше в Prometheus считается SLI:

# Availability SLI: доля успешных запросов POST /orders за 30 дней
sum(rate(http_server_requests_seconds_count{route="/orders",method="POST",status_class="success"}[30d]))
  /
sum(rate(http_server_requests_seconds_count{route="/orders",method="POST"}[30d]))

# Latency SLI: 95-й процентиль задержки
histogram_quantile(0.95,
  sum by (le) (rate(http_server_requests_seconds_bucket{route="/orders",method="POST"}[30d]))
)

Latency считается через histogram_quantile (p95 или p99), никогда через среднее — среднее скрывает хвосты, а именно там живут медленные запросы, которые бесят пользователей.

Multi-window алерты: быстрый и медленный огонь

Простой алерт «ошибок стало больше N%» плохо работает на практике: или слишком чувствительный (будит ночью из-за кратковременного всплеска), или слишком тупой (замечает проблему только когда бюджет уже почти сожжён).

Решение — подход из Google SRE Workbook: считать не просто ошибки, а темп сжигания бюджета (burn rate).

Формула:

burn rate = текущий темп ошибок / (1 - SLO target)

Для SLO 99.9% знаменатель равен 0.001. Если за последний час ошибок было 1.44% — burn rate равен 14.4. Это значит, что при таком темпе весь месячный бюджет закончится примерно через 2 дня.

На практике используют два окна:

  • Fast burn (окно 1 час, порог burn rate > 14.4) — 5% бюджета за час, это катастрофа. Будит дежурного немедленно.
  • Slow burn (окно 6 часов, порог burn rate > 6) — устойчивая деградация. Создаёт тикет, чтобы разобрались утром.
# Alertmanager — алерты для POST /orders, SLO 99.9%
- alert: OrdersSloFastBurn
  expr: |
    (
      sum(rate(http_server_requests_seconds_count{route="/orders",method="POST",status_class="server_error"}[1h]))
      /
      sum(rate(http_server_requests_seconds_count{route="/orders",method="POST"}[1h]))
    ) > (14.4 * (1 - 0.999))
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "POST /orders fast burn — SLO под угрозой"
    runbook: "https://runbooks.internal/orders-slo-fast-burn"

- alert: OrdersSloSlowBurn
  expr: |
    (
      sum(rate(http_server_requests_seconds_count{route="/orders",method="POST",status_class="server_error"}[6h]))
      /
      sum(rate(http_server_requests_seconds_count{route="/orders",method="POST"}[6h]))
    ) > (6 * (1 - 0.999))
  for: 15m
  labels:
    severity: warning
  annotations:
    summary: "POST /orders slow burn — деградация"
    runbook: "https://runbooks.internal/orders-slo-slow-burn"

for: 2m на fast burn исключает мгновенные всплески — алерт не сработает, если проблема прошла сама за минуту. for: 15m на slow burn убеждается, что деградация устойчивая, а не случайная.

Когда бюджет почти кончился

Отдельный алерт срабатывает, когда остаток error budget падает ниже 10%. Это не сигнал чинить что-то ночью — это сигнал product owner и команде: следующий спринт нужно сфокусировать на надёжности, а рискованные релизы приостановить.

- alert: OrdersErrorBudgetExhausted
  expr: <budget_remaining_expr> < 0.1
  for: 1h
  labels:
    severity: warning
  annotations:
    summary: "Осталось меньше 10% error budget на POST /orders"
    description: |
      Команда переключается с новых функций на устойчивость.
      Рискованные релизы приостановлены до восстановления бюджета.
    runbook: "https://runbooks.internal/orders-error-budget"

Формула остатка бюджета:

1 - (
  (1 - sum(rate(http_server_requests_seconds_count{route="/orders",method="POST",status_class="success"}[30d]))
       / sum(rate(http_server_requests_seconds_count{route="/orders",method="POST"}[30d])))
  / (1 - 0.999)
)

Бизнес-метрики рядом с SLO

HTTP-метрики говорят о технических сбоях, но не всегда о бизнес-проблемах. Запрос может вернуть 200 OK, но заказ при этом не создался из-за ошибки в логике.

Доменные счётчики в NestJS помогают это отслеживать:

// metrics/order.metrics.ts
export const orderCreatedTotal = new Counter({
  name: 'order_created_total',
  help: 'Orders successfully created',
  labelNames: ['type'] as const,
});

export const orderFailedTotal = new Counter({
  name: 'order_failed_total',
  help: 'Orders failed during processing',
  labelNames: ['reason'] as const,
});
// use-cases/create-order/create-order.handler.ts
async handle(cmd: CreateOrderCommand): Promise<OrderId> {
  try {
    const order = await this.orderRepository.save(Order.create(cmd));
    orderCreatedTotal.inc({ type: cmd.type });
    return order.id;
  } catch (err) {
    orderFailedTotal.inc({ reason: classifyReason(err) });
    throw err;
  }
}

Метка reason — это категория (validation_error, payment_declined, inventory_unavailable), а не сырое сообщение об ошибке. Сырые сообщения — это опять high-cardinality, которая ломает Prometheus.

Инфраструктурные алерты: ранние признаки

Эти алерты не про SLO напрямую, но они предупреждают о проблеме раньше, чем она скажется на пользователях.

Event loop lag — если Node.js тормозит в event loop, задержки вырастут через 1–2 минуты:

- alert: NodeEventLoopLag
  expr: nodejs_eventloop_lag_seconds > 0.1
  for: 3m
  annotations:
    summary: "Event loop lag > 100ms"
    runbook: "https://runbooks.internal/node-event-loop-lag"

Нехватка памяти — когда heap занят больше 85%:

- alert: NodeHeapSaturation
  expr: nodejs_heap_used_bytes / nodejs_heap_size_limit > 0.85
  for: 5m
  annotations:
    summary: "Node.js heap заполнен на 85%+"

Очередь задач — если BullMQ накапливает задачи быстрее, чем обрабатывает:

- alert: OrderEventsQueueLag
  expr: bullmq_queue_waiting_total{queue="order-events"} > 500
  for: 5m
  annotations:
    summary: "BullMQ order-events queue lag > 500"
    runbook: "https://runbooks.internal/bullmq-order-events-lag"

Метрики пула соединений с базой данных удобно собирать через setInterval:

@Injectable()
export class PgPoolMetricsService implements OnModuleInit {
  private readonly poolWaiting: Gauge<string>;

  constructor(private readonly pool: Pool) {
    this.poolWaiting = new Gauge({
      name: 'pg_pool_waiting_count',
      help: 'pg pool waiting connections',
      labelNames: [],
    });
  }

  onModuleInit(): void {
    setInterval(() => {
      this.poolWaiting.set(this.pool.waitingCount);
    }, 5000);
  }
}

Runbook — инструкция к каждому алерту

Алерт без объяснения что делать — это звонок в 3 ночи без объяснения зачем. Дежурный открывает Slack, видит «что-то сломалось» и начинает с нуля: искать графики, вспоминать архитектуру, угадывать причину.

Каждый алерт должен содержать annotations.runbook — ссылку на страницу с чётким порядком действий: что проверить первым, как диагностировать, кого позвонить, если проблема не решается за N минут.

Хороший runbook отвечает на вопросы:

  • Что означает этот алерт?
  • Какие графики смотреть?
  • Что делать пошагово?
  • Когда эскалировать?

Частые ошибки

Алерт на каждое событие в логах. logger.error(...) — не алерт. Алерт — это аномалия в темпе. Используйте rate(app_errors_total[5m]) > threshold с группировкой по категории.

SLO 100%. Это недостижимо без огромных затрат и блокирует команду: любой релиз становится рискованным. Обсудите с product owner реалистичный target.

Один широкий алерт «что-то сломалось». Раздельные категории — SLO burn, инфраструктура, домен, очереди — помогают быстро понять, где копать.

Нет for: в алерте. Без него алерт срабатывает на любой мгновенный всплеск. Минимум: for: 2m для критических, for: 5m для предупреждений.

Коротко

  • SLO — конкретное обещание (например, 99.9% успешных запросов за 30 дней). Error budget — допустимое количество ошибок, вытекающее из SLO.
  • SLI считается через http_server_requests_seconds с меткой status_class, latency через histogram_quantile (p95/p99), не через среднее.
  • Multi-window burn rate: fast burn (1 час, rate > 14.4) — будит дежурного, slow burn (6 часов, rate > 6) — создаёт тикет.
  • Алерт на исчерпание бюджета (< 10%) — не чинить ночью, а сигнал команде переключиться на устойчивость.
  • Инфраструктурные алерты (event loop lag, heap, пул соединений, очереди) — отдельная категория, ранние признаки проблем.
  • Бизнес-метрики (order_failed_total) — отдельные счётчики в хендлерах, метки — категории, не сырые сообщения.
  • Каждый алерт содержит annotations.runbook. Без него алерт — звонок без объяснения.

Что почитать дальше

  • Метрики в Node.js — RED-метрики, prom-client, настройка buckets для SLI.
  • Трассировка в Node.js — детальный разбор инцидента через OpenTelemetry.
  • Health checks в Node.js — почему liveness/readiness не заменяет SLO.
  • Логирование в Node.js — структурированные логи для разбора инцидентов.