🦔

Grafonnet + TerraformでGrafanaのダッシュボードを作ってみる

に公開

はじめに

こんにちは、kazz です。
この記事は Grafana Advent Calendar 2025 の 21 日目の記事です。
https://qiita.com/advent-calendar/2025/grafana

Grafanaのダッシュボードや権限(組織・フォルダなど)をTerraformで管理していると、ダッシュボードが扱いづらいと感じることがあります。

Terraformでダッシュボードを管理する場合、基本的にはエクスポートしたJSONをそのまま config_json に渡す形になります。
ただ、ダッシュボードのJSONは大きくなりがちで、差分も追いづらく、同じようなダッシュボードをテンプレート的に使いまわしたい時などに少し不便です。

そこでこの記事では、Grafonnetを使ってダッシュボードの定義をコードとして書く方法を試していきます。

Grafonnet / Jsonnet とは

Grafonnetは、JsonnetでGrafanaのダッシュボードを記述しやすくするためのライブラリです。

Jsonnetは、JSONをベースにしつつ、関数や変数、条件分岐などを扱える言語です。プログラムのように記述できるJSONといったイメージです。

Grafonnetについては、Grafana OSSのOpenAPIから生成されていること、旧grafonnet-libの後継であること、現時点ではexperimentalであることが説明されています。利用する場合はこれらの点を理解した上で検討するとよいでしょう。

https://github.com/grafana/grafonnet

https://jsonnet.org/

Grafonnetでダッシュボードを書いてみる

ここでは、まずJsonnetでダッシュボードを定義し、Terraformが扱えるJSONを生成するところまでやってみます。

準備

Jsonnetの実行環境と、依存関係を扱うjsonnet-bundlerを用意して、プロジェクトを初期化します。

brew install go-jsonnet jsonnet-bundler

mkdir my-dashboards && cd my-dashboards

jb init

jb install github.com/grafana/grafonnet/gen/grafonnet-latest@main

ダッシュボード定義

例として、複数のパネルを配置したダッシュボードを作ります。JSONを直接編集する代わりに、共通部分を関数にしたり、同じ設定を使い回せるのがポイントです。
今回は、Random Walk を使った適当なパネルを並べたダッシュボードを作ります。

sample.jsonnet
local g = import 'vendor/github.com/grafana/grafonnet/gen/grafonnet-v11.4.0/main.libsonnet';

local ts = g.panel.timeSeries;
local bar = g.panel.barChart;
local stat = g.panel.stat;
local timeline = g.panel.stateTimeline;

local grafanaQuery(refId) = {
  datasource: { type: 'datasource', uid: 'grafana' },
  queryType: 'randomWalk',
  refId: refId,
};

local thresholds = [
  { color: 'green', value: null },
  { color: 'red', value: 80 },
];

local legend = { displayMode: 'list', placement: 'bottom', showLegend: true };
local tooltip = { mode: 'single', sort: 'none' };

local grid(panel, x, y, w, h) = panel + ts.panelOptions.withGridPos(x=x, y=y, w=w, h=h);

local panels = {
  timeSeries:
    ts.new('Sample Panel 1')
    + ts.standardOptions.color.withMode('palette-classic')
    + ts.standardOptions.thresholds.withSteps(thresholds)
    + ts.options.withLegend(legend)
    + ts.options.withTooltip(tooltip)
    + ts.queryOptions.withDatasource('datasource', 'grafana')
    + ts.queryOptions.withTargets([grafanaQuery('A')]),

  barChart:
    bar.new('Sample Panel 2')
    + bar.standardOptions.color.withMode('palette-classic')
    + bar.standardOptions.thresholds.withSteps(thresholds)
    + bar.fieldConfig.defaults.custom.withFillOpacity(80)
    + bar.options.withLegend(legend)
    + bar.options.withTooltip(tooltip)
    + bar.queryOptions.withDatasource('datasource', 'grafana')
    + bar.queryOptions.withTargets([grafanaQuery('A')]),

  stat:
    stat.new('Sample Panel 3')
    + stat.standardOptions.color.withMode('thresholds')
    + stat.standardOptions.thresholds.withSteps(thresholds)
    + stat.options.withGraphMode('area')
    + stat.options.withReduceOptions({ calcs: ['lastNotNull'], values: false })
    + stat.queryOptions.withDatasource('datasource', 'grafana')
    + stat.queryOptions.withTargets([grafanaQuery('A')]),

  stateTimeline:
    timeline.new('Sample Panel 4')
    + timeline.standardOptions.color.withMode('continuous-GrYlRd')
    + timeline.standardOptions.thresholds.withSteps(thresholds)
    + timeline.fieldConfig.defaults.custom.withFillOpacity(70)
    + timeline.options.withMergeValues(true)
    + timeline.options.withRowHeight(0.9)
    + timeline.options.withLegend(legend)
    + timeline.options.withTooltip(tooltip)
    + timeline.queryOptions.withDatasource('datasource', 'grafana')
    + timeline.queryOptions.withTargets([grafanaQuery('A')]),
};

g.dashboard.new('Sample Dashboard')
+ g.dashboard.withUid('sample')
+ g.dashboard.withEditable(true)
+ g.dashboard.graphTooltip.withSharedCrosshair()
+ g.dashboard.time.withFrom('now-6h')
+ g.dashboard.time.withTo('now')
+ g.dashboard.withTimezone('browser')
+ g.dashboard.withPanels([
    grid(panels.timeSeries, 0, 0, 12, 11),
    grid(panels.barChart, 12, 0, 12, 11),
    grid(panels.stat, 0, 11, 12, 10),
    grid(panels.stateTimeline, 12, 11, 12, 10),
  ])

JSON生成

Terraformに渡すためのJSONを生成します。

jsonnet -J vendor sample.jsonnet > dashboard.json

実際に上記のJsonnetファイルから生成したJSONファイルは以下です。70行ほどのコードから、250行のJSONファイルが生成されました。

生成されたJSON
{
   "editable": true,
   "graphTooltip": 1,
   "panels": [
      {
         "datasource": {
            "type": "datasource",
            "uid": "grafana"
         },
         "fieldConfig": {
            "defaults": {
               "color": {
                  "mode": "palette-classic"
               },
               "thresholds": {
                  "steps": [
                     {
                        "color": "green",
                        "value": null
                     },
                     {
                        "color": "red",
                        "value": 80
                     }
                  ]
               }
            }
         },
         "gridPos": {
            "h": 11,
            "w": 12,
            "x": 0,
            "y": 0
         },
         "id": 1,
         "options": {
            "legend": {
               "displayMode": "list",
               "placement": "bottom",
               "showLegend": true
            },
            "tooltip": {
               "mode": "single",
               "sort": "none"
            }
         },
         "pluginVersion": "v11.4.0",
         "targets": [
            {
               "datasource": {
                  "type": "datasource",
                  "uid": "grafana"
               },
               "queryType": "randomWalk",
               "refId": "A"
            }
         ],
         "title": "Sample Panel 1",
         "type": "timeseries"
      },
      {
         "datasource": {
            "type": "datasource",
            "uid": "grafana"
         },
         "fieldConfig": {
            "defaults": {
               "color": {
                  "mode": "palette-classic"
               },
               "custom": {
                  "fillOpacity": 80
               },
               "thresholds": {
                  "steps": [
                     {
                        "color": "green",
                        "value": null
                     },
                     {
                        "color": "red",
                        "value": 80
                     }
                  ]
               }
            }
         },
         "gridPos": {
            "h": 11,
            "w": 12,
            "x": 12,
            "y": 0
         },
         "id": 2,
         "options": {
            "legend": {
               "displayMode": "list",
               "placement": "bottom",
               "showLegend": true
            },
            "tooltip": {
               "mode": "single",
               "sort": "none"
            }
         },
         "pluginVersion": "v11.4.0",
         "targets": [
            {
               "datasource": {
                  "type": "datasource",
                  "uid": "grafana"
               },
               "queryType": "randomWalk",
               "refId": "A"
            }
         ],
         "title": "Sample Panel 2",
         "type": "barchart"
      },
      {
         "datasource": {
            "type": "datasource",
            "uid": "grafana"
         },
         "fieldConfig": {
            "defaults": {
               "color": {
                  "mode": "thresholds"
               },
               "thresholds": {
                  "steps": [
                     {
                        "color": "green",
                        "value": null
                     },
                     {
                        "color": "red",
                        "value": 80
                     }
                  ]
               }
            }
         },
         "gridPos": {
            "h": 10,
            "w": 12,
            "x": 0,
            "y": 11
         },
         "id": 3,
         "options": {
            "graphMode": "area",
            "reduceOptions": {
               "calcs": [
                  "lastNotNull"
               ],
               "values": false
            }
         },
         "pluginVersion": "v11.4.0",
         "targets": [
            {
               "datasource": {
                  "type": "datasource",
                  "uid": "grafana"
               },
               "queryType": "randomWalk",
               "refId": "A"
            }
         ],
         "title": "Sample Panel 3",
         "type": "stat"
      },
      {
         "datasource": {
            "type": "datasource",
            "uid": "grafana"
         },
         "fieldConfig": {
            "defaults": {
               "color": {
                  "mode": "continuous-GrYlRd"
               },
               "custom": {
                  "fillOpacity": 70
               },
               "thresholds": {
                  "steps": [
                     {
                        "color": "green",
                        "value": null
                     },
                     {
                        "color": "red",
                        "value": 80
                     }
                  ]
               }
            }
         },
         "gridPos": {
            "h": 10,
            "w": 12,
            "x": 12,
            "y": 11
         },
         "id": 4,
         "options": {
            "legend": {
               "displayMode": "list",
               "placement": "bottom",
               "showLegend": true
            },
            "mergeValues": true,
            "rowHeight": 0.90000000000000002,
            "tooltip": {
               "mode": "single",
               "sort": "none"
            }
         },
         "pluginVersion": "v11.4.0",
         "targets": [
            {
               "datasource": {
                  "type": "datasource",
                  "uid": "grafana"
               },
               "queryType": "randomWalk",
               "refId": "A"
            }
         ],
         "title": "Sample Panel 4",
         "type": "state-timeline"
      }
   ],
   "schemaVersion": 39,
   "time": {
      "from": "now-6h",
      "to": "now"
   },
   "timezone": "browser",
   "title": "Sample Dashboard",
   "uid": "sample"
}

生成されたJSONは、通常のダッシュボードJSONと同じように扱えます。UIからのインポートにも使えますし、TerraformやGit Syncの入力としても利用できます。
実際にGrafanaでインポートしたところ、以下のようなダッシュボードが出来上がりました。

Grafonnetで生成したダッシュボード

Terraformと組み合わせたCI/CD

今回は、GrafonnetとTerraformを組み合わせたダッシュボードの管理をやってみます。
Terraformの適用前にGrafonnetをJSONに変換する工程をCI/CDに組み込みます。ここではGitHub Actionsを例にします。

Terraform

まずはTerraformで組織などのリソースを定義し、ダッシュボードはJSONを読み込んで渡します。

resource "grafana_organization" "main" {
  name = "Sample Organization"

  admin_user   = "admin"
  create_users = false
}

resource "grafana_dashboard" "main" {
  depends_on = [grafana_organization.main]
  org_id = grafana_organization.main.id
  config_json = templatefile(
    "./dashboards/sample.json",{}
  )
}

GitHub Actions

CI/CDの中で、Terraformのplan/applyの前に、Grafonnet からJSONを生成する処理を挟みます。

jobs:
  deploy-grafana:
    runs-on: ubuntu-slim
    steps:
      - uses: actions/checkout@v3

      - name: Setup Jsonnet
        run: |
          go install github.com/google/go-jsonnet/cmd/jsonnet@latest
          go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb@latest
      
      - name: Build Dashboards
        run: |
          jb install
          mkdir -p generated
          jsonnet -J vendor main.jsonnet > dashboards/sample.json
    
      - name: Terraform Init & Plan
        run: |
          terraform init
          terraform plan

    # 省略のためApplyも一つのWorkflow にまとめています
      - name: Terraform Init & Apply
        run: |
          terraform init
          terraform apply -auto-approve

これで、ダッシュボードのJSONはあくまで中間の成果物として扱えます。

やってみた感想

Grafonnetを使ってみた感想として、現状の自分としてはメリットよりも新たにJsonnetをキャッチアップするコストの方が大きいなと思いました。UIで編集してエクスポートしたほうが、色々捗りそうです。

一方で、慣れたら楽そう、レビューしやすそう、UIからの変更ができない場合は活躍しそう、といったメリットはあるなと感じました。
既存ダッシュボードの変換も、AIを併用すると現実的な手間で進められそうなので、うまく使えばObservability as Codeを実践できると思います。

Discussion