Monitoring as Code на базе VictoriaMetrics и Grafana

3c49ab7f32a7140effc8492dcf8edb40.jpeg

Приветствую всех любителей Infrastructure as Code.

Как я уже писал в предыдущей статье, я люблю заниматься автоматизацией инфраструктуры. Сегодня представляю вашему вниманию вариант построения GitOps для реализации подхода Monitoring as Code.

Немного контекста

Инфраструктура проекта, в котором я сейчас работаю, очень разнородна: k8s-кластера, отдельные docker-хосты с контейнерами, сервисы в обычных systemd-демонах и т.д. Кроме этого, у нас есть PROD, STAGE и DEV-окружения, которые с точки зрения архитектуры могут отличаться. Все эти окружения очень динамичны, постоянно деплоятся новые машины и удаляются старые. К слову, эту часть мы выполняем с помощью Terraform и Ansible (возможно расскажу подробнее в своей очередной статье). Для каждого окружения у нас используется своя инфраструктура мониторинга.

Исторически мы в проекте используем Prometheus-стек. Он отлично подходит для нашей динамической инфраструктуры. Если пройтись по отдельным компонентам, то получится следующий стандартный список компонентов:

  • Сбор и хранение метрик — Prometheus

  • Экспорт метрик — различные экспортеры (Node exporter, Postgres exporter, MongoDB exporter, …).

  • Визуализация — Grafana

  • Алертинг — Alertmanager

В какой-то момент мы заменили Prometheus на VictoriaMetrics (кластерную версию), благодаря чему сэкономили кучу ресурсов и начали хранить наши метрики глубиной в 1 год. Если кто-то еще не знаком с этим замечательным продуктом, советую почитать про него. Мы мигрировали на него практически безболезненно, даже не меняя свои конфиги. В результате Prometheus у нас был заменен на несколько компонентов: vmagent + amalert + vmselect + vminsert + vmstorage.

Большинство из описанных в статье конфигураций подходят как для VictoriaMetrics, так и для Prometheus.

Этапы автоматизации мониторинга

1 этап. Исходное состояние, отсутствие автоматизации

Изначально изменения в конфигурацию Prometheus мы вносили вручную. В Prometheus не использовался никакой Service Discovery, использовался обычный static_config. И, как вы уже наверное догадались, очень быстро наш файл prometheus.yml превратился в портянку из 1000+ строк, которые могли содержать в себе какие-то старые закомментированные targets, лишние jobs и т.д. Почему? Потому что админы никогда не удаляют строки из конфигов, строки просто комментируются до лучших времен.

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

Аналогичная ситуация была и с конфигурацией алертов prometheus, а также Alertmanager.

Дашборды Grafana редактировались также вручную и перетаскивались между несколькими инстансами Grafana через механизмы экспорта/импорта.

На данном этапе у нас не было никакой автоматизации, и, следовательно, тут мы мучались со следующими проблемами:

  • Создали новую машину, но забыли добавить в мониторинг. Когда понадобились метрики, вспомнили про эту машины.

  • Удалили машину, но забыли удалить из мониторинга. Заморгал алертинг, возбудилась группа дежурных (у нас и такое тоже есть).

  • Проводим работы на какой-либо машине, забыли заглушить для неё алерты. Дежурная смена опять звонит.

  • Внесли изменения в дашборд Grafana в одном окружении, забыли перенести в другое окружение. В результате получаем разные дашборды в окружениях.

  • Конфигурация мониторинга является черным ящиком для всех, кто не имеет доступ к машине по ssh. У разработчиков часто возникают вопросы по метрикам, алертам и дашбордам.

  • Разработчики не могут внести изменения в дашборд, потому что у них права только Viewer.

  • И т.д.

2 этап. Хранение статической конфигурации в Git

Очень быстро мы намучились с ручной конфигурацией VictoriaMetrics и пришли к следующему варианту: решили хранить конфиги VictoriaMetrics и Alertmanager в Git. Доставка конфигурации пока выполнялась вручную (по факту — одна команда git pull).

Также мы переделали scrape-конфиги VictoriaMetrics в file_sd_config. Это не сильно упростило конфигурацию, но зато позволило структурировать её за счёт вынесения таргетов в отдельные файлы.

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

3 этап. GitOps для мониторинга

На данном этапе мы решили кардинально пересмотреть все наши подходы к управлению мониторингом. По сравнению с предыдущими этапами, тут много изменений, поэтому данный этап мы рассмотрим более подробно, каждый компонент по отдельности.

Сразу хочу обозначить, что в данный момент описанное решение находится в стадии тестирования и некоторые части могут измениться при вводе в продакшн.

Service Discovery

Вместо статической конфигурации мы решили использовать Service Discovery. У нас в инфраструктуре уже давно был Hashicorp Consul (в качестве KV-хранилища), но теперь мы решили его использовать как Service Discovery для мониторинга.

Для этого на каждую машину во всех наших окружениях мы установили consul-агент в режиме клиента. Через него мы начали регистрировать наши prometheus-экспортеры как сервисы в Consul. Делается это очень просто: в каталог конфигурации consul-агента необходимо подложить небольшой JSON-файл с минимальной информацией о сервисе. А затем сделать релоад сервиса consul на данном хосте, чтоб агент перечитал конфигурацию и отправил изменения в кластер. Подробнее о регистрации сервисов можно почитать в документации Consul.

Например, для Node Exporter файл может выглядеть следующим образом:

node_exporter.json

{
  "service": {
    "name": "node_exporter",
    "port": 9100,
    "meta": {
      "metrics_path": "/metrics",
      "metrics_scheme": "http"
    }
  }
}

Такой способ регистрации сервиса очень удобен, потому что всю работу за нас делает нативный consul-агент, от нас требуется лишь подложить в нужное место JSON-файл. При этом обновление и дерегистрация сервиса выполняется аналогичным образом (с помощью обновления или удаления файла).

Дерегистрация машин/сервисов (например, для последующего удаления машины) может также производиться с помощью штатного выключения сервиса consul на машине. При остановке consul-агент выполняет graceful-shutdown, который выполняет дерегистрацию.

Кроме этого, дерегистрацию можно выполнить через Consul API.

VictoriaMetrics Configuration

Поскольку мы перешли на Service Discovery, теперь мы можем использовать consul_sd_config в нашем scrape-конфиге VictoriaMetrics. Таким образом, наш файл из 1000+ строк превратился в 30+ строк примерно следующего вида:

prometheus.yml

global:
  scrape_interval: 30s

scrape_configs:
  - job_name: exporters
    consul_sd_configs:
      - server: localhost:8500
    relabel_configs:
      # drop all targets that do not have metrics_path key in metadata
      - source_labels: [__meta_consul_service_metadata_metrics_path]
        regex: ^/.+$
        action: keep
      # set metrics path from metrics_path metadata key
      - source_labels: [__meta_consul_service_metadata_metrics_path]
        target_label: __metrics_path__
      # set metrics scheme from metrics_scheme metadata key
      - source_labels: [__meta_consul_service_metadata_metrics_scheme]
        regex: ^(http|https)$
        target_label: __scheme__
      - source_labels: [__meta_consul_dc]
        target_label: consul_dc
      - source_labels: [__meta_consul_health]
        target_label: consul_health
      - action: labelmap
        regex: __meta_consul_metadata_(.+)
        replacement: $1
      - source_labels: [__meta_consul_node]
        target_label: host
      - source_labels: [__meta_consul_node, __meta_consul_service_port]
        separator: ":"
        target_label: instance
      - source_labels: [__meta_consul_service]
        target_label: job
      - source_labels: [__meta_consul_node, __meta_consul_service_port]
        separator: ":"
        target_label: __address__

Такая конфигурация заставляет Prometheus брать список хостов из Consul Service Discovery. Т.е. если хост добавился в Consul, то он через несколько секунд появляется в Prometheus.

С помощью relabel_config мы можем делать любые преобразования данных, полученных из Consul в лейблы Prometheus. Например, мы через метаданные сервиса Consul передаем схему (http или https) и путь к метрикам экспортера (обычно /metrics, но бывает и другой).

Также с помощью метаданных и тегов consul, мы можем фильтровать хосты, которые будут добавлены в Prometheus (при условии, что эти теги или метаданные мы добавили в конфигурацию Сonsul при регистрации сервиса). Например, вот так мы можем брать только хосты из DEV-окружения:

prometheus.yml

scrape_configs:
  - job_name: exporters
    consul_sd_configs:
      - server: localhost:8500
        tags:
          - dev
    relabel_configs:
    ...

При использовании Consul Service Discovery мы можем также получать статус хоста (метка __meta_consul_health). С помощью данного поля мы можем выводить наши хосты в Maintenance-режим. Для этого у агента Consul есть специальная команда maint.

Примеры команд

# включение maintenance-режима для хоста
consul maint -enable
# включение maintenance-режима для отдельного сервиса
consul maint -service=node_exporter -enable
# выключение maintenance-режима для хоста
consul maint -disable

С помощью этой метки мы может обрабатывать событие вывода хостов на обслуживание и не создавать лишние алерты. Для этого необходимо заранее предусмотреть данное исключение в своих правилах алертинга.

Grafana Provisioning

Если вы работали с Grafana, то Вы, наверное, уже знаете, что каждый дашборд представляет собой JSON-файл. Также у Grafana есть API, через который можно пропихивать эти дашборды.

Кроме этого, есть специальный механизм Grafana Provisioning, который позволяет вообще всю конфигурацию Grafana хранить в виде файлов в формате YAML. Этот механизм работает следующим образом:

  1. Мы пишем конфигурацию наших data sources, plugins, dashboards и складываем её в определенный каталог.

  2. Grafana при старте создает все описанные в YAML объекты и импортирует дашборды из указанного каталога.

При импорте дашбордов есть следующие возможности:

  • Grafana может импортировать структуру каталогов и создать их у себя в UI. Импортированные дашборды будут разложены по каталогам в соответствии с расположением JSON-файлов.

  • После импорта дашборды можно сделать нередактируемыми через UI (актуально, если планируете вносить все изменения только через код).

  • Для дашбордов можно задать статические uid, чтоб зафиксировать ссылки на получившиеся дашборды.

  • Grafana умеет перечитывать содержимое каталога и применять изменения в дашбордах.

  • Если JSON-файл исчез из каталога, Grafana может соответственно убирать его из UI.

Примеры конфигурации Grafana Provisioning:

datasources.yml

# config file version
apiVersion: 1

# list of datasources that should be deleted from the database
deleteDatasources: []

# list of datasources to insert/update depending
# what's available in the database
datasources:
  #  name of the datasource. Required
  - name: VictoriaMetrics
    #  datasource type. Required
    type: prometheus
    #  access mode. proxy or direct (Server or Browser in the UI). Required
    access: proxy
    #  org id. will default to orgId 1 if not specified
    orgId: 1
    #  custom UID which can be used to reference this datasource in other parts of the configuration, if not specified will be generated automatically
    uid: victoria_metrics
    #  url
    url: http://my.victoria.metrics:8481/select/0/prometheus
    #  Deprecated, use secureJsonData.password
    password:
    #  database user, if used
    user:
    #  database name, if used
    database:
    #  enable/disable basic auth
    basicAuth:
    #  basic auth username
    basicAuthUser:
    #  Deprecated, use secureJsonData.basicAuthPassword
    basicAuthPassword:
    #  enable/disable with credentials headers
    withCredentials:
    #  mark as default datasource. Max one per org
    isDefault: true
    #  fields that will be converted to json and stored in jsonData
    jsonData:
    #  json object of data that will be encrypted.
    secureJsonData:
    # datasource version
    version: 1
    #  allow users to edit datasources from the UI.
    editable: false

dashboards.yml

apiVersion: 1

providers:
  #  an unique provider name. Required
  - name: dashboards
    #  Org id. Default to 1
    orgId: 1
    #  name of the dashboard folder.
    folder: ''
    #  folder UID. will be automatically generated if not specified
    folderUid: ''
    #  provider type. Default to 'file'
    type: file
    #  disable dashboard deletion
    disableDeletion: false
    #  how often Grafana will scan for changed dashboards
    updateIntervalSeconds: 10
    #  allow updating provisioned dashboards from the UI
    allowUiUpdates: false
    options:
      #  path to dashboard files on disk. Required when using the 'file' type
      path: /var/lib/grafana/dashboards
      #  use folder names from filesystem to create folders in Grafana
      foldersFromFilesStructure: true

Согласно нашей конфигурации Grafana должна создать Data Source типа prometheus с URL http://my.victoria.metrics:8481/select/0/prometheus. Также из каталога /var/lib/grafana/dashboards должны быть импортированы каталоги и дашборды.

Таким образом, мы получаем полностью определяемое состояние Grafana из кода.

Dashboards as Code

Перейдем к самим JSON-файлам дашбордов. Те, кто видел эти JSON-ы, справедливо сделают замечание о том, что формировать и поддерживать их вручную (без Grafana UI) невозможно. С этим я соглашусь, но к счастью, для этого создали специальный фреймворк grafonnet-lib, который позволяет писать дашборды с использованием языка Jsonnet.

Указанный фреймворк уже содержит набор функций, с помощью которых можно формировать панели для дашбордов. Язык Jsonnet также позволяет писать собственные функции, а также структурировать код, раскладывая его по отдельным файлам и каталогам.

Язык Jsonnet очень простой, поэтому инженер даже с небольшими навыками программирования сможет через пару часов экспериментов создать свой первый дашборд Grafana из кода.

GitOps

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

Мы давно у себя используем Gitlab для хранения наших инфраструктурных репозиториев, а также Gitlab CI для CI/CD.

Собрав всё в кучу, мы получили следующую структуру каталогов.

  • /сi — файлы, используемые в Gitlab CI

  • /grafonnet-lib — git submodule для исходников фреймворка grafonnet-lib

  • /dev — конфигурация мониторинга DEV-окружения

  • /stage — то же самое для STAGE

  • /prod — то же самое для PROD

  • /tests — файлы для тестирования дашбордов (например docker-compose для запуска Grafana)

Каждый из каталогов dev, stage, prod в свою очередь содержит следующий набор каталогов:

  • alertmanager

  • blackbox

  • grafana

  • vmagent

  • vmalert

В указанных каталогах хранится конфигурация соответствующих компонентов системы мониторинга. В каталоге grafana, кроме конфигурации Provisioning, хранятся также исходники дашбордов на языке jsonnet, которые компилируются в JSON-файлы в процессе деплоя в Gitlab CI.

Конфигурация Gitlab CI у нас выглядит следующим образом:

.gitlab-ci.yml

variables:
  CA_CERT_FILE: /etc/gitlab-runner/certs/ca.crt
  VMETRICS_IMAGE: $CI_REGISTRY_IMAGE/vm:ci-0.0.5
  JSONNET_IMAGE: $CI_REGISTRY_IMAGE/jsonnet:ci-0.0.5
  YAMLLINT_IMAGE: cytopia/yamllint:1.26
  RSYNC_IMAGE: instrumentisto/rsync-ssh:alpine3.14

  # can be overrided in project CI/CD settings
  GRAFANA_USER: admin
  GRAFANA_PASSWORD: admin

  # should be defined in project CI/CD settings
  #SSH_PRIVATE_KEY_FILE:
  #SSH_USERNAME:

include:
  - local: '*/.gitlab-ci.yml'

stages:
  - build_image
  - validate
  - build
  # - review
  - deploy

build_image:
  before_script: []
  stage: build_image
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  variables:
    REGISTRY_TAG: $CI_COMMIT_TAG
    CONTEXT_DIR: $CI_PROJECT_DIR/ci/$IMAGE_NAME
  script:
    - mkdir -p /kaniko/.docker
    - cat $CA_CERT_FILE >> /kaniko/ssl/certs/additional-ca-cert-bundle.crt
    - cp -L $CA_CERT_FILE $CONTEXT_DIR/ca.crt
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor --context $CONTEXT_DIR --dockerfile $CONTEXT_DIR/Dockerfile $BUILD_ARGS --destination $CI_REGISTRY_IMAGE/$IMAGE_NAME:$REGISTRY_TAG
  cache: {}
  rules:
    - if: '$CI_COMMIT_TAG =~ /^ci-.*/'
  parallel:
    matrix:
      - IMAGE_NAME: jsonnet
        GO_JSONNET_VERSION: '0.17.0'
        BUILD_ARGS: '--build-arg GO_JSONNET_VERSION'
      - IMAGE_NAME: vm
        VMETRICS_TAG: 'v1.62.0'
        ALERTMANAGER_TAG: 'v0.22.2'
        BLACKBOX_TAG: 'v0.19.0'
        BUILD_ARGS: '--build-arg VM_VERSION --build-arg ALERTMANAGER_TAG --build-arg BLACKBOX_TAG'

.yamllint:
  stage: validate
  image:
    name: $YAMLLINT_IMAGE
    entrypoint: [""]
  script:
    - yamllint ${WORK_DIR}/ -c .yamllint.yml

.jsonnetlint:
  stage: validate
  image: $JSONNET_IMAGE
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
    JSONNET_PATH: $CI_PROJECT_DIR/grafonnet-lib
  script:
    - cd $WORK_DIR/grafana/grafonnet
    - if [[ -f "dashboards.jsonnet" ]]; then jsonnetfmt --test $(find . -name '*.jsonnet'); fi
    - if [[ -f "dashboards.jsonnet" ]]; then jsonnet-lint dashboards.jsonnet; fi

.config_validate:
  stage: validate
  image: $VMETRICS_IMAGE
  script:
    - vmalert -dryRun -rule ${WORK_DIR}/vmalert/*.yml
    - vmagent -dryRun -promscrape.config ${WORK_DIR}/vmagent/scrape_config.yml -promscrape.config.strictParse
    - blackbox_exporter --config.check --config.file=${WORK_DIR}/blackbox/blackbox.yml
    - amtool check-config ${WORK_DIR}/alertmanager/

.build:
  stage: build
  image: $JSONNET_IMAGE
  variables:
    GIT_SUBMODULE_STRATEGY: recursive
    JSONNET_PATH: $CI_PROJECT_DIR/grafonnet-lib
  script:
    - cd ${WORK_DIR}/grafana/grafonnet
    - jsonnet -m dashboards -c -V dasboardEditable=false dashboards.jsonnet
  artifacts:
    paths:
      - ${WORK_DIR}/grafana/grafonnet/dashboards

.deploy:
  stage: deploy
  image: $RSYNC_IMAGE
  variables:
    SSH_CONFIG: |
      Host *
        StrictHostKeyChecking no
        UserKnownHostsFile=/dev/null
        LogLevel ERROR
    SSH_SERVER: $SSH_USERNAME@$MON_SERVER
    GRAFANA_PORT: 3000
    GRAFANA_SCHEME: http
  environment:
    name: $WORK_DIR
  script:
    - set -x
    - eval $(ssh-agent -s)
    - mkdir ~/.ssh/
    - chmod 700 ~/.ssh
    - echo "$SSH_CONFIG" > ~/.ssh/config
    - cat $SSH_PRIVATE_KEY_FILE | tr -d '\r' | ssh-add - > /dev/null
    - alias rsync="rsync -ai --delete --no-perms --no-owner --no-group --rsync-path='sudo rsync' --timeout=15"
    - RSYNC_OUT=$(rsync ${WORK_DIR}/vmagent $SSH_SERVER:/opt/vm/)
    - |
        if [ -n "$RSYNC_OUT" ]; then
          ssh $SSH_SERVER "sudo chown -R vmcluster:vmcluster /opt/vm/vmagent/* && sudo systemctl reload vmagent.service"
        fi
    - RSYNC_OUT=$(rsync ${WORK_DIR}/vmalert $SSH_SERVER:/opt/vm/)
    - |
        if [ -n "$RSYNC_OUT" ]; then
          ssh $SSH_SERVER "sudo chown -R vmcluster:vmcluster /opt/vm/vmalert/* && sudo systemctl reload vmalert.service"
        fi
    - RSYNC_OUT=$(rsync ${WORK_DIR}/blackbox/blackbox.yml $SSH_SERVER:/opt/blackbox_exporter/)
    - |
        if [ -n "$RSYNC_OUT" ]; then
          ssh $SSH_SERVER "sudo chown -R blackbox_exporter:blackbox_exporter /opt/blackbox_exporter/blackbox.yml && sudo systemctl reload blackbox_exporter.service"
        fi
    - rsync ${WORK_DIR}/grafana/provisioning $SSH_SERVER:/etc/grafana/
    - ssh $SSH_SERVER "sudo chown -R root:grafana /etc/grafana/provisioning"
    - rsync ${WORK_DIR}/grafana/grafonnet/dashboards $SSH_SERVER:/var/lib/grafana/
    - ssh $SSH_SERVER "sudo chown -R grafana:grafana /var/lib/grafana/dashboards"
    - ssh $SSH_SERVER "curl -X POST -sSf $GRAFANA_SCHEME://$GRAFANA_USER:$GRAFANA_PASSWORD@localhost:$GRAFANA_PORT/api/admin/provisioning/dashboards/reload"
    - ssh $SSH_SERVER "curl -X POST -sSf $GRAFANA_SCHEME://$GRAFANA_USER:$GRAFANA_PASSWORD@localhost:$GRAFANA_PORT/api/admin/provisioning/datasources/reload"

Какие действия мы выполняем в CI/CD:

  1. Валидация всех файлов конфигурации (yamllint + check конфигов всех компонентов)

  2. Компиляция дашбордов Grafana

  3. Деплой всей конфигурации на сервер мониторинга (также можно использовать несколько инстансов, объединенных в кластер).

Для деплоя мы используем обычный rsync с набором необходимых ключей (например, для удаления лишних файлов на сервере назначения).

Для локальной разработки мы используем скрипт, который компилирует дашборды и запускает Grafana в docker-compose. Разработчик дашборда может сразу увидеть внесенные изменения.

Заключение

В данной статье описаны этапы автоматизации системы мониторинга на базе Prometheus и Grafana. Используемые подходы позволяют решить ряд задач:

  • Используя Service Discovery, мы получаем полную автоматизацию добавления и удаления хостов в мониторинг. Т.е. новые машины встают на мониторинг сразу после деплоя. Для удаления машин с мониторинга, можно использовать любые механизмы (наприме, можно использовать Destroy-Time Provisionersдля Terraform, который будет выполнять дерегистрацию сервиса в Consul)

  • С помощью maintenance-режима мы можем выводить хосты на обслуживание и не получать при этом лишних алертов. Дежурная смена может спать спокойно :)

  • Используя подход Grafana as Code, мы получаем полностью детерминированное состояние наших дашбордов. При внесении изменений в конфигурацию Prometheus, мы сразу вносим изменения в дашборды.

  • Используя Gitlab CI, мы выстраиваем процесс GitOps для нашей системы мониторинга. Т.е. Git становится единым источником правды для всей системы мониторинга. Больше не требуется никаких ручных кликов в Grafana UI и никакой правки файлов конфигурации в консоли Linux.

  • И самое главное: теперь наши разработчики могут приходить в этот репозиторий, вносить изменения и присылать Pull Request.

Всем спасибо за внимание! Буду рад ответить на любые вопросы касательно данной темы.

© Habrahabr.ru