Jsonnet mixins で実現する環境別ブランチ運用からの脱却

こんにちは!ソフトウェアエンジニアとして活動している @nissy_dev です。

サイボウズでは、各プロダクトを新しい Kubernetes 基盤に移行する取り組みを進めています。この記事では、Kubernetes リソースの管理において、従来の環境別ブランチ運用から Jsonnet mixins を活用した単一ブランチ運用への移行について紹介します。


目次


ArgoCD での環境別ブランチ運用

Kubernetes リソースを管理するプロジェクトの構成は、次のようになっていました。

├─ namespace-a/               // ArgoCD で管理しているリソースの置き場
│  ├─ stg/                    // ステージング環境に適用するリソース
│  │  ├─ resource-a.jsonnet
│  │  ├─ resource-b.jsonnet
│  │  └─ ....
│  └─ prod/                   // プロダクション環境に適用するリソース
│     └─ ....
├─ namespace-b/              // ArgoCD で管理しているリソースの置き場
│  ├─ stg
│  └─ prod
└─ libs/                     // stg や prod で共通で使う libsonnet の置き場
   ├─ lib-a.libsonnet
   ├─ lib-b.libsonnet
   └─ ...

環境ごとにリソースの変更をデプロイしたい場合は、stg や prod ディレクトリ配下を更新する運用をしていました。一方で、stg と prod で共通して利用している libs 配下の libsonnet を修正したときは、まずステージング環境に適用して問題がないことを確認してから、プロダクション環境にも反映させるようにしたいです。

そのため、ステージングとプロダクションの両環境に対応するブランチ (stg, prod) を作成しました。ArgoCD ではそれぞれのブランチを環境ごとに対応させ、以下の方針で運用していました。

  • 修正は必ず先に stg ブランチへマージする
  • stg ブランチで問題がなければ、同じ変更を prod ブランチにも適用する

発生していた課題

一方で、この運用方法にはいくつかの課題がありました。

  • プロダクション環境のみに適用したい変更でも、必ず stg ブランチへのマージが必要になる
    • stg → prod への PR 作成は自動化していたものの、環境ごとにレビューが発生して手間が増えていました
  • stg ブランチに “まだプロダクション環境に適用したくない差分” が残っている場合、prod ブランチだけに修正を加えたい状況に対応しづらい
    • prod ブランチに直接差分を push する方法もありましたが、その場合は後で stg ブランチへ差分を cherry-pick する必要があり、環境間のリソース差分の解消が複雑になります
  • ディレクトリ階層で環境ごとのリソースをすでに分けている一方で、ブランチでも環境ごとの制御を行う必要性が分かりづらく、混乱の原因になりやすい

以下のブログ記事でも、環境ごとの複数ブランチ運用の問題点が詳しく解説されています。上記以外にも、環境が増えた際にスケールしづらいことなどが指摘されています。

Jsonnet mixins を使った環境別ブランチの廃止

この課題を踏まえて、環境別のブランチを廃止しつつ、各環境に安全にリソースの差分をデプロイする方法を考えました。この時にうまく活用できたのが Jsonnet mixins です。

Jsonnet mixins

Jsonnet mixins は、既存のオブジェクトに新しいオブジェクトを合成する機能です。+ 演算子を使ってオブジェクトのフィールドを再帰的にマージします。

local base = {
  apiVersion: 'v1',
  kind: 'ConfigMap',
  metadata: {
    name: 'app-config',
  },
  data: {
    env: 'stg',
  }
};

local mixin = {
  data+: {
    env: 'prod'
  }
};

base + mixin

この時 base + mixin は、次のように "env" の値が上書きされた json になります。

{
  "apiVersion": "v1",
  "kind": "ConfigMap",
  "metadata": {
    "name": "app-config"
  },
  "data": {
    "env": "prod"
  }
}

この mixins を使うことで、環境固有の設定を分離して管理し、共通設定のオブジェクトに対して各環境に必要な差分のみを適用できるようになります。

設計方法

libs ディレクトリを再構成し、全環境共通のベース設定を管理する base ディレクトリと、環境固有の設定を上書きするための mixin ディレクトリに分割しました。

└── libs/
    ├── base/       // 全ての環境で使うベース設定の置き場
    │   ├── base-a.libsonnet
    │   └── ...
    └── mixin/      // それぞれの環境で上書きする設定のための mixin の置き場
        ├── base-a-mixin.libsonnet
        └── ...

base ディレクトリに配置する libsonnet ファイル (libs/base/deployment.libsonnet) の例は、次のようになります。

// パラメータで mixin を受け取れるようにする
function(name, replicas=1, mixins=[])
  local base = {
    apiVersion: 'apps/v1',
    kind: 'Deployment',
    metadata: {
      name: name,
    },
    spec: {
      replicas: replicas,
      selector: {
        matchLabels: {
          'app.kubernetes.io/instance': name,
        },
      },
      template: {
        metadata: {
          labels: {
            'app.kubernetes.io/instance': name,
          },
        },
        spec: {
          containers: [
            {
              name: 'container',
              image: 'image:0.0.1',
            },
          ],
        },
      },
    },
  };

  // mixin を base に全てマージする
  std.foldl(function(acc, mixin) acc + mixin, mixins, base)

この関数は、全環境で共通するベースの Deployment リソースを生成し、mixins パラメータで渡された環境固有の設定を std.foldl を使ってマージする仕組みになっています。

mixin ディレクトリに配置する libsonnet ファイル (libs/mixin/grace-period.libsonnet) の例は、次のようになります。

function(seconds) {
  spec+: {
    template+: {
      spec+: {
        terminationGracePeriodSeconds: seconds,
      },
    },
  },
}

この関数は、受け取った seconds パラメータを使って Pod の terminationGracePeriodSeconds を設定する mixin を生成します。

上記のファイルを利用して、各環境の最終的なリソース (namespace-a/prod/resource-a.jsonnet) を生成します。

local Deployment = import 'base/deployment.libsonnet';
local GracefulPeriodMixin = import 'mixin/grace-period.libsonnet';

Deployment(
  name='sample',
  replicas=3,
  mixins=[GracefulPeriodMixin(30)]
)

この設計により、ベース設定を変更したい場合でもその変更を mixin に切り出すことで、stg と prod に段階的に適用することが可能になります。こうして、環境別ブランチ運用を廃止することができました。

トレードオフ

Jsonnet mixins を活用することで環境別ブランチ運用から脱却できましたが、いくつかのトレードオフも存在します。

パラメータと mixins の使い分けが難しい

設定を変更する際に、関数のパラメータで対応するか mixin で対応するかの判断が必要になります。あらかじめ全環境で変更する必要があるとわかっている値はパラメータでも十分だと思います。特定の環境でしか適用しない設定は mixin が適していると思います。この使い分けのガイドラインを明確にしておかないと、チーム内で実装方法が一貫しない可能性があります。

mixins では配列の上書きの実装が複雑になる

Jsonnet mixins は主にオブジェクトのマージを前提としており、配列の部分的な上書きは複雑になります。例えば、Pod の spec.containers は配列で複数のコンテナを定義できますが、その特定のコンテナの設定だけを変更する mixin は簡単に実装することができません。

例えば、mixin を使って次のように container の image の値を変更したいとします。

local Deployment = import 'base/deployment.libsonnet';
local OverrideImageMixin = import 'mixin/override-image.libsonnet';

Deployment(
  name='sample',
  replicas=3,
  mixins=[OverrideImageMixin("container", "image:0.0.2")]
)

この時の mixin の実装は次のようになり、std.mapstd.filter を使った複雑な実装になりがちです。

function(containerName, newImage) {
  spec+: {
    template+: {
      spec+: {
        containers: std.map(
          function(container)
            if container.name == containerName then
              container + { image: newImage }
            else
              container,
          super.containers
        ),
      },
    },
  },
}

このような mixin は最終的な Jsonnet のレンダリング結果をイメージするのが難しいです。このためチームでは、Jsonnet のレンダリング結果の差分を GitHub の PR コメントで通知するワークフローを実装しています。

Jsonnet のレンダリング結果の差分の PR コメント

まとめ

この記事では、ArgoCD における環境別ブランチ運用から Jsonnet mixins を活用した単一ブランチ運用への移行について紹介しました。

環境別ブランチ運用では、stg と prod のブランチ間の差分管理や cherry-pick の手間が課題となっていました。Jsonnet mixins を活用することで、ベース設定と環境固有の設定を分離し、単一ブランチでも各環境へ段階的にリソースを適用できるようになりました。

一方で、パラメータと mixin の使い分けや配列の上書きが複雑になるといったトレードオフも存在するため、これらを踏まえた上でチーム内で運用していくことが重要だと思います。

環境別ブランチ運用に課題を感じている方の参考になれば幸いです。