はじめに
GitOpsでKubernetesへデプロイする構成では、CIがGit上のデプロイ設定を自動更新することがあります。
このような構成で、イメージタグを手動で以前の値へ戻したあと、CIが生成したタグ更新コミットを環境ブランチへrebaseする際に、kustomization.yamlで競合が繰り返し発生するケースがあります。
この記事では、特定のシステムや実際の運用構成に依存しない一般化した例として、次を整理します。
- なぜ一度の手動切り戻しが、その後の自動デプロイでも競合を発生させるのか
- rebase時にGitから何が見えているのか
- どのように復旧を考えるべきか
- 再発防止として何を考えるべきか
ポイントは、GitOpsで管理する値を手動変更するときに、現在の値だけでなく、CIがどのGit履歴を前提として更新コミットを作るかまで確認することです。
CIがGitのイメージ参照を更新する構成
例として、コンテナイメージを作成したあと、CIがデプロイ用GitリポジトリのKustomize設定を更新し、GitOpsツールがその変更をKubernetesへ反映する構成を考えます。
環境ブランチを仮にenvironment-branchとします。kustomization.yamlでは、次のようにデプロイ対象のイメージタグを管理しているとします。
images:
- name: example-app
newTag: tag-A
また、別の管理ファイルにも現在のタグを保持している構成を考えます。通常はCIが両方を更新するため、値は一致しています。
kustomization.yaml : tag-A
管理ファイル : tag-A
Buildを再実行しても同じrebase競合が起きる
ある時点から自動デプロイが次のようなエラーで失敗するようになったとします。
Auto-merging path/to/kustomization.yaml
CONFLICT (content): Merge conflict in path/to/kustomization.yaml
error: could not apply <commit>... Update Image Tag to <new-tag>
単発の失敗ではなく、Buildを再実行しても同じnewTag付近でrebase競合が発生し続けることがあります。この場合、CIの実行環境や一時的な通信失敗ではなく、Git履歴そのものに原因が残っている可能性があります。
手動切り戻しで二つの管理値がずれる
手動切り戻しでkustomization.yamlだけを以前のイメージへ変更したとします。
kustomization.yaml : tag-B
管理ファイル : tag-A
一方、CIが作成していた自動更新コミットは、切り戻し前の状態を前提としていました。
tag-A → tag-C
これを手動変更後のenvironment-branchへrebaseすると、同じnewTag行について二つの変更が存在します。
tag-A
/ \
手動変更 tag-B tag-C CIの自動更新
Gitには、手動切り戻しのtag-BとCIのtag-Cのどちらを採用すべきか判断できません。競合の直接原因は、同じ行を別の値へ変更したことです。
設定ファイルだけを見ても「なぜ再実行しても直らないのか」は分かりません。CIのコミットがどの履歴を前提に作られたのかまで確認する必要があります。
rebaseはCIのコミットを新しい履歴へ適用し直す
rebaseは、単純に二つのブランチを結合する処理ではありません。自分のコミットの変更を、新しいベースの上へ適用し直します。
A --- B environment-branch
\
C CIが作った更新コミット
Cをenvironment-branchへrebaseすると、GitはCの変更をBの後へ適用し直します。
A --- B --- C'
しかしBとCが同じ行を別々の値へ変更していると、自動的には統合できません。実際にはコミットの差分を新しいベースへ再適用し、必要に応じて3-way mergeで統合可能かを判断します。Gitはどちらが正しい意図なのかを判断できません。
Buildの再実行では履歴のずれは消えない
競合の原因がGit履歴上に残っている場合、Buildを再実行しても同じ行について競合する状態が続きます。
Build失敗
↓
再実行
↓
同じGit履歴を前提に更新
↓
同じ箇所でrebase conflict
CIが毎回同じ古い前提から変更を作り直しているのであれば、単純な再実行では根本原因は解消しません。
復旧は履歴を同期し、CIが更新できる状態まで確認する
まず環境ブランチとCI側が前提としている履歴を確認し、必要であれば最新の履歴を取り込んだうえで競合を解消します。
競合マーカーを消すこと自体を目的にするのではなく、最終的に環境へデプロイすべきタグが何なのかを確認する必要があります。復旧の完了条件として、次の状態を確認します。
-
kustomization.yamlと関連する管理ファイルが意図した同じタグを指している - 新しいCI実行が最新の環境ブランチを基準に更新コミットを追加できる
- GitOpsツールがそのコミットを同期し、意図したイメージがデプロイされる
一度だけBuildが成功したかではなく、その後の自動更新が正常な状態へ戻ったかまで確認することが重要です。
rebase --skipはデプロイ対象を確認してから使う
競合しているコミットがデプロイ対象イメージを更新するものであれば、git rebase --skipによってタグ更新そのものが適用されません。
一方で、古いBuildを破棄し、最新HEADから新しいBuildを作り直す運用なら、古い生成コミットを採用しないこと自体は問題ありません。重要なのは競合を消すことではなく、最終的に意図したイメージタグがデプロイ対象になっていることです。git rebase --skip、--continue、--abortの意味は、git-rebaseの公式ドキュメントで確認できます。
再発を防ぐには更新者と更新手順を一つに寄せる
CIが自動管理する値を人間も直接変更できる構成では、同じ箇所を複数の主体が変更するため競合しやすくなります。
自動管理する値の責務を明確にする
images[].newTagなど、CIが更新する値は原則としてCIだけが変更する方が安全です。切り戻しも含めて同じ経路で更新できるようにすると、更新経路を追いやすくなります。
切り戻し専用のジョブを用意する
以前のイメージへ戻す必要がある場合は、Gitファイルを直接編集するのではなく、指定したタグへ切り戻すパイプラインを用意する方法があります。通常デプロイと切り戻しが同じ更新経路を通るため、状態の不整合を減らせます。
複数の管理値を同時に更新する
同じタグを複数の場所で管理する構成では、一方だけを変更すると状態が分かりにくくなります。手動操作が必要なら、関連する値を一貫した状態へ更新します。可能なら、同じ情報を複数箇所で保持する必要があるかを見直し、Single Source of Truthへ寄せます。
最新HEADから更新コミットを生成する
古い状態で作成した更新コミットを何度も最新ブランチへrebaseするより、毎回最新の環境ブランチを取得してから更新コミットを生成する方が競合に強くなります。
git fetch
↓
最新の environment-branch をcheckout
↓
newTagを更新
↓
commit
↓
push
取得後からpushまでの間に別の更新が入る競合はあり得ます。必要に応じて、push失敗時に最新状態から再生成する仕組みも検討します。
調査ではエラーログと履歴を時系列で対応付ける
CI/CDのGit競合では、エラーログだけを見るより、Git履歴を時系列で追うことが重要です。
最初に確認したいのは、成功していた最後のBuild、失敗した最初のBuild、その間に対象ファイルを変更したコミットです。そのうえで、競合した更新コミットの親、merge-base、同じ設定値を複数箇所で管理していないかを確認します。
単に「rebaseで競合した」という事実だけでなく、誰が、いつ、どの値を前提として同じ行を変更したのかを見ると原因を絞り込みやすくなります。
まとめ
手動切り戻しとCIの自動更新がkustomization.yamlの同じnewTag行を異なる値へ変更すると、CI生成コミットを環境ブランチへrebaseするときに競合します。
また、CI側の前提となる履歴とのずれが残っていれば、単純なBuild再実行では解消しません。GitOpsではGit上の設定ファイルがデプロイ状態そのものになるため、手動変更時は現在の値だけでなく、その後の自動化がどの履歴を前提として変更を適用するのかまで確認することが重要です。