Дежурный получает двести алертов за ночь. К утру он начинает мьютить каналы и игнорировать уведомления. В какой-то момент приходит настоящий инцидент — и никто не реагирует.
Это классическая «усталость от алертов». Чтобы её избежать, нужна система, где каждый алерт означает реальную проблему и каждый алерт объясняет, что делать. Вот для этого и существуют 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 — структурированные логи для разбора инцидентов.