5.2PromQL и Prometheus в работе
Прочитаешь PromQL как преобразование временных рядов: rate, агрегация, сопоставление и правила алертов. Разберёшь, как пропавшие серии, сбросы счётчиков и высокая кардинальность меняют результат.
- Как
rateдостраивает прирост до границ окна, переживает сброс счётчика и почему окно не короче четырёх интервалов опроса. - Почему сначала
rate, потомsum, и как приклеить к доле ошибок версию черезgroup_left. - Куда девается серия пропавшей цели и чем ловить отсутствие данных.
- Как алерт проходит pending, firing, группировку и inhibition и как проверить правило в
promtool test rules. - Что дают нативные гистограммы, что лежит в каталоге TSDB и как найти метрику, раздувшую кардинальность.
Стенд и модель данных
Стенд собран из контейнеров: Prometheus 3.14.0 и Alertmanager 0.34.0 из образов
quay.io/prometheus/*, два экземпляра сервиса orders на Go 1.27.1 с
prometheus/client_golang 1.24.1 и генератор нагрузки: 8 запросов в секунду к
app-1, 4 к app-2. Сервис отдаёт счётчик http_requests_total{route, code},
гистограмму http_request_duration_seconds{route} и info-метрику
orders_build_info{version} со значением 1. Prometheus опрашивает его раз в 15 секунд
(scrape_interval: 15s) и с тем же интервалом вычисляет правила.
Серия задаётся именем и полным набором меток. Имя хранится как метка
__name__, метки job и instance добавляет Prometheus при опросе,
другое значение любой метки даёт другую серию со своей историей. Запрос по имени возвращает
instant vector: по одной точке на серию, самую свежую не старше пяти минут. Суффикс
[1m] делает range vector (тип matrix) со всеми точками окна. Ответы API,
сокращённо:
GET /api/v1/query?query=http_requests_total
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"http_requests_total","code":"200","instance":"app-2:8080","job":"orders","route":"/api/orders"},
"value":[1789604917.035,"1426"]},
…ещё три серии]}}
GET /api/v1/query?query=http_requests_total{instance="app-1:8080",route="/api/orders",code="200"}[1m]
{"status":"success","data":{"resultType":"matrix","result":[{"metric":{…},
"values":[[1789604863.373,"2685"],[1789604878.373,"2745"],[1789604893.375,"2805"],[1789604908.373,"2866"]]}]}}
В минутное окно попали четыре опроса, отметки гуляют на пару миллисекунд. С Prometheus 3.0 окно открыто
слева: точка ровно на левой границе в него не попадает. Range vector не нарисуешь, не сложишь и не
агрегируешь, его принимают функции вроде rate и *_over_time:
http_requests_total[1m] + 1 parse error: binary expression must contain only scalar and instant vector types
sum(http_requests_total[1m]) parse error: expected type instant vector in aggregation expression, got range vector
На каждый опрос Prometheus пишет и синтетические серии: up (1 при удачном опросе),
scrape_duration_seconds, scrape_samples_scraped. У app-1 это 2 мс
и 256 точек, большая часть от метрик рантайма Go.
rate, irate и increase: что происходит внутри окна
rate(v[5m]) считает среднюю скорость роста счётчика в секунду. На границы окна точки почти
никогда не ложатся, и зазоры до границ Prometheus достраивает. Серия app-1 с 4 запросами в
секунду в момент T = 00:28:17.121:
s = http_requests_total{instance="app-1:8080",route="/api/orders",code="200"}
s[1m] 2625 (T−48.745 с) 2685 2745 2805 (T−3.746 с)
increase(s[1m]) 240.0053334518545
rate(s[1m]) 4.000088890864242
irate(s[1m]) 3.9994667377682975
increase(s[5m]) 1198.9305413257357
s - s offset 5m 1200
increase(s[5m] anchored) 1200
Четыре точки покрывают 44,999 с из 60, прирост между крайними 180. Зазор слева 11,255 с, справа
3,746 с, оба меньше 1,1 среднего интервала между точками (16,5 с), и Prometheus считает, что серия шла
через всё окно: 180 × 60 / 44,999 = 240,0053. Это increase, а rate делит его на
60. Отсюда дробные числа на целочисленном счётчике: increase оценивает прирост по средней
скорости между крайними точками, и за пять минут оценка 1198,93 разошлась с разностью значений 1200.
В extrapolatedRate (promql/functions.go) ещё два правила: при зазоре больше
1,1 среднего интервала серия, видимо, началась или кончилась внутри окна, и прирост тянут на половину
интервала; слева не заходят дальше момента, где счётчик был бы нулём. По умолчанию float-серии в 3.14
считаются как в 2.53. irate берёт две последние точки без
достройки, 60 / 15,002 с = 3,9995: он ловит всплеск за один опрос, хорош на графике и плох в алерте.
Модификатор anchored (экспериментальный, с 3.7 за флагом
--enable-feature=promql-extended-range-selectors) берёт последнюю точку до окна и последнюю в
нём без экстраполяции и возвращает целое.
Сброс счётчика и длина окна
В 00:28:37 app-2 заменили контейнером с новой версией, счётчик нового процесса начался с нуля:
s2 = http_requests_total{instance="app-2:8080",route="/api/orders",code="200"}
s2[3m] в 00:29:52.672 1246 1276 1306 1336 1366 1396 1426 | 5 33 62 92 121
increase(s2[3m]) 328.3616462930528
resets(s2[3m]) 1
increase считает падение значения
сбросом до нуля и прибавляет значение перед падением, а прирост между крайними точками растягивает на
всё окно. Запросы, которые старый процесс обслужил после своего последнего опроса, не видит никто.
Любое уменьшение rate и increase принимают за сброс, так что на gauge они выдают
мусор. Пять запросов нового процесса до первого опроса учтены, а 12,7 секунды трафика
старого после опроса в 00:28:24 пропали.
Теперь длина окна. За десять минут в каждый из 601 момента в [15s] попадала одна точка, в
[30s] две, в [1m] четыре. rate(s[15s]) не вернул ни одного
значения: ему нужны две точки. [30s] работает, пока не пропал ни один опрос: один таймаут,
и на графике дыра, а алерт гаснет. Отсюда правило четырёх интервалов: потеряешь опрос, и в окне ещё три
точки. Grafana держит для этого $__rate_interval, по её документации это
max($__interval + scrape_interval, 4 × scrape_interval).
Агрегация, подзапросы и сдвиг во времени
sum by (job) оставляет перечисленные метки, sum without (instance) убирает
перечисленные. В правилах удобнее without: до алерта доедут метки, о которых автор не
подумал. Порядок функций важен, через 75 секунд после замены app-2:
sum by (job) (rate(http_requests_total{job="orders"}[3m])) 11.684251307748013
rate(sum by (job) (http_requests_total{job="orders"})[5m]) parse error: ranges only allowed for vector selectors
rate(sum by (job) (http_requests_total{job="orders"})[3m:15s]) 46.43030303030302
sum by (job) (http_requests_total{job="orders"})[3m:15s] 7501 7681 … 8401 8582 5861 6040 … 6580
rate по каждой серии сам разбирается со сбросом app-2, сумма скоростей
около 12 в секунду. А сумма счётчиков разных процессов уже не счётчик: она упала с 8582 до 5861,
rate принял это за сброс, прибавил 8582 и завысил ответ вчетверо. Документация
rate и irate прямо требует: сначала скорость, потом агрегация.
[3m:15s] задаёт подзапрос: внутреннее выражение вычисляется с шагом 15 с и даёт range
vector. offset сдвигает выборку назад, @ ставит её на Unix-момент, оба пишутся
сразу после селектора или подзапроса:
max_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 12.023648103284325
min_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 7.977777777777777
count_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 60
sum(rate(http_requests_total[5m])) / sum(rate(http_requests_total[5m] offset 10m)) 1.000240920794823
sum(http_requests_total @ 1789604793) 7141
sum(rate(http_requests_total[5m])) offset 10m
parse error: offset modifier must be preceded by an instant vector selector or range vector selector or a subquery
Минимум 7,98 пришёлся на минуты, когда app-2 стоял. Точек в [1h:1m]
60, а не 61: левая граница подзапроса тоже открыта, и по миграционному гайду Prometheus 3
выровненный foo[1m:1m] теперь даёт одну точку. Без шага подзапрос берёт evaluation_interval.
Сопоставление векторов: доля ошибок и метки из info-метрики
Бинарная операция ищет пары серий с одинаковым набором меток без учёта имени. Новая версия на
app-2 отдаёт 3 % ответов 5xx, и доля «в лоб» получается единицей:
rate(http_requests_total{code="500"}[2m]) / rate(http_requests_total[2m])
{code="500", instance="app-2:8080", job="orders", route="/api/orders"} 1
В пару числителю нашёлся он сам. ignoring (code) исключает метку из сравнения
(on (…), наоборот, оставляет только перечисленные), но тогда справа на группу две серии,
200 и 500, и Prometheus откажется выбирать. Поэтому обе стороны сначала агрегируют до одного
набора меток, а версию приклеивают тут же:
(
sum by (instance) (rate(http_requests_total{code=~"5.."}[2m]))
or
sum by (instance) (rate(http_requests_total[2m])) * 0
)
/ sum by (instance) (rate(http_requests_total[2m]))
* on (instance) group_left (version) orders_build_info
{instance="app-2:8080", version="1.5.0"} 0.026190476190476195
{instance="app-1:8080", version="1.4.2"} 0
Без or … * 0 app-1 из результата пропадает: ошибок у него не было, а
CounterVec заводит серию при первом обращении к этим меткам (здесь это
Inc), так что паре в делении взяться неоткуда, и на дашборде вместо нуля «нет данных». Info-метрику держат ради меток, как
kube_pod_info с узлом пода или target_info из OpenTelemetry. Умножение на её единицу значение не меняет, а
group_left (version) переносит метку справа. Слева на группу on (instance) может
быть несколько серий, справа только одна:
rate(http_requests_total[2m]) * on (instance) orders_build_info
multiple matches for labels: many-to-one matching must be explicit (group_left/group_right)
sum by (instance) (rate(http_requests_total[2m])) * on (job) group_left (version) orders_build_info
found duplicate series for the match group {job="orders"} on the right hand-side of the operation: […];
many-to-many matching not allowed: matching labels must be unique on one side
В проде вторую ошибку ловят, когда у info-метрики ненадолго две серии на объект и старая не помечена
устаревшей: по документации экспериментальной info() такой запрос падает до пяти минут, а
сама info() в этом конфликте выбирает новую серию.
Staleness: куда девается серия пропавшей цели
В 00:36:59 контейнер app-2 остановили, последняя точка счётчика записана в 00:36:54. Через
20 секунд:
up{instance="app-2:8080"} 0
s2 пусто
last_over_time(s2[10m]) 960
absent(up{job="orders",instance="app-2:8080"}) пусто
absent(s2) {code="200", instance="app-2:8080", route="/api/orders"} 1
absent_over_time(s2[5m]) пусто, через 5 мин после последней точки: 1
Серия пропала из instant-запросов сразу, а не через пять минут. Когда опрос не удался или серия исчезла из
ответа цели, Prometheus пишет в прежние серии staleness marker, особое значение NaN, и после него
селектор считает серию закончившейся; так же при исчезновении цели из service discovery и с сериями,
которые правило перестало возвращать. Пять минут --query.lookback-delta остаются пределом
поиска точки для серий без маркера. Prometheus передаёт маркеры и по remote write, а
promtool push metrics их не шлёт: записанная им серия отвечала через 290 секунд и пропала к 310-й. absent возвращает 1 для пустого вектора,
absent_over_time для окна без точек; алерты на пропажу данных во втором вопросе.
Recording rules и правила алертов
Recording rule раз в evaluation_interval вычисляет выражение и сохраняет результат
новой серией: тяжёлый запрос считается один раз, алерт и дашборд смотрят на одно число. Имена по
соглашению документации Prometheus: level:metric:operations, то есть оставшиеся метки, имя
метрики без _total, операции от последней к первой.
groups:
- name: orders-recording
rules:
- record: instance_code:http_requests:rate1m
expr: sum without (route) (rate(http_requests_total{job="orders"}[1m]))
- record: instance:http_requests_5xx_per_requests:ratio_rate1m
expr: |2
sum without (code) (instance_code:http_requests:rate1m{code=~"5.."})
/
sum without (code) (instance_code:http_requests:rate1m)
- record: job:http_requests_5xx_per_requests:ratio_rate1m
expr: |2
sum without (instance, code) (instance_code:http_requests:rate1m{code=~"5.."})
/
sum without (instance, code) (instance_code:http_requests:rate1m)
- name: orders-alerts
rules:
- alert: OrdersInstanceErrors
expr: instance:http_requests_5xx_per_requests:ratio_rate1m > 0.05
for: 3m
labels: {severity: ticket}
annotations:
summary: "{{ $labels.instance }}: {{ $value | humanizePercentage }} ответов 5xx"
- alert: OrdersErrorBudgetBurn
expr: job:http_requests_5xx_per_requests:ratio_rate1m > 0.2
for: 1m
keep_firing_for: 2m
labels: {severity: page}
annotations:
summary: "{{ $labels.job }}: {{ $value | humanizePercentage }} ответов 5xx"
Для доли по job делят сумму на сумму, а не усредняют доли экземпляров, иначе 8 и 4 запроса
в секунду весили бы одинаково. Пока выражение возвращает серию, а for не истёк, алерт
pending, потом firing. Условие пропало, и алерт гаснет на первом же вычислении,
а с keep_firing_for горит ещё заданное время. $labels и $value берутся
из сработавшей серии, активные алерты пишутся в серию ALERTS.
$ promtool check rules bad.yml
Checking bad.yml
FAILED:
bad.yml: 5:15: group "bad", rule 1, "instance:http_requests:rate5m": could not parse expression: 1:49: parse error: unclosed left parenthesis
bad.yml: group "bad", rule 2, "Broken": annotation "summary": template: __alert_Broken:1: unexpected "}" in operand
promtool test rules гоняет правила на синтетических сериях. В тесте стенда
'0+150x40' даёт 10 запросов в секунду, а '_x7 0+30x33' добавляет 2 ошибки в
секунду начиная с 1m45s. Ожидание «в 6m горит OrdersInstanceErrors» проходит, в 5m падает:
$ promtool test rules tests/orders_test.yml
SUCCESS
$ promtool test rules tests/orders_5m_test.yml
FAILED:
alertname: OrdersInstanceErrors, time: 5m,
exp:[
0:
Labels:{alertname="OrdersInstanceErrors", instance="app-1:8080", job="orders", severity="ticket"}
…
got:[]
В 2m у серии ошибок точки 0 и 30, экстраполяция влево упирается в ноль, rate даёт
0,5 вместо 2, и доля 4,76 % ниже порога. В 2m15s доля 9,1 % и алерт в pending, в 5m15s он firing. В CI такой тест ловит правку, после которой алерт перестал срабатывать.
Alertmanager: маршруты, группировка, inhibition и silence
После вычисления Prometheus шлёт в Alertmanager алерты в firing и погасшие, один и тот
же не чаще --rules.alert.resend-delay (минута), а кому и в каком составе прийти
уведомлению, решает Alertmanager. Получатель на стенде печатает строку на каждый webhook:
route:
receiver: team
group_by: [alertname, job]
group_wait: 30s
group_interval: 2m
repeat_interval: 4h
routes:
- matchers: ['severity="page"']
receiver: pager
group_wait: 10s
receivers:
- name: team
webhook_configs: [{url: "http://hook:9000/hook"}]
- name: pager
webhook_configs: [{url: "http://hook:9000/hook"}]
inhibit_rules:
- source_matchers: ['alertname="OrdersErrorBudgetBurn"']
target_matchers: ['severity="ticket"']
equal: [job]
Алерт спускается в первый подходящий дочерний маршрут (дальше только при continue: true), и
алерты с одинаковыми метками из group_by составляют группу. group_wait
держит её первое уведомление, чтобы дождаться соседей и подавляющих алертов. По тику
group_interval уходят изменения, а repeat_interval повторяет то, что не менялось. Inhibition глушит цели,
пока горит источник с теми же метками из equal, silence глушит по матчерам на время.
Сценарий: в 00:46:33 обе реплики отдают 40 % ответов 5xx, в 00:51:04 10 %, в 00:58:04 на
app-2 ставят silence, в 00:58:09 ошибки кончаются. Лог получателя:
00:48:25 receiver=pager status=firing group={alertname=OrdersErrorBudgetBurn,job=orders} alerts=1
00:54:15 receiver=team status=firing group={alertname=OrdersInstanceErrors,job=orders} alerts=2
00:54:25 receiver=pager status=resolved group={alertname=OrdersErrorBudgetBurn,job=orders} alerts=1
01:00:15 receiver=team status=resolved group={alertname=OrdersInstanceErrors,job=orders} alerts=1
resolved {alertname=OrdersInstanceErrors,instance=app-1:8080,severity=ticket}
for и group_wait. Тикеты
загораются позже и молчат, пока горит page, включая keep_firing_for; потом уходят одним
уведомлением на тике group_interval. Алерт под silence не попадает даже в уведомление о погасании.
Без inhibition команде пришло бы второе уведомление о том же сбое. Тикеты ушли в 00:54:15, а не в
00:53:45, когда погас page: группа появилась в 00:49:45, её тики пришлись на 00:50:15, 00:52:15 и
00:54:15, и на первых двух всё было подавлено. Оба экземпляра пришли одним уведомлением, потому что
instance нет в group_by. Silence ставили так:
$ amtool --alertmanager.url=http://localhost:9093 silence add \
alertname=OrdersInstanceErrors instance=app-2:8080 \
--duration=1h --author=oncall --comment="canary rollback in progress"
c1d66a93-b559-41e2-b482-dcc7f8304d12
Нативные гистограммы
Классическая гистограмма из главы 5.1 состоит из серий _bucket{le} с заранее выбранными
границами. Нативная укладывает распределение в одну серию: границы растут в геометрической
прогрессии, а пустые бакеты не хранятся вовсе. В client_golang её включают поля опций:
Buckets: []float64{.01, .025, .05, .1, .25, .5, 1}, // классические остаются
NativeHistogramBucketFactor: 1.1, // соседние границы отличаются не больше чем на 10%
NativeHistogramMaxBucketNumber: 160, // потолок числа бакетов
NativeHistogramMinResetDuration: time.Hour, // не сбрасывать чаще раза в час
Библиотека берёт ближайший не больший множитель вида 22−n, для 1.1 это
21/8 ≈ 1,0905, восемь бакетов на удвоение. Сверх потолка гистограмма сбрасывается,
а если MinResetDuration не прошёл, снижает разрешение; без потолка документация поля
называет её вектором для DoS. В client_golang 1.24 поля ещё экспериментальные, в
Prometheus нативные гистограммы стабильны с 3.8 и включаются scrape_native_histograms.
Нативную часть client_golang отдаёт только в protobuf, curl /metrics покажет классические
бакеты, а always_scrape_classic_histograms сохраняет обе формы.
Для сравнения 3 % запросов замедлили до 260–320 мс. Генератор нагрузки считал точный p99 по своим замерам, поминутно от 281 до 298 мс:
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) 0.39941170380740243
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m]))) 0.29715458167274456
histogram_fraction(0, 0.3, sum(rate(http_request_duration_seconds[5m]))) 0.9910951475092551
count by (__name__) ({__name__=~"http_request_duration_seconds.*"})
http_request_duration_seconds 4 _bucket 32 _count 4 _sum 4
Медленные запросы попали в классический бакет (0.25, 0.5], и интерполяция дала 399 мс, на 40 % больше
правды. У нативной вокруг 300 мс бакеты шириной 23–27 мс, и ошибка меньше ширины бакета. На вопрос
«какая доля быстрее 300 мс» histogram_fraction ответит при любом пороге, а классическая
знает ответ только для своих le. Серия одна на маршрут и экземпляр, в каждой около 40
занятых бакетов, и sum складывает гистограммы целиком, без by (le). Одна
ловушка: remote_write с протоколом 1.0 по умолчанию их не отправляет
(send_native_histograms: false), с сообщением 2.0 отправляет всегда.
Хранение и кардинальность
Свежие точки живут в head, в памяти, а перед этим дописываются в WAL, по которому head восстанавливается после рестарта; заполненные чанки head сбрасывает в
chunks_head и читает через mmap. Двухчасовыми порциями данные уходят в неизменяемые
блоки с чанками, индексом и meta.json, позже блоки сливаются. Через 46 минут работы
блоков ещё нет. Снапшот через админский API (флаг --web.enable-admin-api) сбрасывает head в
блок; снапшотами же документация советует делать резервные копии:
$ docker exec dv-c2-prom du -sh /prometheus/wal /prometheus/chunks_head
1.7M /prometheus/wal
128.0K /prometheus/chunks_head
$ curl -s -XPOST localhost:19090/api/v1/admin/tsdb/snapshot
{"status":"success","data":{"name":"20260917T010210Z-322a559e0a48212c"}}
В каталоге блока лежат chunks/, index, meta.json и
tombstones. В meta.json 1395 серий и 239 712 точек за 45,5 минуты, чанки заняли 711 КБ, около трёх
байт на точку (документация для оценки диска называет в среднем 1–2). Retention по умолчанию
15 дней, удаляются только целиком устаревшие блоки. В 3.14 флаги --storage.tsdb.retention.time
и .size в --help помечены [DEPRECATED]: настройки переехали в
перечитываемый конфиг, и экземпляр с настройкой ниже отдаёт в /api/v1/status/runtimeinfo
"30d or 50GiB".
storage:
tsdb:
retention:
time: 30d
size: 50GB
Лимит размера учитывает WAL, но удаляет только блоки, а компакция временно держит и старые
блоки, и новый: отсюда совет документации ставить не выше 80–85 % диска. Долгое хранение и общий вид по
многим серверам даёт remote_write: Prometheus читает свой WAL и шлёт точки во
внешнее хранилище, а недоступность приёмника переживает без потерь около двух часов, пока WAL не обрезан.
Режим --agent оставляет только опрос и remote_write.
| Thanos | Grafana Mimir | VictoriaMetrics | |
|---|---|---|---|
| Приём | sidecar выгружает двухчасовые блоки в объектное хранилище; Receive принимает remote_write | remote_write в distributor; с 3.0 рекомендована схема с Kafka перед ingester | remote_write в single-node или vminsert |
| Хранение | блоки TSDB в объектном хранилище | блоки TSDB в объектном хранилище | свой формат на дисках vmstorage, MetricsQL поверх PromQL |
| HA-пара Prometheus | склейка на чтении по --query.replica-label | HA tracker берёт одну реплику по cluster и __replica__ | -dedup.minScrapeInterval при одинаковых внешних метках |
Почему user_id в метке убивает Prometheus, разобрано в главе 5.1. Виновника дешевле всего
искать на странице TSDB status (/tsdb-status) или в её API: числа берутся из индекса head.
$ curl -s 'localhost:19090/api/v1/status/tsdb?limit=3' \
| jq -r '.data | .seriesCountByMetricName[], .labelValueCountByLabelName[] | "\(.value) \(.name)"'
80 prometheus_http_request_duration_seconds_bucket
72 prometheus_http_response_size_bytes_bucket
65 prometheus_http_requests_total
428 __name__
92 le
63 handler
Лидирует сам Prometheus: счётчик запросов он заводит
сразу на все 63 своих HTTP-обработчика, а у вызванных гистограммы множатся на бакеты. Запрос
topk(3, count by (__name__) ({__name__=~".+"})) вернул те же три метрики, но читает точку
из каждой серии, а promtool tsdb analyze по блоку показывает ещё метки с наибольшим
churn серий.
Метрики из Go: свой Registry, promhttp и метрики рантайма
reg := prometheus.NewRegistry()
requests := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "HTTP requests by route and status code.",
}, []string{"route", "code"})
reg.MustRegister(
collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll)),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
promauto.NewCounter без With регистрирует метрику в глобальном
DefaultRegisterer, а promhttp.Handler() отдаёт глобальный реестр, где уже есть
коллекторы Go и процесса. Это удобно, пока конструктор вызывается один раз; второй вызов, например во
втором тесте пакета, падает с panic: duplicate metrics collector registration attempted. Свой
реестр, переданный в конструктор, это снимает: promauto.With(reg) сохраняет удобство, а
коллекторы в пустой реестр добавляют явно. HandlerFor договаривается о
формате, включая protobuf, а HandlerOpts.Registry заводит счётчик ошибок сбора
promhttp_metric_handler_errors_total.
GoCollector по умолчанию 29 семейств go_*, 35 строк: go_memstats_*, go_goroutines, go_gc_duration_seconds…
+ WithGoCollectorRuntimeMetrics 133 семейства, 219 строк: всё из runtime/metrics Go 1.27
histogram_quantile(0.99, sum by (instance, le) (rate(go_sched_latencies_seconds_bucket[5m])))
app-1 0.000574 app-2 0.000464 prometheus 0.000703
Самая полезная из новых go_sched_latencies_seconds: сколько горутины провели runnable до
получения процессора, то есть насыщение CPU из USE. Рядом go_cpu_classes_gc_total_cpu_seconds_total
для доли CPU на сборку мусора и go_sync_mutex_wait_total_seconds_total для конкуренции за
мьютексы. go_memstats_* в client_golang тоже считаются через runtime/metrics;
сопровождающие советуют их оставить, а остальное включать по надобности правилами
MetricsScheduler, MetricsGC, ведь MetricsAll добавил
восемь классических гистограмм по 8–12 бакетов.
Вопросы
4for добавляются отставание выражения (опрос, окно
rate, расписание правил) и таймеры Alertmanager. На стенде 42 секунды ушли, пока доля ошибок в
окне [1m] перешла порог и правило это увидело, 60 на for, 10 на
group_wait.Разбор по этапам
| Этап и что его задаёт | На стенде |
|---|---|
ошибки попали в серию: scrape_interval | до 15 с после 00:46:33 |
| выражение перешло порог: доля растёт, пока заполняется окно | 5,1 % в 00:46:58, 24,6 % в 00:47:13 |
алерт вычислен: evaluation_interval, смещение группы | pending в 00:47:15 |
pending → firing: for | firing в 00:48:15 |
уведомление: group_wait или тик group_interval | webhook в 00:48:25 |
По умолчанию каждый этап длиннее: scrape_interval и evaluation_interval
минута, group_wait 30 секунд, group_interval 5 минут. Гаснет алерт тоже
с опозданием: page стал inactive в 00:53:45, а resolved ушёл на тике группы в 00:54:25.
Что крутить
- Окно не короче четырёх интервалов опроса,
forкороткий только при высоком пороге, как у быстрого burn rate из главы 5.1. - Группы правил. Правила группы выполняются по порядку, группы параллельно: алерт после своего recording rule в той же группе видит результат этого цикла, в другой может отстать до интервала.
- Таймеры по маршрутам. Для page короткие; новый алерт в группе, о которой уже было
уведомление, ждёт тика
group_interval.
Одно пустое вычисление (упал опрос, в окне меньше двух точек), и pending удаляется, отсчёт идёт
заново: в rules/alerting.go keep_firing_for проверяется только у firing.
После рестарта Prometheus восстанавливает for из серии ALERTS_FOR_STATE, если
простой короче --rules.alert.for-outage-tolerance (1h), и только у правил с
for от --rules.alert.for-grace-period (10m).
up == 0, absent, absent_over_time, число живых реплик с
or vector(0).Почему гаснет
Счётчик остановленного app-2 пропал из запросов за 20 секунд. up остался с
нулём потому, что цель статическая; в Kubernetes под уходит из discovery вместе с
up, и up == 0 молчит. keep_firing_for только откладывает ложное погасание.
Чем ловить
up{job="orders"} == 0 цель в discovery есть, но не отвечает
absent(up{job="orders"}) пропали все цели job
(count(up{job="orders"} == 1) or vector(0)) < 2 живых реплик меньше двух
absent_over_time(batch_last_success_timestamp_seconds{job="batch"}[26h])
count(up{job="nope"} == 1) < 2 на стенде ничего не вернул: count от пустоты
пуст, а с or vector(0) пришло {} 0. Метки absent берёт только из
равенств: absent(up{job="nope",instance=~"app.*"}) дал {job="nope"} 1,
absent(sum(…)) дал {} 1, поэтому его пишут на каждую важную серию.
Серии из promtool push, OTLP из SDK и от целей с явными отметками
времени видны до --query.lookback-delta после последней точки, и absent по ним срабатывает с
этой задержкой. Свежесть таких данных проверяют по отметке внутри значения:
time() - batch_last_success_timestamp_seconds > ….
alert_relabel_configs,
Alertmanager собирают в кластер и шлют алерты во все экземпляры, а графики склеивает долговременное
хранилище.Опыт на стенде
Две реплики с правилом Watchdog: vector(1) и external_labels: {replica: a|b}
шлют алерт в один Alertmanager:
replica=a и replica=b
/api/v2/alerts: [{"alertname":"Watchdog","replica":"a",…},{"alertname":"Watchdog","replica":"b",…}]
01:22:08 receiver=team status=firing group={alertname=Watchdog} alerts=2
те же реплики с alert_relabel_configs: [{action: labeldrop, regex: replica}]
/api/v2/alerts: [{"alertname":"Watchdog","severity":"none"}]
01:23:08 receiver=team status=firing group={alertname=Watchdog} alerts=1
Дубли пришли одним уведомлением только потому, что replica нет в group_by;
при другой группировке это два звонка.
Alertmanager и данные
- Кластер Alertmanager (флаги
--cluster.*) реплицирует через gossip silence и журнал отправленных уведомлений; при разрыве сети он по документации лучше пришлёт дубль, чем потеряет уведомление. Балансировщик перед Alertmanager README запрещает: Prometheus перечисляет все экземпляры. - Графики двоятся, потому что серии различаются меткой
replica. Склеивает их долговременное хранилище (таблица в разделе про хранение), причём не усреднением: реплики опрашивают цели в разные моменты, и VictoriaMetrics, например, оставляет на интервал-dedup.minScrapeIntervalодну точку с наибольшей отметкой времени.
increase оценивает прирост по
точкам опроса с экстраполяцией, не видит инкрементов после последнего опроса перед рестартом и первого
значения серии, родившейся посреди окна. Точные бизнес-числа берут из базы, метрики отвечают на
«сколько в секунду и как меняется».Что показал стенд
http_requests_total{instance="app-1:8080",code="500",route="/api/orders"}
родилась с первыми ошибками в 00:46:33; первые точки 00:46:43=15, 00:46:58=47, 00:47:13=67
в 01:03:07 значение счётчика 576
increase(…[1h]) 570.708957021963
increase(…[1h] anchored) 561
Первые 15 ошибок случились до первого опроса, и для Prometheus серия началась с 15.
anchored вернул разность 561, а increase дотянул прирост влево на половину
интервала (7,5 с), вправо до границы (9,4 с): 561 × 991,9 / 975,0 = 570,7. Ещё одну потерю показал
раздел про сброс: трафик старого процесса после его последнего опроса.
Что с этим делать
- Создавать серии при старте через
WithLabelValues: серия есть с нулём, и первые события считаются приростом. - Start timestamp. client_golang отдаёт в protobuf время создания счётчика, флаг
--enable-feature=created-timestamp-zero-ingestionвставляет по нему нулевую точку. Новый экземпляр получил 20 запросов между опросами:без флага http_requests_total[5m] 20 20 20 20 20 20 increase 0 с флагом http_requests_total[5m] 0 20 20 20 20 20 20 increase 20.605229719788674
Что стоит запомнить
- Для счётчика сначала вычисляют rate по каждой серии, затем агрегируют.
- Отсутствующая серия не равна нулю и требует отдельной проверки присутствия данных.
- Окно rate должно включать несколько scrape-интервалов, чтобы пережить несовпадение границ и пропуск опроса.
Частые ошибки
- Суммирование сырых counter до rate мешает корректно обработать reset каждой серии.
- Алерт firing появляется после pending-периода, если выражение остаётся истинным.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Посчитай долю ошибок
За окно уже вычислены rates: 5xx - 2 запроса/с, все ответы - 50 запросов/с. Какой результат даст выражение и что означает число?
sum(rate(http_requests_total{code=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
Ответ и разбор
Числитель равен 2, знаменатель 50, отношение равно 0,04, то есть 4% запросов в выбранном наборе завершились 5xx. Проверяй, что numerator и denominator используют одинаковые фильтры по job/route и что нет проблем с отсутствующим рядом или нулевым знаменателем.
Код-ревью
2. Перенеси rate до sum
Нужно суммировать скорость счётчика по route. Найди и синтаксическую, и семантическую проблему в выражении.
rate(sum by (route) (http_requests_total)[5m])
Ответ и разбор
В таком виде выражение не парсится: range selector нельзя применить напрямую к aggregation expression. Исправь порядок операций: sum by (route) (rate(http_requests_total[5m])). Это не subquery: rate применяется отдельно к каждой исходной серии, поэтому Prometheus отдельно учитывает reset каждого экземпляра, после чего значения агрегируются по route.
Применение
3. Найди причину пустой панели
Панель показывала queue_depth для worker-a, а теперь линия пропала. Значение 0 и отсутствие ряда для дашборда выглядят похоже. Назови проверки, чтобы различить пустую очередь и пропавший target.
Ответ и разбор
Проверь up для соответствующего job/instance и состояние targets, затем наличие самой серии queue_depth в диапазоне и labels после изменения конфигурации. Если target исчез, алерт на нулевую очередь не сработает, потому что ряда нет. Для обнаружения этого используй проверку отсутствия метрики/цели отдельно от значения самой метрики.
Применение
4. Проверь pending alert
Правило for: 5m стало истинным в 10:00, но уведомление пришло в 10:06. Как объяснить задержку и какие настройки сверить при разборе?
Ответ и разбор
Сначала правило остаётся pending весь период for; затем оно становится firing. Доставка может добавить ожидание evaluation interval, группировки и маршрутизации Alertmanager. Сверь интервалы вычисления правила, значения for, group_wait/group_interval и момент срабатывания receiver; точные границы зависят от фаз опроса.