Метрики, перцентили и SLO
Метрики — не логи и не трейсы: это дешёвые агрегаты, по которым ты алертишь. Возьми правильный тип (counter/gauge/histogram), меряй хвост через p99, а не лгущее среднее, держи метки низкокардинальными, иначе Prometheus уйдёт в OOM, и алерть на прожиг бюджета SLO.
Дашборд был зелёным. Среднее время ответа на маршруте оформления заказа держалось на плоских 90ms — та же линия, что рисовалась месяцами, — дежурный глянул на неё и вернулся к обеду. А тем временем копились тикеты поддержки: «страница висит», «нажал оплатить дважды, списали один раз», «крутится бесконечно». Среднее не двигалось, потому что и не могло: среднее заякорено плотной серединой распределения, и только самый медленный один запрос из ста разнесло до четырёх секунд. Этот один-из-ста — это реальные пользователи, упирающиеся в клиентский таймаут в 3 секунды, делающие ретрай и удваивающие ту самую нагрузку, что и вызывала медленность. Фикс начался не с изменения кода — с изменения измерения: перестать смотреть на среднее, начать смотреть на p99 и повесить на него SLO. Этот урок про то, как делать метрики правильно: типы, перцентили, метка, которая кладёт Prometheus, и бюджет, по которому ты на самом деле алертишь.
Четыре типа метрик: бери тот, что отвечает на твой вопрос
Метрика — это не строка лога и не span. Это число, семплируемое во времени и агрегируемое на стороне сервера: дёшево хранить, дёшево запрашивать, и именно по нему ты строишь алерты. Prometheus даёт четыре типа, и неверный выбор — первая ошибка.
import { Counter, Gauge, Histogram } from 'prom-client';
// COUNTER: монотонен, только растёт (или сбрасывается в 0 при рестарте).
// Сырое значение не читают — запрашивают rate() по нему.
const httpRequests = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status'] as const, // НИЗКАЯ кардинальность — см. ниже
});
// GAUGE: значение, которое растёт И падает — снимок «прямо сейчас».
const inFlight = new Gauge({
name: 'http_requests_in_flight',
help: 'Requests currently being served',
});
// HISTOGRAM: бакетизирует наблюдение (здесь — длительность) в заранее заданные бакеты,
// чтобы вычислять квантили НА СТОРОНЕ СЕРВЕРА по всем инстансам.
const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Request duration in seconds',
labelNames: ['method', 'route', 'status'] as const,
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5], // boundaries decide p99 accuracy
});Counter монотонен — суммы запросов, суммы ошибок. Ты никогда не рисуешь сырое число; ты рисуешь rate(http_requests_total[5m]), чтобы получить запросы-в-секунду. Gauge ходит в обе стороны — in-flight запросы, использование памяти, глубина очереди, размер пула соединений: снимок, который ты читаешь напрямую. Histogram кладёт каждое наблюдение в бакет; поскольку бакеты живут на сервере, ты можешь посчитать histogram_quantile(0.99, …) и, что критично, сначала сложить бакеты по всем своим инстансам, а потом взять квантиль. Четвёртый тип — summary — считает квантили на стороне клиента в каждом процессе, а значит, их нельзя агрегировать: p99 трёх инстансов — это не среднее их трёх p99. В любом мультиинстансном сервисе предпочитай гистограммы.
В Nest ты подключаешь это через @willsoto/nestjs-prometheus (или сырой prom-client), выставляешь эндпоинт /metrics для скрейпа Prometheus и записываешь длительности в интерсепторе, оборачивающем каждый запрос: запускаешь таймер в intercept, останавливаешь его в tap/finalize observable, размечая по методу, шаблону маршрута и статусу.
RED и golden signals: что на самом деле мерить
Ты не меряешь всё; ты меряешь горстку сигналов, которые говорят, здоров ли сервис. Для request-driven сервиса спина — это метод RED: Rate (запросов в секунду, из counter), Errors (упавших запросов в секунду, тот же counter, отфильтрованный по 5xx), Duration (распределение задержки, из histogram). Четыре golden signals от Google добавляют saturation (насколько полон самый загруженный ресурс) к latency, traffic и errors. USE (Utilization, Saturation, Errors) — это зеркало со стороны ресурсов: применяй к CPU, памяти, диску, пулу соединений. RED для пути запроса, USE для ресурсов, на которые он опирается.
Перцентили: среднее лжёт, меряй хвост
Вот ключевой инсайт, на котором держится инцидент из начала. Среднее время ответа прячет аварию, которую обнажает p99. Среднее в 80ms может сидеть поверх p99 в две секунды, потому что среднее доминируется массой быстрых запросов, и небольшой медленный хвост едва его подталкивает. Но этот хвост — не шум, это реальные пользователи, и это ровно та когорта, что упирается в таймауты, запускает ретраи и усиливает нагрузку. SLO на среднее — это SLO на неверное число.
# PromQL: p99 latency на маршрут, агрегированная по ВСЕМ инстансам.
# sum by (...) — ПЕРЕД histogram_quantile — именно поэтому histogram, а не summary:
# сначала складываем бакеты, потом берём квантиль.
histogram_quantile(
0.99,
sum by (le, route) (rate(http_request_duration_seconds_bucket[5m]))
)Поэтому ты отслеживаешь p50 (типичный пользователь), p95 и p99 (хвост, который болит) и p999, когда трафик достаточно велик, чтобы один-из-тысячи был ощутимым числом людей. Одна оговорка: квантиль гистограммы Prometheus хорош ровно настолько, насколько хороши границы твоих бакетов. histogram_quantile линейно интерполирует внутри того бакета, в который попадает квантиль, так что если твой p99 ложится между бакетами 1s и 5s, отчётное значение — это догадка где-то в этом промежутке, возможно сильно неверная. Ставь границы бакетов там, где тебе реально важно (вокруг порога твоего SLO), а не на круглых числах.
▸Почему это работает
Почему среднее прячет аварию, которую обнажает p99? Потому что среднее — это центр масс: его тянет плотная середина распределения, где сидит большинство запросов. Если 99 запросов занимают 50ms, а один — 4 секунды, среднее около 90ms — один медленный запрос едва его сдвигает. Но этот один медленный запрос — не погрешность округления: при тысяче запросов в секунду это десять пользователей в секунду, упирающихся в глухую стену, а при клиентском таймауте в 3s они делают ретрай, что добавляет нагрузку и ухудшает хвост. Боль системы живёт в её хвосте, поэтому хвост надо мерить напрямую — p99, p999, — а не число, математически устроенное так, чтобы его стереть. SLA или SLO, заданный на среднем, — это SLO на неверном числе, и он будет светиться зелёным сквозь реальную, видимую пользователю аварию.
Кардинальность: метка, которая кладёт Prometheus
Это тот режим отказа, что убивает саму систему мониторинга. В Prometheus каждая отдельная комбинация значений меток — это свой временной ряд, хранимый и индексируемый отдельно. http_requests_total{method="GET", route="/users/:id", status="200"} — это один ряд; измени любое значение метки — получишь другой. Это нормально, пока метки ограничены: горстка методов, пара десятков маршрутов, несколько классов статусов, так что произведение — может, несколько сотен рядов.
Это становится катастрофой в момент, когда ты вешаешь на метрику высококардинальную метку: userId, requestId, email или сырой path, в который вшиты id. Метка userId означает один ряд на пользователя — миллион пользователей — это миллион рядов на метрику, у каждого своя память и своя запись в индексе. Prometheus держит head-ряды в RAM; взрыв кардинальности уводит сервер в OOM, раздувает время скрейпа и хранилище и кладёт мониторинг ровно тогда, когда он тебе нужен.
// БОМБА КАРДИНАЛЬНОСТИ — сырой путь со вшитыми id = один ряд НА каждый id.
// /users/1, /users/2, … /users/9999999 → миллионы различных значений метки.
httpRequests.inc({ method: 'GET', route: req.path, status: '200' });
// напр. route="/users/8f3a-...-91/orders/4412" ← неограничен, уникален на запрос
// БЕЗОПАСНО — сворачивай до ШАБЛОНА маршрута; id — в значении, а не в метке.
httpRequests.inc({ method: 'GET', route: '/users/:id/orders/:orderId', status: '200' });Правило: метки должны быть низкокардинальными и ограниченными — метод, шаблон маршрута (а не сырой путь), класс статуса. Когда возникнет соблазн добавить метку userId или requestId, чтобы «докопаться» до отдельного пользователя — остановись: этот id принадлежит трейсу или строке лога, а не измерению Prometheus. То, что тебя тянет положить в метку (id пользователя, id запроса), принадлежит трейсу или строке лога, а не измерению метрики. Военная история банальна и распространена: кто-то разметил http_requests по полному URL, включая литеральные id /users/{id}, число рядов взорвалось, Prometheus съел всю свою RAM и упал — а дашборд погас посреди инцидента.
SLO и бюджет ошибок: то, по чему ты алертишь
SLO — это цель: «99.9% запросов оформления завершаются быстрее 300ms, измеряется за 30 дней». Его зеркальное отражение — бюджет ошибок, те 0.1%, что тебе разрешено промахнуть. Этот бюджет — не отказ, которого надо избегать; это ресурс, который надо тратить. Прожигаешь медленно — у тебя есть простор катить фичи и рисковать; прожигаешь быстро — замораживаешь деплои и чинишь надёжность. И ты алертишь не на сырое число ошибок — горстка ошибок в 3 ночи — это шум. Ты алертишь на скорость прожига: насколько быстро ты потребляешь бюджет. Скорость прожига, которая исчерпала бы 30-дневный бюджет за час, — это пейдж; та, что заняла бы неделю, — тикет. Это разница между «разбудить человека» и «не будить».
Нужно отслеживать задержку HTTP-запросов для SLO (99.9% быстрее 300ms) на сервисе, работающем на множестве инстансов, и считать p99 на дашбордах. Какая метрика и метки?
Нужно отслеживать задержку HTTP-запросов и показывать корректный p99 по сервису на 12 инстансах. Какой тип метрики и почему?
Инженер добавляет метку `userId` на counter http_requests_total, чтобы разбить трафик по пользователям. У сервиса ~2 миллиона пользователей. Что станет с Prometheus?
- 01Назови четыре типа метрик Prometheus, для чего каждый, и почему для задержки запросов в мультиинстансном сервисе ты предпочитаешь histogram, а не summary.
- 02Объясни, почему среднее время ответа прячет аварии, что такое взрыв кардинальности и как его избежать, и что такое бюджет ошибок SLO и на что ты алертишь.
Метрики — это дешёвые серверные агрегаты, по которым ты строишь алерты, отличные от логов и трейсов. В Prometheus четыре типа: counter монотонен (суммы запросов и ошибок — ты запрашиваешь rate() по нему), gauge ходит в обе стороны (in-flight, память, глубина очереди), histogram бакетизирует наблюдения, чтобы считать квантили на сервере, а summary считает квантили на клиенте и потому не агрегируется — предпочитай гистограммы в любом мультиинстансном сервисе. Меряй сигналы RED для пути запроса (Rate, Errors, Duration) и USE для ресурсов. Ключевой инсайт: среднее время ответа лжёт — среднее тянет плотная середина, так что p99 в секунды может прятаться под плоским средним в 90ms, пока реальные пользователи упираются в таймауты и делают ретрай; отслеживай p50/p95/p99/p999 и вешай SLO на хвост, помня, что histogram_quantile интерполирует ровно настолько хорошо, насколько позволяют границы бакетов. Убийственный режим отказа — кардинальность: каждая комбинация значений меток — отдельный ряд, так что метка userId, requestId или сырого пути создаёт миллионы рядов и уводит Prometheus в OOM — метки должны быть ограниченными (метод, шаблон маршрута, статус), а id идёт в трейс, а не в метку. Наконец, SLO — это цель (99.9% быстрее 300ms за 30 дней → бюджет ошибок 0.1% ≈ 43 мин/месяц), и ты алертишь на скорость прожига бюджета, а не на сырое число ошибок, так что быстрый прожиг — пейдж, а медленный — тикет. Теперь, когда видишь дашборд «среднее 90ms, всё зелёное», знаешь, что нужно проверить p99 — именно там прячется авария, которую уже переживают твои пользователи.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.