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