Скорее всего, сегодня уже ни у кого не возникает вопрос, зачем нужно собирать метрики сервисов. Следующий логичный шаг – настроить алертинг на собираемые метрики, который будет оповещать о любых отклонениях в данных в удобные вам каналы (почту, Slack, Telegram). В сервисе онлайн-бронирования отелей Ostrovok.ru все метрики наших сервисов льются в InfluxDB и отображаются в Grafana, там же настроен базовый алертинг. Для задач типа «нужно посчитать что-то и сравнить с этим» мы используем Kapacitor.


Kapacitor – часть TICK-стека, который умеет обрабатывать метрики из InfluxDB. Он может соединить несколько измерений между собой (join), из полученных данных вычислить что-то полезное, записать результат обратно в InfluxDB, отправить алерт в Slack/Telegram/почту.

Весь стек имеет крутую и подробную документацию, но всегда найдутся полезные штуки, которые в явном виде в мануалах не указаны. В этой статье я решил собрать ряд таких полезных неочевидных советов (основный синтаксис TICKscipt описан здесь) и показать, как их можно применять, на примере решения одной из наших задачек.

Поехали!

float & int, ошибки вычислений


Абсолютно стандартная проблема, решается через каст:

var alert_float = 5.0
var alert_int = 10
data|eval(lambda: float("value") > alert_float OR float("value") < float("alert_int"))

Использование default()


Если тег/поле не заполнено, возникнут ошибки в вычислениях:

|default()
        .tag('status', 'empty')
        .field('value', 0)

fill в join (inner vs outer)


По умолчанию join отбросит точки, где данных нет (inner).
При fill('null') будет выполнен outer join, после которого нужно сделать default() и заполнить пустые значения:

var data = res1
    |join(res2)
        .as('res1', 'res2)
        .fill('null')
    |default()
        .field('res1.value', 0.0)
        .field('res2.value', 100.0)

Тут все равно есть нюанс. Если в примере выше одна из серий (res1 или res2) будет пустой, итоговая серия (data) также будет пустой. На эту тему есть несколько тикетов на гитхабе (1633, 1871, 6967) – ждем фиксов и немного страдаем.

Использование условий в вычислениях (if в lambda)


|eval(lambda: if("value" > 0, true, false)

Последние пять минут из пайплайна за период


Например, вам нужно сравнить значения последних пяти минут с предыдущей неделей. Можно взять две пачки данных двумя отдельными batch’ами или вытащить часть данных из большего периода:

 |where(lambda: duration((unixNano(now()) - unixNano("time"))/1000, 1u) < 5m)

Альтернативой для последних пяти минут может быть использование ноды BarrierNode, которая отсекает данные раньше указанного времени:

|barrier()
        .period(5m)

Примеры использования Go’шных шаблонов в message


Шаблоны соответствуют формату из пакета text.template, ниже несколько часто встречающихся задачек.

if-else


Наводим порядок, не триггерим людей текстом лишний раз:

|alert()
    ...
    .message(
        '{{ if eq .Level "OK" }}It is ok now{{ else }}Chief, everything is broken{{end}}'
    )

Две цифры после запятой в message


Улучшаем читабельность сообщения:

|alert()
    ...
    .message(
        'now value is {{ index .Fields "value" | printf "%0.2f" }}'
    )

Разворачивание переменных в message


Выводим в сообщение больше информации для ответа на вопрос «Почему орет-то»?

var warnAlert = 10
  |alert()
    ...
    .message(
       'Today value less then '+string(warnAlert)+'%'
    )

Уникальный идентификатор алерта


Нужная штука, когда в данных больше одной группы, иначе будет генерироваться только один алерт:

|alert()
      ...
      .id('{{ index .Tags "myname" }}/{{ index .Tags "myfield" }}')

Кастомные handler’s


В большом списке хендлеров есть exec, который позволяет выполнить свой скрипт с переданными параметрами (stdin) – творчество да и только!

Один из наших кастомов – это небольшой питонячий скрипт для отправки уведомлений в слак.
Сначала нам захотелось отправлять в сообщении картинку из графаны, защищенной авторизацией. После – писать OK в тред к предыдущему алерту из той же группы, а не отдельным сообщением. Ещё чуть позже – дописывать в сообщение самую частую ошибку за последние Х минут.

Отдельная тема – связь с другими сервисами и какие-либо действия, инициированные алертом (только если ваш мониторинг работает достаточно хорошо).
Пример описания хендлера, где slack_handler.py – наш самописный скрипт:

topic: slack_graph
id: slack_graph.alert
match: level() != INFO AND changed() == TRUE
kind: exec
options:
  prog: /sbin/slack_handler.py
  args: ["-c", "CHANNELID", "--graph", "--search"]

Как дебажить?


Вариант с выводом в лог


|log()
      .level("error")
      .prefix("something")

Смотреть (cli): kapacitor -url host-or-ip:9092 logs lvl=error

Вариант с httpOut


Показывает данные в текущем пайплайне:

|httpOut('something')

Смотреть (get): host-or-ip:9092/kapacitor/v1/tasks/task_name/something

Схема выполнения


  • Каждая таска возвращает дерево выполнения с полезными цифрами в формате graphviz.
  • Берем блок dot.
  • Вставляем в viewer, наслаждаемся.


Где ещё можно получить граблями


timestamp в influxdb при обратной записи


Например, мы настраиваем алерт на сумму запросов за час (groupBy(1h)) и хотим записать случившийся алерт в influxdb (чтобы красиво показать факт наличия проблемы на графике в grafana).

influxDBOut() запишет в timestamp значение time из алерта, соответственно, точка на графике будет записана раньше/позже, чем пришел алерт.

Когда требуется точность: обходим эту проблему через вызов кастомного handler'а, который запишет данные в influxdb с текущим timestamp'ом.

docker, сборка и деплой


При старте kapacitor может подгружать таски, шаблоны и хендлеры из директории, прописанной в конфиге, в блоке [load].

Для корректного создания таски нужны следующие вещи:

  1. Название файла – разворачивается в id/название скрипта
  2. Тип – stream/batch
  3. dbrp – кейворд для указания в какой базе + политике работает скрипт (dbrp «supplier».«autogen»)

Если в какой-то batch-таске не будет строки с dbrp, весь сервис откажется запускаться и честно напишет об этом в лог.

В chronograf’е же, напротив, этой строки быть не должно, через интерфейс она не принимается и выдаёт ошибку.

Хак при сборке контейнера: Dockerfile выходит с -1, если есть строки с //.+dbrp, что позволит сразу понять причину фейла при сборке билда.

join один ко многим


Задача-пример: нужно взять 95-й перцентиль времени работы сервиса за неделю, сравнить каждую минуту из 10 последних с этим значением.

Нельзя сделать join один ко многим, last/mean/median по группе точек превращают ноду в stream, вернется ошибка «cannot add child mismatched edges: batch -> stream».

Результат batch’а, как переменной в lambda-выражении, тоже не подставляется.

Есть вариант сохранять нужные цифры из первого батча в файл через udf и загружать этот файл через sideload.

Что мы этим решали?


У нас есть около 100 поставщиков отелей, к каждому из них может быть несколько подключений, назовем это каналом. Этих каналов примерно 300, каждый из каналов может отвалиться. Из всех записываемых метрик будем мониторить рейт ошибок (requests и errors).

Почему не графана?


Алерты по ошибкам, настроенные в графане, имеют несколько минусов. Какие-то критичные, на какие-то можно закрыть глаза, в зависимости от ситуации.

Графана не умеет вычисления между измерениями + алертинг, а нам же нужен рейт (requests-errors)/requests.

Ошибки выглядят злобно:



И менее злобно, если смотреть с успешными запросами:



Окей, мы можем предварительно посчитать рейт в сервисе до графаны, и в каких-то случаях это подойдет. Но не в нашем, т.к. для каждого канала свое соотношение считается «нормальным», а алерты работают по статичным значениям (ищем глазками, меняем, если часто алертит).

Это примеры «нормально» для разных каналов:





Пренебрегаем предыдущим пунктом и предположим, что у всех поставщиков «нормальная» картина похожа. Теперь-то все хорошо, и мы можем обойтись алертами в grafana?
Можем, но очень не хочется, потому что надо выбирать один из вариантов:
а) сделать множество графиков под каждый канал отдельно (и мучительно их сопровождать)
б) оставить один график со всеми каналами (и потеряться в цветастых линиях и настроенных алертах)



Как сделали?


Опять же, в документации есть хороший стартовый пример (Calculating rates across joined series), можно подглядеть или взять за основу в аналогичных задачах.

Что сделали в итоге:

  • join двух серий за несколько часов, группировка по каналам;
  • заполняем серии по группам, если данных не было;
  • сравниваем медиану последних 10 минут с предыдущими данными;
  • кричим, если что-то обнаружили;
  • пишем посчитанные рейты и случившиеся алерты в influxdb;
  • отправляем полезное сообщение в slack.

На мой взгляд, нам максимально красиво удалось всё, что хотели бы получить на выходе (и даже чуть больше с кастомными хэндлерами).

На github.com можно посмотреть пример кода и минимальную схему (graphviz) полученного скрипта.

Пример получившегося кода:
dbrp "supplier"."autogen"
var name = 'requests.rate'
var grafana_dash = 'pczpmYZWU/mydashboard'
var grafana_panel = '26'
var period = 8h
var todayPeriod = 10m
var every = 1m
var warnAlert = 15
var warnReset = 5
var reqQuery = 'SELECT sum("count") AS value FROM "supplier"."autogen"."requests"'
var errQuery = 'SELECT sum("count") AS value FROM "supplier"."autogen"."errors"'

var prevErr = batch
    |query(errQuery)
        .period(period)
        .every(every)
        .groupBy(1m, 'channel', 'supplier')

var prevReq = batch
    |query(reqQuery)
        .period(period)
        .every(every)
        .groupBy(1m, 'channel', 'supplier')

var rates = prevReq
    |join(prevErr)
        .as('req', 'err')
        .tolerance(1m)
        .fill('null')
    // заполняем значения нулями, если их не было
    |default()
        .field('err.value', 0.0)
        .field('req.value', 0.0)
    // if в lambda: считаем рейт, только если ошибки были
    |eval(lambda: if("err.value" > 0, 100.0 * (float("req.value") - float("err.value")) / float("req.value"), 100.0))
        .as('rate')

// записываем посчитанные значения в инфлюкс
rates
    |influxDBOut()
        .quiet()
        .create()
        .database('kapacitor')
        .retentionPolicy('autogen')
        .measurement('rates')

// выбираем данные за последние 10 минут, считаем медиану
var todayRate = rates
    |where(lambda: duration((unixNano(now()) - unixNano("time")) / 1000, 1u) < todayPeriod)
    |median('rate')
        .as('median')

var prevRate = rates
    |median('rate')
        .as('median')

var joined = todayRate
    |join(prevRate)
        .as('today', 'prev')
    |httpOut('join')

var trigger = joined
    |alert()
        .warn(lambda: ("prev.median" - "today.median") > warnAlert)
        .warnReset(lambda: ("prev.median" - "today.median") < warnReset)
        .flapping(0.25, 0.5)
        .stateChangesOnly()
        // собираем в message ссылку на график дашборда графаны
        .message(
            '{{ .Level }}: {{ index .Tags "channel" }} err/req ratio ({{ index .Tags "supplier" }})
{{ if eq .Level "OK" }}It is ok now{{ else }}
'+string(todayPeriod)+' median is {{ index .Fields "today.median" | printf "%0.2f" }}%, by previous '+string(period)+' is {{ index .Fields "prev.median" | printf "%0.2f" }}%{{ end }}
http://grafana.ostrovok.in/d/'+string(grafana_dash)+
'?var-supplier={{ index .Tags "supplier" }}&var-channel={{ index .Tags "channel" }}&panelId='+string(grafana_panel)+'&fullscreen&tz=UTC%2B03%3A00'
        )
        .id('{{ index .Tags "name" }}/{{ index .Tags "channel" }}')
        .levelTag('level')
        .messageField('message')
        .durationField('duration')
        .topic('slack_graph')

// "today.median" дублируем как "value", также пишем в инфлюкс остальные филды алерта (keep)
trigger
    |eval(lambda: "today.median")
        .as('value')
        .keep()
    |influxDBOut()
        .quiet()
        .create()
        .database('kapacitor')
        .retentionPolicy('autogen')
        .measurement('alerts')
        .tag('alertName', name)


А вывод-то какой?


Kapacitor замечательно умеет выполнять мониторинг-алертинг с кучей группировок, производить дополнительные вычисления по уже записанным метрикам, выполнять кастомные действия и запускать скрипты (udf).

Порог вхождения не очень высокий – попробуйте его, если графана или другие инструменты не до конца удовлетворяют ваши хотелки.

Комментарии (6)


  1. Maximuzzz
    05.12.2019 12:13

    Спасибо за подборку хинтов, пригодится.

    А вы полностью управляете инфаструктурой инфлюикса и пишите скрипты для алертеинга или юзеры тоже могут писать свои скрипты? Если последнее — как решаете вопрос с рзделением прав доступа?


    1. nsnoow Автор
      05.12.2019 12:30

      Тут было бы круто получить уточнение — кто есть юзер в данном случае?
      В графане разработчики сами настраивают необходимые алерты
      Если же этот алертинг не достаточен/не очень удобен — иногда приходят с задачками для капаситора: «посчитать первое и второе, записать сюда» или «хочу шикарный алерт по вот такой хитрой логике»


      1. Maximuzzz
        05.12.2019 19:00

        Получается, что юзер = разработчик в вашем случае, у нас в добавок ещё и админы классические.
        Как я понял доступа самим «покрутить» скрипты в капаситоре у ваших разработчиков нет?


        1. nsnoow Автор
          06.12.2019 18:26

          Да, отдельно над задачей «раздать доступы» не думали, не было нужды.
          Капаситор все же немногим сложнее алертилки в графане, дополнительный синтаксис не нужен -> все более очевидно.


  1. M0rdecay
    05.12.2019 12:31

    Прекрасная статья, спасибо!
    Отмечу следующее — в который уже раз встречаю утверждение "Графана не умеет вычисления между измерениями", но ведь дело тут не в графане!


    Объясню по порядку (защищу графану :))
    Во-первых, возможность вести вычисления между сериями данных это заслуга использования движка запросов Flux, а не Kapacitor. Дефолтный язык запросов InfluxDB в межсерийные вычисления не умеет, с чем вы и столкнулись при работе с графаной (для работы с Flux есть плагин, но он ещё сырой).
    Из этого следует второе — визуализатор/алерт менеджер ограничен только возможностями при запросов, который предоставляет источник данных. Пример — если использовать в качестве источника Elasticsearch (коим я для целей хранения данных мониторинга пользуюсь уже давно, а вам советую попробовать), внезапно, возможность вычислений между сериями появляется, т.к. её предоставляет ES search api — https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-pipeline-bucket-script-aggregation.html
    Из минусов относительно InfluxDB — по-умолчанию в Elasticsearch не включен Index lifecycle management (нечто близкое к retention policy), эту вещь нужно настраивать ручками, но это не сложно.


    Поработайте с эластиком в связке с графаной, очень мощный получается инструмент.


    1. nsnoow Автор
      05.12.2019 12:42

      Спасибо за комментарий! Вы правы, это моя ошибка формулировок.
      В вычисления между измерениями не умеет дефолтный язык InfluxDB, конечно же.
      Про flux читал ранее, на прошлой неделе вспомнил ещё раз про него, но потыкать пока не дошли руки. В теории, штука-то очень крутая и ожидаемая, на практике пока сказать не могу.
      В InfluxDB льются только метрики, Elasticsearch естественно есть, используем для хранения логов (кстати про метрики и логи очень понравилась статья: whiteink.com/2019/logs-vs-metrics-a-false-dichotomy)