Когда мы в Tarantool столкнулись с задачей настройки мониторинга для сдачи проекта заказчику, мы решили её с помощью grafonnet. Это библиотека для написания дашбордов Grafana с помощью кода на языке jsonnet, которая заметно облегчила нам жизнь.
Рассказ поделён на две части. В первой я делюсь нашей историей знакомства с grafonnet, причинами, по которым мы выбрали этот инструмент, и задачами, которые мы решили с его помощью. Вторая представляет собой пошаговое обучение написанию простого дашборда для Prometheus. Так что если ситуации, описанные мной в первой половине статьи, покажутся вам знакомыми, вторая позволит вам совершить первый шаг на пути к их разрешению.
Как водится, всё началось с проблемы.
В конце 2019 года команда, частью которой я являюсь, сдавала в эксплуатацию заказчику два микросервиса-кэша (почитать про них можно здесь и здесь). Одной из "галочек" в чек-листе был настроенный мониторинг с автоматическими оповещениями об авариях. В инфраструктуре заказчика уже существовал полный стек: Telegraf, InfluxDB и Grafana. После настройки стандартных метрик и интреграции с местными системами возникла задача превратить десятки тысяч значений в графики для эксплуатации. И досталась она мне.
По большей части команда состояла из людей, которые начали работать с Tarantool около года назад. Мы уже набили руку в вопросе разработки, но задача мониторинга была неизведанной землёй. Новые метрики и изменения в уже существующих возникали по несколько раз в неделю. Разработка дашборда Grafana велась итеративно: панели добавлялись, удалялись и заменялись на новые. Начало каждой итерации было творческим процессом, который сводился к рутине: изменять нужно было не одну дашборду, а целых четыре.
Задача иметь возможность следить за метриками на двух разных зонах стояла с самого начала, и у неё было как минимум два решения. В запросы Grafana можно встраивать динамические переменные: наборы констант или значения из базы с метриками. Переменные позволяют лёгким движением руки превратить дашборд для зоны А в дашборд для зоны Б. Это удобный инструмент, который, к сожалению, вступает в противоречие с не менее полезными оповещениями. Если в запросе панели есть переменная, на неё нельзя повесить алёрт. Поэтому мы приняли решение поддерживать по две статические дашборды для каждого из проектов.
Рутину захотелось оптимизировать. На начальном этапе дашборды отличались только в фиксированных полях запросов, и проблема решалась с помощью Ctrl+H.
Grafana умеет экспортировать дашборд в формат json. Мы делали изменения в одном из дашбордов, скачивали его и заменяли значения переменных в запросах в текстовом редакторе. Когда замены стали сложнее, был написан Python-скрипт, который читал правила трансформации из yml-файла. Кэши используют разные механизмы репликации из Oracle и холодной загрузки данных, и в один момент дашборды разошлись в наборе графиков. Удалять панель скриптом легко, но добавление — на порядок более сложная задача. Когда мы поняли, что скрипт для трансформации дашбордов превращается в инструмент для их генерирования, стало ясно, что эту проблему решал кто-то до нас.
В открытом доступе существуют такие проекты, как Weaveworks grafanalib, Uber Grafana dashboard generator, Showmax Grafana dashboard generator, grafyaml и grafonnet. Кроме того, Grafana из коробки поддерживает создание дашбордов с помощью JavaScript. Рассматривался и вариант разработки собственного генератора. Сузить выбор помогли наши обстоятельства: только grafonnet умел делать запросы к InfluxDB.
Grafonnet — opensource-проект под эгидой Grafana для создания дашбордов (страница на GitHub). Он представляет собой набор шаблонов для примитивов Grafana: панелей и запросов разных типов, различные переменные — с методами для объединения их в дашборд.
Основным преимуществом grafonnet является используемый в нём язык jsonnet. Он прост и минималистичен. Работа обычно происходит над json-объектами (точнее, их расширенной версией, которая поддерживает поля-функции и скрытые поля), благодаря чему на любом этапе работы результат можно "допилить" под себя, не внося при этом изменений в используемый код.
Но работа с grafonnet в итоге началась именно с доработок исходного кода. В панелях типа table и stat не хватало некоторых элементов, появившихся в Grafana v6, а шаблона для пользовательского расширения Status panel не было вовсе. Кроме того, хоть grafonnet и поддерживал запросы в InfluxDB, поддержка "блочных" запросов отсутствовала: писать можно было только сырой InfluxQL. Добавление любой новой панели в дашборд приводило к необходимости вручную пересчитывать координаты всех нижестоящих панелей. Этот момент также был решён с помощью скрипта на jsonnet, который встраивался в момент сборки панелей в дашборд и расставлял их, исходя из указанного размера. Ключевые наработки PR'ами были переданы в grafonnet и сейчас доступны всем пользователям инструмента.
После того, как мы перенесли на grafonnet описанные выше проекты, возник новый заказ — универсальный дашборд для всех сервисов на базе Tarantool для клиента. Кроме того, что нам удалось быстро соорудить универсальный шаблон, использовав код из первой версии, он получился дополняемым. Любой желающий мог написать специфичные для его проекта графики и добавить их в дашборд при сборке. Аналогичная работа с сырым json была бы куда более трудной.
Когда проект пришёл к определённой степени готовности, мы решили создать на его основе opensource-дашборд для стандартного приложения Tarantool Cartridge. Найти результат можно в каталоге Grafana Official and Community dashboards: версия для InfluxDB, версия для Prometheus. Исходники доступны в репозитории GitHub.
Изначально в разработке был дашборд только для InfluxDB (так как имелись и наработки, и опыт работы). Пакет metrics поддерживает вывод метрик в формате Prometheus, и это пользуется спросом, поэтому вскоре появилась версия и для него. Визуализация в панелях организуется независимо от запросов, что позволяет эффективно переиспользовать код. А работа с готовыми шаблонами сильно упрощает жизнь при добавлении большого количества однотипных панелей.
Нам понадобится jsonnet версии v0.16.0. В своих проектах мы используем имплементацию на Go: страница проекта на GitHub.
Для начала обсудим основные моменты работы с jsonnet. На официальном сайте проекта можно найти развёрнутый туториал и описание стандартной библиотеки функций.
Jsonnet-скрипт возвращает валидный json: строку, булеву переменную, число, null, массив или объект (словарь). Напишем простейший скрипт.
# script1.jsonnet
{
field: 1 + 2
}
Чтобы выполнить его, воспользуйтесь следующей командой:
jsonnet script1.jsonnet
Результат будет выведен в консоль:
{
"field": 3
}
Чтобы сохранить вывод в файл, воспользуйтесь флагом -o:
jsonnet script1.jsonnet -o result.json
Иногда бывает удобно помещать результат в буфер обмена. В этом поможет утилита xclip:
jsonnet script1.jsonnet | xclip -selection clipboard
Jsonnet-скрипты разделяют на два типа: .libsonnet и .jsonnet. Результатом работы первых являются промежуточные структуры, вторые предназначены для генерирования конечного результата.
Кроме типов json, jsonnet поддерживает функции, а объекты могут содержать скрытые поля (назначаются через оператор :. Сделаем библиотеку математических утилит.
# math.libsonnet
{
sum(a, b): a + b
}
Подключение файлов происходит с помощью import. Воспользуемся нашей библиотекой в скрипте.
# script2.jsonnet
local math = import 'math.libsonnet';
{
field: math.sum(1, 2)
}
Результатом будет уже знакомый нам объект.
Кроме возвращаемой структуры, в скрипте можно объявлять локальные переменные:
# script3.jsonnet
local math = import 'math.libsonnet';
local value = math.sum(1, 2);
{
field: value
}
Локальные переменные можно объявлять и внутри объектов:
# script4.jsonnet
local math = import 'math.libsonnet';
{
local value = math.sum(1, 2),
field: value
}
Они игнорируются при импорте и вычислении конечного результата.
jsonnet script4.jsonnet
{
"field": 3
}
Для установки зависимостей используем jsonnet-bundler.
Для инициализации запустите следующую команду:
jb init
В проекте появится файл jsonnetfile.json. Необходимо добавить в него репозиторий grafonnet:
jsonnetfile.json
{
"version": 1,
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet-lib",
"subdir": "grafonnet"
}
},
"version": "master"
}
],
"legacyImports": true
}
В результате вызова команды
jb install
jsonnet-bundler скачает нужные репозитории, а также зафиксирует коммиты в lock-файле. Это полезно, если вы устанавливаете зависимость от ветки.
jsonnetfile.lock.json
{
"version": 1,
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet-lib.git",
"subdir": "grafonnet"
}
},
"version": "3082bfca110166cd69533fa3c0875fdb1b68c329",
"sum": "4/sUV0Kk+o8I+wlYxL9R6EPhL/NiLfYHk+NXlU64RUk="
}
],
"legacyImports": false
}
При дальнейших вызовах jb install будет обращаться именно к lock-файлу. Во избежание расхождений рекомендую использовать ту же версию, что указана в файле выше.
Если опустить некоторые подробности, дашборд устроен следующим образом.
В рамках обучения я буду вести работу в рамках Grafana v6. Дашборд подойдёт и для более поздних версий Grafana, но мы не будем использовать их нововведения.
Для начала создадим пустой дашборд.
# dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard'
)
Скомпилировать его можно с помощью следующей команды:
jsonnet -J ./vendor dashboard.jsonnet -o dashboard.json
Флаг -J предназначен для подключения внешних библиотек. В нашем случае это папка ./vendor, в которую jsonnet-bundler устанавливает зависимости.
Для отработки упражнений я подготовил docker-кластер с простым приложением на Tarantool, Prometheus и Grafana. Он доступен в репозитории на GitHub. Склонируйте его и запустите следующую команду (вам понадобится docker-compose).
docker-compose up
После запуска Grafana будет доступна по адресу localhost:3000.
Для того чтобы загрузить ваш дашборд из json, воспользуйтесь функцией импорта в левом меню Grafana.
Загрузите файл или вставьте содержимое из буфера обмена. На следующем этапе можно задать имя дашборда и выбрать папку для его хранения.
Результатом импорта будет пустой дашборд.
Давайте добавим панель типа graph (обычный график).
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
# разрешить пользователю делать изменения в Grafana
editable=true,
# избавляет от проблем при импорте в Grafana различных версий
schemaVersion=21,
).addPanel(
grafana.graphPanel.new(
title='My first graph',
# набор демонстрационных данных
datasource='-- Grafana --'
),
# задаёт положение и размеры панели
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Соберём дашборд с помощью уже известной команды.
jsonnet -J ./vendor dashboard.jsonnet -o dashboard.json
При импорте можно перезаписать уже существующий дашборд или выбрать другое имя.
Остановимся подробнее на gridPos. Размеры и положение каждой панели описываются с помощью координат на прямоугольной сетке. Ширина дашборда — 24 единицы. Например, панель типа row имеет размеры 24 x 1, а шаблон панели, которая появляется при вызове Add panel, имеет размеры 12 x 9.
Панели создаются как результат вызова функций. Настройка визуализации метрик в панелях происходит с помощью параметров этих функций.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='-- Grafana --',
# минимальное отображаемое значение
min=0,
# подпись левой оси ординат
labelY1='pending',
# интенсивность цвета, заполняющего область под графиком
fill=0,
# количество знаков после запятой в значениях
decimals=2,
# количество знаков после запятой в значениях на левой оси ординат
decimalsY1=0,
# сортировка значений в порядке убывания
sort='decreasing',
# легенда в форме таблицы
legend_alignAsTable=true,
# выводить значения на легенде
legend_values=true,
# выводить на легенде среднее значение
legend_avg=true,
# выводить на легенде текущее значение
legend_current=true,
# выводить на легенде максимальное значение
legend_max=true,
# сортировать в легенде по текущему значению
legend_sort='current',
# сортировать в легенде по убыванию
legend_sortDesc=true,
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Параметры панелей описаны в исходниках grafonnet. В частности, опции graph можно посмотреть в vendor/grafonnet/graph_panel.libsonnet.
Визуализируем метрику server_pending_requests из тестового приложения.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
# отображать метрики за последние полчаса
time_from='now-30m'
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
# направлять запросы в источник данных с названием `Prometheus`
datasource='Prometheus',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
# запрос на получение метрики 'server_pending_requests' из источника datasource
expr='server_pending_requests',
# результаты запроса подписываются лейблом 'alias'
# (соответствуют отдельным инстансам в кластере приложения)
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Метод _addTarget()_ можно вызывать несколько раз.
Json-дашборд может включать в себя переменные, которые задаются на моменте импорта. Таким образом удобно настраивать datasource — источник метрик для дашборда.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput( # добавить переменную импорта
# имя переменной для использования в коде дашборда
name='DS_PROMETHEUS',
# имя переменной на экране импорта
label='Prometheus',
# переменная для задания источника данных
type='datasource',
# плагин для Prometheus
pluginId='prometheus',
pluginName='Prometheus',
# описание переменной на экране импорта
description='Prometheus metrics bank'
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
# использовать значение переменной в качестве источника
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Теперь выбрать нужный источник данных можно из выпадающего списка, который автоматически создан Grafana.
В Grafana есть два типа переменных: __inputs и templating. Первые задаются один раз на импорте и "вмораживаются" в дашборд. Вторые можно изменять во время работы.
Работа с переменной __inputs — это текстовая замена строк ${VAR_NAME} на выбранное значение. Такую замену нельзя делать внутри запросов к источнику данных. Для этого используются переменные templating.
Один из способов разделять данные от различных приложений — собирать их разными job-ами Prometheus. Имеет смысл фиксировать job в запросах и конфигурировать его на импорте.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput( # строковая константа, которая заполняется на импорте
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addTemplate(
grafana.template.custom( # динамическая переменная
# имя переменной для использования в коде дашборда
name='job',
# задаёт начальное значение динамической переменной из переменной импорта
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
# не отображать переменную на экране с панелями
hide='variable',
# имя переменной в UI
label='Prometheus job',
)
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
# используем переменную в запросе
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Значение job для тестового docker-кластера — tarantool_app.
Мы применили двухуровневую параметризацию, job в дашборде регулируется переменной templating, а её начальное значение мы задаём при импорте через переменную __inputs. Если при импорте job была указана неправильно, её можно изменить через параметры дашборда.
Давайте добавим ещё одну панель, которая изображает rps.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput(
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addInput( # начальное значение rate_time_range
name='PROMETHEUS_RATE_TIME_RANGE',
label='Rate time range',
type='constant',
value='2m',
description='Time range for computing rps graphs with rate(). At the very minimum it should be two times the scrape interval.'
).addTemplate(
grafana.template.custom(
name='job',
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
hide='variable',
label='Prometheus job',
)
).addTemplate( # динамическая переменная для использования в запросе
grafana.template.custom(
name='rate_time_range',
query='${PROMETHEUS_RATE_TIME_RANGE}',
current='${PROMETHEUS_RATE_TIME_RANGE}',
hide='variable',
label='rate() time range',
)
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
).addPanel( # график нагрузки на сервер
grafana.graphPanel.new(
title='Server load',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='rps',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_values=true,
legend_sort='current',
legend_sortDesc=true,
legend_rightSide=true,
).addTarget(
grafana.prometheus.target(
# использовать данные за период rate_time_range для вычисления изменений
expr='rate(server_requests_process_count{job=~"$job"}[$rate_time_range])',
legendFormat='{{alias}}',
)
),
# разместить панель справа от первой на том же уровне
gridPos = { h: 8, w: 16, x: 9, y: 0 }
)
Переменной импорта можно задать начальное значение в коде.
В результате получим дашборд с двумя панелями.
Вынесем общий шаблон графика в функцию, чтобы уменьшить количество дублирующего кода.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
local myGraphPanel(
title=null,
labelY1=null,
legend_rightSide=false # значение по умолчанию
) = grafana.graphPanel.new(
title=title,
datasource='${DS_PROMETHEUS}',
min=0,
labelY1=labelY1,
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
legend_rightSide=legend_rightSide
);
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput(
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addInput(
name='PROMETHEUS_RATE_TIME_RANGE',
label='Rate time range',
type='constant',
value='2m',
description='Time range for computing rps graphs with rate(). At the very minimum it should be two times the scrape interval.'
).addTemplate(
grafana.template.custom(
name='job',
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
hide='variable',
label='Prometheus job',
)
).addTemplate(
grafana.template.custom(
name='rate_time_range',
query='${PROMETHEUS_RATE_TIME_RANGE}',
current='${PROMETHEUS_RATE_TIME_RANGE}',
hide='variable',
label='rate() time range',
)
).addPanel(
myGraphPanel(
title='Pending requests',
labelY1='pending'
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
).addPanel(
myGraphPanel(
title='Server load',
labelY1='rps',
legend_rightSide=true,
).addTarget(
grafana.prometheus.target(
expr='rate(server_requests_process_count{job=~"$job"}[$rate_time_range])',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 16, x: 9, y: 0 }
)
Шаблон удобно вынести в отдельный файл. Предлагаю попробовать сделать это самостоятельно в качестве тренировки. Подключение внешних файлов обсуждалось в разделе про jsonnet.
Создание дашбордов для Grafana с помощью кода — не лекарство от всех болезней. Но есть и проблемы, в решении которых такой подход показал свою эффективность. Возможность переиспользования результатов между проектами, широкий потенциал для статической конфигурации и удобное версионирование сделало для меня написание дашбордов с помощью grafonnet историей, которой я хочу поделиться.
Рассказ поделён на две части. В первой я делюсь нашей историей знакомства с grafonnet, причинами, по которым мы выбрали этот инструмент, и задачами, которые мы решили с его помощью. Вторая представляет собой пошаговое обучение написанию простого дашборда для Prometheus. Так что если ситуации, описанные мной в первой половине статьи, покажутся вам знакомыми, вторая позволит вам совершить первый шаг на пути к их разрешению.
1. Часть первая, лирическая
1.1. Как я встретил grafonnet
Как водится, всё началось с проблемы.
В конце 2019 года команда, частью которой я являюсь, сдавала в эксплуатацию заказчику два микросервиса-кэша (почитать про них можно здесь и здесь). Одной из "галочек" в чек-листе был настроенный мониторинг с автоматическими оповещениями об авариях. В инфраструктуре заказчика уже существовал полный стек: Telegraf, InfluxDB и Grafana. После настройки стандартных метрик и интреграции с местными системами возникла задача превратить десятки тысяч значений в графики для эксплуатации. И досталась она мне.
По большей части команда состояла из людей, которые начали работать с Tarantool около года назад. Мы уже набили руку в вопросе разработки, но задача мониторинга была неизведанной землёй. Новые метрики и изменения в уже существующих возникали по несколько раз в неделю. Разработка дашборда Grafana велась итеративно: панели добавлялись, удалялись и заменялись на новые. Начало каждой итерации было творческим процессом, который сводился к рутине: изменять нужно было не одну дашборду, а целых четыре.
Задача иметь возможность следить за метриками на двух разных зонах стояла с самого начала, и у неё было как минимум два решения. В запросы Grafana можно встраивать динамические переменные: наборы констант или значения из базы с метриками. Переменные позволяют лёгким движением руки превратить дашборд для зоны А в дашборд для зоны Б. Это удобный инструмент, который, к сожалению, вступает в противоречие с не менее полезными оповещениями. Если в запросе панели есть переменная, на неё нельзя повесить алёрт. Поэтому мы приняли решение поддерживать по две статические дашборды для каждого из проектов.
Рутину захотелось оптимизировать. На начальном этапе дашборды отличались только в фиксированных полях запросов, и проблема решалась с помощью Ctrl+H.
Grafana умеет экспортировать дашборд в формат json. Мы делали изменения в одном из дашбордов, скачивали его и заменяли значения переменных в запросах в текстовом редакторе. Когда замены стали сложнее, был написан Python-скрипт, который читал правила трансформации из yml-файла. Кэши используют разные механизмы репликации из Oracle и холодной загрузки данных, и в один момент дашборды разошлись в наборе графиков. Удалять панель скриптом легко, но добавление — на порядок более сложная задача. Когда мы поняли, что скрипт для трансформации дашбордов превращается в инструмент для их генерирования, стало ясно, что эту проблему решал кто-то до нас.
В открытом доступе существуют такие проекты, как Weaveworks grafanalib, Uber Grafana dashboard generator, Showmax Grafana dashboard generator, grafyaml и grafonnet. Кроме того, Grafana из коробки поддерживает создание дашбордов с помощью JavaScript. Рассматривался и вариант разработки собственного генератора. Сузить выбор помогли наши обстоятельства: только grafonnet умел делать запросы к InfluxDB.
1.2. Конфетно-букетный период
Grafonnet — opensource-проект под эгидой Grafana для создания дашбордов (страница на GitHub). Он представляет собой набор шаблонов для примитивов Grafana: панелей и запросов разных типов, различные переменные — с методами для объединения их в дашборд.
Основным преимуществом grafonnet является используемый в нём язык jsonnet. Он прост и минималистичен. Работа обычно происходит над json-объектами (точнее, их расширенной версией, которая поддерживает поля-функции и скрытые поля), благодаря чему на любом этапе работы результат можно "допилить" под себя, не внося при этом изменений в используемый код.
Но работа с grafonnet в итоге началась именно с доработок исходного кода. В панелях типа table и stat не хватало некоторых элементов, появившихся в Grafana v6, а шаблона для пользовательского расширения Status panel не было вовсе. Кроме того, хоть grafonnet и поддерживал запросы в InfluxDB, поддержка "блочных" запросов отсутствовала: писать можно было только сырой InfluxQL. Добавление любой новой панели в дашборд приводило к необходимости вручную пересчитывать координаты всех нижестоящих панелей. Этот момент также был решён с помощью скрипта на jsonnet, который встраивался в момент сборки панелей в дашборд и расставлял их, исходя из указанного размера. Ключевые наработки PR'ами были переданы в grafonnet и сейчас доступны всем пользователям инструмента.
После того, как мы перенесли на grafonnet описанные выше проекты, возник новый заказ — универсальный дашборд для всех сервисов на базе Tarantool для клиента. Кроме того, что нам удалось быстро соорудить универсальный шаблон, использовав код из первой версии, он получился дополняемым. Любой желающий мог написать специфичные для его проекта графики и добавить их в дашборд при сборке. Аналогичная работа с сырым json была бы куда более трудной.
1.3. Выход в свет
Когда проект пришёл к определённой степени готовности, мы решили создать на его основе opensource-дашборд для стандартного приложения Tarantool Cartridge. Найти результат можно в каталоге Grafana Official and Community dashboards: версия для InfluxDB, версия для Prometheus. Исходники доступны в репозитории GitHub.
Изначально в разработке был дашборд только для InfluxDB (так как имелись и наработки, и опыт работы). Пакет metrics поддерживает вывод метрик в формате Prometheus, и это пользуется спросом, поэтому вскоре появилась версия и для него. Визуализация в панелях организуется независимо от запросов, что позволяет эффективно переиспользовать код. А работа с готовыми шаблонами сильно упрощает жизнь при добавлении большого количества однотипных панелей.
2. Часть вторая, практическая
2.1. Введение в jsonnet
Нам понадобится jsonnet версии v0.16.0. В своих проектах мы используем имплементацию на Go: страница проекта на GitHub.
Для начала обсудим основные моменты работы с jsonnet. На официальном сайте проекта можно найти развёрнутый туториал и описание стандартной библиотеки функций.
Jsonnet-скрипт возвращает валидный json: строку, булеву переменную, число, null, массив или объект (словарь). Напишем простейший скрипт.
# script1.jsonnet
{
field: 1 + 2
}
Чтобы выполнить его, воспользуйтесь следующей командой:
jsonnet script1.jsonnet
Результат будет выведен в консоль:
{
"field": 3
}
Чтобы сохранить вывод в файл, воспользуйтесь флагом -o:
jsonnet script1.jsonnet -o result.json
Иногда бывает удобно помещать результат в буфер обмена. В этом поможет утилита xclip:
jsonnet script1.jsonnet | xclip -selection clipboard
Jsonnet-скрипты разделяют на два типа: .libsonnet и .jsonnet. Результатом работы первых являются промежуточные структуры, вторые предназначены для генерирования конечного результата.
Кроме типов json, jsonnet поддерживает функции, а объекты могут содержать скрытые поля (назначаются через оператор :. Сделаем библиотеку математических утилит.
# math.libsonnet
{
sum(a, b): a + b
}
Подключение файлов происходит с помощью import. Воспользуемся нашей библиотекой в скрипте.
# script2.jsonnet
local math = import 'math.libsonnet';
{
field: math.sum(1, 2)
}
Результатом будет уже знакомый нам объект.
Кроме возвращаемой структуры, в скрипте можно объявлять локальные переменные:
# script3.jsonnet
local math = import 'math.libsonnet';
local value = math.sum(1, 2);
{
field: value
}
Локальные переменные можно объявлять и внутри объектов:
# script4.jsonnet
local math = import 'math.libsonnet';
{
local value = math.sum(1, 2),
field: value
}
Они игнорируются при импорте и вычислении конечного результата.
jsonnet script4.jsonnet
{
"field": 3
}
2.2. Установка grafonnet
Для установки зависимостей используем jsonnet-bundler.
Для инициализации запустите следующую команду:
jb init
В проекте появится файл jsonnetfile.json. Необходимо добавить в него репозиторий grafonnet:
jsonnetfile.json
{
"version": 1,
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet-lib",
"subdir": "grafonnet"
}
},
"version": "master"
}
],
"legacyImports": true
}
В результате вызова команды
jb install
jsonnet-bundler скачает нужные репозитории, а также зафиксирует коммиты в lock-файле. Это полезно, если вы устанавливаете зависимость от ветки.
jsonnetfile.lock.json
{
"version": 1,
"dependencies": [
{
"source": {
"git": {
"remote": "https://github.com/grafana/grafonnet-lib.git",
"subdir": "grafonnet"
}
},
"version": "3082bfca110166cd69533fa3c0875fdb1b68c329",
"sum": "4/sUV0Kk+o8I+wlYxL9R6EPhL/NiLfYHk+NXlU64RUk="
}
],
"legacyImports": false
}
При дальнейших вызовах jb install будет обращаться именно к lock-файлу. Во избежание расхождений рекомендую использовать ту же версию, что указана в файле выше.
2.3. Создание дашборда
Если опустить некоторые подробности, дашборд устроен следующим образом.
В рамках обучения я буду вести работу в рамках Grafana v6. Дашборд подойдёт и для более поздних версий Grafana, но мы не будем использовать их нововведения.
2.3.1. Пустой дашборд
Для начала создадим пустой дашборд.
# dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard'
)
Скомпилировать его можно с помощью следующей команды:
jsonnet -J ./vendor dashboard.jsonnet -o dashboard.json
Флаг -J предназначен для подключения внешних библиотек. В нашем случае это папка ./vendor, в которую jsonnet-bundler устанавливает зависимости.
2.3.2. Тестовый docker-кластер
Для отработки упражнений я подготовил docker-кластер с простым приложением на Tarantool, Prometheus и Grafana. Он доступен в репозитории на GitHub. Склонируйте его и запустите следующую команду (вам понадобится docker-compose).
docker-compose up
После запуска Grafana будет доступна по адресу localhost:3000.
2.3.3. Импорт дашборда
Для того чтобы загрузить ваш дашборд из json, воспользуйтесь функцией импорта в левом меню Grafana.
Загрузите файл или вставьте содержимое из буфера обмена. На следующем этапе можно задать имя дашборда и выбрать папку для его хранения.
Результатом импорта будет пустой дашборд.
2.3.4. Добавление панелей
Давайте добавим панель типа graph (обычный график).
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
# разрешить пользователю делать изменения в Grafana
editable=true,
# избавляет от проблем при импорте в Grafana различных версий
schemaVersion=21,
).addPanel(
grafana.graphPanel.new(
title='My first graph',
# набор демонстрационных данных
datasource='-- Grafana --'
),
# задаёт положение и размеры панели
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Соберём дашборд с помощью уже известной команды.
jsonnet -J ./vendor dashboard.jsonnet -o dashboard.json
При импорте можно перезаписать уже существующий дашборд или выбрать другое имя.
Остановимся подробнее на gridPos. Размеры и положение каждой панели описываются с помощью координат на прямоугольной сетке. Ширина дашборда — 24 единицы. Например, панель типа row имеет размеры 24 x 1, а шаблон панели, которая появляется при вызове Add panel, имеет размеры 12 x 9.
2.3.5. Настройка визуализации
Панели создаются как результат вызова функций. Настройка визуализации метрик в панелях происходит с помощью параметров этих функций.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='-- Grafana --',
# минимальное отображаемое значение
min=0,
# подпись левой оси ординат
labelY1='pending',
# интенсивность цвета, заполняющего область под графиком
fill=0,
# количество знаков после запятой в значениях
decimals=2,
# количество знаков после запятой в значениях на левой оси ординат
decimalsY1=0,
# сортировка значений в порядке убывания
sort='decreasing',
# легенда в форме таблицы
legend_alignAsTable=true,
# выводить значения на легенде
legend_values=true,
# выводить на легенде среднее значение
legend_avg=true,
# выводить на легенде текущее значение
legend_current=true,
# выводить на легенде максимальное значение
legend_max=true,
# сортировать в легенде по текущему значению
legend_sort='current',
# сортировать в легенде по убыванию
legend_sortDesc=true,
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Параметры панелей описаны в исходниках grafonnet. В частности, опции graph можно посмотреть в vendor/grafonnet/graph_panel.libsonnet.
2.3.6. Составление запроса в Prometheus
Визуализируем метрику server_pending_requests из тестового приложения.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
# отображать метрики за последние полчаса
time_from='now-30m'
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
# направлять запросы в источник данных с названием `Prometheus`
datasource='Prometheus',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
# запрос на получение метрики 'server_pending_requests' из источника datasource
expr='server_pending_requests',
# результаты запроса подписываются лейблом 'alias'
# (соответствуют отдельным инстансам в кластере приложения)
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Метод _addTarget()_ можно вызывать несколько раз.
2.3.7. Переменные импорта
Json-дашборд может включать в себя переменные, которые задаются на моменте импорта. Таким образом удобно настраивать datasource — источник метрик для дашборда.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput( # добавить переменную импорта
# имя переменной для использования в коде дашборда
name='DS_PROMETHEUS',
# имя переменной на экране импорта
label='Prometheus',
# переменная для задания источника данных
type='datasource',
# плагин для Prometheus
pluginId='prometheus',
pluginName='Prometheus',
# описание переменной на экране импорта
description='Prometheus metrics bank'
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
# использовать значение переменной в качестве источника
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Теперь выбрать нужный источник данных можно из выпадающего списка, который автоматически создан Grafana.
2.3.8. Динамические переменные
В Grafana есть два типа переменных: __inputs и templating. Первые задаются один раз на импорте и "вмораживаются" в дашборд. Вторые можно изменять во время работы.
Работа с переменной __inputs — это текстовая замена строк ${VAR_NAME} на выбранное значение. Такую замену нельзя делать внутри запросов к источнику данных. Для этого используются переменные templating.
Один из способов разделять данные от различных приложений — собирать их разными job-ами Prometheus. Имеет смысл фиксировать job в запросах и конфигурировать его на импорте.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput( # строковая константа, которая заполняется на импорте
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addTemplate(
grafana.template.custom( # динамическая переменная
# имя переменной для использования в коде дашборда
name='job',
# задаёт начальное значение динамической переменной из переменной импорта
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
# не отображать переменную на экране с панелями
hide='variable',
# имя переменной в UI
label='Prometheus job',
)
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
# используем переменную в запросе
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
)
Значение job для тестового docker-кластера — tarantool_app.
Мы применили двухуровневую параметризацию, job в дашборде регулируется переменной templating, а её начальное значение мы задаём при импорте через переменную __inputs. Если при импорте job была указана неправильно, её можно изменить через параметры дашборда.
2.3.9. Использование функций
Давайте добавим ещё одну панель, которая изображает rps.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput(
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addInput( # начальное значение rate_time_range
name='PROMETHEUS_RATE_TIME_RANGE',
label='Rate time range',
type='constant',
value='2m',
description='Time range for computing rps graphs with rate(). At the very minimum it should be two times the scrape interval.'
).addTemplate(
grafana.template.custom(
name='job',
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
hide='variable',
label='Prometheus job',
)
).addTemplate( # динамическая переменная для использования в запросе
grafana.template.custom(
name='rate_time_range',
query='${PROMETHEUS_RATE_TIME_RANGE}',
current='${PROMETHEUS_RATE_TIME_RANGE}',
hide='variable',
label='rate() time range',
)
).addPanel(
grafana.graphPanel.new(
title='Pending requests',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='pending',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
).addPanel( # график нагрузки на сервер
grafana.graphPanel.new(
title='Server load',
datasource='${DS_PROMETHEUS}',
min=0,
labelY1='rps',
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_values=true,
legend_sort='current',
legend_sortDesc=true,
legend_rightSide=true,
).addTarget(
grafana.prometheus.target(
# использовать данные за период rate_time_range для вычисления изменений
expr='rate(server_requests_process_count{job=~"$job"}[$rate_time_range])',
legendFormat='{{alias}}',
)
),
# разместить панель справа от первой на том же уровне
gridPos = { h: 8, w: 16, x: 9, y: 0 }
)
Переменной импорта можно задать начальное значение в коде.
В результате получим дашборд с двумя панелями.
Вынесем общий шаблон графика в функцию, чтобы уменьшить количество дублирующего кода.
dashboard.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
local myGraphPanel(
title=null,
labelY1=null,
legend_rightSide=false # значение по умолчанию
) = grafana.graphPanel.new(
title=title,
datasource='${DS_PROMETHEUS}',
min=0,
labelY1=labelY1,
fill=0,
decimals=2,
decimalsY1=0,
sort='decreasing',
legend_alignAsTable=true,
legend_values=true,
legend_avg=true,
legend_current=true,
legend_max=true,
legend_sort='current',
legend_sortDesc=true,
legend_rightSide=legend_rightSide
);
grafana.dashboard.new(
title='My dashboard',
editable=true,
schemaVersion=21,
time_from='now-30m'
).addInput(
name='DS_PROMETHEUS',
label='Prometheus',
type='datasource',
pluginId='prometheus',
pluginName='Prometheus',
description='Prometheus metrics bank'
).addInput(
name='PROMETHEUS_JOB',
label='Job',
type='constant',
pluginId=null,
pluginName=null,
description='Prometheus Tarantool metrics job'
).addInput(
name='PROMETHEUS_RATE_TIME_RANGE',
label='Rate time range',
type='constant',
value='2m',
description='Time range for computing rps graphs with rate(). At the very minimum it should be two times the scrape interval.'
).addTemplate(
grafana.template.custom(
name='job',
query='${PROMETHEUS_JOB}',
current='${PROMETHEUS_JOB}',
hide='variable',
label='Prometheus job',
)
).addTemplate(
grafana.template.custom(
name='rate_time_range',
query='${PROMETHEUS_RATE_TIME_RANGE}',
current='${PROMETHEUS_RATE_TIME_RANGE}',
hide='variable',
label='rate() time range',
)
).addPanel(
myGraphPanel(
title='Pending requests',
labelY1='pending'
).addTarget(
grafana.prometheus.target(
expr='server_pending_requests{job=~"$job"}',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 8, x: 0, y: 0 }
).addPanel(
myGraphPanel(
title='Server load',
labelY1='rps',
legend_rightSide=true,
).addTarget(
grafana.prometheus.target(
expr='rate(server_requests_process_count{job=~"$job"}[$rate_time_range])',
legendFormat='{{alias}}',
)
),
gridPos = { h: 8, w: 16, x: 9, y: 0 }
)
Шаблон удобно вынести в отдельный файл. Предлагаю попробовать сделать это самостоятельно в качестве тренировки. Подключение внешних файлов обсуждалось в разделе про jsonnet.
3. Эпилог
Создание дашбордов для Grafana с помощью кода — не лекарство от всех болезней. Но есть и проблемы, в решении которых такой подход показал свою эффективность. Возможность переиспользования результатов между проектами, широкий потенциал для статической конфигурации и удобное версионирование сделало для меня написание дашбордов с помощью grafonnet историей, которой я хочу поделиться.
Grafana as code, или как я перестал кликать мышкой в UI и полюбил grafonnet
Когда мы в Tarantool столкнулись с задачей настройки мониторинга для сдачи проекта заказчику, мы решили её с помощью grafonnet. Это библиотека для написания дашбордов Grafana с помощью кода на языке...
habr.com