LaurVas

Осваиваем мониторинг с Prometheus. Часть 2. PromQL и метки

   linux

В прошлой статье я говорил, что Prometheus — это не готовое решение, а скорее фреймворк. Чтобы использовать его возможности полноценно, надо разбираться. Что ж, начнём.

PromQL — это про то, как вытаскивать метрики не из экспортеров, а уже из самого Prometheus’а. Например, чтобы узнать сколько ядер у процессора, надо написать:

count(count(node_cpu_seconds_total) without (mode)) without (cpu)

PromQL дословно расшифровывается как Prometheus query language, т.е. язык запросов. Он не имеет ничего общего с SQL, это принципиально другой язык. Поначалу он казался мне каким-то запутанным, а документация не особо помогала. Потихоньку разобрался и мне даже понравилось.

Пробуем простые запросы

Prometheus server хранит все данные в виде временных последовательностей (time series). Каждая временная последовательность определяется именем метрики и набором меток (labels) типа ключ-значение (key-value). Давайте сразу посмотрим несколько примеров в Prometheus web UI. Напомню, он работает на localhost:9090. Чтобы не городить скриншотов, я буду показывать запросы в своём псевдо-терминале, а вы не ленитесь и пробуйте у себя.

Expression: node_load1
0.96node_load1{instance="localhost:9100",job="node"}

node_load1 — имя метрики,
instance и job — имена меток,
localhost:9100 и node — соответствующие значения меток,
0.96 — значение метрики.

Можно запустить какой-нибудь top и убедиться в том, что одноминутный load average на локалхосте действительно равен 0.96.

Если у вас несколько машин, результат будет интереснее:

Expression: node_load1
0.96node_load1{instance="localhost:9100",job="node"}
0.44node_load1{instance="anotherhost:9100",job="node"}

Prometheus разрабатывался так, чтобы наблюдать за группой машин было так же легко, как за одной. И метки этому способствуют. Прежде всего они позволяют фильтровать вывод:

Expression: node_load1{instance='localhost:9100'}
0.96node_load1{instance="localhost:9100",job="node"}

Expression: node_load1{instance!='localhost:9100'}
0.44node_load1{instance="anotherhost:9100",job="node"}

Expression: node_load1{job='node'}
0.96node_load1{instance="localhost:9100",job="node"}
0.44node_load1{instance="anotherhost:9100",job="node"}

Кроме = и != есть ещё совпадение и несовпадение с регулярным выражением: =~, !~. Мне не нравится одинарное равно для точного сопадения. Это против правил. Должно быть двойное! Эх, молодёжь… А вот разницы в кавычках я не заметил: одинарные и двойные работают одинаково. Да, если задать несколько условий, они будут объединяться логическим И.

Возьмём другой пример. Посмотрим свободное место на дисках:

Expression: node_filesystem_avail_bytes
node_filesystem_avail_bytes{device="/dev/nvme0n1p1",fstype="vfat",instance="localhost:9100",job="node",mountpoint="/boot"}  143187968
node_filesystem_avail_bytes{device="/dev/nvme0n1p2",fstype="ext4",instance="localhost:9100",job="node",mountpoint="/"}      340473708544
node_filesystem_avail_bytes{device="/dev/sda1",fstype="ext4",instance="anotherhost:9100",job="node",mountpoint="/"}         429984710656
node_filesystem_avail_bytes{device="run",fstype="tmpfs",instance="localhost:9100",job="node",mountpoint="/run"}             4120506368
node_filesystem_avail_bytes{device="tmpfs",fstype="tmpfs",instance="localhost:9100",job="node",mountpoint="/tmp"}           4109291520
node_filesystem_avail_bytes{device="tmpfs",fstype="tmpfs",instance="anotherhost:9100",job="node",mountpoint="/run"}         104542208

Да, в байтах получаются огромные непонятные числа, но сейчас не обращайте на это внимания — мы упражняемся в запросах.

Можно получить свободное место в процентах:

Expression: node_filesystem_avail_bytes / node_filesystem_size_bytes * 100
{device="/dev/nvme0n1p1",fstype="vfat",instance="localhost:9100",job="node",mountpoint="/boot"}  54.17850016466805
{device="/dev/nvme0n1p2",fstype="ext4",instance="localhost:9100",job="node",mountpoint="/"}      73.94176693455515
{device="/dev/sda1",fstype="ext4",instance="anotherhost:9100",job="node",mountpoint="/"}         68.54660550632083
{device="run",fstype="tmpfs",instance="localhost:9100",job="node",mountpoint="/run"}             99.96800174897272
{device="tmpfs",fstype="tmpfs",instance="localhost:9100",job="node",mountpoint="/tmp"}           99.69591724179051
{device="tmpfs",fstype="tmpfs",instance="anotherhost:9100",job="node",mountpoint="/run"}         99.83961821311219

tmpfs — это не про диск, уберём его:

Expression: node_filesystem_avail_bytes{fstype!='tmpfs'} / node_filesystem_size_bytes{fstype!='tmpfs'} * 100
{device="/dev/nvme0n1p1",fstype="vfat",instance="localhost:9100",job="node",mountpoint="/boot"}  54.17850016466805
{device="/dev/nvme0n1p2",fstype="ext4",instance="localhost:9100",job="node",mountpoint="/"}      73.94172957355208
{device="/dev/sda1",fstype="ext4",instance="anotherhost:9100",job="node",mountpoint="/"}         68.5466042003818

Не обязательно указывать одинаковый фильтр для всех операндов, как я только что сделал. Достаточно одного, а дальше Prometheus сам возьмёт пересечение по меткам. Следовательно, последний запрос можно с чистой совестью сократить до такого:

Expression: node_filesystem_avail_bytes{fstype!='tmpfs'} / node_filesystem_size_bytes * 100
или
Expression: node_filesystem_avail_bytes / node_filesystem_size_bytes{fstype!='tmpfs'} * 100

Мы получили свободное место в процентах, но как-то привычнее другая величина — занятое место в процентах:

Expression: 100 - node_filesystem_avail_bytes{fstype!='tmpfs'} / node_filesystem_size_bytes * 100
или
Expression: (1 - node_filesystem_avail_bytes{fstype!='tmpfs'} / node_filesystem_size_bytes) * 100
{device="/dev/nvme0n1p1",fstype="vfat",instance="localhost:9100",job="node",mountpoint="/boot"}  45.82149983533195
{device="/dev/nvme0n1p2",fstype="ext4",instance="localhost:9100",job="node",mountpoint="/"}      26.05827042644792
{device="/dev/sda1",fstype="ext4",instance="anotherhost:9100",job="node",mountpoint="/"}         31.453395799618207

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

Агрегация

По меткам можно делать агрегацию:

Expression: foo by (label) (some_metric_name)
или
Expression: foo(some_metric_name) by (label)

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

Expression: avg by (job)(node_load1)
0.7{job="node"}

Expression: max by (job)(node_load1)
0.96{job="node"}

Expression: min by (job)(node_load1)
0.44{job="node"}

Expression: sum by (job)(node_load1)
1.4{job="node"}

Expression: count by (job)(node_load1)
2{job="node"}

Метки работают как измерения в многомерном пространстве. Агрегация с использованием by как бы схлопывает все измерения, кроме указанного. В примере с node_load1 это не очень заметно, потому что у меня мало меток и хостов. Ок, вот пример получше:

Expression: node_cpu_seconds_total
27687.16node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="idle"}
349.98node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="iowait"}
0node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="irq"}
4.5node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="nice"}
342.47node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="softirq"}
0node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="steal"}
734.43node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="system"}
2386.23node_cpu_seconds_total{cpu="0",instance="localhost:9100",job="node",mode="user"}
27613.56node_cpu_seconds_total{cpu="1",instance="localhost:9100",job="node",mode="idle"}
328.26node_cpu_seconds_total{cpu="1",instance="localhost:9100",job="node",mode="iowait"}
...

Эта метрика показывает сколько времени каждое ядро работало в каждом режиме. В сыром виде от неё никакого толку, но сейчас это не важно. Важно, что у неё куча меток: cpu, instance, job, mode.

Expression: max by (instance) (node_cpu_seconds_total)
18309.45{instance="localhost:9100"}
3655352.98{instance="anotherhost:9100"}

Expression: max by (instance, cpu) (node_cpu_seconds_total)
17623.34{cpu="3",instance="localhost:9100"}
18295.97{cpu="0",instance="localhost:9100"}
3529407.76{cpu="0",instance="anotherhost:9100"}
18252.21{cpu="1",instance="localhost:9100"}
3655379.15{cpu="1",instance="anotherhost:9100"}
18334{cpu="2",instance="localhost:9100"}

Expression: max without (mode) (node_cpu_seconds_total)
3655736.46{cpu="1",instance="anotherhost:9100",job="node"}
18752.74{cpu="2",instance="localhost:9100",job="node"}
18022.19{cpu="3",instance="localhost:9100",job="node"}
18716.18{cpu="0",instance="localhost:9100",job="node"}
3529779.38{cpu="0",instance="anotherhost:9100",job="node"}
18670.74{cpu="1",instance="localhost:9100",job="node"}

Оператор without работает как by, но в другую сторону, по принципу: “что получится, если убрать такую-то метку”. На практике лучше использовать именно without, а не by. Почему? Дело в том, что Prometheus позволяет навесить кастомных меток при объявлении таргетов, например разный env для машин тестового и боевого окружений (я покажу как это делается в одной из следующих статей). При составлении запроса вы заранее не знаете какие дополнительные метки есть у метрики и есть ли они вообще. А если и знаете, то не факт, что их число не изменится в будущем… В любом случае при использовании by все метки, которые не были явно перечислены, пропадут при агрегации. Это скорее всего будет некритично в дашбордах, но будет неприятностью в алертах. Так что лучше подумать дважды, прежде чем использовать by.

Попробуйте самостоятельно поиграться с агрегациями и понять как формируется результат. Полный список агрегирующих операторов вы найдёте в документации.

Считаем ядра

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

Expression: count(node_cpu_seconds_total) without (mode)
8{cpu="2",instance="localhost:9100",job="node"}
8{cpu="3",instance="localhost:9100",job="node"}
8{cpu="0",instance="localhost:9100",job="node"}
8{cpu="0",instance="anotherhost:9100",job="node"}
8{cpu="1",instance="localhost:9100",job="node"}
8{cpu="1",instance="anotherhost:9100",job="node"}

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

Expression: count(node_cpu_seconds_total) by (cpu, instance, job)

Теперь… просто посчитаем ядра:

Expression: count(count(node_cpu_seconds_total) without (mode)) without (cpu)
{instance="localhost:9100",job="node"}    4
{instance="anotherhost:9100",job="node"}  2

Внимательный читатель заметил, что значение метрики node_cpu_seconds_total никак не влияет на конечный результат, используются только метки. А поскольку результат первой агрегации тоже не используется, число ядер можно посчитать и так:

Expression: count(max without (mode) (node_cpu_seconds_total)) without (cpu)
{instance="localhost:9100",job="node"}    4
{instance="anotherhost:9100",job="node"}  2

Да, мне тоже кажется, что получение простой по смыслу метрики (число ядер) выглядит как-то заковыристо. Как будто мы ухо ногой чешем. Привыкайте.

Мгновенный и диапазонный вектор

Простите, я не смог придумать лучшего перевода. В оригинале это называется instant and range vector. Сейчас мы смотрели только мгновенные вектора, т.е. значения метрик в конкретный момент времени. Почему вообще результат запроса называется вектором? Вспоминаем, что Prometheus ориентирован на работу с группами машин, не с единичными машинами. Запросив какую-то метрику, в общем случае вы получите не одно значение, а несколько. Вот и получается вектор (с точки зрения алгебры, а не геометрии). Возможно, если погрузиться в исходный код Prometheus, всё окажется сложнее, но, к счастью, в этом нет необходимости.

Диапазонный вектор (range vector) — это вектор, который хранит диапазон значений метрики за определённый период времени. Он нужен, когда этого требует арифметика запроса. Проще всего объяснить на графике функции avg_over_time от чего-нибудь. В каждый момент времени она будет вычислять усреднённое значение метрики за предыдущие X минут (секунд, часов…). По-научному это называется “скользящее среднее” (moving average). Взгляните на 2 графика:

Оранжевый получен из зелёного усреднением за 10 минут. Да, это был мой любимый load average:

Expression: avg_over_time(node_load1{instance='localhost:9100'}[10m])
{instance="localhost:9100",job="node"}  0.23

Собственно, диапазонный вектор — это когда мы дописываем временной интервал в квадратных скобочках. Интервал времени для диапазонного вектора указывается очень по-человечески: 1s — одна секунда, 1m — одна минута, 1h — один час, 1d — день. А что, если нужно указать полтора часа? Просто напишите 90m.

Домашнее задание: посмотрите на графики max_over_time и min_over_time.

Типы метрик

Метрики бывают разных типов. Это важно, потому что для разных типов метрик применимы те или иные запросы.

Шкала (gauge). Самый простой тип метрик. Примеры: количество свободной/занятой ОЗУ, load average и т.д.

Счётчик (counter). Похож на шкалу, но предназначен совершенно для других данных. Счётчик может только увеличиваться, поэтому он подходит только для тех метрик, которые по своей природе могут только увеличиваться. Примеры: время работы CPU в определённом режиме (user, system, iowait…), количество запросов к веб-серверу, количество отправленных/принятых сетевых пакетов, количество ошибок. На практике вас не будет интересовать точное значение счётчика, вас будет интересовать первая производная по времени — скорость роста этого счётчика: количество запросов в минуту или количество ошибок за день.

Гистограмма (histogram). Я пока не сталкивался с таким типом, поэтому ничего путного не скажу.

Саммари (summaries). Что-то похожее на гистограммы, но другое.

Смотрим счётчики

Посмотрим счётчики на примере сетевого трафика:

Expression: node_network_receive_bytes_total{instance='localhost:9100'}
node_network_receive_bytes_total{device="br-03bbefe4ab97",instance="localhost:9100",job="node"}  0
node_network_receive_bytes_total{device="docker0",instance="localhost:9100",job="node"}          0
node_network_receive_bytes_total{device="lo",instance="localhost:9100",job="node"}               16121707
node_network_receive_bytes_total{device="vethab0ac76",instance="localhost:9100",job="node"}      0
node_network_receive_bytes_total{device="vethd3da657",instance="localhost:9100",job="node"}      0
node_network_receive_bytes_total{device="wlp3s0",instance="localhost:9100",job="node"}           4442690377

Сырое значение счётчика не несёт никакого смысла, его надо оборачивать функцией rate или irate. Эти функции принимают на вход диапазонный вектор, поэтому правильный запрос выглядит так:

Expression: rate(node_network_receive_bytes_total{device="wlp3s0"}[5m])
node_network_receive_bytes_total{device="wlp3s0",instance="localhost:9100",job="node"}  1210.6877192982456

Expression: irate(node_network_receive_bytes_total{device="wlp3s0"}[5m])
node_network_receive_bytes_total{device="wlp3s0",instance="localhost:9100",job="node"}  1210.6877192982456

В чём разница между rate и irate? Первая функция для вычисления производной берёт весь диапазон (5 минут в нашем случае), а вторая берёт лишь два последних сэмпла из всего диапазона, чтобы максимально приблизиться к мгновенному значению (первоисточник). Собственно, её название расшифровывается как instant rate.

Почему мы берём диапазонный вектор за 5 минут, а не за 1 или 10? Не знаю. Почему-то так делают во всех примерах и в дашборде Node exporter full тоже так. Для rate получается, что чем меньше интервал, тем больше пиков, а чем больше интервал, тем сильнее их сглаживание: с математикой не поспоришь. Для irate величина диапазона не имеет значения. На самом деле при определённых обстоятельствах всё-таки имеет, но это настолько тонкий нюанс, что на него можно забить.

Другой вопрос: 1210 — это в каких попугаях? Во-первых смотрим исходную метрику, там явно написано bytes. Функция rate делит исходную размерность на секунды, получается байт в секунду. Вообще Prometheus предпочитает стандартные единицы измерения: секунды, метры, ньютоны и т.п. Как в школе на уроках физики.

И это всё?

Думаю, на сегодня достаточно. Да, я рассказал не про все возможности PromQL. Например, за кадром остались операторы ignoring и on, логические and и or а также сдвиг offset. Есть всякие интересности типа производной по времени deriv или предсказателя будущего predict_linear. При желании вы сможете почитать про них в документации: операторы и функции. Я же вернусь к ним, когда мы будем решать практические задачи мониторинга.