Метрики и Prometheus: RED, USE и бизнес-метрики

Метрики по делу: типы (counter/gauge/histogram), методологии RED и USE, кастомные бизнес-метрики через OTel Metrics, PromQL и главная ловушка Prometheus — кардинальность лейблов. Экспорт OTel в Prometheus. Практика на Go и Java.

Метрики — единственный сигнал, который дёшево держать «всегда включённым» и смотреть на систему целиком. Но метрики легко превратить в свалку: сотни счётчиков, которые никто не читает, и один лейбл с высокой кардинальностью, который кладёт Prometheus. Полезные метрики — это дисциплина: понимать, что за вопрос ты задаёшь системе, и заводить метрику под ответ.

Это четвёртая статья серии «Наблюдаемость на практике». Трейсы и логи отвечают на «что случилось с этим запросом» — метрики отвечают на «как система чувствует себя в целом» и ведут к SLO. Всё, что ниже, снято с того же стенда, что и предыдущие три статьи: Go-фронтенд, Java-бэкенд, PostgreSQL, NATS, а телеметрия идёт через OTel Collector в Prometheus.

Главное, что я вынес из этой части работы: в метриках ошибаются не на инструментировании, а на чтении. Правило, которое успешно вычисляется и возвращает пустоту. Квантиль, который отличается от квантиля тех же самых запросов только из-за сетки бакетов. Алерт, который молчит одинаково и при здоровье, и при полной остановке сервиса. Всё это здесь есть, с числами.

Две одинаковые кривые распределения: слева на неё наложена редкая сетка из нескольких широких полос, справа — густая из множества узких; маркеры на пересечениях кривой с линиями сетки стоят на разной высоте

В статье

Что приезжает само: 721 имя метрики без строчки кода

Прежде чем заводить свои метрики, полезно посмотреть, что уже есть. На стенде — Go-сервис с OTel SDK и Java-сервис с автоматическим агентом; прикладного кода про метрики в них ровно два инструмента (счётчик и гистограмма, о них ниже). В Prometheus при этом лежит:

Семейство Имён метрик Откуда
go_* 163 рантайм Go, автоматически
jvm_* 14 Java-агент, автоматически
db_* 13 Hikari через Java-агент, автоматически
http_server_*, http_client_* латентность, коды, rate otelhttp и агент
khorost_tech_* 4 мои два инструмента
otelcol_*, tempo_*, loki_* телеметрия самого стека скрейп компонентов

Всего в TSDB 721 уникальное имя метрики. Из них четыре — мои.

Это не повод радоваться количеству. Это повод сначала посмотреть, а потом писать: значительная часть «кастомных» метрик, которые заводят в сервисах, дублирует то, что уже приехало от автоматики. Про то, что именно даёт автоматика и чего она принципиально не может знать, — статья 1 серии.

Одна деталь из того же ряда, отдельно ценная для USE (см. ниже): Java-агент отдаёт db_client_connections_pending_requests — сколько потоков стоят в очереди за соединением к БД. Это готовая метрика насыщения пула, и инструментировать Hikari руками для неё не нужно.

Три типа и почему латентность — не gauge

Рассмотрим три типа — counter, gauge и histogram; Prometheus поддерживает ещё summary, но в OTel-конвейере стенда он не используется. Выбор между ними — не вопрос вкуса.

Counter только растёт. Запросы, ошибки, созданные заказы. Читается не сам, а через производную: rate(counter[5m]). Абсолютное значение счётчика бессмысленно — оно зависит от того, когда запустился процесс.

Gauge — мгновенное значение: горутины, длина очереди, занятая память. Читается как есть.

Histogram — распределение. Латентность, размеры ответов. Экспортируется тремя семействами: _bucket (сколько наблюдений уложилось в границу), _sum, _count.

Соблазн подменить гистограмму gauge’ем («буду писать длительность последнего запроса») выглядит экономным и рушится на первом же инциденте. Gauge отдаёт значение на момент скрейпа: между двумя скрейпами прошло, скажем, двадцать запросов, из них один длился две секунды — и вы увидите его, только если повезло попасть. Усреднение не спасает: среднее по латентности прячет ровно тот хвост, из-за которого пользователи жалуются. Гистограмма считает все наблюдения, а не подсмотренные.

Обратная ошибка тоже встречается: гистограмма там, где хватило бы счётчика. Она стоит дороже, и вот насколько.

Цена гистограммы: 15 серий против одной

Считать по формуле «бакетов плюс два» не стоит — в реальном экспорте бывают сюрпризы. Я замерил фактом, через count() по каждому суффиксу. У обеих метрик один и тот же набор лейблов: outcome со значениями ok и error, то есть две комбинации.

khorost_tech_orders_created_total             2 серии    (1 на комбинацию)
khorost_tech_order_duration_seconds_bucket   26 серий
khorost_tech_order_duration_seconds_sum       2 серии
khorost_tech_order_duration_seconds_count     2 серии
ИТОГО гистограмма                            30 серий = 15 на комбинацию

Пятнадцать против одной. Двенадцать границ дают тринадцать бакетов (последний — +Inf), плюс _sum и _count.

Важно, как это масштабируется: каждый новый лейбл умножает всё число целиком. Добавили к гистограмме лейбл с пятью значениями — стало 75 серий вместо 15. Именно поэтому в лейбл гистограммы нельзя класть то, что спокойно живёт в атрибуте спана: у спана атрибут стоит байты в хранилище трейсов, у гистограммы — умножение всей сетки бакетов.

Для сравнения, серверная HTTP-гистограмма от Java-агента на стенде — 45 серий на один сервис, и у каждой серии 35 лейблов, включая service_instance_id. Последнее означает, что при каждом рестарте приложения набор серий сменяется целиком. Про то, чем это чревато в индексе логов, было в статье 3; в Prometheus последствие мягче — старые серии просто перестают обновляться, — но место они занимают.

Границы бакетов — это и есть точность квантиля

Это самая полезная часть замеров, и началась она с числа, которое не могло быть верным.

Я завёл гистограмму на бизнес-операцию — создание заказа — с такими границами: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5. Прогон 300 запросов дал:

p95 бизнес-операции  : 0,164 с
p95 HTTP POST /order : 0,160 с

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

Ошибки в коде не было. Число не значило того, что кажется: оба значения попали в бакет (0,1; 0,25] шириной 150 мс, а histogram_quantile внутри бакета ничего не измеряет — он интерполирует по прямой между его границами. Точность оценки равна ширине бакета. Разница 4 мс в тридцать раз меньше разрешения метода: разброс шире эффекта, вывода нет ни в ту, ни в другую сторону.

Я добавил границы там, где реально лежат значения — 0.075, 0.15, 0.2 — и повторил прогон. Кумулятивные доли двух гистограмм совпали точно на всех общих границах:

граница   бизнес-операция   HTTP POST /order
0.005          247               247
0.01           268               268
0.025          271               271
0.05           271               271
0.075          271               271
0.1            271               271
всего          300               300

Распределение одно и то же — метрики меряют практически одно время, как и должно быть. А p95 при этом разошёлся:

бизнес-операция (есть границы 0.15 и 0.2) : 0,17414 с
HTTP (после 0.1 сразу 0.25)               : 0,17241 с

Оба значения я проверил ручным расчётом по формуле линейной интерполяции — сошлось до пятого знака:

order: 0,15 + 14/29 * 0,05 = 0,17414
HTTP : 0,10 + 14/29 * 0,15 = 0,17241

Отсюда три практических следствия.

Квантили двух метрик с разными границами сравнивать нельзя. Разница может целиком объясняться сеткой бакетов, а не поведением системы. Если сравниваете — сначала посмотрите на le.

Границы ставятся густо там, где лежат значения. Дефолтная сетка otelhttp (…, 0.1, 0.25, 0.5, 0.75, …) в зоне 100–250 мс даёт разрешение 150 мс. Для операции, которая там и живёт, это почти ничего. Дефолтная сетка OTel Metrics подходит ещё хуже: она идёт до 10 000 (миллисекунды старой конвенции), и операция в 5 мс целиком проваливается в первый бакет — квантиль тогда вычисляется по его границе и от реальности не зависит вовсе.

Плата за разрешение считается прямо. Девять границ — 12 серий на комбинацию лейблов, двенадцать границ — 15. Разрешение покупается сериями, и покупать его надо там, где оно нужно, а не по всей шкале.

Ловушка, в которую я попал во время самой проверки

Границы я менял на живой метрике. Пока окно rate накрывало момент смены, p95 давал сначала завышенное 0,224, а потом NaN: новые серии le=0.15 и le=0.2 начинались с нуля посреди окна, и rate по ним занижен относительно остальных. Сравнимые числа получаются только по накопленному итогу либо по окну целиком после смены.

Вывод общий: набор границ — часть контракта метрики. Менять его так же несовместимо, как менять имя. В проде это означает новое имя метрики или как минимум разрыв в дашбордах и в алертах поверх них.

RED: три метрики и одна ошибка в правиле

RED отвечает на вопрос «здоров ли сервис» тремя числами: Rate (запросов в секунду), Errors (доля ошибочных), Duration (распределение латентности).

Писать эти выражения в каждой панели — плохая идея по двум причинам. Первая: выражение с гистограммой и rate по 45 сериям пересчитывается при каждом открытии дашборда и при каждом срабатывании правила алерта. Вторая, важнее: если алерт и панель написаны разными выражениями, они показывают разные числа, и разбор инцидента начинается со спора о том, кто прав.

Поэтому — правила записи. Соглашение об именах уровень:метрика:операция; job: здесь значит «агрегировано до сервиса, инстансы схлопнуты».

- record: job:http_server_requests:rate5m
  expr: |
    sum by (service_name) (
      rate(http_server_request_duration_seconds_count[5m])
    )

- record: job:http_server_request_duration_seconds:p95_5m
  expr: |
    histogram_quantile(0.95,
      sum by (service_name, le) (
        rate(http_server_request_duration_seconds_bucket[5m])
      )
    )

Порядок в квантиле имеет значение: сначала sum by (service_name, le), потом histogram_quantile. Обратный порядок — квантиль по каждой серии, а потом среднее из квантилей — даёт другое число и не значит ничего. Это одна из самых частых ошибок в дашбордах.

sum by (service_name) здесь не украшение: без него правило унаследовало бы все лейблы исходной серии (у Java-гистограммы их 35) и не сэкономило бы ни байта.

Правило, которое замалчивало здоровые сервисы

Errors я написал так, как пишут все:

sum by (service_name) (
  rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])
)

На стенде два сервиса. Правило вернуло один:

job:http_server_requests_errors:rate5m   java-backend=0   (go-frontend ОТСУТСТВУЕТ)
job:http_server_requests_errors:ratio5m  java-backend=0   (go-frontend ОТСУТСТВУЕТ)

Причина: селектор {status=~"5.."} требует, чтобы серия существовала. У java-backend пятисотые были в прошлых прогонах — серия есть, и rate по ней честно равен нулю. У go-frontend их не было ни разу с момента старта, поэтому серии нет вовсе. Это не «ноль ошибок», это отсутствие данных.

Чем плохо. На панели будет «No data» вместо «0 %» — читается как поломка мониторинга, а не как здоровье. Алерт «доля ошибок больше 1 %» при этом не сломан: пока ошибок нет, ему и не нужно срабатывать, а с первой пятисотой серия появится, и алерт сможет сработать. Зануление нужно для другого — для полноты картины. Пока ряда нет, «сервис здоров» неотличим от «метрик сервиса нет вовсе»: проверка покрытия вида «у каждого сервиса есть ряд доли ошибок» такой сервис пометит как непокрытый, а панели и расчёты по всем сервисам его молча пропустят вместо нуля.

Лечится занулением числителя с сохранением лейблов:

- record: job:http_server_requests_errors:rate5m
  expr: |
    sum by (service_name) (
      rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])
    )
    or on (service_name)
    (0 * job:http_server_requests:rate5m)

Привычное or vector(0) здесь не годится: vector(0) — серия без лейблов, под by (service_name) она не подставится и имени сервиса не даст. Нужно именно умножение знаменателя на ноль — оно сохраняет service_name.

После правки оба сервиса присутствуют со значением 0.

А вот знаменатель занулять не надо, и это не симметричная ситуация:

- record: job:http_server_requests_errors:ratio5m
  expr: |
    job:http_server_requests_errors:rate5m
      /
    job:http_server_requests:rate5m

Если трафика нет, правильный ответ — «данных нет», а не «ошибок 0 %». Подставленный сюда ноль выглядит как здоровый сервис и прячет полную остановку приёма запросов.

Ещё одно замечание про Errors: пятисотые, а не «всё, что не 200». На стенде 404 на несуществующий артикул — нормальная работа системы. Включить его в бюджет ошибок значит получать алерт каждый раз, когда пользователь ошибся в артикуле.

Молчание длиной в NaN

Отдельная находка, которая касается и этой статьи, и следующей.

Останавливаем трафик и смотрим на правило p95:

job:http_server_request_duration_seconds:p95_5m   java-backend=NaN
                                                  go-frontend=NaN

Это не пустота: серия есть, у правила health="ok". histogram_quantile от нулевого rate возвращает именно NaN. И NaN не проходит ни одно сравнение:

job:...:p95_5m > 0.5   ->  0 серий    (алерт на деградацию не сработает)
job:...:p95_5m < 0.5   ->  0 серий    (обратная проверка живости — тоже)

Алерт по латентности на остановленном сервисе молчит в обе стороны, и это молчание выглядит ровно как здоровье. Обычный приём «поставлю обратное условие, чтобы поймать пропажу данных» здесь тоже не работает.

Ловить такое надо отдельным условием — на наличие трафика (absent() или rate == 0), а не порогом на квантиль. Подробно — в статье про SLO и алертинг.

Попутный вывод, который стоит повторить: health="ok" у правила Prometheus значит только, что выражение не упало. Правило может успешно вычислять пустоту, и API покажет его здоровым.

USE: где смотреть, когда RED уже плох

RED говорит, что сервису плохо. USE говорит, из-за чего: Utilization (насколько занят ресурс), Saturation (сколько стоит в очереди сверх ёмкости), Errors (аппаратные и системные отказы).

На стенде готовая пара приехала от Java-агента без всякого кода:

- record: job:db_pool:utilization
  expr: |
    sum by (service_name) (db_client_connections_usage{state="used"})
      /
    sum by (service_name) (db_client_connections_max)

- record: job:db_pool:pending
  expr: sum by (service_name) (db_client_connections_pending_requests)

Главное число здесь — второе. Пока в очереди никто не стоит, пул не узкое место, каким бы высоким ни было utilization. Пул, занятый на 100 % без ожидающих, работает идеально: все соединения делают полезную работу. Пул, занятый на 60 %, но с тремя ожидающими — уже проблема конфигурации. Utilization без saturation регулярно приводит к тому, что «оптимизируют» здоровый ресурс.

И один нестандартный USE, который стоит завести всем:

- record: job:otelcol_exporter_queue:utilization
  expr: |
    sum by (exporter) (otelcol_exporter_queue_size)
      /
    sum by (exporter) (otelcol_exporter_queue_capacity)

Забитая очередь экспортёра означает молчаливую потерю данных: приложение работает, метрики формально «есть», но до бэкенда не доезжают. Наблюдаемость собственного конвейера наблюдаемости проверяется ровно теми же приёмами, что и прикладные очереди. Про устройство Collector — отдельная статья серии.

Бизнес-метрика: имя, единицы и что от них останется

Автоматика знает протоколы. Она не знает, что заказ был создан. Бизнес-метрика — это два инструмента, но в них есть о чём подумать.

counter, err := otel.Meter("go-frontend/order").Int64Counter(
	"khorost_tech.orders.created",
	metric.WithDescription("Созданные заказы"),
	metric.WithUnit("{order}"),
)
if err != nil {
	return err
}
s.orders = counter

dur, err := otel.Meter("go-frontend/order").Float64Histogram(
	"khorost_tech.order.duration",
	metric.WithDescription("Длительность создания заказа"),
	metric.WithUnit("s"),
	metric.WithExplicitBucketBoundaries(
		0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.25, 0.5, 1, 2.5,
	),
)

Что стало с этими именами в Prometheus:

khorost_tech.orders.created  ->  khorost_tech_orders_created_total
khorost_tech.order.duration  ->  khorost_tech_order_duration_seconds_bucket
                                 khorost_tech_order_duration_seconds_sum
                                 khorost_tech_order_duration_seconds_count

Три преобразования сразу: точки стали подчёркиваниями, счётчику добавился суффикс _total, а единица s вошла в имя как _seconds. Единица {order} в имя не вошла — фигурные скобки в OTel означают «безразмерная штука», такие единицы не переносятся. Если искать свою метрику в Prometheus по тому имени, которое написано в коде, — не найдёте.

Гистограмма меряет бизнес-операцию, а не HTTP-запрос, и это разные вещи. HTTP-метрика скажет «200 OK» и в том случае, когда заказ не создан из-за недостатка товара на складе. Итог записывается один раз, в defer:

started := time.Now()
// Итог операции пишется один раз, в defer: иначе ветки с ошибкой пришлось бы
// размечать по отдельности, и какая-нибудь непременно осталась бы без записи.
outcome := "error"
defer func() {
	s.orderDur.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(
		attribute.String("outcome", outcome),
	))
}()

outcome инициализируется значением error и переключается на ok только на успешном пути. Обратный порядок («по умолчанию ok, на ошибках выставлю error») выглядит так же, но при добавлении новой ветки отказа тихо считает её успехом.

exported_job вместо job

Ещё одна деталь, на которую уходит время при первом знакомстве. Метрики приложений приходят не в Prometheus напрямую, а в Collector по OTLP; Prometheus скрейпит то, что Collector экспонирует. В результате:

job                otelcol-apps        <- job скрейпа, то есть Collector
instance           otelcol:8889        <- адрес Collector
exported_job       go-frontend         <- а вот это исходный сервис
service_name       go-frontend

Prometheus не позволяет данным переписать собственные job и instance скрейпа, поэтому одноимённые лейблы из данных получают приставку exported_. Группировать надо по service_name (он есть всегда) или по exported_job; запрос с by (job) схлопнет все сервисы в один Collector и покажет одну строку вместо нескольких.

Кардинальность: чего бояться и чего не надо

Правило простое: лейбл — это ограниченное множество значений, а не идентификатор. user_id, request_id, order_id, полный URL с параметрами в лейбле — прямой путь к тому, чтобы положить Prometheus. Каждая комбинация значений всех лейблов — отдельный временной ряд; в гистограмме, как показано выше, ещё и умноженный на сетку бакетов.

Но именно здесь легко испугаться не того. Я проверил самый частый повод для страха — параметризованный маршрут — и он оказался безопасным.

Добавил в Go-сервис маршрут GET /order/{id} и сделал пять запросов к /order/ord-1 … /order/ord-5. В метрике получилась одна серия:

http_route = "/order/{id}"          <- шаблон, не конкретный путь
http_request_method = "GET"
http_response_status_code = "200"

otelhttp берёт шаблон из http.ServeMux (r.Pattern, Go 1.22 и новее), поэтому идентификатор в лейбл не попадает. Взрыва серий по order_id нет, и группировать метрики по маршруту руками не нужно.

Оговорка есть, и она важная: это работает потому, что роутер отдаёт шаблон. Если роутер шаблона не знает, otelhttp v0.70.0 (версия стенда) не подставляет вместо него конкретный путь — при пустом маршруте атрибут http.route в метрику просто не добавляется. Кардинальность тогда не взрывается, но пропадает разбивка по маршрутам: все запросы сливаются в одну серию на метод и код ответа. Взрыв случается, когда конкретный путь в лейбл кладут сами — собственной обёрткой или middleware, которая выставляет маршрут из URL.Path, — вот там он настоящий, ровно как в страшилках.

Попутно любопытная асимметрия: в спане атрибута http.route у otelhttp нет даже для параметризованного маршрута. Шаблон там лежит в имени спана (GET /order/{id}), а url.path содержит конкретный /order/ord-4. У Java-агента http.route есть отдельным атрибутом. Данные одни и те же, способ подачи разный — на этом я ошибся в первой редакции статьи 1 и исправил после проверки.

Что действительно стоит держать в голове про кардинальность в Prometheus:

  • service_instance_id в лейблах Java-гистограммы означает полную смену набора серий при каждом рестарте приложения. В Prometheus это переносимо — старые серии просто перестают обновляться, — а в индексе логов гораздо болезненнее, замер есть в статье 3;
  • 35 лейблов у одной серии — норма для автоматического агента, и большая часть из них (версии рантайма, путь к исполняемому файлу, описание ОС) постоянны, то есть кардинальности не добавляют. Опасны не многочисленные лейблы, а меняющиеся;
  • считать надо не «сколько метрик», а count() по каждой: числа из раздела про цену гистограммы получены именно так.

Полноценно про стоимость всех трёх сигналов и способы её удержать — в отдельной статье серии.

Путь метрики целиком

flowchart LR subgraph app["Приложение"] instr["Instrument: counter, histogram
khorost_tech.order.duration"] sdk["OTel SDK
периодический reader"] instr --> sdk end subgraph col["OTel Collector"] recv["OTLP receiver"] promexp["prometheus exporter
:8889/metrics"] recv --> promexp end sdk -->|"OTLP push"| recv subgraph prom["Prometheus"] scrape["scrape :8889
job=otelcol-apps
exported_job=go-frontend"] tsdb[("TSDB
15 серий на комбинацию")] rules["recording rules
job:...:p95_5m"] scrape --> tsdb tsdb --> rules rules --> tsdb end promexp -->|"pull"| scrape tsdb --> query["PromQL: дашборд и алерт
читают ОДНО правило"] rules --> query

flowchart LR
    subgraph app["Приложение"]
        instr["Instrument: counter, histogram
khorost_tech.order.duration"] sdk["OTel SDK
периодический reader"] instr --> sdk end subgraph col["OTel Collector"] recv["OTLP receiver"] promexp["prometheus exporter
:8889/metrics"] recv --> promexp end sdk -->|"OTLP push"| recv subgraph prom["Prometheus"] scrape["scrape :8889
job=otelcol-apps
exported_job=go-frontend"] tsdb[("TSDB
15 серий на комбинацию")] rules["recording rules
job:...:p95_5m"] scrape --> tsdb tsdb --> rules rules --> tsdb end promexp -->|"pull"| scrape tsdb --> query["PromQL: дашборд и алерт
читают ОДНО правило"] rules --> query

Два места на этом пути, где имена и лейблы меняются, уже разобраны выше: prometheus exporter переименовывает метрику (точки, _total, _seconds), а скрейп добавляет приставку exported_ к конфликтующим лейблам. Ещё одно, о чём стоит знать: модель OTel допускает и delta, и cumulative, а Prometheus работает только с cumulative. На стенде Go SDK отдаёт cumulative, и преобразование не требуется; если экспортёр отдаёт delta, в Collector нужен процессор deltatocumulative.

И третье, менее очевидное: cumulative-счётчик обнуляется при рестарте приложения. На графике это выглядит обвалом — в моём случае ряд шёл 206 206 206 6 15 …. rate такой сброс распознаёт и компенсирует, а вот прямое сравнение мгновенных значений двух счётчиков с разной точкой отсчёта вводит в заблуждение. Я на это попался при проверке и получил «одинаковые» числа там, где сравнивать было нечего.

Самопроверка вместо зелёного дашборда

Зелёный дашборд не доказательство. Правило с health="ok" — тоже. Поэтому в стенде есть скрипт, который проверяет метрики так же, как verify-signals.sh из статьи 1 проверяет три сигнала: прогон нагрузки с известным планом, затем сверка точными равенствами.

./scripts/verify-metrics.sh 200 42

Почему проверка начинается с прогрева

Эта команда выглядела самодостаточной и такой не была — ошибка, которую видно только на чистом стенде. Сразу после подъёма:

count by (service_name) (http_server_request_duration_seconds_count)
-> НЕТ НИ ОДНОЙ серии

Java-агент экспортирует cumulative-гистограмму только после первых обслуженных запросов, а rate() нужны минимум две точки скрейпа: по одной точке производную не посчитать. Поэтому первый же измеряемый прогон на холодном стенде давал java-backend=NaN у доли ошибок и у p95 — и, как следствие, ложное срабатывание алерта на отсутствие трафика. Повторный прогон на тёплом стенде проходил полностью, то есть при обычном порядке работы дефект был невидим.

Лечится явным прогревом: гоняем небольшую нагрузку и ждём ровно то, что потом проверяем — чтобы у всех ожидаемых сервисов серия появилась и rate по ней стал конечным.

if [ "$WARM_SERVICES" -ge 2 ] 2>/dev/null && [ "$WARM_FINITE" -ge 2 ] 2>/dev/null; then
    break
  fi

На чистом стенде прогрев занимает около десяти секунд. Урок шире одного скрипта: команда из статьи обязана работать на чистом стенде. Тёплый стенд прощает отсутствие прогрева и прячет дефект.

ГОДЕН   счётчик заказов (дельта)           186 (ожидалось ровно 186)
ГОДЕН   наблюдений гистограммы (дельта)    200 (ожидалось ровно 200)
ГОДЕН   правил с ошибкой вычисления        0 (ожидалось ровно 0)
ГОДЕН   R: rate запросов                   0.673929
ГОДЕН   D: p95 HTTP go-frontend            0.178571
ГОДЕН   D: p95 бизнес-операции             0.177857
ГОДЕН   сервисов в правиле rate            2 (ожидалось ровно 2)
ГОДЕН   сервисов в правиле errors          2 (ожидалось ровно 2)
ГОДЕН   сервисов в правиле ratio           2 (ожидалось ровно 2)
ГОДЕН   сервисов в правиле p95             2 (ожидалось ровно 2)

Четыре строки про сервисов — это и есть проверка на замалчивание из раздела про RED: сколько сервисов отдают исходную HTTP-метрику, столько же обязаны присутствовать в каждом правиле. Именно она поймала пропавший go-frontend.

Но покрытия сервисов недостаточно, и это выяснилось на собственной проверке. Серия может существовать, а значение быть NaN — тогда count() покажет полное покрытие, критерий пройдёт, а алерт поверх такого правила будет молчать. Я воспроизвёл это, остановив java-backend:

job:http_server_requests_errors:ratio5m        java-backend=NaN  go-frontend=1
job:http_server_request_duration_seconds:p95_5m java-backend=NaN  go-frontend=4.875

Поэтому конечность значений — отдельные критерии, по одному на каждое правило RED, и виновник называется прямо:

НЕ КОНЕЧНО в ratio: java-backend=NaN
    НЕ КОНЕЧНО в p95: java-backend=NaN
...
ОТКАЗ   конечных значений E (доля) 1 (ожидалось ровно 2) — НЕДОСТАЧА
ОТКАЗ   конечных значений D (p95) 1 (ожидалось ровно 2) — НЕДОСТАЧА

Правило общее и переносимое: «серия есть» и «значение пригодно» — разные утверждения, и проверять надо оба. Первое ловит замалчивание сервиса, второе — NaN и бесконечности.

Обратите внимание на первые две строки: дельта счётчика 186, дельта наблюдений гистограммы 200. Числа разные и оба верные — счётчик заказов растёт только на успешных, а гистограмма пишет итог каждой операции, включая отказы. Если бы они совпали, это означало бы дефект в одной из двух.

Отдельно скрипт различает три разных «ничего», которые легко спутать: пустой результат (серии нет), NaN (серия есть, значения нет) и честный ноль. Первые два — отказ:

case "$got" in
  EMPTY | ERR | -1)
    printf "  ОТКАЗ   %-34s правило вычисляется, но значения нет\n" "$name"
    FAILED=1
    ;;
  nan | NaN | NAN)
    printf "  ОТКАЗ   %-34s NaN — трафика в окне нет, алерт поверх молчал бы\n" "$name"
    FAILED=1
    ;;
  *)
    printf "  ГОДЕН   %-34s %s\n" "$name" "$got"
    ;;
esac

Ещё одна деталь, мелкая, но стоившая времени: чтобы Prometheus перечитал правила без рестарта — а рестарт на стенде означает потерю всего накопленного в TSDB, — нужен флаг --web.enable-lifecycle. Без него POST /-/reload отвечает 403.

Итог

Что стоит забрать:

  1. Сначала посмотрите, что уже приехало. 721 имя метрики в TSDB, из них четыре мои. Значительная часть «кастомных» метрик дублирует автоматику.
  2. Гистограмма стоит 15 серий против 1 у счётчика, и каждый лейбл умножает это число целиком. Замеряйте count(), а не считайте по формуле.
  3. Границы бакетов — это и есть точность квантиля. Два p95 на одних и тех же запросах разошлись (0,17414 против 0,17241) только из-за сетки; кумулятивные доли при этом совпали точно. Квантили метрик с разными границами не сравнивают.
  4. Набор границ — часть контракта метрики. Менять его на живой метрике так же несовместимо, как менять имя: в переходном окне rate квантиль даёт бессмыслицу.
  5. Селектор {status=~"5.."} замалчивает здоровые сервисы. Серии с 5xx у сервиса, который их не отдавал, не существует — это не ноль. Числитель занулить через or on (service_name) (0 * …), знаменатель — нет.
  6. histogram_quantile от нулевого rate даёт NaN, а NaN не проходит ни одно сравнение. Алерт по латентности на остановленном сервисе молчит в обе стороны.
  7. health="ok" у правила значит только, что выражение не упало. Правило может успешно вычислять пустоту.
  8. «Серия есть» и «значение пригодно» — разные утверждения. Покрытие сервисов ловит замалчивание, конечность значений ловит NaN. Проверять надо оба: моя же проверка проходила при NaN у одного из сервисов, потому что считала только покрытие.
  9. В USE главное число — saturation, а не utilization. Пул на 100 % без ожидающих здоров; пул на 60 % с тремя ожидающими — нет.
  10. Имена метрик в Prometheus не те, что в коде: точки, _total, _seconds из единицы. А job у прикладной метрики — это Collector; исходный сервис лежит в exported_job и service_name.
  11. Параметризованный маршрут в Go не взрывает кардинальность: otelhttp кладёт в лейбл шаблон /order/{id}. Роутер без шаблона кардинальность не взрывает — у otelhttp v0.70.0 маршрут тогда просто пропадает из метрики; взрывает конкретный путь, положенный в лейбл вручную.

Дальше — SLO, бюджет ошибок и алертинг: те же метрики превращаются в обещание пользователю и в правило, по которому будят человека. Заодно там доводится до конца история с NaN.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии