Введение

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

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


  1. xztau
    06.09.2023 17:17
    +1

    Если я не ошибаюсь, Grafana все свои настройки дашборды, датасорсы и т.п. хранит в grafana.db sqlite БД.

    Она переносима вроде бы.


    1. AzamatKomaev Автор
      06.09.2023 17:17

      Да, но при этом будет ли перенос безболезненным? Как насчет UID и ID ресурсов? Плюсом у меня есть некоторые различия между дашбордами dev и prod среды (разные имена namespace-ов и deployment-ов), которые можно гибко настроить через Terraform.


      1. arheops
        06.09.2023 17:17
        +1

        Да, но у вас и сложность переноса в разы больше, не?
        Я просто храню все настройки в mysql и делают инкрементальный дамп.
        Ну и вы простой инструмент превратили в супер-сложный для обращения.


  1. m1skam
    06.09.2023 17:17
    +4

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

    Допустим, хочется поправить одну визуализацию?

    В grafana мы просто заходим, правим, тут же видим результат, понимаем, что не совсем то, что мы хотели, правим запрос, еще раз смотрим то или нет, если то, то нажимаем сохранить и все.

    В terraform , мы же не одни скорее всего работаем? Значит у нас настроен pipeline для автоматического применения правок. Да? Правим код, делаем коммит, push в репозиторий, init, validate, plan и appy, идем смотреть что получилось, не понравилось, опять коммит, push в репозиторий, init, validate, plan и appy. Это долго, это сложно, коллеги не понимаю, зачем им так страдать, когда grafana вот она рядом )

    Пробовали другой формат, правим визуализацию, экспортируем json, закидываем в репозиторий. Но, так не сделать в датасорсами, алертами и тд

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


    1. AzamatKomaev Автор
      06.09.2023 17:17

      С алертами тоже изначально искал заветную кнопку экспорта как у дашбордов, но найти не смог. Дальше изучил REST API Графаны и тоже ничего особого не нашёл. На форумах самой графаны кто-то скинул рабочий endpoint, но как оказалось JSON с алертами можно только выгрузить - как загружать непонятно.

      Изначально мы использовали Grafana v8.x. В блоге Grafana нашёл [статью](https://grafana.com/blog/2023/03/06/grafana-alerting-12-ways-we-made-creating-and-managing-alerts-easier-than-ever/) про то, что заветная кнопка экспорта для алертов была добавлена в версии 9.4, решил сразу апгрейднуться до 10.0.5, но кнопки не обнаружил.

      Фото из блога по ссылке
      Фото из блога по ссылке

      В случае если схожие друг с другом алерты создаются не так часто, то бэкап БД будет весьма кстати. Скорее всего так и сделаю - заменю sqlite базу данных на PostgreSQL, которая развернута в Yandex Cloud и бэкапится каждый день.

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


      1. aborouhin
        06.09.2023 17:17
        +1

        решил сразу апгрейднуться до 10.0.5, но кнопки не обнаружил.

        В 10.1.0 заветная кнопочка есть, хоть и прячется:

        заменю sqlite базу данных на PostgreSQL

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


  1. eapdark
    06.09.2023 17:17
    +2

    а почему вы не используете встроенный функционал grafana
    https://grafana.com/tutorials/provision-dashboards-and-data-sources/
    что позволит вам все хранить в git и от туда тащить ?

    вы описываете все dashboards, contactPoints, datasources и можете не париться по поводу бэкапов учитывая что у вас все в кубере.




  1. MonsterCatz
    06.09.2023 17:17

    Простите, а чего ради?

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

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

    Про работу в команде уже до меня написали.

    В общем какая то работа ради работы.


    1. AzamatKomaev Автор
      06.09.2023 17:17

      Возможно так и есть после того как скинули информацию, что можно средствами Grafana импортировать не только дашборды, но еще и другие сущности. Ранее я об этом не знал и конечно это выглядит намного удобнее и лучше, чем использование terraform


      1. eapdark
        06.09.2023 17:17

        Еще момент, вы можете завязаться на argocd или flux которые будут все изменения в репозитории применять автоматоматически .