テンプレートエンジン — 文字列を作ってYAMLのふりをする仕事
一言でいうと
HelmはYAMLを編集しません。Goテンプレートで文字列を作り、作り終えてからはじめて、それをYAMLとしてパースします。
なぜ必要なのか
この一文を知らないと、1日を失います。resourcesブロックを入れたのにレンダリング結果から丸ごと消えたり、値を1つ入れただけなのにパーサーエラーが出たりします。原因はほとんどいつもインデントです。テンプレートエンジンにとってYAMLはただの文字であり、2スペースずれたブロックは、エラーではなく別の意味のドキュメントになります。
そのため、Helmを上手に使うとは、関数をたくさん覚えることではなく、「この場所に文字列がどんな形で入り込むか」を常に意識することです。toYaml、nindent、default、requiredの4つが実務の8割をカバーする理由も、すべてこの感覚に関係しています。
どう動くのか
テンプレートの処理は、パースと実行の2段階です。パースでテンプレートのテキストが構文木になり、実行でデータコンテキスト(ドット1つで表されるもの)を適用して、最終的な文字列を作ります。実行中に使える組み込みオブジェクトは、.Values(デフォルト値とユーザー値のマージ結果)、.Release(名前・ネームスペース・リビジョン)、.Chart(Chart.yamlの内容)、.Capabilities(クラスターがサポートするAPI)、.Files、.Templateです。
主要な関数は次のように分かれます。
| 関数 | いつ使うか | 落とし穴 |
|---|---|---|
default |
値が空のときに、代わりに使うものを決める | デフォルト値をlatestにすると、再現不可能なデプロイになる |
required |
なければ、レンダリング自体を失敗させる | メッセージに「何が欠けているか」を書かないと役に立たない |
toYaml |
マップやリストを丸ごと文字列に展開する | 単独で使うとインデントが合わない |
nindent |
前に改行を入れて、nスペース分インデントする | indentは改行がないので、最初の行が前のキーにくっついてしまう |
range |
リストやマップを展開する | マップを回すときは、キーの順序がソートされていて決定的 |
include |
名前付きテンプレートを呼び出す | templateは結果をそのまま出力するので、パイプでつなげない |
templateとincludeの違いは、些細に見えて決定的です。templateはレンダリング結果をその場にそのまま吐き出すので、後ろにパイプを付けられません。includeは結果を文字列として返すので、| nindent 4のような後処理をつなげて書けます。ラベルブロックのようにインデントが必要な場所は、すべてincludeを使う理由です。
値がどこから来るかにも、規則があります。弱いものから順に、チャートのvalues.yaml、-fで渡した値ファイル(左から右の順)、--setの順です。ここで、もう1つ覚えておくことがあります。マップは深くマージされますが、リストは丸ごと置き換えられます。環境変数をリストで設計しておくと、値ファイル1つで項目を1つだけ変えることが不可能になります。そのため、「上書きすることが多い値」は、マップで設計するほうがよいです。
最後に、慣例を1つ。ConfigMapの内容が変わっても、Podはそのまま残ります。Podスペックが変わっていないので、ロールアウトが起きないのです。そのため、Podテンプレートのアノテーションに、設定ファイルのハッシュをchecksum/configとして入れておきます。内容が変わればハッシュが変わり、ハッシュが変われば、Podスペックが変わって、ロールアウトが自然に発生します。
現場での姿
1つ目は、消えたブロックです。resourcesやnodeSelectorがレンダリング結果にまったくないなら、値が空か、インデントがずれているのです。フィールド1つが間違っていればエラーになりますが、ブロック全体のインデントがずれると、黙って別の場所にくっついたり、消えたりします。そのようなときは、helm templateの出力を目で見ることが唯一の診断方法です。
2つ目は、lookupの落とし穴です。クラスターを調べるlookup関数は、helm templateでは常に空の結果を返します。ローカルでうまく動いていた条件文が、実際のインストールでは違う動作をする、代表的な理由です。
3つ目は、再現性です。テンプレートの中でnowやランダム関数を使うと、レンダリングのたびに結果が変わり、毎回のデプロイが変更として検出されます。GitOpsツールを使うと、永遠に同期が終わらない状態になります。
テンプレートが静かに間違う場所
Helmテンプレートは文字列を作るツールなので、文法が合っていれば、意味が間違っていても通ります。よく引っかかる4つを決めておいて、確認します。
インデントがずれます。toYamlの結果は、インデントのない状態で出てくるので、nindentで合わせます。indentとnindentの違いは、前に改行を入れるかどうかです。
resources:
{{- toYaml .Values.resources | nindent 2 }}
空の値と存在しない値は別です。.Values.fooがなければ<no value>になり、その文字列がそのままYAMLに入ります。requiredで防ぐか、defaultで埋めます。
image: {{ required "image.repository 가 필요합니다" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
数値と文字列が入れ替わります。YAMLは1.0を数値として、"1.0"を文字列として読みます。タグが1.10なら、数値として読まれて1.1になる事故が、実際に起きます。タグやポートのように、文字列でなければならない値には、quoteを付けます。
tag: {{ .Values.image.tag | quote }}
ifとwithの範囲が違います。withは.を変えてしまうので、その中で.Valuesや.Releaseをそのまま使ってはいけません。そのときは、$で最上位を指します。
{{- with .Values.ingress }}
host: {{ .host }}
release: {{ $.Release.Name }}
{{- end }}
確認は3段階で行います。構文、結果、そして実際のクラスターとの差です。
helm lint .
helm template . -f values-prod.yaml | kubeconform -strict -
helm template . -f values-prod.yaml | kubectl diff -f -
helm templateはクラスターを見ないので、lookup関数は空の値を返します。それに依存するテンプレートは、レンダリング結果だけを見て判断できません。
次のラボですること
/root/helm/tpl/labhub-apiチャートで、defaultで空の値を埋め、requiredで必須の値を強制して、わざと失敗させてみます。toYamlとnindentでリソースのブロックを丸ごと渡し、rangeで環境変数を展開します。値ファイル2つと--setを同時に掛けて、何が勝つかを確認し、最後に、設定ハッシュとセキュリティコンテキストまで備えた完成形のレンダリングを作ります。