CIOpsでGrafanaダッシュボードを運用していく~ansible実装編~

この記事は Ansible Advent Calendar 2021 の6日目の記事です。

adventar.org

はじめに

以下の続きとなります。

yassan.hatenablog.jp

以下で紹介するロールをどう使うか、メリットなどは前回の記事を参照ください。

Grafanaダッシュボードを更新する際に利用するAnsibleモジュールの紹介

Grafana向けにCommunity.Grafanaという Ansible Collectionがあるのでこちらを利用します1

docs.ansible.com

今回作成するロールについて

今回は以下の名前でロールを4つ作成します。

  • import_datasource (データソースをGrafanaに反映する)
  • import_notification_channel (通知チャンネルをGrafanaに反映する)
  • export_resources (Grafanaからダッシュボードを取得する)
  • import_dashboardダッシュボードをGrafanaに反映する)

また、ダッシュボードを更新する際は処理の順番に注意が必要です。
先にダッシュボードで必要となるデータソースや通知チャンネルを更新する必要があるので実行順序に注意が必要です。

また、ロールの全てについては紹介しません。今回はtaskの実行部分の紹介のみとし、moleculeのシナリオなどは省略します。

import_datasource(データソースをGrafanaに反映する)

データソースは community.grafana.grafana_datasource を利用して追加・更新します。

グループ変数の設定

Grafana毎に必要なデータソース用の変数を以下のように設定します。

---
# group_vars/prod/datasources.yml
datasources:
- name: "Prometheus Sample"
  is_default: true
  type: prometheus
  access: proxy
  orgId: 1
  url: http://prom.example.com:9090/

- name: "Elasticsearch Sample"
  type: elasticsearch
  orgId: 1
  url: http://es.example.com:9200
  database: "[monitor-]YYYY.MM.DD"
  interval: "Daily"
  time_field: "@timestamp"
  es_version: 70
  max_concurrent_shard_requests: 5
  additional_json_data:
    logLevelField: "log.status"
    logMessageField: "log.message"

- name: "PostgreSQL Sample"
  type: postgres
  orgId: 1
  host: pg.example.com:5432
  database: digdag1xx
  user: digdag
  password: "{{ vault_grafana.pg_pw }}"

- name: "MySQL Sample"
  type: mysql
  orgId: 1
  host: mysql.example.com:5432
  database: grafana03_devel
  user: grafana
  password: "{{ vault_grafana.mysql_pw }}"

ロールの処理

main.yml は以下の通り、グループ変数 {{datasources}} を実行処理の import_datasource.yml に渡してデータソース単位に更新します。

---
# roles/import_datasource/tasks/main.yml
- name: Grafanaホストにデータソースをインポート
  include_tasks: import_datasource.yml
  with_items: "{{ datasources }}"

import_datasource.yml ではデータソースのimportするための記述となりますが、 ds_type 単位に設定を書くと記述量が大量になってしまいます。 そこで、1タスクで community.grafana.grafana_datasource で用しているパラメータをすべて列挙しています。
また、 {{datasources}} で定義していない項目は default(omit) で利用しないようにしています。

---
# roles/import_datasource/tasks/import_datasource.yml
- name: "Import datasource :{{ item.name }}"
  community.grafana.grafana_datasource:
    grafana_url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}"
    grafana_password:  "{{ vault_grafana.url_password | default(omit) }}"
    grafana_user:  "{{ vault_grafana.url_username | default(omit) }}"
    access: "{{ item.access | default(omit) }}"
    additional_json_data: "{{ item.additional_json_data | default(omit) }}"
    additional_secure_json_data: "{{ item.additional_secure_json_data | default(omit) }}"
    basic_auth_password: "{{ item.basic_auth_password | default(omit) }}"
    basic_auth_user: "{{ item.basic_auth_user | default(omit) }}"
    client_cert: "{{ item.client_cert | default(omit) }}"
    database: "{{ item.database | default(omit) }}"
    ds_type: "{{ item.type }}"
    ds_url: "{{ item.url }}"
    enforce_secure_data: "{{ item.enforce_secure_data | default(omit) }}"
    es_version: "{{ item.es_version | default(omit) }}"
    interval: "{{ item.interval | default(omit) }}"
    is_default: "{{ item.is_default | default(omit) }}"
    max_concurrent_shard_requests: "{{ item.max_concurrent_shard_requests | default(omit) }}"
    name: "{{ item.name }}"
    org_id: "{{ item.orgId }}"
    sslmode: "{{ item.sslmode | default(omit) }}"
    state: "{{ item.state | default(omit) }}"
    time_field: "{{ item.time_field | default(omit) }}"
    time_interval: "{{ item.time_interval | default(omit) }}"
    tls_skip_verify: "{{ item.tls_skip_verify | default(omit) }}"
    tsdb_resolution: "{{ item.tsdb_resolution | default(omit) }}"
    tsdb_version: "{{ item.tsdb_version | default(omit) }}"
    use_proxy: "{{ item.use_proxy | default(omit) }}"
    user: "{{ item.user | default(omit) }}"
    validate_certs: "{{ item.validate_certs | default(omit) }}"
    with_credentials: "{{ item.validate_certs | default(omit) }}"

import_notification_channel(通知チャンネルをGrafanaに反映する)

通知チャンネルは、community.grafana.grafana_notification_channel を利用して追加・更新します。

グループ変数の設定

Grafana毎に必要な通知チャンネル用の変数を以下のように設定します。

---
# group_vars/prod/notification_channels.yml
notification_channels:
- name: "info notice slack"
  type: slack
  uid: "c80pnXDvYq"
  recipient: "#info"
  orgId: 1
  is_default: true
  include_image: true
  disable_resolve_message: false
  url: https://hooks.slack.com/services/XXXX/YYYY/ZZZZZ
  token: "{{ vault_grafana.slack_token_info }}"

- name: "grafana Info mail"
  type: email
  orgId: 1
  uid: "EQ8Pn7paaX"
  is_default: false
  include_image: true
  disable_resolve_message: true
  reminder_frequency: ""
  email_addresses:
    - "issue@example.com"
  email_single: false

ロールの処理

データソースの場合と同様です。

main.yml では、グループ変数 {{notification_channels}} を実行処理の import_notification_channel.yml に渡しています。

---
# roles/import_notification_channel/tasks/main.yml
- name: Grafanaに通知チャンネルをインポート
  include_tasks: import_notification_channel.yml
  with_items: "{{ notification_channels }}"

import_notification_channel.yml も同様です。
community.grafana.grafana_notification_channelのパラメータ にある項目を1つにまとめて記述します。

---
# roles/import_notification_channel/tasks/import_notification_channel.yml
- name: "Create slack notification channel :{{ item.name }}"
  community.grafana.grafana_notification_channel:
    grafana_url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}"
    url_password:  "{{ vault_grafana.url_password | default(omit) }}"
    url_username:  "{{ vault_grafana.url_username | default(omit) }}"
    uid: "{{ item.uid }}"
    name: "{{ item.name }}"
    org_id: "{{ item.orgId }}"
    is_default: "{{ item.is_default }}"
    include_image: "{{ item.include_image }}"
    disable_resolve_message: "{{ item.disable_resolve_message }}"
    reminder_frequency: "{{ item.reminder_frequency | default(omit) }}"
    type: "{{item.type}}"
    slack_url: "{{ item.url | default(omit) }}"
    slack_token: "{{ item.token | default(omit) }}"
    slack_recipient: "{{ item.recipient | default(omit) }}"
    slack_mention_users: "{{ item.mention_users | default(omit) }}"
    slack_mention_groups: "{{ item.mention_groups | default(omit) }}"
    email_addresses: "{{ item.email_addresses | default(omit) }}"
    email_single: "{{ item.email_single | default(omit) }}"

import_dashboardダッシュボードをGrafanaに反映する)

ダッシュボードを追加・更新するには community.grafana.grafana_dashboard のImportを利用します。
また、フォルダに分けてダッシュボードを登録することになるので、合わせて community.grafana.grafana_folder も利用します。
さらに、既存のダッシュボードの一覧を変数として取得する grafana_dashboardのLookup Plugin も利用します。

グループ変数の設定

Grafana毎に必要なダッシュボード用の変数を以下のように設定します。
こちらにはImport/exportの対象となるダッシュボードのフォルダ名を列挙します。

また、 Dashboard HTTP API の "Create / Update dashboard" にあるようにデフォルトフォルダがGeneralなのでこれは必ず入れておきます。

---
# group_vars/prod/dashboards.yml
gf_folder:
  - General
  - XXXXX

ロールの処理

ダッシュボードフォルダ以下のダッシュボードは以下のファイルパス上に保存しています。

dashboards/(グループ名)/(フォルダ)/(ダッシュボードのUID).json

そこでフォルダ以下の全てのJSONファイルパスを取得して、ダッシュボード1つずつGrafanaにimportします。

mail.ymlでは、対象のダッシュボードのフォルダのリストとなるグループ変数 gf_folder をループして、ファルダを作成します(import_folder.yml)。
次にダッシュボードも同様にgf_folderを使ってimportしていきます(import_dashboard.yml)。

---
# roles/import_dashboard/tasks/main.yml
- name: フォルダ
  include_tasks: import_folder.yml
  with_items: "{{ gf_folder }}"
  loop_control:
    loop_var: folder

- name: ダッシュボード
  include_tasks: import_dashboard.yml
  with_items: "{{ gf_folder }}"
  loop_control:
    loop_var: folder

フォルダのimport(import_folder.yml)について、グループ変数 gf_folder から対象のフォルダ名がループで渡ってくるので、それをもとにフォルダをimport(作成)します。ただし、デフォルトで入っている General については不要なので実施内容にしている(無視して実行するとエラーになります)。

---
# roles/import_dashboard/tasks/import_folder.yml
- name: "{{ folder }} をGrafanaに作成"
  community.grafana.grafana_folder:
    url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}"
    url_password:  "{{ vault_grafana.url_password | default(omit) }}"
    url_username:  "{{ vault_grafana.url_username | default(omit) }}"
    name: "{{ folder }}"
    state: present
  when: "folder != 'General'"

最後にダッシュボードは、フォルダ単位にダッシュボードをimportします(import_dashboard.yml)。
まず、ダッシュボードのJSONファイルを dashboards/prod_grafana/(フォルダ) 以下から fileglob を使って取得します。そして取得したリストでloopしてダッシュボードを1つずつimportしています。

---
# roles/import_dashboard/tasks/import_dashboard.yml
- name: "{{ folder }} のダッシュボードをGrafanaに反映"
  community.grafana.grafana_dashboard:
    grafana_url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}"
    url_password:  "{{ vault_grafana.url_password | default(omit) }}"
    url_username:  "{{ vault_grafana.url_username | default(omit) }}"
    state: present
    overwrite: yes
    commit_message: "Push hash: {{ revision }}"
    path: "{{ _json_file_path }}"
    folder: "{{ folder }}"
  loop: "{{ lookup('fileglob', playbook_dir + '/dashboards/' + grafana_group + '/' + folder + '/*.json', wantlist=True) }}"
  loop_control:
    loop_var: _json_file_path

export_resources(Grafanaからダッシュボードを取得する)

既存のダッシュボードを取得するには community.grafana.grafana_dashboard のExportを利用します。 さらに、既存のダッシュボードの一覧を変数として取得する grafana_dashboardのLookup Plugin も利用します2

グループ変数の設定

import_dashboard で紹介したグループ変数 gf_folder を使うので割愛します。

ロールの処理

大まかな処理の流れとしては、Grafanaから根こそぎダッシュボード情報を取得してfactsに保存する。
その後、保存したfactsを使ってダッシュボード1つずつloop変数に入れて回しながらダッシュボードのフォルダ単位にディレクトリを作成しつつ整形してJSONファイルに変換しています。

---
# roles/export_resources/tasks/main.yml
- name: ダッシュボード情報を取得
  include_tasks: get_all_dashbords.yml

- name: JSONファイルに変換
  include_tasks: export_dashboard_to_json.yml
  with_items: "{{ __dashboards }}"
  loop_control:
    loop_var: dashboards

では、掘り下げていきます。

まず、get_all_dashbords.yml ではGrafanaサイトからダッシュボード情報をfacts __dashboards に保存します。

---
# roles/export_resources/tasks/get_all_dashbords.yml
- name: すべてのダッシュボードをfacts __dashboards にセット
  set_fact:
    __dashboards: "{{ lookup('grafana_dashboard', 'grafana_url=' ~ grafana_url + ' ' + 'grafana_api_key=' ~ vault_grafana.grafana_api_key | replace('==', '') ) }}"

次に、実行処理となる export_dashboard_to_json.yml についてです。 大まかには、1つのダッシュボード情報が 変数 dashboards に入ってるのでGrafanaにフォルダ→ダッシュボードの順でimportします。

まず、ダッシュボードJSONファイルを出力するパスを作成します。 「変数 dashboards にあるフォルダ名」と「確定しているGrafanaグループまでのパス({{ playbook_dir }}/dashboards/{{ grafana_group }})」を組み合わせて、facts export_dir にフォルダまでのパスを取得します。

次に、作成したexport_dirを使ってJSONファイルの出力先のフォルダを作成します。

JSONファイルの出力先のフォルダパスが決まったので一時的に tmp.json として、GrafanaからExportします。 書き出したJSONファイルは1行になっているのでPRする際に差分が取りにくくレビューしづらいので整形して出力します。 また、JSONファイル名は、他のダッシュボードのファイル名と衝突しないようにダッシュボードのUIDを使うことで回避しています。

---
# roles/export_resources/tasks/export_dashboard_to_json.yml
- name: JSONファイルの出力先ディレクトリ変数
  set_fact:
    export_dir: "{{ playbook_dir }}/dashboards/{{ grafana_group }}/{{ dashboards.folderTitle | default('General') }}"

- name: "{{ export_dir }}を作成"
  ansible.builtin.file:
    path: "{{ export_dir }}"
    state: directory
    recurse: true

- name: "Export dashboard :{{ dashboards.title }}"
  community.grafana.grafana_dashboard:
    grafana_url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}"
    url_password:  "{{ vault_grafana.url_password | default(omit) }}"
    url_username:  "{{ vault_grafana.url_username | default(omit) }}"
    org_id: 1
    state: export
    uid: "{{ dashboards.uid }}"
    path: "{{ export_dir }}/tmp.json"

- name: "JSONファイルを読み込み"
  command: "cat {{ export_dir }}/tmp.json"
  register: raw_json_contents

- name: "仮出力したJSONファイル raw_json_contents を facts __dashboard_json_contents に格納"
  set_fact:
    __dashboard_json_contents: "{{ raw_json_contents.stdout | from_json }}"

- name: "__dashboard_json_contentsを整形してJSONファイル出力する"
  copy:
    content: "{{ __dashboard_json_contents | to_nice_json(indent=2) }}"
    dest: "{{ export_dir }}/{{ dashboards.uid }}.json"

- name: "rm tmp.json"
  ansible.builtin.file:
    path: "{{ export_dir }}/tmp.json"
    state: absent

Grafanaとの認証に関する注意事項

各ロールでは、Grafanaにアクセスする際は認証が必要になります。
Grafana v7.4以降を利用している場合は、認証にパラーメタ grafana_api_key を利用します。 しかし、Grafana v7.0辺りの古いバージョンを利用している場合は、認証にパラーメタ grafana_usergrafana_password を利用するので注意してください。

# 例
- name: "Import datasource :{{ item.name }}"
  community.grafana.grafana_datasource:
    grafana_url: "{{ grafana_url }}"
    grafana_api_key: "{{ vault_grafana.grafana_api_key | default(omit) }}" # v7.4以降はこれだけ
    grafana_password:  "{{ vault_grafana.url_password | default(omit) }}"  # v7.0以前はこっちを使う 
    grafana_user:  "{{ vault_grafana.url_username | default(omit) }}"      # v7.0以前はこっちを使う

特に、 roles/export_resources/tasks/get_all_dashbords.yml で利用している grafana_dashboardのlookupプラグインの記述方法が異なるのでご注意ください。

最後に

次回は、 @Endyさんの「 Ansible初心者向けの学習ロードマップ」(12/8)とのことです。楽しみですね!


  1. 各モジュールの利用方法は、Collectionの各モジュールのExamplesも参考になりますが、GrafanaのCollectionのあるリポジトリのtest環境が非常に参考になります。
    github.com
    他にも、各処理は最終的にはHTTP APIを使って処理しているので、以下のドキュメントの調べたいロールのAPIを参考にすると良いです。
    HTTP API | Grafana Labs

  2. Tips: Lookup Plugins: grafana_dashboard について
    set_factsでダッシュボードの情報を根こそぎディクショナリ変数に変換出来る。
    community.grafana.grafana_dashboard の情報よりも以下の実装を直接見た方が分かりやすいです。
    https://github.com/ansible-collections/community.grafana/blob/main/plugins/lookup/grafana_dashboard.py
    また、上記の実装を見てわかるように Folder/Dashboard Search HTTP API | Grafana Labs を叩いていることがわかります。