この記事はKubernetes Advent Calendar 2023の10日目の記事です。
はじめに
Helmチャートのテストと継続的な一貫性の確保は、Helmチャートを作成した経験のある方にとっては永遠についてくるタスクです。新機能やKubernetesのAPIへの変化に対応するためにHelmチャートに新しいプロパティなどを追加する場合など、バージョンアップしてもユーザーが保持するValuesに対して一貫性を維持する必要があります。
Helmチャートを直接作成していない場合でも、他の誰かが作成したHelmチャートを自身のクラスターにインストールするために作成したvaluesファイルがある限り、チャートがアップデートされていく過程で問題なく機能するか、またDeprecateされたプロパティがないか、期待される生成結果に変更がないかを確認する必要があります。
Helmの実行に必要なのは基本的に対象のチャートとValuesだけで、入力されたValuesに対して、マニフェストの全量が出力されます。
Valuesファイルのパターンに応じて出力が異なる部分と、Valuesの組み合わせによりその他の部分に影響がないことを確認する必要があるでしょう。
となると、思いつくのはWebのスナップショットテストです。
helm template YOUR_CHART -f VALUES.yaml
の実行結果をスナップショットとしてCIで継続的に取得し、差分を確認していけたらいいなと思いました。
そのためスナップショットが出来るツールをいくつか探したのですが、手軽に実行できるものを見つけることができず、Helmに特化したシンプルなスナップショットテストツールを作成しました。
Helmにはプラグイン機構があり、Helmプラグインとして作成したため、最後にそのナレッジもまとめたいと思います。
既存ツール
Helmチャートのスナップショットテストを行えるツールはすでにいくつかあります。1
-
helm-unittest
: Helmのユニットテストツールです。一般的な単体テストのassertによるテストに加えてスナップショットテストの機能もあります。
https://github.com/helm-unittest/helm-unittest#snapshot-testing
helm-unittestでスナップショットテストを行うには下記のようなテストスペックを用意します。
templates:
- templates/deployment.yaml
tests:
- it: manifest should match snapshot
asserts:
- matchSnapshot: {}
-
terratest
: IaCの包括的なテストツールで、Goでテストコードを記載します。Helmの実行結果を扱うパッケージがあります。ただスナップショットを取得するコードは自分で書かないといけなそうです。
調べた限りアクティブなプロジェクトは上記の2つかなと思います。
ただこれらはスナップショットテストに特化したツールではなく、単にhelm template YOUR_CHART -f VALUES.yaml
をやりたいだけの私としては、以下の点で求めていた機能が不足していました。
-
素のValuesファイルが使える:
helm template YOUR_CHART -f VALUES.yaml
をやりたいだけなので、あくまでも素のValuesファイルを用意するだけで実行できる手軽さが欲しいです。ツール固有のテストスペックファイルを用意したくありません。 -
生成結果全てを扱う: 確認したいのはテンプレートの一部ではなく、生成結果全てです。
例えばhelm-unittest
はファイルベースの考え方であるため、テストスペックファイルにどのファイルのスナップショットを撮るか指定する必要があり、以下のパターンではうまく機能しませんでした。
よくあるtemplateの構成としては次のような構成があると思います。
例1: ingressのオプションで、kind: Ingress
のマニフェストファイルを用意し、ファイル全体をifブロックで囲う。最近だとIngressリソースに加えてGatewayリソースのオプションも追加が必要な場合もあります。
例2: httpsのオプションで、cert-managerを利用するか、Helmの自己証明書生成機能で生成された証明書を使用するか選べる。cert-managerのkind: Certificate
のマニフェストファイルを用意し、ファイル全体がifブロックで囲う。
helm-unittest
のスナップショットでは、ファイルに対する生成結果が存在しない場合のテストを行うことができません。
-
Helm Functionへの対応: Helmにはランダム値や自己証明書などを動的を生成する機能があります。スナップショットテストにおいては動的に生成された項目のみ対象から除外したいですが、これらのツールは動的な値を扱うことができなかったり、扱う場合にコードを書く必要があります。
https://helm.sh/docs/chart_template_guide/function_list/#randalphanum-randalpha-randnumeric-and-randascii -
他のチャートに対するValuesファイルの確認: 自分で作成したHelmチャートだけでなく、他の人のHelmチャートに対して自分のValuesファイルで生成された結果を保持したいです。既存のツールはあくまで自分で作成したチャートを対象としており、できても手軽には取得できません。
helm-chartsnap
上記のような点からHelmに特化したシンプルなスナップショットテストツールを作成しました。
Helmにはプラグイン機構があるので、Helmプラグインとして作成しています。
実行イメージは下記のとおりです。
インストール
以下のコマンドでHelmプラグインとしてインストールできます。
helm plugin install https://github.com/jlandowner/helm-chartsnap
主な機能
手軽さ
まず、デフォルトのValuesファイルに対してスナップショットをとる場合はチャートを指定するだけです。
# デフォルトのValuesを使用する場合
helm chartsnap -c YOUR_CHART
もちろん特定のValuesファイルに対して実行することもできます。
# 特定のvaluesファイルを使用する場合
helm chartsnap -c YOUR_CHART -f https-enabled-values.yaml
テストケースはあくまで期待するパターンのvalues.yaml
を複数用意するだけです。
次の通り、まとめてスナップショットも撮れます。
# 複数のvaluesファイルを配置したディレクトリに対してまとめてスナップショットを取得する
helm chartsnap -c YOUR_CHART -f ./tests/
内部では単にhelm template
を実行しているだけなので、helm template
コマンドの任意のオプションが使えます。
(任意のオプションを渡せるようにパッケージを読み込むのではなく、helmのバイナリを実行するようにしました。)
# helm testをスキップする
helm chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --skip-tests
# 特定のリソースだけ出力する
chartsnap -c YOUR_CHART -f YOUR_TEST_VALUES_FILE -- --show-only SOMETHING
Jestと同様で、スナップショットファイルがない場合はスナップショットを作成し、スナップショットがあれば差分を出力します。また差分を更新する場合は-u
オプションをつけて実行するだけです。
# スナップショットの更新
helm chartsnap -c YOUR_CHART -u
外部のチャートのスナップショットの取得
単にhelm template
を実行しているだけなので、他の人のチャートのスナップショットも取得可能です。
例: ingress-nginxのHelmチャートのスナップショットを取得
# helm templateコマンドの`--repoオプション`を使用することで、事前にリポジトリの追加をすることなく実行できる
helm chartsnap -c ingress-nginx -f YOUR_VALUES_FILE -- --repo https://kubernetes.github.io/ingress-nginx --namespace kube-system --version 4.8.3
例: Hubbleを有効化したciliumのHelmチャートのスナップショットを取得
helm chartsnap -c cilium -- --repo https://helm.cilium.io --namespace kube-system --set hubble.relay.enabled=true --set hubble.ui.enabled=true
例: OCIレジストリに格納されたチャートのスナップショットを取得する
helm chartsnap -c oci://ghcr.io/nginxinc/charts/nginx-gateway-fabric -n nginx-gateway
その他の例はこちらを参照してください。
https://github.com/jlandowner/helm-chartsnap/tree/main/example/remote
Helmに特化した機能
差分箇所の詳細表示
Helm特化のツールであるため、単にスナップショットの差分を表示するのではなく、複数のYAMLで構成された出力結果を読み込んで、どのリソースの何行目の差分かを表示します。
Helm Functionによる動的な出力の取り扱い
Helm Functionを使用して動的な値となる箇所は、values.yamlファイル内にJSONPathで指定することで、実行毎に差分として出力されないようにできます。
# chartsnapの動作変更用のプロパティをValuesファイルに追加する
# この場合、生成された結果のうち`auth-env`という名前のSecretの`.data.SESSI0N_KEY`という項目が固定値に置換されます。
testSpec:
dynamicFields:
- apiVersion: v1
kind: Secret
name: auth-env
jsonPath:
- /data/SESSI0N_KEY
# 他は普通のValuesでOK
# ...
これにより対象箇所は固定値に置換され、スナップショット上で差分として現れてこないようになります。
GitHub Actionも用意しました
継続的にchartsnapを使用してスナップショットテストを実行するためのGitHubアクションも用意しました。これにより継続的にスナップショットを取得し、GitHub上でも差分の確認ができます。
GitHub ActionsのWorkflowファイルの例です。どのチャートにどのValuesファイルを取得して実行するかを指定するだけです🙌
# GitHub Actionでingress-nginxのスナップショットを取得し、差分があればプルリクエストを作成して通知する例
jobs:
chartsnap-ingress-nginx:
runs-on: ubuntu-latest
name: Do snapshot ingress-nginx and create PR if snapshot changed
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Chart Snapshot for ingress-nginx
uses: jlandowner/helm-chartsnap-action@v1
with:
repo: https://kubernetes.github.io/ingress-nginx # Helmリポジトリ
chart: ingress-nginx # チャート名
values: charts/remote/ingress-nginx.values.yaml # Valuesファイルパス
update_snapshot: true # スナップショットの差分があった場合にプルリクエストを作成する
このアクションを定期的に実行することで、以下を実現できます。
チャートの新バージョンがリリースされたことが検知できる。
スナップショットには実行したチャートやアプリケーションのバージョンが含まれるため、dependabotのように、チャートが更新されるとバージョン番号の差分が出ます。
以下のようなプルリクエストが作成されます。
GitHub上で差分を可視化できる。
差分が問題ない時はプルリクエストをマージするだけです!
Helmプラグインについて
今回Helmのプラグインを初めて作りました。Helmプラグインの歴史は長く、helm template
コマンドも元々プラグインとして実装されていたようです。少しわかりにくい点もあったので簡単にまとめておきたいと思います。
Helmプラグインの仕様
Helmプラグインは何かHelmコマンドにコードを組み込む機能ではなく、プラグインとして定義した任意のコマンドを実行できる機能です。
kubectlのkrewのように中央集権的なパッケージリポジトリはなく、Helmプラグインの仕様を定義したplugin.yaml
という名のファイルが配置されたGitリポジトリを指定するだけでプラグインとしてインストールが可能です。
Helmの公式ドキュメント上にRelated Projects and Documentationというページがあり、プラグインのリストがあります。Helmプラグインはプラグインリポジトリはないのでプラグインの全量がリストされているわけではなく、どういう基準でリストされているのかわかりませんでした。ので、とりあえずchartsnapnの追加をプルリクエストしてみたところすぐにマージしてくれましたので、有名かどうかに関わらず問題なければマージしてくれるのかと思いました。
plugin.yaml
の仕様は下記のとおりです。
いくつかのHelmプラグインリポジトリを参考に、chartsnapは以下のようなファイルにしました。
# helm plugin config file
# Ref: https://helm.sh/docs/topics/plugins/
name: chartsnap
version: 0.0.8
usage: Snapshot testing for Helm charts
description: Snapshot testing for Helm charts
command: "$HELM_PLUGIN_DIR/bin/chartsnap"
ignoreFlags: false
hooks:
install: "cd $HELM_PLUGIN_DIR; scripts/install_plugin.sh"
update: "cd $HELM_PLUGIN_DIR; scripts/install_plugin.sh"
helmのプラグインドキュメントを見ると他にもいくつかプロパティがありますが、確認した既存プラグインは概ね上記のようになってました。
インストールスクリプトも大体同じで、GitHubリリースから最新のtar.gzを取得して解凍するスクリプトを実行するようになっており、install, updateに関わらずGitHubの最新リリースを見に行くようになってました。そのためchartsnapも他のリポジトリから流用させていただきましたが、GitHubの最新リリースではなく、plugin.yaml
内のversion
のリリースを落とすように修正しました。
最後にコマンドラインオプションについてです。helmにはいくつかグローバルオプションがありますが、helmのプラグインのスクリプトに渡るコマンドラインオプションのうち、helmのグローバルオプションと同名のオプションはプラグイン側のコマンドラインオプションとして渡ってきません。
具体的には、例えば--debug
オプションをツールに実装したとして、単体で実行する時と、Helmプラグインとして実行するときで挙動が変わります。
今回のツールは次のようにGoでcobraパッケージを使用したCLIとして作成しています。
var (
o = &option{}
)
type option struct {
...
// Below properties are the same as helm global options
// They are passed to the plugin as environment variables
NamespaceFlag string
DebugFlag bool
}
Cobraにフラグとして--debug
や--namespace
を実装しています。これらはHelmのグローバルオプションと被っていますが、単体での実行時には必要なオプションです。
func main() {
...
rootCmd.PersistentFlags().BoolVar(&o.DebugFlag, "debug", false, "debug mode")
rootCmd.PersistentFlags().StringVar(&o.NamespaceFlag, "namespace", "default", "namespace. this flag is passed to 'helm template RELEASE_NAME CHART --values VALUES --namespace NAMESPACE' as 'NAMESPACE'")
...
}
これらのオプションを指定した場合、単体で実行する時と、Helmプラグインとして実行するときで挙動が変わります。
# 単体で実行する場合
go run main.go --debug # o.DebugFlagはtrueになる
# Helmプラグインとして実行する場合
helm chartsnap --debug # o.DebugFlagはfalseになる
Helmプラグインとして実行する場合、Helmのグローバルオプションは一度Helmコマンド側で消費され、プラグインには環境変数として渡ってきます。
そのため、開発やテストで単体でも実行可能とするためには、コマンドラインオプション+環境変数も見に行くように実装が必要です。
今回はフラグ用の変数名はXXXFlag
とし、オプションの値を参照する場合は以下のようにXXX()
関数を用意して呼び出すようにしました。
func (o *option) Debug() bool {
helmDebug, err := strconv.ParseBool(os.Getenv("HELM_DEBUG"))
if err == nil {
return helmDebug
}
return o.DebugFlag
}
func (o *option) Namespace() string {
helmNamespace := os.Getenv("HELM_NAMESPACE")
if helmNamespace != "" {
return helmNamespace
}
return o.NamespaceFlag
}
その他、渡ってくる環境変数の一覧は下記のとおりです。
https://helm.sh/docs/topics/plugins/#environment-variables
最後に
HelmのCIの一助となれば幸いです。
手軽に使えるので、ぜひ一度使ってみてフィードバックいただけると嬉しいです。