Метрики — единственный сигнал, который дёшево держать «всегда включённым» и смотреть на систему целиком. Но метрики легко превратить в свалку: сотни счётчиков, которые никто не читает, и один лейбл с высокой кардинальностью, который кладёт Prometheus. Полезные метрики — это дисциплина: понимать, что за вопрос ты задаёшь системе, и заводить метрику под ответ.
Это четвёртая статья серии «Наблюдаемость на практике». Трейсы и логи отвечают на «что случилось с этим запросом» — метрики отвечают на «как система чувствует себя в целом» и ведут к SLO. Всё, что ниже, снято с того же стенда, что и предыдущие три статьи: Go-фронтенд, Java-бэкенд, PostgreSQL, NATS, а телеметрия идёт через OTel Collector в Prometheus.
Главное, что я вынес из этой части работы: в метриках ошибаются не на инструментировании, а на чтении. Правило, которое успешно вычисляется и возвращает пустоту. Квантиль, который отличается от квантиля тех же самых запросов только из-за сетки бакетов. Алерт, который молчит одинаково и при здоровье, и при полной остановке сервиса. Всё это здесь есть, с числами.
В статье
- Что приезжает само: 721 имя метрики без строчки кода
- Три типа и почему латентность — не gauge
- Цена гистограммы: 15 серий против одной
- Границы бакетов — это и есть точность квантиля
- RED: три метрики и одна ошибка в правиле
- Молчание длиной в NaN
- USE: где смотреть, когда RED уже плох
- Бизнес-метрика: имя, единицы и что от них останется
- Кардинальность: чего бояться и чего не надо
- Путь метрики целиком
- Самопроверка вместо зелёного дашборда
- Итог
- Документация и первоисточники
Что приезжает само: 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-frontendPrometheus не позволяет данным переписать собственные 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()по каждой: числа из раздела про цену гистограммы получены именно так.
Полноценно про стоимость всех трёх сигналов и способы её удержать — в отдельной статье серии.
Путь метрики целиком
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.
Итог
Что стоит забрать:
- Сначала посмотрите, что уже приехало. 721 имя метрики в TSDB, из них четыре мои. Значительная часть «кастомных» метрик дублирует автоматику.
- Гистограмма стоит 15 серий против 1 у счётчика, и каждый лейбл умножает это число целиком. Замеряйте
count(), а не считайте по формуле. - Границы бакетов — это и есть точность квантиля. Два p95 на одних и тех же запросах разошлись (0,17414 против 0,17241) только из-за сетки; кумулятивные доли при этом совпали точно. Квантили метрик с разными границами не сравнивают.
- Набор границ — часть контракта метрики. Менять его на живой метрике так же несовместимо, как менять имя: в переходном окне
rateквантиль даёт бессмыслицу. - Селектор
{status=~"5.."}замалчивает здоровые сервисы. Серии с 5xx у сервиса, который их не отдавал, не существует — это не ноль. Числитель занулить черезor on (service_name) (0 * …), знаменатель — нет. histogram_quantileот нулевогоrateдаётNaN, аNaNне проходит ни одно сравнение. Алерт по латентности на остановленном сервисе молчит в обе стороны.health="ok"у правила значит только, что выражение не упало. Правило может успешно вычислять пустоту.- «Серия есть» и «значение пригодно» — разные утверждения. Покрытие сервисов ловит замалчивание, конечность значений ловит
NaN. Проверять надо оба: моя же проверка проходила приNaNу одного из сервисов, потому что считала только покрытие. - В USE главное число — saturation, а не utilization. Пул на 100 % без ожидающих здоров; пул на 60 % с тремя ожидающими — нет.
- Имена метрик в Prometheus не те, что в коде: точки,
_total,_secondsиз единицы. Аjobу прикладной метрики — это Collector; исходный сервис лежит вexported_jobиservice_name. - Параметризованный маршрут в Go не взрывает кардинальность:
otelhttpкладёт в лейбл шаблон/order/{id}. Роутер без шаблона кардинальность не взрывает — уotelhttpv0.70.0 маршрут тогда просто пропадает из метрики; взрывает конкретный путь, положенный в лейбл вручную.
Дальше — SLO, бюджет ошибок и алертинг: те же метрики превращаются в обещание пользователю и в правило, по которому будят человека. Заодно там доводится до конца история с NaN.
Документация и первоисточники
- Prometheus: recording rules — соглашение об именах и порядок вычисления.
- Prometheus: histogram_quantile — как считается квантиль по бакетам и почему это оценка.
- Prometheus: scrape_config и honor_labels — что происходит с конфликтующими лейблами.
- OpenTelemetry: Metrics API — типы инструментов и их семантика.
- OpenTelemetry: совместимость с Prometheus и OpenMetrics — правила преобразования имён и единиц.
- The RED Method — исходная формулировка методологии.
- Brendan Gregg: The USE Method — utilization, saturation, errors.
- Стенд, правила, скрипты проверок и сырые выводы —
digital-cookbook/observability/practice-stack.
Комментарии