Введение
Вам сказали развернуть систему мониторинга, вы выбрали связку 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
