GitOps — 状態を定義する権限をリポジトリに渡す
一言でいうと
GitOpsはデプロイツールの名前ではなく、クラスターの状態を定義する権限はgitリポジトリにだけあるという所有権の宣言です。
なぜ必要なのか
kubectl applyでデプロイするチームでは、必ずこの質問が出ます。「今プロダクションで動いているのは、正確にどのコミットですか」。そして誰も自信を持って答えられません。先週の障害のとき、誰かがkubectl scaleでPodを増やし、誰かがkubectl editで環境変数を直し、その変更はどこにも記録されなかったからです。時間が経つと、クラスターはリポジトリのどこにもない状態へと漂流していきます。これがドリフトです。
ドリフトの本当のコストは「設定が少し違う」ことではなく、再現できないことです。クラスターを作り直さなければならないとき、リポジトリをそのまま適用しても、以前と同じシステムにはなりません。著者のホームラボでも、3ノードを7ノードに増やすとき、新しく加えたノードに以前のクラスターの名残(旧バージョンのkubelet、古い証明書)が残っていて、その違いを人が記憶に頼って合わせ始めた瞬間からミスが起きました。
GitOpsは、この問題を1つのルールで引っくり返します。リポジトリにないものはクラスターにあってはならず、リポジトリにあるものはクラスターに必ずなければなりません。そうすれば、「今何が動いているか」はgit logで答えられる質問になります。
どう動くのか
原則は4つで、4つすべてを満たして初めてGitOpsです。
| 原則 | 核心となる質問 | 破ったときの症状 |
|---|---|---|
| 宣言的 | 望む状態がコードで書かれているか | 再現不能、設定ドリフト |
| バージョン管理 | すべての変更がコミットとして残るか | 誰がいつなぜ変えたかわからない |
| 自動適用 | 人の手なしで適用されるか | デプロイの遅れ、ヒューマンエラー |
| 自己修復 | ずれたら自動で元に戻すか | ドリフトの蓄積、環境の不一致 |
3つ目がpull方式であることが、特に重要です。CIがクラスターに直接押し込むpush方式では、CIにクラスターの認証情報を持たせなければなりません。pull方式では、クラスター内のエージェントがリポジトリを読みに来るので、認証情報がクラスターの外に出ません。セキュリティ境界がまるごと変わる選択です。
実務のルールがあと2つ付きます。1つ目は、アプリのコードリポジトリとマニフェストリポジトリを分けることです。1つのリポジトリに置くと、アプリのCIが動くたびに同期がトリガーされ、コードレビューとデプロイレビューが1つのPRの中で混ざります。2つ目は、:latestタグを使わないことです。同じコミットが昨日と今日で違うイメージを立ち上げるなら、そのリポジトリはもう状態を定義できていません。イメージのタグは、コミットハッシュやセマンティックバージョンのように、一度決まったら変わらない値でなければなりません。
現場での姿
1つ目は、コミットメッセージがロールバック判断の唯一の根拠だということです。障害のさなかに人が見るのは、コードではなくgit log --onelineの1画面です。fix、updateのようなメッセージが10行積み上がっていると、どのコミットを元に戻せばよいのか選べません。GitOpsでロールバックはgit revertであり、revertする対象を選ぶことが、そのままコミットメッセージを読むことです。
2つ目は、急ぎの手当ては必ずコードに戻ってこなければならないということです。kubectl scaleでサービスを救ったなら、その値をリポジトリにコミットするか、リポジトリを基準に元に戻すか、どちらかをしなければなりません。そのままにしておくと、次の同期が静かにそれを元に戻し、同じ障害を再現します。Ansibleの-eやTerraformの手動変更と、まったく同じ構造の事故です。
3つ目は、リポジトリの構造がそのまま権限の境界だということです。著者のホームラボは、Gitea(10.0.0.200)にマニフェストを置き、Argo CD(10.0.0.201)がそれを読みます。ディレクトリをアプリ・環境ごとに分けておけば、あとでレビュアーとデプロイ権限をその境界のまま分けられます。逆に、1つのディレクトリにすべてを集めておくと、権限を分ける場所がありません。
GitOpsが実際に難しくなる3か所
原則は単純なのに、運用するといつも同じ3か所でつまずきます。
1つ目は、秘密はgitに上げられないことです。そのため、「すべてがgitにある」という原則が最初に崩れます。答えは次の3つのどれかです。暗号化してアップロードする(SOPS、sealed-secrets)、外部のストアを参照する(External Secrets)、そもそもクラスターの外から注入する、の3つです。前の2つはGitOpsと相性がよく、どちらを選んでも、鍵を管理する場所は依然として残ることを認めたうえで始める必要があります。
2つ目は、クラスターが自分で変える値があることです。HPAがreplicasを調整し、Webhookがアノテーションを付け、Operatorが状態を書き込みます。それをgitが元に戻すと、終わりのない巻き戻しのループが生まれます。そうしたフィールドは、無視するように書いておきます。
spec:
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers: ["/spec/replicas"]
3つ目は、急いでいるときに手で直したものが、静かに元に戻されることです。障害のときkubectl editで塞いでおいて忘れると、自動同期が数分後に元どおりに戻します。そのため、手で直したならその事実をgitに残す習慣が、手順の一部でなければなりません。自動同期を一時的に止めるのも1つの方法ですが、再び有効にするのを忘れると、そのときからgitと現実が分かれます。
Syncedの緑は「gitと同じ」という意味であって、「正しく動いている」という意味ではありません。マニフェストどおりに適用されていても、PodがCrashLoopBackOffになっていることはありえます。ヘルス状態は別に見て、デプロイが実際に終わったかは、イメージのダイジェストやリビジョンで確認します。
元に戻せることがGitOpsの最大の利点です。元に戻すことがそのままコミット1つを元に戻す作業になるので、何がどう変わったかの記録がひとりでに残ります。ただし、データベースのスキーマのように元に戻せない変更は、この利点の外にあるので、そのような変更は前後の互換性を保つように分けて出します。
次のラボですること
/root/gitops/repoにマニフェストリポジトリを作ってコミットし、その宣言をgitops-labネームスペースに適用します。そのあと、わざとkubectl scaleでドリフトを作って、kubectl diffがそれをどう捉えるか(差があれば終了コード1)を確認し、リポジトリを基準に元に戻します。最後に、diff → apply → 適用したコミットの記録までを行う同期スクリプトを自分で書いて、Argo CDコントローラーが代わりにやってくれることが正確に何なのかを、手で体験します。