Kubernetesマニフェスト記述言語としてのJsonnet

この記事は[武蔵野Advent Calendar](https://qiita.com/advent-calendar/2018/musashino) 2日目の記事です。

武蔵野にある某所には1回くらいしか行ったこと無いんですけど、細かいことは気にしない。
今日はKubernetesマニフェストを記述する言語として、「Jsonnet」を紹介したいと思います。

## Jsonnet とは

Jsonnet とは、Json を生成するためのDSLであり、ざっくり言えば変数定義などが可能な、ちょっといい感じの Json です。

機能的にはJsonのスーパーセットになっており、公式サイトによれば
* 変数
* 条件分岐
* 算術演算
* 関数
* インポート
* エラー伝搬
などがあります。

本記事では詳細な機能には立ち入りませんが、例えば

```jsonnet

// コメントの記載や
{
  foo: 'キーに対するクォーテーションの省略、',
  bar: {
    buz: 'ぶら下がりカンマが可能、といった',
  },
  json: 'JSONのツライところが緩和されていることに加え',
  concat: '文字列' + 'の' + '結合',
  format: '%(lang)s 風の %(feature)s など記述するうえでの便利な拡張や' % {
    feature: '文字列フォーマット',
    lang: 'Python',
  },
  local result = 2 * 10,  // 変数定義や
  arithmetic: '算術計算(2 * 10 = %d)' % result,
  local factorial = function(n) if n == 1 then 1 else n * factorial(n - 1),
  func: '関数の定義、実行など: factorial(5) = %d' % factorial(5),
  // プログラミング言語にも似た機能を備えています
}
```

といった感じで、便利な拡張がいろいろ用意されています。（ラムダがあるので、チューリング完全だと思います）

## ユースケース

JSONが使えるところであれば、どこでも使えるのですが、最も力を発揮するのは設定ファイルの記述だと思います。（私自身、この用途で利用しています）

JSONを設定ファイルとして用いるソフトウェアといえば、みんなが大好きな Kubernetes です。人間が設定ファイルを書く場合、JSONよりもYAMLのほうが好まれると思いますが、Kubernetes では JSON も利用可能です。

### Kubernetes マニフェスト記述言語としての Jsonnet

Kubernetes のマニフェストをどのように管理するかは、それだけで記事がいくつも書けそうなほど大きなテーマです。 

例えば、最近ではマニフェストを管理するだけでなく各種CDのベストプラクティスをサポートする Spinnaker が注目を集めています。しかしながら、 Spinnaker はそれ自体が大きなソフトウェアであり、その運用はある程度のクラスタ規模/チーム規模でないと、オーバースペックになりそうです。

本記事では、全体の運用を数人程度で行うような、ごく小規模なKuberneetesクラスタの運用において Jsonnet を使う場合の利点や欠点を書いていきたいと思います。

なお、 ksonnet という、 Jsonnet ベースの Kubernetes マニフェスト管理ソフトウェアがありますが、Jsonnet 以上に覚えることがたくさんあるので、 本記事では ksonnet は扱いません。

## Jsonnet の利点

いくつかありますが、私が強く感じるのは以下の3つです。

* 仕様、実行環境が安定している
* 遅延評価
* 直交性が高い

順に説明します

### 仕様、実行環境が統一されている

Jsonnet は https://github.com/google/jsonnet をインストールすれば実行環境のセットアップが完結します。
ロジックを組んで Json を生成する手法として、他に Node.js を使う、という簡易な方法があります。 Node.js の実行環境を複数箇所、複数の時期にわたって同一に保持するのは、結構気を使いますが、 Jsonnet であれば、あまり気にする必要がありません。

なお、Jsonnet 処理系の実装としては Go 言語による https://github.com/google/go-jsonnet もありますが、一部のコマンドラインオプションが未実装で、まだオリジナルを置き換えるほどではありませんので、原則としてはオリジナル実装を使えば良いです。

### 遅延評価

まず、次のJsonnetの例を見てください

```jsonnet
{
  devel: {
    service: 'foo',
    env: 'devel',
    db_host: '%s-db-%s.example.com' % [self.service, self.env],
  },
  prod: self.devel {
    env: 'prod',
  },
}
```

devel, prod という2つの環境があって、それぞれで異なるDBホスト名を使いたい、というシチュエーションです。

```jsonnet
prod: self.devel {
  name: 'prod',
}
```

この部分は `devel` をベースに差分だけを定義しています。
これを評価して JSON を生成すると、

```json
{
   "devel": {
      "service": "foo"
      "env": "devel",
      "db_host": "foo-db-devel.example.com",
   },
   "prod": {
      "service": "foo"
      "env": "prod",
      "db_host": "foo-db-prod.example.com",
   },
}
```

となります。（キーの順番はJsonnetに合わせてソートしています）
普通のプログラミング言語のセマンティクスだと、 `devel` 定義時に `db_host` の値が評価されてしまい、 `prod` の環境でも `foo-db-devel.example.com` になってしまいそうですが、 Jsonnet は値が遅延評価されるので、 ちゃんと `foo-db-prod.example.com` になっています。

このように、値が遅延評価されるJsonnetでは、定義の順番を気にすることなく差分を与えてやれば、いい感じの結果になります。これは、異なる環境のために微妙に異なる設定ファイルを差分定義する、という Kubernetes 等でよくある状況において、非常に強力です。

### 直交性が高い

直交性が高い、というのは依存関係が少なく、使い回しが効く、という意味です。

Jsonnet ファイルは評価されて JSON に変換されますが、 Jsonnet の中に別の JSON ファイルを import することも可能です。つまり、JSONを読み書きするソフトウェアと相互にデータを交換可能です。

例えば、私が経験したシチュエーションだと、

1. Jsonnet によって DB サーバアカウント等の構成情報を定義しておく
2. RDS インスタンス作成用の cli-input-json 入力JSONファイルを生成する
3. RDS インスタンス作成後、そのエンドポイントの情報を AWS から JSON フォーマットで取得
4. Kubernetes 用マニフェストに RDS インスタンスエンドポイントの情報を import

といった処理を比較的用意に作成、自動化できました。
もちろん JSON を読み書きできるサービスやソフトウェアに限定されますが、外部のサービスであっても連携をやりやすいという利点があります。

## Jsonnet の欠点

もちろん良いところばかりではありません。

* マイナー
* 自由度が高い
* エラーがわかりにくい

### マイナー

やはり情報が少ないです。また、 Web 検索時にはグーグルが気を使って JSON キーワードでの結果を混ぜ込んでくるので、 「俺はJsonnetの情報がほしいんだよ〜」とよく叫びたくなります。（徐々に改善されていくと思いますが）

### 自由度が高い

Jsonnet は記述力が高く、 Kubernetes のマニフェストを生成する、という用途ひとつでも、やりかたが幾通りも考えられます。
環境ごとの書き分け方やファイルの分割など、かなり自由度が高いので、最初は手探りになるでしょう。

また、関数を自分で定義することで、かなり複雑な処理も可能になりますが、複雑な処理による記述の省力化をやりすぎてしまうと、逆に複雑な処理がちゃんと動くかどうかをテストしなければならなくなったりして、 設定ファイルのメンテにしては労力がかかりすぎてしまうことにもなりかねません。

このあたりはポリシーにもよりますが、あまり複雑な処理は行わず、Jsonnetを知らない人がチームに入ってきてもKubernetesのことを知っていれば読み解ける、くらいのシンプルさを維持するのが良いと思います。

### エラーがわかりにくい 

慣れてくると、よく読めばわかることも多いのですが、エラー表示が初心者には結構不親切な場合が多い気がします。これも慣れるしかないです。ツライ。

## Kubernetes のマニフェストに Jsonnet を使う上でのコツ

ここまで見てきたように、多少ツライこともありますが、 Jsonnet はスジが良い技術だと思います。
半年間運用してきて、いくつかコツというかTIPSが溜まっているので、それらを共有して記事を終わりたいと思います。

* エディタのサポートは必須
* タスクランナーは必須
* 定義ファイルが多くならないような工夫をしよう
* とは言え、 apply の最小単位は維持しよう
* YAMLの使用は必要最低限に
* 現状との差分チェックは json-diff で

上から順に見ていきます。

### エディタのサポートは必須

フォーマッタが jsonnet コマンドのいち機能として付いてきますが、エディタによるサポートがあるとないとでは作業効率が大きくことなります。
私は Vim の [google/vim-jsonnet](https://github.com/google/vim-jsonnet) を使っています。セーブ時のオートフォーマットが成功するかどうかで、エラーの有無を見るようにしています。

### タスクランナーは必須

私は autotools を使って make コマンドで管理下にある全サービスのマニフェストを生成できるようにしています。 autotools はそれ自体学習コストが高いのであまりオススメしませんが、自分の得意なタスクランナーで必要に応じて JSON を再生性できるよう自動化しておくのは重要です。

### 定義ファイルが多くならないような工夫をしよう

開発環境やステージング環境、プロダクション環境等々、環境ごとに foo-devel-deployment.jsonnet, foo-staging-deployment.jsonnet, foo-prod-deployment, のように定義ファイルを用意すると、最初は分かりやすいのですが、 Kubernetes は一つのサービスでも deployment, configmap, secret, service と3〜4程度のファイルを必要とするので、ファイル数がかなり多くなります。

ディレクトリを namespace ごとに切って整理しても、1 namespace 内に複数のサービスを入れることはよくあるので、かなりファイル数が増加してしまいます。さらに環境ごとにディレクトリをネストしても良いですが、そうすると今度は逆に環境間の違いを見るのに別ディレクトリを見に行く必要がでてきたり、と結構悩ましいです。

私のところでは、結局差分を

```jsonnet
local prod = {
  service: 'foo',
  ...
};

local staging = prod {
  ...
};

local devel = staging {
  ...
};

if std.extVar('ENV') == 'prod' then
  prod
else if std.extVar('ENV') == 'staging' then
  staging
else if std.extVar('ENV') == 'devel' then
  devel
else
```

のように環境依存の情報をまとめ、 外部変数 ENV によって内容を切り替える `env.jsonnet` という環境スイッチ用のファイルを用意し、deployment や service などの Kubernetes オブジェクト定義ファイルはすべての環境で共用しています。例えばサービスの定義だと次のように、

```
local env = (import 'env.jsonnet').foo.service;
{
  apiVersion: 'v1',
  kind: 'Service',
  metadata: {
    name: 'foo',
    namespace: env.namespace,
    labels: env.labels,
  },
  spec: {
    ports: [
      {
        port: 80,
        targetPort: 8080,
        nodePort: env.nodePort.http,
      },
    ],
    selector: env.labels,
    type: 'NodePort',
  },
}
```

といった感じで必要なところだけ env.jsonnet を参照するようにしています。
これだと環境ごとに変更している箇所が明確になりつつ、ファイル数が爆発しないので、比較的扱いやすいかと思います。

### とは言え、 apply の最小単位は維持しよう

Kubernetes のマニフェストファイルは List オブジェクトを使うと、 deployment や service などの定義を一つの JSON にまとめることが可能です。これを利用して、ファイル数を更にへらすこともできますが、あまりオススメしません。

実際に運用していると複数種類のオブジェクトの定義をまとめて書き換えるような場合でも、とくに開発作業では各オブジェクトごとにインクリメンタルに変更しては適用してはチェックしていくほうが楽ですので、あまりまとめすぎない方が良いです。

### YAMLの使用は必要最低限に

Jsonnet へあまり習熟していない段階では、YAMLのほうが読み書きしやすいので、変更が必要なものは configmap と secret に追い出して deployment と service のマニフェストは YAML で書きたい、という欲求にかられるかもしれません。

しかし、結局は最終的に変更したくなって全部 Jsonnet に寄せることになると思うので YAML はどうしてもYAMLでなければならない場所にとどめて、最初から全部 Jsonnet で作ってしまいましょう。すぐに慣れます。

### 現状との差分チェックは json-diff で

現状 apply されているマニフェストと、変更・生成したJSONファイルの比較には、 [json-diff](https://www.npmjs.com/package/json-diff) が便利です。
`kubectl get -o json` で現状のマニフェストを JSON フォーマットで取得できるので、生成したJSONと容易に比較できます。 各オブジェクトの `.status` などの比較に要らない情報や、 `.metadata.uuid` など差分が必ず出る項目については `jq` コマンドなどで予め取り除いておくと、差分を読み取りやすくなります。

なお、 `kubectl alpha diff` というコマンドがありますが、まだ不安定なようです（私の環境では panic します）


## まとめ

* Jsonnet という設定ファイル記述に有用な言語があるんだよ
* ツラさもあるけど便利だよ
* みんな使おうね

以上です。