こんにちは、株式会社はてなでアプリケーションエンジニアとしてアルバイトをしている
id:walnuts1018 です。
この記事は はてなエンジニア Advent Calendar 2025 の28日目です。昨日の記事は、
id:k1s1eee さんの 複数AIプロバイダを一元管理できる LiteLLMを組織で活用できないかを試す でした。
今回は、KubernetesのManifest管理にJsonnetを利用する方法についてご紹介します。
Kustomizeにおける課題
Kubernetes Manifestを定義する際、ほとんどの人がKustomizeを利用しているのではないでしょうか。Kustomizeは、あるManifestに対するパッチを記述することで、テンプレート化することなく共通化を実現できる便利なツールです。ArgoCDやFluxCDでもネイティブにサポートされていますね。
一方で、Patchの記述が複雑になると、どこでどのフィールドが上書きされて、最終的にどのようなManifestになるのかを直感的に確認するのが難しくなります。また、機能的にどうしても共通化できない部分が出てきてしまうこともあります。
Jsonnetを使おう
そこで、walnuts1018/infraでは、一年ほど前からJsonnetによるManifest管理に移行しました。
Jsonnetとは
Jsonnetは、JSONを拡張して作られた設定記述言語です。文字列結合や別フィールドの参照、変数定義、ループや条件分岐、インポート機能などを備えています。標準ライブラリを用いると、YAMLのパースやSHA256ハッシュの生成なども可能です。
例えば、以下のようなJsonnetコードは、
{
person1: {
name: "Alice",
welcome: "Hello " + self.name + "!",
},
person2: self.person1 { name: "Bob" },
}
以下のようなJSONに変換されます。
{ "person1": { "name": "Alice", "welcome": "Hello Alice!" }, "person2": { "name": "Bob", "welcome": "Hello Bob!" } }
その他の機能は、公式のチュートリアル(Jsonnet - Tutorial)や標準ライブラリのドキュメント(Jsonnet - Standard Library)を参照してください。
こんなときJsonnetではどう書く?
さて、Kubernetes Manifestを記述する際の様々なケースについて、Kustomizeでの例にも触れながら、Jsonnetを用いた書き方をご紹介します。
Baseの共通化
同じアプリケーションをStaging環境とProduction環境にデプロイする場合など、環境変数やレプリカ数のみ異なるマニフェストを複数作成する場合はどうすれば良いでしょうか。
Kustomizeなら、
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - ../base patches: - path: deployment.patch.yaml
apiVersion: apps/v1 kind: Deployment metadata: name: app spec: replicas: 10 template: spec: containers: - name: app env: - name: POSTGRES_HOST value: postgres://postgres-production/app
といった書き方をしますね。
Jsonnetだと、
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'app',
},
spec: {
template: {
spec: {
containers: [
{
name: 'app',
image: 'registry.walnuts.dev/app:latest',
},
],
},
},
},
}
std.mergePatch((import '../base/deployment.libsonnet'), { spec: { replicas: 10, template: { spec: { containers: [ { name: 'app', env: [ { name: 'POSTGRES_HOST', value: 'postgres://postgres-production/app', }, ], }, ], }, }, }, } )
のように、インポートしてきたBaseのManifestに対して、std.mergePatchを使ってJSON Merge Patch形式で上書きすることができます。
Patchの共通化
例えば、全てのContainerに共通のSecurity Contextを設定したい場合など、一部のフィールドを共通化する(Patchを共通化する)にはどうすれば良いでしょうか。
Kustomizeなら、Componentsを使うと良いでしょう。
apiVersion: kustomize.config.k8s.io/v1alpha1 kind: Component patches: - path: deployment.yaml target: kind: Deployment
- op: replace path: /spec/template/spec/containers/0/securityContext value: runAsNonRoot: true allowPrivilegeEscalation: false readOnlyRootFilesystem: true seccompProfile: type: RuntimeDefault capabilities: drop: - ALL
というComponentを作成し、
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml components: - ../components
というように利用すると、全てのDeploymentのContainer0に共通のSecurity Contextを設定できます。
Jsonnetだと、
{
securityContext: {
runAsNonRoot: true,
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
seccompProfile: {
type: 'RuntimeDefault',
},
capabilities: {
add: ['NET_BIND_SERVICE'],
drop: [
'all',
],
},
},
}
をあらかじめ定義しておき、各アプリケーションからは、
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'app',
},
spec: {
template: {
spec: {
containers: [
(import '../components/container.libsonnet') {
name: 'app',
image: 'registry.walnuts.dev/app:latest',
...
のようにインポートして利用します。
好きな階層でimportできるので、kindやコンテナ数に依存せずに共通化できるのが便利です。 また、必要があれば上書きも簡単にできます。
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'app',
},
spec: {
template: {
spec: {
containers: [
std.mergePatch((import '../components/container.libsonnet') {
name: 'app',
image: 'registry.walnuts.dev/app:latest',
}, {
securityContext: {
runAsNonRoot: false,
},
}),
...
のように、std.mergePatchを使うと、securityContext配下の特定のフィールドだけ上書きすることもできます。securityContext:: nullとすることで、securityContext自体を削除することも可能です。
リソース名の参照
DeploymentからConfigMapなど、別のリソースの名前を参照するときKustomizeではコピペしてくることになり、リソース名変更に対する追従が大変になりますが、Jsonnetでは、importを使って参照することで、リソース名の変更に強いManifestを作成できます。
volumes: [ { name: 'setting', configMap: { name: (import 'configmap.jsonnet').metadata.name, }, }, ],
このようにしておけば、configmap.jsonnetのmetadata.nameを変更したときに、参照側も自動的に追従します。
特に、Gateway API系のリソースは〇〇Refという記述が多いので非常に便利です。
{
apiVersion: 'gateway.networking.k8s.io/v1',
kind: 'HTTPRoute',
metadata: {
name: "app",
},
spec: {
parentRefs: [
{
name: (import '../shared-gateway/gateway.jsonnet').metadata.name,
namespace: (import '../shared-gateway/gateway.jsonnet').metadata.namespace,
},
],
hostnames: [
'app.example.walnuts.dev',
],
rules: [
{
backendRefs: [
{
kind: 'Service',
name: (import 'service.jsonnet').metadata.name,
port: 8080,
weight: 1,
},
],
matches: [
{
path: {
type: 'PathPrefix',
value: '/',
},
},
],
},
],
},
}
ConfigMap Generator
KustomizeにはConfigMap Generatorという機能があります。ConfigMapのdataフィールドの内容を外部ファイルから生成できたり、Suffixとしてハッシュを付与することでConfigMap変更時にPodを再起動させることができたりと便利な機能です。
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - deployment.yaml configMapGenerator: - name: app-config files: - ./_config/settings.yaml
便利な機能ですが、CRDなどではConfigMapのSuffixが参照側に反映されないため、nameReferenceを使って手動で修正する必要が出てきます。
Jsonnetでは、importstrを使って外部からファイルを読み込み、std.md5を使ってハッシュを生成することで、同様のことが実現できます。
{
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'app' + '-' + std.md5(std.toString($.data))[0:6],
},
data: {
'settings.yaml': (importstr '/_config/settings.yaml'),
},
}
参照側は、
(import 'configmap.jsonnet').metadata.name
のようにすれば、Suffixの計算を気にすることなく参照できます。明示的にインポートしてきているので、どんなリソースやフィールででも対応できます。
同じテンプレートに基づいたリソースを複数作成する
例えば、複数のNamespaceを作るとき、Namespace名の配列から生成できれば管理が楽なのに、と思うことはないでしょうか。 私は、Namespaceの定義をCIで自動生成したかったので、Namespace名を配列として管理できると便利でした。
Jsonnetなら、標準ライブラリのstd.mapを使って簡単に実現できます。
local namespaces = ['app-1', 'app-2', 'app-3']; local gen = function(namespace) { apiVersion: 'v1', kind: 'Namespace', metadata: { name: namespace, }, }; std.map(gen, namespaces)
生成結果
[ { "apiVersion": "v1", "kind": "Namespace", "metadata": { "name": "app-1" } }, { "apiVersion": "v1", "kind": "Namespace", "metadata": { "name": "app-2" } }, { "apiVersion": "v1", "kind": "Namespace", "metadata": { "name": "app-3" } } ]
もちろんnamespacesは別のJSONファイルに分割することもできます。
また、forを使うことでフィールドレベルでのループも可能です。
例えば、CloudNativePGのClusterにおいて、複数のユーザーを作成したい場合、
[ "user1", "user2", "user3" ]
...
roles: [{
name: user,
ensure: 'present',
login: true,
superuser: false,
inRoles: ['pg_monitor', 'pg_signal_backend'],
passwordSecret: {
name: user + '-db-password',
},
} for user in (import 'users.json')],
のように書くことができます。
生成結果
"roles": [ { "name": "user1", "ensure": "present", "login": true, "superuser": false, "inRoles": [ "pg_monitor", "pg_signal_backend" ], "passwordSecret": { "name": "user1-db-password" } }, { "name": "user2", "ensure": "present", "login": true, "superuser": false, "inRoles": [ "pg_monitor", "pg_signal_backend" ], "passwordSecret": { "name": "user2-db-password" } }, { "name": "user3", "ensure": "present", "login": true, "superuser": false, "inRoles": [ "pg_monitor", "pg_signal_backend" ], "passwordSecret": { "name": "user3-db-password" } } ]
Helm
Kustomizeでは、Helm Chart Generatorが存在し、Helm Chartから生成したManifestをKustomizeのリソースとして取り込むことができ、Patchを当てることも可能です。
apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization helmCharts: - name: loki repo: https://grafana.github.io/helm-charts releaseName: loki namespace: loki includeCRDs: true version: 6.49.0 valuesFile: values.yaml
Jsonnetでは流石にHelm Chartを直接扱うことはできませんが、Grafana TankaのHelm Supportを利用することで、Helm ChartをJsonnetで扱うことが可能です。
詳細は 公式ドキュメント を参照してください。
ArgoCDにおいても、ConfigManagementPluginを自分で定義してあげるとGrafana Tankaを利用できるようです(私はまだ試したことがありません、いつか使ってみたい)。
JsonnetでKubernetes Manifestを管理する欠点
Jsonnetは便利ではありますが、Kubernetes Manifestの管理で利用する際にはいくつか欠点があります。
カンマと中括弧
やはり一番のデメリットはこれです。ずっとYAMLでManifestを書いていると閉じ括弧の連続に嫌気がさしてきます。 とはいえ、LSPやVSCodeの拡張機能がGrafanaによって提供されているので、意外とどうにかなります。コードジャンプも効くので便利です。
カンマのつけ忘れに気付けない
例えば以下のような記述はJsonnetではエラーになりません。
{
env: [
{
name: 'ENV_A',
value: 'A',
}
{
name: 'ENV_B',
valueFrom: {
secretKeyRef: {
name: "app-secret",
key: 'ENV_B',
},
},
},
],
}
これをビルドすると、以下のように出力されます
{ "env": [ { "name": "ENV_B", "value": "A", "valueFrom": { "secretKeyRef": { "name": "app-secret", "key": "ENV_B" } } } ] }
よく見ると、ENV_AとENV_Bの間にカンマがありません。
Jsonnetでは{}の連続は、+演算子と同様にマージされるため、配列の要素間のカンマを忘れると、意図しないマージが発生してしまいます。
これは非常に厄介です。マージする際は+演算子を利用するようにして、{}の連続はLinterなどで検出するようにすると良いかもしれません(どうやって?)。
複数人での管理が難しい?
そもそもJsonnetに慣れている人が少ないこと、表現力の高さゆえにスタイルの統一が難しいことなどから、複数人での管理が難しくなるかもしれません。
まとめ
Jsonnetを用いてManifestを記述することで、より扱いやすい共通化が可能になったり、柔軟なManifest生成が可能になったりします。 おひとり様クラスターのManifest管理であれば割とアリな選択肢だと思うので、ぜひ試してみてください。
はてなエンジニア Advent Calendar 2025、明日12月29日の記事は
id:ma2sakaさんです。お楽しみに!