Dashboards-as-Code: Grafanaダッシュボードをコードから生成する

Grafanaのダッシュボードをコードから生成する**Dashboards-as-Code**を試してみたので紹介します。

## 背景

Grafanaはとても便利な可視化ツールですが、ダッシュボードが増えてくるといくつか使いづらい点が出てきます。
最近は特に以下の2点を課題に感じています。

### 課題1: ダッシュボードのJSONのレビューがつらい...

GrafanaのダッシュボードはJSON形式でエクスポート/インポートできるので、ダッシュボードをJSONファイルでgit管理する場合が多いと思います。
この場合、GrafanaのUIで作成したダッシュボードをJSON形式でエクスポートしてプルリクエストするのですが、GrafanaのJSONはメタデータが多くて人間が読むのは正直厳しいです。
そのためJSONではなく実際のダッシュボードの画面だけみてレビューする場合がほとんどではないかと思います。

### 課題2: 似たようなグラフを複数作成するのが面倒...

Grafanaのダッシュボードは基本的にUI上で編集していくことになります。
HTTPリクエストのRPSやキューの長さなど、メトリクス名が少し違うだけで同じようなグラフを作成する時に一つずつタイトルやメトリクス名をUI上で編集していくことになるので非常に面倒です。
GrafanaのVariables機能で値を切り替えて何とかなる場合もありますが、一覧して見たい時などはやはり複製して編集していくしかありません。


これらの課題を解決できそうなのがDashboards-as-Codeです。
Dashboards-as-Codeは、ダッシュボードのJSONをプログラムで生成するというアプローチで、これを実現するためのOSSが複数公開されています。
今回はGrafanaCon EU 2018で紹介されていた以下の2つ[^1]を検証してみました。

* [grafanalib](https://github.com/weaveworks/grafanalib)
* [grafonnet](https://github.com/grafana/grafonnet-lib)

## 作成するダッシュボード

ダッシュボードのサンプルとして、Kubernetes & Prometheusの環境で、[kube-state-metrics](https://github.com/kubernetes/kube-state-metrics/)の`kube_{k8sリソース名}_created`というメトリクスをnamespaceラベルごとにカウントしたグラフを各k8sリソースごとに作ってみます。
{k8sリソース名}の部分には、pod、service等のリソース名が入ります。
UIで作成する場合は、リソースごとに複製して{k8sリソース名}部分を修正していく感じになります。

## grafanalib

https://github.com/weaveworks/grafanalib

grafanalibはWeaveworksがOSSとして公開しているPython製のライブラリです。

### インストール

pipでインストールすると、`generate-dashboard`コマンドが使えるようになります。

```
$ pip install grafanalib
```

### コーディング

Pythonで記述します。
今回はKubernetesのリソース名を与えるとそのグラフを返すようなfunctionを用意しました。
APIが直感的なのでPythonを普段使わない人でもすぐに書けると思います。

```kube-state-metrics.dashboard.py
from grafanalib.core import *


def resource_row(resource):
    return Row(panels=[
        Graph(
            title='Number of %s by namespace' % resource,
            dataSource='prometheus',
            targets=[
                Target(
                    expr='count(kube_%s_created) by (namespace)' % resource,
                    legendFormat='{{namespace}}',
                ),
            ],
            yAxes=[
                YAxis(format=NO_FORMAT),
                YAxis(format=SHORT_FORMAT),
            ]
        )
    ])


dashboard = Dashboard(
    title="grafanalib: Kubernetes resource count",
    rows=[
        resource_row('deployment'),
        resource_row('daemonset'),
        resource_row('job'),
        resource_row('cronjob'),
        resource_row('pod'),
        resource_row('configmap'),
        resource_row('secret'),
        resource_row('service'),
        resource_row('endpoint'),
    ],
).auto_panel_ids()
```

これをUIで作ろうとすると、リソースの種類の数だけグラフを複製してメトリクス名とタイトルを修正するという地味な作業繰り返すことになるので、かなり楽になっているのがわかると思います。

### JSON生成

`generate-dashboard`コマンドで、PythonコードからJSONを生成します。

```
$ generate-dashboard -o kube-state-metrics.json kube-state-metrics.dashboard.py
```

36行のコードから1008行のJSONが生成されました。

```
$ wc -l kube-state-metrics.dashboard.py
      36 kube-state-metrics.dashboard.py
$ wc -l kube-state-metrics.json
    1008 kube-state-metrics.json
```

生成されたJSONをインポートするとこんな感じのグラフになります。

![スクリーンショット 2018-12-17 18.28.36.png](https://qiita-image-store.s3.amazonaws.com/0/100092/fe0df89a-84df-2511-ddb0-47f408d5f399.png)

### 感想

実際の運用では、*.pyをgitでバージョン管理して、CI/CDでJSONを生成、生成したJSONをインポート or プロビジョニングしてコンテナイメージ作成、という流れになると思います。
レビュー対象が1008行のJSONから36行のPythonコードになるので、レビューのしやすさという観点では劇的に改善されそうです。

気になったのは、生成されたJSONが`"schemaVersion": 12`となっていて少し古い点です。
今後のGrafanaのアップデートがどのくらいのペースでサポートされていくのかが懸念です。

## grafonnet

https://github.com/grafana/grafonnet-lib

grafonnetは、JSONを生成するための言語である[Jsonnet](https://jsonnet.org/)のGrafana用ライブラリです。
生成コードはJsonnetで書きます。

### インストール

まずJsonnetをインストールします。

```
$ brew install jsonnet
```

次にgrafonnetのライブラリをcloneしておきます。

```
$ git clone git@github.com:grafana/grafonnet-lib.git
```

Jsonnetコードの先頭でimportし、

```
local grafana = import 'grafonnet/grafana.libsonnet';
```

`jsonnet`コマンド実行時にcloneしたgrafonnetのパスを指定すると利用できます。

```
jsonnet -J /path/to/grafonnet dashboard.jsonnet
```

### コーディング

grafanalibの時とほぼ同じコードを書きました。

```kube-state-metrics.jsonnet
local grafana = import 'grafonnet/grafana.libsonnet';
local dashboard = grafana.dashboard;
local graphPanel = grafana.graphPanel;
local prometheus = grafana.prometheus;

local resourcePanel(resource) =
  graphPanel.new(
    title='Number of %s count by namespace' % resource,
    datasource='prometheus',
  ).addTarget(
    prometheus.target(
      expr='count(kube_%s_created) by (namespace)' % resource,
      legendFormat='{{namespace}}',
    )
  );

local gridPos = {
  x: 0,
  y: 0,
  w: 24,
  h: 8,
};

dashboard.new(
  'grafonnet: Kubernetes resource count',
)
.addPanel(resourcePanel('deployment'), gridPos)
.addPanel(resourcePanel('daemonset'), gridPos)
.addPanel(resourcePanel('job'), gridPos)
.addPanel(resourcePanel('cronjob'), gridPos)
.addPanel(resourcePanel('pod'), gridPos)
.addPanel(resourcePanel('configmap'), gridPos)
.addPanel(resourcePanel('secret'), gridPos)
.addPanel(resourcePanel('service'), gridPos)
.addPanel(resourcePanel('endpoint'), gridPos)
```

Jsonnetを初めて使用したのとエラーメッセージが結構わかりづらいので、grafanalibよりは導入ハードルが高いと感じました。
https://jsonnet.org/ を読んで最低限のシンタックスを覚えてから書くのがよいと思います。
Vimのプラグインがあったので、シンタックスハイライトや保存時の自動フォーマットができて助かりました。
https://github.com/google/vim-jsonnet

### JSON生成

`jsonnet`コマンドをgrafonnetのパスを指定して実行します。

```
$ jsonnet -J /path/to/grafonnet kube-state-metrics.jsonnet -o grafonnet-kube-state-metrics.json
```

35行のコードから800行のJSONが生成されました。

```
% wc -l kube-state-metrics.jsonnet
      35 kube-state-metrics.jsonnet
$ wc -l grafonnet-kube-state-metrics.json
     800 grafonnet-kube-state-metrics.json
```

書き方にもよりますが、コード量はgrafanalibと同じくらいになると思います。
生成されたJSONをインポートするとこんな感じのグラフになります。

![スクリーンショット 2018-12-17 18.29.04.png](https://qiita-image-store.s3.amazonaws.com/0/100092/dd0352d6-cc76-85d9-2298-34f95ae8f9a9.png)

### 感想

Jsonnetを使っている方は少ないと思うのでPythonで書くgrafanalibに比べると導入ハードルが高いと思います。最低限のシンタックスを調べれば、ダッシュボードのJSON生成程度であれば何とかなりそうです。
あとはJsonnetを調べるうちに他の言語にはない特徴があることに気づきました。実は**JsonnetはJSONのスーパーセット**です。つまり既存のダッシュボードのJSONは全てJsonnetとしても解釈可能ということです。
この特徴は、既存ダッシュボードの移行という観点では大きなメリットになると思います。

```
# JSONをそのままJsonnetとして利用可能
$ cp a.json a.jsonnet
$ jsonnet -J /path/to/grafonnet a.jsonnet -o a-generated.json
# 生成されるJSONは同じ
$ diff a-generated.json a.json
$ diff a-generated.json a.jsonnet
# 拡張子は.jsonnetじゃなくてもOK
$ jsonnet -J path/to/grafonnet a.json -o a-generated.json
```

上記の例からもわかるように、既存のJSONファイルをそのまま流用してJsonnetを使用したCI/CDにすぐに移行できるということになります。
まずはJsonnetからJSONを生成するCI/CDパイプラインを整備して、その後にリファクタリングしてダッシュボードの似たような部分を共通化する、というアプローチができそうです。

```
...
local resourcePanel(resource) = graphPanel.new(                                                   
...
{                                                                                  
...
  panels: [                                                                        
    {                                                                              
      aliasColors: {},                                                             
      bars: false,                                                                 
      dashLength: 10,                                                              
      ...
    },
    resourcePanel('cronjob'),      # 元のJSONの一部分だけ共通化した生成コードに置き換え
    resourcePanel('statefulset'),
    ...
    {                                                                              
      aliasColors: {},                                                             
      bars: false,
      ...
    },
...
}        
```

実際にダッシュボードのJSONの一部分だけをgrafonnetで生成したJSONに置き換えるという使い方ができました。

## まとめ

Dashboards-as-Codeの2つのライブラリを検証してみました。
最初に触った感じはPythonで書けるgrafanalibの方が導入しやすいと思ったのですが、grafonnet（というかJsonnet）にはJSONをそのまま使用できるというメリットがあるので、grafonnetの方が既存プロジェクトには導入しやすいのではないかと思います。
今後もGrafanaのJSONスキーマが変更されることはもちろんあると思うので、その時にgrafonnetであれば最新のJSONをとりあえずそのまま使用できます。
Python等の他の言語で生成する場合は、ライブラリが最新のGrafanaのJSONスキーマに対応しない限り、最新の機能を使えないということになるので、Jsonnetを利用しているgrafonnetはこの点でかなり有利だと思いました。

[^1]: 2セッションともYouTubeで動画が公開されています。[grafanalibの動画](https://www.youtube.com/watch?v=OOyEGG98B7w)、[grafonnetの動画](https://www.youtube.com/watch?v=zmsZq9Pfp1g)