ドットが変わり行がくっつく — テンプレート事故の二つの根
一言でいうと
テンプレート事故の大部分は、文法ではなく2つから来ます。with・rangeがドットの指す対象を変えること、そして空白のトリムが改行を消してしまうことです。
なぜこれが問題になるのか
Helmのテンプレートで出るエラーは、たいていこんな形です。
Error: YAML parse error on frontier/templates/portal.yaml:
error converting YAML to JSON: yaml: line 5: mapping values are not allowed in this context
このメッセージは、YAMLパーサーが言ったものです。テンプレートをすべてレンダリングしたあとで、その結果をYAMLとして読もうとして失敗したので、メッセージに書かれた行番号は、レンダリング結果の行番号であって、テンプレートファイルの行番号ではありません。そのため、テンプレートの5行目をいくら見ても、何も問題がありません。犯人は、3行上のタグの末尾に付いた-}}の2文字ですが、その文字は、エラーのどこにも出てきません。
ここから抜け出す道は1つです。壊れた結果を直接見ることです。helm template --debugは、YAMLとしてパースできない結果も、そのまま出力します。出力を見ると、annotations:のあとにコメントのブロックが同じ行にくっついていて、さらにlabels:まで前の行の末尾にくっついているのが、一目でわかります。原因を推測する作業が、観察する作業に変わります。
ドットはブロックごとに変わる
withとrangeは、便利な構文ではなく、コンテキストを差し替えるブロックです。
{{- with .Values.app }}
data:
team: {{ .team }}
release: {{ .Release.Name }}
{{- end }}
ブロックの中で.は、もはやルートではなく.Values.appです。そのため、.teamは見つかり、.Releaseはありません。Helmは次のように言います。
nil pointer evaluating interface {}.Name
このメッセージも、原因を指していません。.Releaseがnilだという意味ですが、人の目には.Releaseは常にあるものなので、疑いません。解決策は、ルートを指す変数$です。$は、テンプレートが始まったときのコンテキストに結び付けられていて、どのブロックの中でも変わりません。{{ $.Release.Name }}と書けば済みます。
rangeも同じです。range $i, $e := .Values.envsのように変数を受け取っておけば、インデックスと要素を安全に使えて、ルートの値は$.Values...で取り出します。変数を受け取らず.だけを使っていて、その中で再びルートの値が必要になった瞬間に、行き詰まります。
indentとnindent、そして空白の除去記号
{{-はタグの前の空白と改行を消し、-}}はタグのあとの空白と改行を消します。事故の大部分は、後ろ側で起きます。次の行がまるごと前の行にくっつくからです。
indentとnindentの違いも、同じ軸にあります。
| 関数 | やること | 使う場所 |
|---|---|---|
indent 4 |
各行の前に4スペースを入れる | すでに行が変わっている場所 |
nindent 4 |
改行を先に入れて、4スペースを入れる | キーのすぐ下にブロックを差し込むとき |
annotations:の下にマップ1つを差し込む場所は、ほとんどいつもnindentです。ここでindentを使うと、ブロックの最初の行がannotations:と同じ行にくっついて、annotations: owner: sreのようなものが作られ、YAMLパーサーは、これを「マッピングの値が来る場所ではない」と拒否します。
引用符を外すと値の型が変わる
3つ目の落とし穴は、レンダリングが成功したあとで爆発します。
data:
tag: {{ .Values.release.tag }} # values 에는 "1.10"
enabled: {{ .Values.release.enabled }} # values 에는 "no"
レンダリング結果はtag: 1.10とenabled: noです。引用符がなくなったので、この値たちはもう文字列ではありません。YAML 1.1の規則を使うパーサーは、noを偽として読み、数値として読まれた1.10は末尾の0を失って1.1になります。ConfigMapのdataは文字列しか受け付けないので、APIサーバーが拒否します。
cannot unmarshal bool into Go struct field ConfigMap.data of type string
イメージタグ、バージョン、電話番号、国コードのように、人が文字列として扱う値は、テンプレートでquoteを通すのが基本です。逆に、数値でなければならない場所(replicas、port)にquoteを付けると、そちらで同じ種類の拒否が起きます。
現場での姿
この3つは、たいてい、デプロイパイプラインの異なる地点で捕まります。スコープと空白の事故は、レンダリングですぐに捕まりますが、引用符の事故は、クラスターが受け取って確認するまで生き残ります。そのため、helm templateだけを実行して「レンダリングできた」と済ませると、最も遅い地点で爆発します。CIにhelm template | kubectl apply --dry-run=serverを1行入れているチームが多い理由が、これです。スキーマと型を、本物のAPIサーバーが見てくれます。
デプロイの前に値そのものを止める仕組みも、一緒に使います。requiredは値がないとき、failは値が意味をなさないときに、レンダリングを止めます。どちらもメッセージを人が書くので、ここに「何をどう直すべきか」を書いておくと、障害の時間に読む人がすぐに動けます。そして、helm lint --strictは、デフォルトのリントが警告として知らせるだけのもの(大文字が入ったオブジェクト名など)を、失敗に変えてくれます。同じチャートが、helm lintでは0で終わり、--strictでは1で終わるのを一度見れば、CIにどちらを掛けるべきかがはっきりします。
次のラボですること
withの中で.Releaseが見つからないエラーを自分で起こして、$で直します。rangeで変数を受け取って、インデックスとルートの値を一緒に使います。空白のトリムをわざと間違えてレンダリングを壊したあと、--debugで壊れた結果を読んで、nindentで直します。引用符のない値がAPIサーバーに拒否されることを確認し、最後に、required・fail・helm lint --strictで、同じ事故がデプロイまで行かないように止めます。