⚙️

Jsonnet で Kubernetes マニフェストを快適に書く

2022/04/24に公開

Kubernetes マニフェストを書くとき、非常にしばしば複数の環境(典型的には production, staging, development)ごとに一部の設定だけ異なる他はほぼ同一の内容のマニフェストを用意しなければならない、ということがあります。このようなとき、繰り返し部分と差分を分ける手段としては複数の選択肢があります。なかでも、kustomize を使って、ベースとなるマニフェストを用意したうえで各環境に応じた差分を適用するためのパッチをあてる、というのがよく選ばれる選択肢でしょうか。この記事では、繰り返しを避けるという目的を達成するのに、kustomize ではなく Google 開発の設定記述用言語 Jsonnet を使うという選択肢もあるということをご紹介します。

Jsonnet には普通のプログラミング言語にはない独特な機能があり、使いこなすにはコツが必要です。この記事では単に Jsonnet の機能を紹介するのではなく、それを使って Kubernetes のマニフェストを具体的にどう記述できるか、にフォーカスをあてたいと思います。Deployment + Service + Ingress からなる典型的な Web サービス用のマニフェストを題材に、それを Jsonnet 化する過程を示します。

速習 Jsonnet

Jsonnet の詳しい解説は公式 tutorial などの既存の記事を参照していただければと思いますが、一応この記事を読むうえで必要となる機能を説明しておきます。

まず、Jsonnet は JSON の superset です。つまり、任意の JSON ファイルは Jsonnet ファイルでもあります。ですので当然

{
  "a": 123,
  "b": {
    "c": 456
  }
}

を実行すると

{
   "a": 123,
   "b": {
      "c": 456
   }
}

となります。

オブジェクトの key の部分のクオートは識別子として valid である場合省略できます。また。計算式やコメントも書けます。

{
  a: 123,
  b: {
    c: 123 * 10 // 10 times a
  }
}

を実行すると

{
   "a": 123,
   "b": {
      "c": 1230
   }
}

となります。+ 演算子をオブジェクト2つに適用してオブジェクトのマージができます。両オペランドに同じキーがある場合は右側が優先されます。

({
   a: 123,
   b: 1230,
} + {
   b: 234,
   c: 345,
})

を実行すると

{
   "a": 123,
   "b": 234,
   "c": 345
}

となります。$ という特殊な変数のようなものがあり、それを含むトップレベルのオブジェクトを指すことが出来ます。

{
  name: 'service',
  deeply: {
    embedded: {
      here: $.name,
    }
  }
}

を実行すると

{
   "name": "service",
   "deeply": {
      "embedded": {
         "here": "service"
      }
   }
}

となります。あるオブジェクト内に同じ値が複数回出現するのはよくあることなので(Kubernetes の Deployment の matchLabels など)これだけでも有用ですが、この $ が面白くなるのは先述のオブジェクトマージと組み合わせたときです。実は次のようなことが可能なのです:

({
  deeply: {
    embedded: {
      here: $.name,
    }
  }
} + {name: 'service'})

これは実行すると先ほどと同じ実行結果になります。明らかに、次の式を評価した段階では $.name は存在しないのでエラーが起きてもおかしくないところですが、

{
  deeply: {
    embedded: {
      here: $.name,
    }
  }
}

$.name の評価は最終的に実行結果を表示する段階まで遅延され、そのときには $.name{name: 'service'} とのマージの結果存在しているので、問題なく評価が完了する、と考えればよいです。

Deployment + Service + Ingress を Jsonnet 化

さて、ここからは具体的なマニフェストを Jsonnet 化する様子を見ていきます。対象とするのは Deployment + Service + Ingress という Kubernetes 上に Web サービスを建てるときの典型的な組み合わせのマニフェスト群です。

まずは YAML の構文を Jsonnet のものに変換しただけのものを示します。

[
  {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      name: 'example-web-app',
      namespace: 'web'
    },
    spec: {
      selector: {
        matchLabels: {
          'k8s-app': 'example-web-app'
        }
      },
      template: {
        metadata: {
          labels: {
            'k8s-app': 'example-web-app'
          },
          name: 'example-web-app'
        },
        spec: {
          containers: [
            {
              name: 'main',
              image: 'registry.example.org/example-web-app:v0.1.0',
              env: [
                {
                  name: 'SERVER_PORT',
                  value: '8080',
                }
              ],
              ports: [
                {
                  containerPort: 8080,
                  name: 'http'
                }
              ],
              resources: {
                limits: {
                  cpu: '500m',
                  memory: '128Mi'
                },
                requests: {
                  cpu: '500m',
                  memory: '128Mi'
                }
              }
            }
          ]
        }
      }
    }
  },
  {
    apiVersion: 'v1',
    kind: 'Service',
    metadata: {
      name: 'example-web-app'
    },
    spec: {
      ports: [
        {
          port: 80,
          targetPort: 'http'
        }
      ],
      selector: {
        'k8s-app': 'example-web-app'
      }
    }
  },
  {
    apiVersion: 'networking.k8s.io/v1beta1',
    kind: 'Ingress',
    metadata: {
      name: 'example-web-app'
    },
    spec: {
      tls: [
        {
          hosts: [
            'example-web-app.example.org'
          ],
          secretName: 'tls-cert'
        }
      ],
      rules: [
        {
          host: 'example-web-app.example.org',
          http: {
            paths: [
              {
                backend: {
                  serviceName: 'example-web-app',
                  servicePort: 80
                }
              }
            ]
          }
        }
      ]
    }
  }
]

ここから繰り返しの除去、そして環境ごとのパラメタライズを目指して変形していきます。以降のステップで $ を使った相互参照を使うので、まずは各マニフェストを配列ではなくオブジェクトに入れます。

{
  deployment: {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      name: 'example-web-app',
      namespace: 'web'
    },
    spec: {
      selector: {
        matchLabels: {
          'k8s-app': 'example-web-app'
        }
      },
      replicas: 1,
      template: {
        metadata: {
          labels: {
            'k8s-app': 'example-web-app'
          },
          name: 'example-web-app'
        },
        spec: {
          containers: [
            {
              name: 'main',
              image: 'registry.example.org/example-web-app:v0.1.0',
              env: [
                {
                  name: 'SERVER_PORT',
                  value: '8080',
                }
              ],
              ports: [
                {
                  containerPort: 8080,
                  name: 'http'
                }
              ],
              resources: {
                limits: {
                  cpu: '500m',
                  memory: '128Mi'
                },
                requests: {
                  cpu: '500m',
                  memory: '128Mi'
                }
              }
            }
          ]
        }
      }
    }
  },
  service: {
    apiVersion: 'v1',
    kind: 'Service',
    metadata: {
      name: 'example-web-app'
    },
    spec: {
      ports: [
        {
          port: 80,
          targetPort: 'http'
        }
      ],
      selector: {
        'k8s-app': 'example-web-app'
      }
    }
  },
  ingress: {
    apiVersion: 'networking.k8s.io/v1beta1',
    kind: 'Ingress',
    metadata: {
      name: 'example-web-app'
    },
    spec: {
      tls: [
        {
          hosts: [
            'example-web-app.example.org'
          ],
          secretName: 'tls-cert'
        }
      ],
      rules: [
        {
          host: 'example-web-app.example.org',
          http: {
            paths: [
              {
                backend: {
                  serviceName: 'example-web-app',
                  servicePort: 80
                }
              }
            ]
          }
        }
      ]
    }
  },
  all: [$.deployment, $.service, $.ingress]
}

配列でマニフェストの一覧が欲しいこともあるので .all でそれを取り出せるようにしました。次に、example-web-app という名前の繰り返しが目につくので、それを $.name に置き換えます。

{
  name: 'example-web-app',
  deployment: {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      name: $.name,
      namespace: 'web'
    },
    spec: {
      selector: {
        matchLabels: {
          'k8s-app': $.name
        }
      },
      replicas: 1,
      template: {
        metadata: {
          name: $.name,
          labels: {
            'k8s-app': $.name
          }
        },
        spec: {
          containers: [
            {
              name: 'main',
              image: 'registry.example.org/example-web-app:v0.1.0',
              env: [
                {
                  name: 'SERVER_PORT',
                  value: '8080',
                }
              ],
              ports: [
                {
                  containerPort: 8080,
                  name: 'http'
                }
              ],
              resources: {
                limits: {
                  cpu: '500m',
                  memory: '128Mi'
                },
                requests: {
                  cpu: '500m',
                  memory: '128Mi'
                }
              }
            }
          ]
        }
      }
    }
  },
  service: {
    apiVersion: 'v1',
    kind: 'Service',
    metadata: {
      name: $.name,
      namespace: 'web'
    },
    spec: {
      ports: [
        {
          port: 80,
          targetPort: 'http'
        }
      ],
      selector: {
        'k8s-app': $.name
      }
    }
  },
  ingress: {
    apiVersion: 'networking.k8s.io/v1beta1',
    kind: 'Ingress',
    metadata: {
      name: $.name,
      namespace: 'web'
    },
    spec: {
      tls: [
        {
          hosts: [
            'example-web-app.example.org'
          ],
          secretName: 'tls-cert'
        }
      ],
      rules: [
        {
          host: 'example-web-app.example.org',
          http: {
            paths: [
              {
                backend: {
                  serviceName: $.service.metadata.name,
                  servicePort: 80
                }
              }
            ]
          }
        }
      ]
    }
  },
  all: [$.deployment, $.service, $.ingress]
}

この調子で最後まで続けます。繰り返しの箇所と、環境によって変わる箇所をトップレベルに移動した上で $ で参照します。

base.libsonnet
{
  name: 'example-web-app',
  namespace: error 'namespace is required',
  image: error 'image is required',
  host: error 'host is required',
  labels: {
    'k8s-app': $.name
  },
  port: 8080,
  resources: {
    cpu: '500m',
    memory: '128Mi'
  },
  deployment: {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      name: $.name,
      namespace: $.namespace
    },
    spec: {
      selector: {
        matchLabels: $.labels
      },
      replicas: 1,
      template: {
        metadata: {
          name: $.name,
          labels: $.labels,
        },
        spec: {
          containers: [
            {
              name: 'main',
              image: $.image,
              env: [
                {
                  name: 'SERVER_PORT',
                  value: std.toString($.port),
                }
              ],
              ports: [
                {
                  containerPort: $.port,
                  name: 'http'
                }
              ],
              resources: {
                limits: $.resources,
                requests: $.resources
              }
            }
          ]
        }
      }
    }
  },
  service: {
    apiVersion: 'v1',
    kind: 'Service',
    metadata: {
      name: $.name,
      namespace: $.namespace
    },
    spec: {
      ports: [
        {
          port: 80,
          targetPort: 'http'
        }
      ],
      selector: $.labels
    }
  },
  ingress: {
    apiVersion: 'networking.k8s.io/v1beta1',
    kind: 'Ingress',
    metadata: {
      name: $.name,
      namespace: $.namespace
    },
    spec: {
      tls: [
        {
          hosts: [$.host],
          secretName: 'tls-cert'
        }
      ],
      rules: [
        {
          host: $.host,
          http: {
            paths: [
              {
                backend: {
                  serviceName: $.service.metadata.name,
                  servicePort: 80
                }
              }
            ]
          }
        }
      ]
    }
  },
  all: [$.deployment, $.service, $.ingress]
}

最終的にはこのようになりました。これを base.libsonnet などの名前で保存し、これをベースに各環境のマニフェストを生成します。まずは development 環境のものを用意しましょう。

dev.libsonnet
(import 'base.libsonnet') + {
  namespace: 'dev',
  image: 'registry.example.org/example-web-app:v0.2.0',
  host: 'example-web-app.dev.example.org',
}

ここで jsonnet -y -e '(import "dev.libsonnet").all' とすると Deployment, Service, Ingress からなる YAML ストリームが手に入ります。つまり、パイプで jsonnet ... | kubectl apply -f - と繋げることが出来ます。 ここで .all としなければならないのは煩わしいですが、 jsonnet -y -e '(import "dev.libsonnet").deployment' とすると Deployment だけを取り出すことができ便利です。

production 環境用のマニフェストも用意します。production なのでリソースを多めにし、replicas を3にします。deployment+: という見慣れない構文が出てきましたが、これは左辺と右辺に同じキーが存在する場合、右辺の値で上書きするのではなく左辺の値と右辺の値でオブジェクトマージを行うという意味です。

prod.libsonnet
(import 'base.libsonnet') + {
  namespace: 'web',
  image: 'registry.example.org/example-web-app:v0.1.0',
  host: 'example-web-app.example.org',
  deployment+: {
    spec+: {
      replicas: 3
    }
  },
  resources: {
    cpu: '1',
    memory: '256Mi',
  }
}

このように、Jsonnet を使うと環境ごとの差分のみを分かりやすく記述できます。なお、両辺がオブジェクトの場合の + 演算子は省略できるので、以下のように見た目もすっきりします。

prod.libsonnet
(import 'base.libsonnet') {
  namespace: 'dev',
  image: 'registry.example.org/example-web-app:v0.2.0',
  host: 'example-web-app.dev.example.org',
}

なぜ関数を使わないのか

Jsonnet のドキュメントを読み関数の存在を知ると、上で書いた base.libsonnet を次のように書く発想がまず浮かぶでしょう。(少なくとも私はそうでした)

base.libsonnet
function (config) {
  deployment: {
    metadata: {
      name: config.name
    }
  }
  // 略
}

base.libsonnet は config を受け取ってそれに従い組み上げたマニフェストを返す関数とする、ということです。こうしたうえで、development 環境用のマニフェストを次のように書きたくなるかもしれません。

dev.libsonnet
(import 'base.libsonnet')({
  namespace: 'dev',
  image: 'registry.example.org/example-web-app:v0.2.0',
  host: 'example-web-app-dev.example.org',
})

この方式でもほとんどの場合は問題ないのですが、 development 環境から namespace と host だけを変更した testing 環境を用意したい、というようなニーズが生じたときに困ります。というのも、関数で書いたうえで、その関数を development 環境を生成するためにすでに適用してしまったからです。関数ではなく最初に紹介した $ + + 演算子で config をマージするという方式だと、

dev.libsonnet
(import 'base.libsonnet') {
  namespace: 'dev',
  image: 'registry.example.org/example-web-app:v0.2.0',
  host: 'example-web-app-dev.example.org',
}
test.libsonnet
(import 'dev.libsonnet') {
  namespace: 'testing',
  host: 'example-web-app-testing.example.org',
}

のようにある環境のマニフェストを参照して別の環境のマニフェストを生成する、ということが容易になります。

補遺: Jsonnet で書かれたマニフェストのデプロイ

実務で適用するとなるとデプロイの話は避けては通れませんので、表題からは外れますがデプロイについても書いておきます。development 環境だと jsonnet コマンドの出力結果を直接 kubectl apply しても問題ないでしょうが、production 環境でそれを行うのは避けるべきでしょう。多くのソフトウェアエンジニアリングの現場では GitOps によるデプロイが採用されていると思いますが、GitOps ツールとして非常によく使われている ArgoCDJsonnet をネイティブでサポートしているので、ArgoCD を使っている場合 Jsonnet の導入はスムーズに行えます。.yaml の代わりに .jsonnet という拡張子でファイルをリポジトリに置いておくと、Jsonnet として評価して得られたマニフェストが ArgoCD に認識されます。.jsonnet ファイルは1つのマニフェストを返しても、マニフェストの配列を返しても ArgoCD に認識されます。

Discussion