Мой опыт перевода Grafana под управление Terraform или что делать если надоело тыкать на кнопки в GUI

—

от автора

Введение

Вам сказали развернуть систему мониторинга, вы выбрали связку Prometheus + Grafana. Развернули Grafana на своих серверах (VM/Docker/Kubernetes) и подключили Data Source Prometheus (а возможно вам еще сказали развернуть логирование и вы используете Grafana Loki) и далее по гайдам из ютуба начали создавать свои дашборды и настраивать алерты.

Все работает идеально, но в один момент вы начинаете думать о том, чтобы хранить созданные сущности Grafana в коде, чтобы их можно было легко восстановить в случае потери данных или же развернуть при создании новой среды (dev/prod). Экспортировать дашборды не составит труда, это можно сделать и через GUI, но как же источники данных, политики уведомлении, contact points и сами алерты?
Знакомая история? Возможно, что нет. А у меня да!

Перед прочтением

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

Что планируем

Задача довольно проста — перевести полностью настроенную Grafana под управление terraform (обожаю его!). Но перед этим изучив её API и получив нужные данные.

С чем начинаем

У меня в распоряжении развернутый Prometheus и Grafana Loki, подключенные к Grafana (все крутится в Kubernetes). Имеется два дашборда: первый для просмотра статуса запущеннных микросервисов и второй для просмотра логов. Для каждой панели в этих двух дашбордах настроен свой алерт. В случае с алертами в первом дашборде, в дискорд приходят уведомления, если микросервис остановился, а также resolved сообщение, что микросервис вновь запущен. В случае с алертами во втором дашборде, в дискорд приходят уведомления, если количество ERROR логов в минуту превысило 25 единиц.

Grafana API

API-ключ

Для начала следует получить API ключ, чтобы иметь возможность обращаться к API графаны. Зайдем в настройки и перейдем во вкладку «Service accounts»

Создадим сервисный аккаунт с ролью Admin и назовем его terraform:

Сгенерируем токен для сервисного аккаунта и по желанию назначим дату истечения токена:

Скопируем токен и сохраним его в переменную окружения:

$ export GRAFANA_TOKEN=<token_itself> 

Получение ID ресурсов

Попробуем выполнить запрос к REST API графаны с полученным выше токеном:

$ curl -H "Authorization: Bearer $GRAFANA_TOKEN" https://<grafana_host>/api/dashboards/home | jq {   "meta": {     "isHome": true,     "canSave": false,     "canEdit": true,     "canAdmin": false, <other_content_is_hidden> } 

Я использую Grafana v10.0.5, поэтому буду использовать конечные точки отсюда. И так, получим ID всех сущностей, которые нам нужно будет импортировать в terraform. Для начала получим список всех источников данных:

curl --location 'https://<grafana_host>/api/datasources' \  --header "Authorization: Bearer $GRAFANA_TOKEN" | jq [   {     "id": 5,     "uid": "FeVw13D2",     "orgId": 1,     "name": "Loki",     "type": "loki", <other_content_is_hidden>   },   {     "id": 1,     "uid": "d4V1dDwe",     "orgId": 1,     "name": "Prometheus",     "type": "prometheus",     <other_content_is_hidden>   } ] 

Список всех дашбордов:

$ curl --location "https://<grafana_host>/api/search?query=%" \        --header "Authorization: Bearer $GRAFANA_TOKEN" | jq [   {     "id": 9,     "uid": "FvDw34Dq",     "title": "Kubernetes deployment running status", <other_content_is_hidden>   },   {     "id": 12,     "uid": "3Cw21Sqwr",     "title": "Kubernetes logging",     <other_content_is_hidden>   } ] 

Список всех папок:

$ curl --location "https://<grafana_host>/api/folders" \  --header "Authorization: Bearer $GRAFANA_TOKEN" | jq [   {     "id": 11,     "uid": "8Dve31Xcs",     "title": "Discord Alerting"   },   {     "id": 14,     "uid": "cDq3s12Zs",     "title": "Discord Alerting Loki"   } ] 

Список contact points:

$ curl --location "https://<grafana_host>/api/v1/provisioning/contact-points" \ --header "Authorization: Bearer $GRAFANA_TOKEN" | jq [   {     "uid": "cMux1S3cS",     "name": "Discord",     "type": "discord",   <other_content_is_hidden>     "disableResolveMessage": false   },   {     "uid": "BqgE23vD",     "name": "Discord (without Resolved)",     "type": "discord",     <other_content_is_hidden>     "disableResolveMessage": true   } ] 

В документации вы не сможете найти как получить список всех алертов. Я долго искал и нашел на форуме графаны конечную точку:

curl --location "https://<grafana_host>/api/ruler/grafana/api/v1/rules" \  --header "Authorization: Bearer $GRAFANA_TOKEN" | jq {   "Discord Alerting": [     {       "name": "default",       "interval": "30s",       "rules": [         {           "expr": "",           "for": "3m",           "labels": {             "discord": "channel"           },           "annotations": {             <other_content_is_hidden>             "description": "site-api status available"           },           "grafana_alert": {             "id": 9,             "orgId": 1,             "title": "site-api status available",             "condition": "B",             "data": [               {                 <other_content_is_hidden>                 "datasourceUid": "d4V1dDwe",                 "model": {                   "datasource": {                     "type": "prometheus",                     "uid": "d4V1dDwe"                   },                   "editorMode": "builder",                   "expr": "kube_deployment_status_replicas_available{namespace=\"development\", deployment=\"site-api\"}",                   <other_content_is_hidden>                 }               }             ],             "intervalSeconds": 30,             "uid": "fW3vDw31S",             <other_content_is_hidden>           }         },       ]     }   ],   "Discord Alerting Loki": [     {       "name": "default",       "interval": "30s",       "rules": [         {           "expr": "",           "for": "30s",           "labels": {             "discord": "channel_resolved_0",           },           "annotations": {         <other_content_is_hidden>             "description": "office-api ERROR logs count MORE THAN 25 for one minute!"           },           "grafana_alert": {             "id": 17,             "orgId": 1,             "title": "office-api ERROR logs count",             "condition": "B",             "data": [               {                 <other_content_is_hidden>                 "datasourceUid": "FeVw13D2",                 "model": {                   "datasource": {                     "type": "loki",                     "uid": "FeVw13D2"                   },                   "editorMode": "code",                   "expr": "count_over_time(({namespace=\"development\", app=\"office-api\"} |= \"ERROR\")[1m])",                   <other_content_is_hidden>                 },                 {                   <other_content_is_hidden>                 }               }             ],             "intervalSeconds": 20,                         "uid": "-vFw2ZdwW",             <other_content_is_hidden>           }         }       ]     }   ] } 

В «rules» содержится список всех алертов и их id. У меня их много, поэтому я решил ограничиться только одним из каждой папки.

Terraform

Создаем первоначальную структуру

Код будет храниться в Git репозитории, поэтому важно правильно огранизовать структуру папок и файлов.

Создадим следующую структуру папок:

$ tree -a . ├── .env.example ├── environments │   └── development │       ├── .env │       ├── main.tf │       └── variables.tf ├── .gitignore └── modules     ├── grafana_alerting     │   ├── main.tf     │   ├── outputs.tf     │   └── variables.tf     └── grafana_oss         ├── main.tf         ├── outputs.tf         └── variables.tf 

Добавим в .gitignore указания не добавлять в git репозитории файлы, связанные с terraform и файлы, хранящие переменные окружения:

# file: ./.gitignore *terraform* .env 

В каждом окружении будет свой .env файл со своими переменными окружения. Так как эти файлы не будут храниться в git, то мы создали файл .env.example, содержащий переменные оружения с пустыми значениями:

# file: ./.env.example export GRAFANA_AUTH= export GRAFANA_URL= 

Эти переменные окружения будут использоваться для настройки провайдера в terraform. GRAFANA_AUTH должен содержать API токен или login/password в base64. Через GRAFANA_URL указывается адрес по которому развернута графана.

Скопируем файл в папку ./environments/development и укажем значения:

$ cp ./.env.example ./environments/development/.env 
# file: ./environments/development/.env export GRAFANA_AUTH=$GRAFANA_TOKEN export GRAFANA_URL="https://<grafana_host>/" 

Настройка провайдера

Подключим провайдер Grafana и наши будущие модули:

// file: ./environments/development/main.tf  terraform {   required_providers {     grafana = {       source = "grafana/grafana"       version = "2.1.0"     }   } }  provider "grafana" { }  module "grafana_oss" {   source = "../../modules/grafana_oss" }  module "grafana_alerting" {   source = "../../modules/grafana_alerting" } 

В main.tf файл в каждом модуле необходимо также добавить Grafana в required_providers:

// files: ./modules/grafana_oss/main.tf && ./modules/grafana_alerts/main.tf  terraform {   required_providers {     grafana = {       source = "grafana/grafana"       version = "2.1.0"     }   } } 

Запустим .env файл тем самым активировав переменные окружения:

$ . ./environments/development/.env 

Выполним terraform init для инициализации модулей и установки провайдера:

$ terraform -chdir=./environments/development init  Initializing the backend... Initializing modules... - grafana_alerting in ../../modules/grafana_alerting - grafana_oss in ../../modules/grafana_oss  Initializing provider plugins... - Finding grafana/grafana versions matching "2.1.0"... - Installing grafana/grafana v2.1.0... - Installed grafana/grafana v2.1.0 (unauthenticated) 

Импорт конфигурации

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

grafana_folder

Импортировать папку можно указав её UID или ID. Везде где можно будем использовать UID. У меня две папки, поэтому импортируем их обе. Добавим в конец корневого main.tf следующее:

// file: ./environments/main.tf <other_content_is_hidden>  import {   id = "8Dve31Xcs"   to = grafana_folder.discord-alerting }  import {   id = "cDq3s12Zs"   to = grafana_folder.discord-alerting-loki } 

Выполним команду terraform plan с параметром -generate-out-config и указав в качестве значения название файла:

terraform -chdir=./environments/development plan -generate-config-out folder.tf grafana_folder.discord-alerting: Preparing import... [id=8Dve31Xcs] grafana_folder.discord-alerting-loki: Preparing import... [id=cDq3s12Zs] grafana_folder.discord-alerting-loki: Refreshing state... [id=cDq3s12Zs] grafana_folder.discord-alerting: Refreshing state... [id=8Dve31Xcs]  Terraform will perform the following actions:    # grafana_folder.discord-alerting will be imported   # (config will be generated)     resource "grafana_folder" "discord-alerting" {         id     = "0:11"         org_id = "0"         title  = "Discord Alerting"         uid    = "8Dve31Xcs"         url    = "https://<grafana_host>/dashboards/f/8Dve31Xcs/discord-alerting"     }    # grafana_folder.discord-alerting-loki will be imported   # (config will be generated)     resource "grafana_folder" "discord-alerting-loki" {         id     = "0:14"         org_id = "0"         title  = "Discord Alerting Loki"         uid    = "cDq3s12Zs"         url    = "https://<grafana_host>/dashboards/f/cDq3s12Zs/discord-alerting-loki"     }  Plan: 2 to import, 0 to add, 0 to change, 0 to destroy. 

terraform вывел в консоль ресурсы, которые планируется импортировать, а также создал файл folder.tf с готовой конфигурацией. Примем изменения тем самым добавив ресурсы в состояние:

$ terraform -chdir=./environments/development apply            Terraform will perform the following actions: <imported_resources> Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.  Do you want to perform these actions?   Terraform will perform the actions described above.   Only 'yes' will be accepted to approve.    Enter a value: yes  grafana_folder.discord-alerting: Importing... [id=8Dve31Xcs] grafana_folder.discord-alerting: Import complete [id=8Dve31Xcs] grafana_folder.discord-alerting-loki: Importing... [id=cDq3s12Zs] grafana_folder.discord-alerting-loki: Import complete [id=cDq3s12Zs]  Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed. 

terraform просит ввести yes, если вы хотите принять изменения. Он также указывает, что в ходе terraform apply будет импортировано два ресурса.

Удалим директивы import из main.tf файла. Теперь осталось перенести импортированную конфигурацию и ее состояние из root модуля в grafana_oss. Сначала переместим конфигурационный файл:

$ mv ./environments/development/folder.tf ./modules/grafana_oss  

Если вы попробуете выполнить terraform plan, то terraform увидит, что конфигурация исчезла и захочет удалить ресурсы. Чтобы этого не случилось, переместим также состояние ресурсов в модуль:

$ terraform -chdir=./environments/development state mv grafana_folder.discord-alerting module.grafana_oss.grafana_folder.discord-alerting Move "grafana_folder.discord-alerting" to "module.grafana_oss.grafana_folder.discord-alerting" Successfully moved 1 object(s).  $ terraform -chdir=./environments/development state mv grafana_folder.discord-alerting-loki module.grafana_oss.grafana_folder.discord-alerting-loki Move "grafana_folder.discord-alerting-loki" to "module.grafana_oss.grafana_folder.discord-alerting-loki" Successfully moved 1 object(s). 

Убедимся, что импорт прошел успешно:

$ terraform -chdir=./environments/development plan  module.grafana_oss.grafana_folder.discord-alerting-loki: Refreshing state... [id=0:14] module.grafana_oss.grafana_folder.discord-alerting: Refreshing state... [id=0:11]  No changes. Your infrastructure matches the configuration.  Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. 

Terraform не видит изменении и сообщает, что инфраструктура не имеет изменении.
Провернем те же самые операции с остальными сущностями.

grafana_data_source

Добавим директивы import для импорта источников данных:

// file: ./environments/main.tf <other_content_is_hidden>  import {   id = "d4V1dDwe"   to = grafana_data_source.prometheus }  import {   id = "FeVw13D2"   to = grafana_data_source.loki } 

Запустим terraform plan:

terraform -chdir=./environments/development plan -generate-config-out data_source.tf Terraform will perform the following actions:    # grafana_data_source.loki will be imported   # (config will be generated)     resource "grafana_data_source" "loki" {         access_mode        = "proxy"         basic_auth_enabled = false         id                 = "1:5"         is_default         = false         json_data_encoded  = jsonencode(             {                 manageAlerts = false             }         )         name               = "Loki"         org_id             = "1"         type               = "loki"         uid                = "FeVw13D2"         url                = "http://loki-stack:3100"     }    # grafana_data_source.prometheus will be imported   # (config will be generated)     resource "grafana_data_source" "prometheus" {         access_mode        = "proxy"         basic_auth_enabled = false         id                 = "1:1"         is_default         = true         json_data_encoded  = jsonencode(             {                 httpMethod    = "POST"                 tlsSkipVerify = true             }         )         name               = "Prometheus"         org_id             = "1"         type               = "prometheus"         uid                = "d4V1dDwe"         url                = "http://prometheus-kube-prometheus-prometheus:9090"     }  Plan: 2 to import, 0 to add, 0 to change, 0 to destroy. 

Выполним terraform apply и перед перемещением состояния и кода в модуль, немного отредактируем его, удалив ненужные значения:

// file: ./environments/development/data_source.tf resource "grafana_data_source" "loki" {   json_data_encoded        = "{\"manageAlerts\":false}"   name                     = "Loki"   org_id                   = "1"   type                     = "loki"   uid                      = "FeVw13D2"   url                      = "http://loki-stack:3100" }  resource "grafana_data_source" "prometheus" {   is_default               = true   json_data_encoded        = "{\"httpMethod\":\"POST\",\"tlsSkipVerify\":true}"   name                     = "Prometheus"   org_id                   = "1"   type                     = "prometheus"   uid                      = "d4V1dDwe"   url                      = "http://prometheus-kube-prometheus-prometheus:9090" } 

Переместим всё в модуль:

$ mv ./environments/development/data_source.tf ./modules/grafana_oss   $ terraform -chdir=./environments/development state mv grafana_data_source.loki module.grafana_oss.grafana_data_source.loki                  Move "grafana_data_source.loki" to "module.grafana_oss.grafana_data_source.loki" Successfully moved 1 object(s).  $ terraform -chdir=./environments/development state mv grafana_data_source.prometheus module.grafana_oss.grafana_data_source.prometheus Move "grafana_data_source.prometheus" to "module.grafana_oss.grafana_data_source.prometheus" Successfully moved 1 object(s). 

Вынесем url источников данных в переменные terraform для гибкой настройки. Создадим переменные в модульном variables.tf со значениями по умолчанию — если у вас развернуты источники данных и Grafana в Kubernetes или docker-compose, то они скорее всего будут такими:

// file: ./modules/grafana_oss/variables.tf variable "loki_data_source_url" {   type = string   default = "http://loki-stack:3100" }  variable "prometheus_data_source_url" {   type = string   default = "http://prometheus-kube-prometheus-prometheus:9090" } 

Удалим значения из конфигурационного файла и вместо этого укажем переменные:

// file: ./modules/grafana_oss/data_source.tf resource "grafana_data_source" "loki" {   <other_content_is_hidden>   url                      = var.loki_data_source_url }  resource "grafana_data_source" "prometheus" {   <other_content_is_hidden>   url                      = var.prometheus_data_source_url } 

grafana_dashboard

Импортируем дашборды. Помимо хранения кода в Terraform, у каждого дашборда в Grafana есть своя JSON схема. Для начала импортируем дашборды в Terraform:

// file: ./environments/development/main.tf <other_content_is_hidden>  import {   id = "FvDw34Dq"   to = grafana_dashboard.k8s-deployment-running-status }  import {   id = "3Cw21Sqwr"   to = grafana_dashboard.k8s-logging } 

Выполним terraform plan с параметром для генерации конфигурации:

$ terraform -chdir=./environments/development plan -generate-config-out=dashboard.tf  <other_content_is_hidden> Plan: 2 to import, 0 to add, 0 to change, 0 to destroy. 

Примем изменения и сразу же переместим всё в модуль:

$ terraform -chdir=./environments/development apply  <other_content_is_hidden> Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.  Do you want to perform these actions?   Terraform will perform the actions described above.   Only 'yes' will be accepted to approve.    Enter a value: yes  grafana_dashboard.k8s-logging: Importing... [id"3Cw21Sqwr] grafana_dashboard.k8s-logging: Import complete [id"3Cw21Sqwr] grafana_dashboard.k8s-deployment-running-status: Importing... [id=FvDw34Dq] grafana_dashboard.k8s-deployment-running-status: Import complete [id=FvDw34Dq]  Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.  $ mv ./environments/development/dashboard.tf ./modules/grafana_oss   $ terraform -chdir=./environments/development state mv grafana_dashboard.k8s-deployment-running-status module.grafana_oss.grafana_dashboard.k8s-deployment-running-status  Move "grafana_dashboard.k8s-deployment-running-status" to "module.grafana_oss.grafana_dashboard.k8s-deployment-running-status" Successfully moved 1 object(s).  $ terraform -chdir=./environments/development state mv grafana_dashboard.k8s-logging module.grafana_oss.grafana_dashboard.k8s-logging Move "grafana_dashboard.k8s-logging" to "module.grafana_oss.grafana_dashboard.k8s-logging" Successfully moved 1 object(s). 

Посмотрим на сгенерированную конфигурацию:

// file: ./modules/grafana_oss/dashboard.tf resource "grafana_dashboard" "k8s-logging" {   config_json = "<huge_json_content>"   folder      = null   message     = null   org_id      = "0"   overwrite   = null }  resource "grafana_dashboard" "k8s-deployment-running-status" {   config_json = "<huge_json_content>"   folder      = null   message     = null   org_id      = "0"   overwrite   = null } 

В config_json содержится JSON схема дашборда. Так как значение очень длинное, то я решил его не вставлять. Хранить такие данные прямо в коде плохая идея, поэтому вынесем JSON схему дашборда в отдельный файл. Создадим папку где будут храниться схемы для всех дашбордов:

$ mkdir dasboard_schemas 

Перед переносом JSON из config_json в файл, его необходимо прежде дезэкранировать и форматировать. Для дезэкранирования можно вывести значение как строку в Python, а затем для форматирования передать вывод echo в утилиту jq. Давайте экспортируем дашборды более легким способом — через GUI Графаны.

Зайдем в дашборд Kubernetes logging и нажмем на Export:

Нажмем Save to file и переместим файл в созданную ранее директорию dasboard_schemas. Переименуем его в k8s-logging.json. Тоже самое провернем с другим дашбордом.

В конечном итоге у нас получиться такая структура проекта:

$ tree . ├── dashboard_schemas │   ├── k8s-deployment-running-status.json │   └── k8s-logging.json ├── environments │   └── development │       ├── main.tf │       └── variables.tf └── modules     ├── grafana_alerting     │   ├── main.tf     │   ├── outputs.tf     │   └── variables.tf     └── grafana_oss         ├── dashboard.tf         ├── data_source.tf         ├── folder.tf         ├── main.tf         ├── outputs.tf         └── variables.tf 

Теперь удалим ненужные поля в конфигурации дашбордов, а также заменим содержимое config_json вызовом функции file() для извлечения содержимого файла:

// file: ./modules/grafana_oss/dashboard.tf resource "grafana_dashboard" "k8s-logging" {   config_json = "${file("../../dashboard_schemas/k8s-logging.json")}"   org_id      = "0" }  resource "grafana_dashboard" "k8s-deployment-running-status" {   config_json = "${file("../../dashboard_schemas/k8s-deployment-running-status.json")}"   org_id      = "0" } 

Выполним terraform plan и убедимся, что конфигурация не была изменена:

$ terraform -chdir=./environments/development plan  No changes. Your infrastructure matches the configuration.  Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. 

Просмотрев схемы дашбордов, я заметил, что там используются UID источников данных. В k8s-logging.json, например, UID источника данных Loki встречается 12 раз:

// file: ./dashboard_schemas/k8s-logging.json <other_content_is_hidden> "datasource": { "type": "loki", "uid": "FeVw13D2" } <other_content_is_hidden> 

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

Воспользуемся функцией templatefile. С помощью этой функции можно передавать в файл переменные и затем использовать их. Переменные задаются также как и в конфигурационных файлах terraform через ${}. Заменим везде значение UID на переменную:

// file: ./dashboard_schemas/k8s-logging.json <other_content_is_hidden> "datasource": {   "type": "loki",   "uid": "${loki_data_source_id}" } <other_content_is_hidden> 

Аналогично заменим строковые значения на переменные в k8s-deployment-running-status.json:

// file: ./dashboard_schemas/k8s-deployment-running-status.json "datasource": {   "type": "prometheus",   "uid": "${prometheus_data_source_uid}" } 

Чтобы вместо ${} использовать значения их нужно указать при вызове функции templatefile. Первым аргументом указывается путь до файла, вторым — список всех переменных:

resource "grafana_dashboard" "k8s-logging" {   config_json = templatefile("../../dashboard_schemas/k8s-logging.json", {     loki_data_source_uid = grafana_data_source.loki.uid   })   org_id      = "0" }  resource "grafana_dashboard" "k8s-deployment-running-status" {   config_json = templatefile("../../dashboard_schemas/k8s-deployment-running-status.json", {     prometheus_data_source_uid = grafana_data_source.prometheus.uid   })   org_id      = "0" } 

grafana_contact_point

Приступим к модулю grafana_alerting. Для начала импортируем contact point-ы:

// file: ./environments/development/main.tf <other_content_is_hidden>  import {   id = "Discord"   to = grafana_contact_point.discord }  import {   id = "Discord (without Resolved)"   to = grafana_contact_point.discord-without-resolved } 

Выполним импорт конфигурации:

$ terraform -chdir=./environments/development plan -generate-config-out=contact-point.tf  Planning failed. Terraform encountered an error while generating this plan.  ╷ │ Error: Missing required argument │  │   with grafana_contact_point.discord, │   on contact-point.tf line 7: │   (source code not available) │  │ The argument "discord.0.url" is required, but no definition was found. ╵ ╷ │ Error: Missing required argument │  │   with grafana_contact_point.discord-without-resolved, │   on contact-point.tf line 7: │   (source code not available) │  │ The argument "discord.0.url" is required, but no definition was found. 

Ошибка! Но файл contact-point.tf был создан:

// file: ./environments/development/contact-point.tf  resource "grafana_contact_point" "discord" {   name = "Discord"   discord {     avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"     disable_resolve_message = false     message                 = null     settings                = null # sensitive     url                     = null # sensitive     use_discord_username    = false   } }  resource "grafana_contact_point" "discord-without-resolved" {   name = "Discord (without Resolved)"   discord {     avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"     disable_resolve_message = true     message                 = null     settings                = null # sensitive     url                     = null # sensitive     use_discord_username    = false   } } 

Дело в том, что значение discord.0.url (Webhook URL Discord бота) является секретным и поэтому terraform не импортирует его. Следует его указать самим. Хранить секреты в коде не стоит, поэтому для этого создадим переменные и будем назначать их через переменные окружения в .env файле. Но для начала укажем URL Webhook бота прямо в коде, а также удалим ненужные поля:

// file: ./environments/development/contact-point.tf  resource "grafana_contact_point" "discord" {   name = "Discord"   discord {     avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"     disable_resolve_message = false     url                     = "https://discord.com/api/webhooks/<other_url_part>"   } }  resource "grafana_contact_point" "discord-without-resolved" {   name = "Discord (without Resolved)"   discord {     avatar_url              = "https://logowik.com/content/uploads/images/prometheus-monitoring-system4911.logowik.com.webp"     disable_resolve_message = true     url                     = "https://discord.com/api/webhooks/<other_url_part>"   } } 

Выполним terraform apply. Я думаю, что достаточно много раз показал как переносить конфигурацию и состояние из root модуля в другой, поэтому обойдемся без команд.
Теперь вынесем секретные данные (discord.0.url) из кода в переменные окружения. Для начала создадим в модульном variables.tf переменную:

// file: ./modules/grafana_alerting/variables.tf  variable "discord_webhook_url" {   type = string } 

Помимо этого, необходимо также создать переменную в «рутовом» модуле:

// file: ./environments/development/variables.tf  variable "discord_webhook_url" {   type = string } 

Теперь необходимо передать переменную из root модуля в модуль grafana_alerting:

// file: ./environments/development/main.tf <other_content_is_hidden>  module "grafana_alerting" {   source = "../../modules/grafana_alerting"    discord_webhook_url = var.discord_webhook_url } 

Значение переменной будет назначаться путем переменной окружения. Для этого необходимо, чтобы переменная окружения начиналась с TF_VAR_:

# file: ./.env.example  export GRAFANA_AUTH= export GRAFANA_URL=  export TF_VAR_discord_webhook_url= 

Добавим также данную переменную в .env, но уже со значением:

# file: ./environments/development/.env  <other_content_is_hidden>  export TF_VAR_discord_webhook_url="https://discord.com/api/webhooks/<other_url_part>" 

В contact-point.tf для discord.0.url укажем вместо строковых значении переменные:

// file: ./modules/grafana_alerting/contact-point.tf  resource "grafana_contact_point" "discord" {   ...   discord {     ...     url = var.discord_webhook_url    } }  resource "grafana_contact_point" "discord-without-resolved" {   ...   discord {     ...     url = var.discord_webhook_url    } }  

Выполним terraform plan, чтобы убедиться, что с инфраструктурой всё в порядке.

grafana_notification_policy

В Terraform политики уведомлений (notification policy) представлены в одном ресурсе. Как указано в документации grafana провайдера, grafana_notification_policy контролирует дерево политик уведомлении. Также, в разделе «Import» указывается, что id ресурса равен «policy». Попробуем импортировать данный ресурс:

// file: ./environments/development/contact-point.tf  import {   id = "policy"   to = grafana_notification_policy.policy-tree } 

Выполним terraform plan, terraform apply, переместим конфигурацию и состояние в модуль grafana_alerting и сразу же удалим лишние поля и заменим строковые значения contact_point значениями из ресурсов:

// file: ./modules/grafana_alerting/notification-policy.tf  resource "grafana_notification_policy" "policy-tree" {   contact_point   = "grafana-default-email"   group_by        = ["grafana_folder", "alertname"]    policy {     contact_point   = grafana_contact_point.discord.name     group_by        = []     matcher {       label = "discord"       match = "="       value = "channel"     }   }    policy {     contact_point   = grafana_contact_point.discord-without-resolved.name     group_by        = []     matcher {       label = "discord"       match = "="       value = "channel_resolved_0"     }   } } 

grafana_rule_group

И вот мы добрались до самого интересного — алертов! На самом деле у меня их очень много, но для статьи я импортирую только два — для каждого contact point-а. В качестве id указывается uid папки и название группы алертов. В моем случае название группы алертов везде default:

Добавим импорты:

// file: ./environments/development/main.tf  import {   id = "8Dve31Xcs;default"   to = grafana_rule_group.discord-alerting }  import {   id = "cDq3s12Zs;default"   to = grafana_rule_group.discord-alerting-loki } 

Импортируем конфигурацию в файл rule-group.tf и сразу перенесем её в модуль grafana_alerting.

У каждого алерта в группе алертов очень много полей. Вот так выглядят импортированные ранее ресурсы:

// file: ./modules/grafana_alerting/rule-group.tf  resource "grafana_rule_group" "discord-alerting" {   folder_uid       = "8Dve31Xcs"   interval_seconds = 30   name             = "default"   org_id           = "1"   rule {     annotations = {       description      = "site-api status available"     }     condition      = "B"     exec_err_state = "Alerting"     for            = "3m"     is_paused      = false     labels = {       discord = "channel"     }     name          = "site-api status available"     no_data_state = "NoData"     data {       datasource_uid = "d4V1dDwe"       model          = "{\"datasource\":{\"type\":\"prometheus\",\"uid\":\"d4V1dDwe\"},\"editorMode\":\"builder\",\"expr\":\"kube_deployment_status_replicas_available{namespace=\\\"development\\\", deployment=\\\"site-api\\\"}\",\"interval\":\"\",\"intervalMs\":15000,\"legendFormat\":\"{{deployment}}\",\"range\":true,\"refId\":\"A\"}"       query_type     = null       ref_id         = "A"       relative_time_range {         from = 300         to   = 0       }     }     data {       datasource_uid = "-100"       model          = "{\"conditions\":[{\"evaluator\":{\"params\":[1],\"type\":\"lt\"},\"operator\":{\"type\":\"and\"},\"query\":{\"params\":[\"A\"]},\"reducer\":{\"params\":[],\"type\":\"last\"},\"type\":\"query\"}],\"datasource\":{\"type\":\"__expr__\",\"uid\":\"-100\"},\"expression\":\"A\",\"hide\":false,\"refId\":\"B\",\"type\":\"classic_conditions\"}"       query_type     = null       ref_id         = "B"       relative_time_range {         from = 0         to   = 0       }     }   } }  resource "grafana_rule_group" "discord-alerting-loki" {   folder_uid       = "cDq3s12Zs"   interval_seconds = 20   name             = "default"   org_id           = "1"   rule {     annotations = {       description      = "office-api ERROR logs count MORE THAN 25 for one minute!"     }     condition      = "B"     exec_err_state = "OK"     for            = "30s"     is_paused      = false     labels = {       discord          = "channel_resolved_0"     }     name          = "office-api ERROR logs count"     no_data_state = "OK"     data {       datasource_uid = "FeVw13D2"       model          = "{\"datasource\":{\"type\":\"loki\",\"uid\":\"FeVw13D2\"},\"editorMode\":\"code\",\"expr\":\"count_over_time(({namespace=\\\"development\\\", app=\\\"office-api\\\"} |= \\\"ERROR\\\")[1m])\",\"legendFormat\":\"{{app}}\",\"queryType\":\"range\",\"refId\":\"A\"}"       query_type     = "range"       ref_id         = "A"       relative_time_range {         from = 21600         to   = 0       }     }     data {       datasource_uid = "-100"       model          = "{\"conditions\":[{\"evaluator\":{\"params\":[25],\"type\":\"gt\"},\"operator\":{\"type\":\"and\"},\"query\":{\"params\":[\"A\"]},\"reducer\":{\"params\":[],\"type\":\"last\"},\"type\":\"query\"}],\"datasource\":{\"type\":\"__expr__\",\"uid\":\"-100\"},\"expression\":\"A\",\"hide\":false,\"refId\":\"B\",\"type\":\"classic_conditions\"}"       query_type     = null       ref_id         = "B"       relative_time_range {         from = 0         to   = 0       }     }   } } 

Как и с JSON схемами дашбордов, для групп алертов необходимо вынести строковые значения и использовать значения из ресурсов terraform. Так как конфигурация источников данных и папок находятся в модуле grafana_oss их необходимо передать в модуль grafana_alerting в качестве переменных.

Сперва добавим output переменные в grafana_oss:

// file: ./modules/grafana_oss/outputs.tf  output "discord_alerting_folder_uid" {   value = grafana_folder.discord-alerting.uid }  output "discord_alerting_loki_folder_uid" {   value = grafana_folder.discord-alerting-loki.uid }  output "prometheus_data_source_uid" {   value = grafana_data_source.prometheus.uid }  output "loki_data_source_uid" {   value = grafana_data_source.loki.uid } 

Аналогично добавим input переменные в grafana_alerting:

// file: ./modules/grafana_alerting/variables.tf  ... variable "discord_alerting_folder_uid" {   type = string }  variable "discord_alerting_loki_folder_uid" {   type = string }  variable "prometheus_data_source_uid" {   type = string }  variable "loki_data_source_uid" {   type = string } 

Передадим переменные из grafana_oss в grafana_alerting:

// file: ./environments/development/main.tf  <other_content_is_hidden>  module "grafana_alerting" {   source = "../../modules/grafana_alerting"    discord_webhook_url = var.discord_webhook_url    discord_alerting_folder_uid      = module.grafana_oss.discord_alerting_folder_uid   discord_alerting_loki_folder_uid = module.grafana_oss.discord_alerting_loki_folder_uid   prometheus_data_source_uid       = module.grafana_oss.prometheus_data_source_uid   loki_data_source_uid             = module.grafana_oss.loki_data_source_uid } 

Вернемся к группам алертов. Отредактируем файл с ресурсами:

// file: ./modules/grafana_alerting/rule-group.tf  resource "grafana_rule_group" "discord-alerting" {   folder_uid = var.discord_alerting_folder_uid   ...   rule {     ...     data {       datasource_uid = var.prometheus_data_source_uid       model          = "{\"datasource\":{\"type\":\"prometheus\",\"uid\":\"${var.prometheus_data_source_uid}\"},\"editorMode\":\"builder\",\"expr\":\"kube_deployment_status_replicas_available{namespace=\\\"development\\\", deployment=\\\"site-api\\\"}\",\"interval\":\"\",\"intervalMs\":15000,\"legendFormat\":\"{{deployment}}\",\"range\":true,\"refId\":\"A\"}"       ...     }     data {       ...     }   } }  resource "grafana_rule_group" "discord-alerting-loki" {   folder_uid = var.discord_alerting_loki_folder_uid   ...   rule {     ...     data {       datasource_uid = var.loki_data_source_uid       model          = "{\"datasource\":{\"type\":\"loki\",\"uid\":\"${var.loki_data_source_uid}\"},\"editorMode\":\"code\",\"expr\":\"count_over_time(({namespace=\\\"development\\\", app=\\\"office-api\\\"} |= \\\"ERROR\\\")[1m])\",\"legendFormat\":\"{{app}}\",\"queryType\":\"range\",\"refId\":\"A\"}"       ...     }     data {       ...     }   } } 

Я заменил folder_uuid, rule.datasource_uid, а также uid в rule.data.model.

Заключение

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

. ├── dashboard_schemas │   ├── k8s-deployment-running-status.json │   └── k8s-logging.json ├── environments │   └── development │       ├── main.tf │       ├── terraform.tfstate │       └── variables.tf └── modules     ├── grafana_alerting     │   ├── contact-point.tf     │   ├── main.tf     │   ├── notification-policy.tf     │   ├── outputs.tf     │   ├── rule-group.tf     │   └── variables.tf     └── grafana_oss         ├── dashboard.tf         ├── data_source.tf         ├── folder.tf         ├── main.tf         ├── outputs.tf         └── variables.tf 


ссылка на оригинал статьи https://habr.com/ru/articles/759348/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *