3方向マージ — 何が戻され何が生き残るか
一言でいうと
Helm 3のアップグレードは、古いマニフェスト・新しいマニフェスト・クラスターの実物の3つでパッチを作り、そのため、チャートが宣言したフィールドは元に戻り、チャートが知らないフィールドは生き残ります。
なぜ必要なのか
障害対応は、たいてい次のように終わります。トラフィックが集中して、kubectl scale deploy/api --replicas=20で急いで上げ、あとで原因を探すために、kubectl label deploy/api owner=opsのような目印を付けておきます。状況が落ち着いて数日後、まったく関係のない機能が1つデプロイされます。そして翌朝、レプリカ数はまた1で、ラベルはそのまま付いています。
この非対称性が理解できないと、Helmは「ときどき私の変更を消すツール」になってしまいます。実際には、とても明確な規則が1つあるだけです。
3つの状態でパッチを作る
Helm 2は、古いマニフェストと新しいマニフェストの2つだけを比較しました。そのため、クラスターで人が変えた値は比較の対象ではなく、2つの間に差がなければ、パッチが何も送られず、人が変えた値が残りました。Helm 3は、ここにクラスターの実物をもう1つ加えました。
| 状況 | 古いマニフェスト | 新しいマニフェスト | 実物 | 結果 |
|---|---|---|---|---|
| 手でレプリカ数を変更 | 1 | 1 | 5 | 1に戻る |
| 手でラベルを追加 | なし | なし | あり | そのまま残る |
| チャートからラベルを削除 | あり | なし | あり | 消える |
1行目が、Helm 2と変わった点です。チャートがreplicasを宣言しているなら、そのフィールドの持ち主はチャートであり、実物が違っていれば、宣言側に合わせます。2行目は、チャートがそのフィールドを知らないので、パッチに含まれません。3行目は、古いマニフェストにはあって新しいマニフェストにはないので、「消せ」という意味として読まれます。
ここから、実務的な結論が1つ出ます。オートスケーラーが管理するreplicasは、チャートで宣言してはいけません。宣言しておくと、デプロイのたびに、オートスケーラーが決めた値をHelmが元に戻し、オートスケーラーがまた上げるという、綱引きが生じます。チャートからreplicasを外して、HPAに任せるのが定石です。
値はなぜ引き継がれないのか
2つ目の落とし穴は、オブジェクトではなく、値の側にあります。
helm upgrade api ./api --set replicas=4 --set logLevel=debug # 리비전 2
helm upgrade api ./api --set replicas=6 # 리비전 3
リビジョン3のユーザー値は、{replicas: 6}だけです。logLevelは消えて、チャートのデフォルト値に戻ります。これはバグではなく、デフォルトの動作です。アップグレードは、「今回渡した値」でreleaseを計算し直します。helm get values apiで確認できます。
例外が1つあり、これが人を最も混乱させます。値を1つも渡さなければ(--setも-fもなければ)、Helmは直前のreleaseのユーザー値をそのまま使います。そのため、helm upgrade api ./apiだけを実行すれば、前回の値が維持され、--setを1つでも渡した瞬間に、残りがすべて落ちます。「値を抜けばデフォルトに戻るだろう」という直感が、まさに逆に動作する場所です。前回の値を確実に捨てるには、--reset-valuesを明示します。
引き継ぎオプションは3つあり、名前が似ていて混乱します。
| オプション | やること |
|---|---|
--reuse-values |
前回のreleaseの値をそのまま引き継ぎ、今回の--setだけを載せる |
--reset-values |
前回の値を捨てて、チャートのデフォルト値の上に今回のものだけを載せる(デフォルトの動作と同じ) |
--reset-then-reuse-values |
チャートのデフォルト値に戻したあと、前回のユーザー値をもう一度載せ、今回のものを載せる |
--reuse-valuesと--reset-then-reuse-valuesの違いは、チャートのデフォルト値が変わったときに現れます。前者は、前回のreleaseが計算しておいた値をまるごと引き継ぐので、チャートの新しいデフォルト値が埋もれ、後者は、デフォルト値を先に新しく敷いてから、ユーザーが明示したものだけを載せ直すので、新しいデフォルト値が反映されます。チャートを上げるときにデフォルト値も一緒に上げた場合、この違いがデプロイ結果を分けます。
--forceはパッチではなく置き換え
--forceは、パッチの代わりにオブジェクトを置き換えます。ServiceのclusterIPのように、パッチでは変更できない不変フィールドにぶつかったときに使う、エスケープハッチですが、置き換えなので、該当のオブジェクトが一瞬消えて、また作られます。Deploymentなら、Podがすべて作り直され、Serviceなら、一時的にルーティングが途切れます。
置き換えには、もう1つ付いてきます。新しいマニフェストでまるごと差し替えるものなので、普段のアップグレードでは生き残っていた「チャートが知らないフィールド」まで消えます。手で付けておいたownerラベルは、普通のアップグレードではそのまま残りますが、--forceのあとはありません。「アップグレードがうまくいかないから、とりあえずforce」は、本番運用で最も高くつく習慣の1つです。
現場での姿
最もよくある事故は、デプロイスクリプトで--setが1つ抜けることです。値が8個くらいになると、誰かが1行を消し、その値だけが黙ってデフォルト値に戻ります。エラーは出ず、デプロイは成功として表示されます。この問題の定石的な解決策は、引き継ぎオプションではなく、値をファイルとしてリポジトリに置くことです。-f values-prod.yamlの1行があれば、何でデプロイしたかがコミットの記録に残り、コードレビューを経て、次の人が読めます。--reuse-valuesは、逆の方向に行きます。楽ですが、値がreleaseの中にだけ残り、リポジトリのどこにも見えません。
デプロイの前に何が変わるかを見たいなら、helm upgrade --dry-run=serverがあります。レンダリング結果を実際のAPIサーバーに送って検証までして、releaseは作りません。ただし、これは「検証」であって、「差分の表示」ではありません。何が変わるかを行単位で見るには、helm-diffのようなプラグインが必要で、このラボ環境にはありません。
次のラボですること
Deploymentをデプロイしておいて、クラスターでレプリカ数とラベルを手で変えたあと、チャートに何も手を加えないアップグレードを実行して、何が元に戻り、何が残るかを確認します。チャートからラベルを外したとき、クラスターからも消えることを確認し、続けて値の側に移って、デフォルトのアップグレードが以前のユーザー値を捨てることと、3つの引き継ぎオプションの違いを、releaseの記録で比較します。最後に、サーバー側のプレビューと--forceを1回ずつ使ってみて、規則をレポートにまとめます。