voidlearn
 

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(s2[3m]) в 00:29:52: сброс счётчика и достройка до границ окна окно (T − 180 с, T], левая граница не входит 1246 1426 5 121 старый процесс: +180 за 90 с новый процесс: 0 → 121 значение упало: сброс, к приросту прибавляется 1426 T − 180 с T замена app-2 не попали ни в один опрос крайние точки покрывают 165,0 с +2,3 с +12,7 с сырой прирост: 121 − 1246 + 1426 = 301; зазоры до границ меньше 1,1 × 15 с, прирост растягивается на всё окно increase = 301 × 180 / 165,0 = 328,36 rate = 328,36 / 180 = 1,82 в секунду
Сброс и экстраполяция в одном окне. 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}
Сбой на стенде 00:46–01:00: от правил Prometheus до уведомлений доля 5xx на обеих репликах 40 % 10 % 0 ErrorBudgetBurn page, for 1m pending firing keep_firing_for InstanceErrors app-2 ticket, for 3m pending firing, inhibited firing silence InstanceErrors app-1 ticket, for 3m pending firing, inhibited firing Alertmanager → pager (wait 10s) firing, 1 алерт resolved Alertmanager → team (wait 30s) firing, 2 алерта в одной группе resolved: app-1 тики без отправки: всё подавлено 00:47 00:49 00:51 00:53 00:55 00:57 00:59
Как два алерта дошли до уведомлений. Состояния снимались с API Prometheus и Alertmanager раз в две секунды. Page звонит после 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.

ThanosGrafana MimirVictoriaMetrics
Приёмsidecar выгружает двухчасовые блоки в объектное хранилище; Receive принимает remote_writeremote_write в distributor; с 3.0 рекомендована схема с Kafka перед ingesterremote_write в single-node или vminsert
Хранениеблоки TSDB в объектном хранилищеблоки TSDB в объектном хранилищесвой формат на дисках vmstorage, MetricsQL поверх PromQL
HA-пара Prometheusсклейка на чтении по --query.replica-labelHA 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 бакетов.

Вопросы

4
Суть: к for добавляются отставание выражения (опрос, окно 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: forfiring в 00:48:15
уведомление: group_wait или тик group_intervalwebhook в 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.
Один пропуск обнуляет for

Одно пустое вычисление (упал опрос, в окне меньше двух точек), и 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).

Суть: серии пропавшей цели получают staleness-маркеры, выражение становится пустым, и алерт гаснет. Пустота не ноль, поэтому на отсутствие данных пишут отдельные правила: 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 > ….

Суть: Alertmanager склеивает одинаковые алерты по набору меток, а Prometheus добавляет к алертам внешнюю метку реплики. Её убирают через 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; точные границы зависят от фаз опроса.