リポジトリをどう割るか — そして昇格を一行のPRにする方法
一言でいうと
GitOpsで最初の設計判断は、ツールの選択ではなくリポジトリの境界です。アプリのソースとデプロイ設定を分けるのか、チームごとにリポジトリを分けるのか、環境をブランチで分けるのかディレクトリで分けるのか。この4つの問いへの答えが、その後のあらゆる運用コストを決めます。
なぜ必要なのか
アプリのリポジトリの中にk8s/ディレクトリを置き、マニフェストも一緒に管理するのは、最初は楽です。ところがすぐに、こんなことが起こります。
イメージタグを上げるコミットがアプリのリポジトリに入ると、そのコミットがまたCIを動かします。CIは新しいイメージを作り、そのタグをまたコミットします。無限ループです。回避しようと[skip ci]を付け始めると、その時点からパイプラインが乱雑になります。
2つ目の問題は権限です。アプリのコードに対するレビュー権限と、本番デプロイ設定に対するレビュー権限は、同じ人の集合ではありません。同じリポジトリにあるとCODEOWNERSで無理に分けることになり、1回のミスで突破されます。
3つ目は変更の周期です。アプリのコードは1日に10回変わり、デプロイ設定は1か月に1回変わります。履歴が混ざると、「このreplicasの値はいつから3だったのか」を探すために数百のコミットをかき分けることになります。
そのため実務での基本形は、アプリリポジトリ(コード+Dockerfile+CI)と設定リポジトリ(マニフェスト+kustomize/Helm values)を分けることです。試験ではこれをseparation of concernsとして問います。
どう動くのか
設定リポジトリをさらにいくつに分けるかが、モノレポ対ポリレポの論争です。
| モノレポ(設定が1つ) | ポリレポ(チーム/アプリごと) | |
|---|---|---|
| 一貫性 | 共通のベースを1か所で強制できます | リポジトリごとに標準が分かれます |
| RBAC | ディレクトリ単位なので細かい制御が難しいです | リポジトリの権限できれいに分離できます |
| アトミックな変更 | 複数のアプリを1つのPRでまとめて変更できます | リポジトリをまたぐ調整が必要です |
| エージェントの負荷 | 1つのリポジトリを複数のアプリが同時にポーリングします | 分散されます |
| 適した規模 | 1チームから複数チーム、アプリ数十個 | 複数の組織、アプリ数百個 |
環境の分離にも3つの方式があります。
- ブランチ分離:
dev/staging/mainブランチです。直感的ですが、ブランチ間のdiffに環境の差と時間の差が混ざって見え、cherry-pickの地獄が口を開けます。最近は推奨されません。 - ディレクトリ分離(オーバーレイ):
overlays/dev、overlays/stage、overlays/prodです。1つのコミットで3つの環境の差を並べて見られます。現在の事実上の標準です。 - リポジトリ分離: 本番設定だけを別のリポジトリにします。規制業界で、監査の境界をリポジトリで引くときに使います。
プロモーションを1行にする
よい構造を見分ける基準は1つです。「stageで問題なく動いていたものをprodに上げるPRのdiffは何行か」
ベースとオーバーレイを正しく分けていれば、答えは1行です。
# apps/checkout/overlays/prod/kustomization.yaml
images:
- name: ghcr.io/labhub/checkout
newTag: 1.5.0 # ← 1.4.0 에서 이 줄만 바뀐다
この1行だけのdiffがレビュアーに与える情報は非常に大きなものです。「prodへ向かう変更はイメージタグだけで、残りの設定はstageと同じ」ということが目で証明されるからです。逆に、プロモーションのPRにreplicas、リソース、環境変数も一緒に変わっているなら、それはプロモーションではなく新しいデプロイです。
ここでタグは不変でなければなりません。latestやブランチ名のタグを使うと、Gitのコミットは変わらないのに実際に動くイメージが変わってしまいます。望ましい状態をGitが完全には決められなくなるので、2つ目の原則が崩れます。
app-of-apps
アプリが増えると、Argo CDのApplicationリソースそのものも数十個になります。これを手で作ると、「デプロイツールをデプロイする手順」が再び手作業になります。app-of-appsは、この再帰を閉じるパターンです。
ルートのApplicationが1つbootstrap/ディレクトリを指し、そのディレクトリの中には子のApplicationのマニフェストが入っています。ルートを同期すると子のApplicationが作られ、子がそれぞれ自分のアプリを同期します。新しいアプリの追加は、bootstrap/にファイルを1つコミットする作業になります。
似た目的のApplicationSetとは性格が違います。app-of-appsは子をファイルで明示し、ApplicationSetはジェネレーター(ディレクトリのスキャン、クラスターの一覧、PRの一覧)で子を計算します。アプリの一覧を明示的にしておきたいならapp-of-apps、一覧が頻繁に変わりルールで表現できるならApplicationSetです。
現場での姿
筆者のホームラボでは、Argo CDが10.0.0.201、Giteaが10.0.0.200です。同じクラスター内のGiteaを、Argo CDが取りに行きます。この構成で面白い落とし穴は、ブートストラップの順序です。Giteaが落ちるとArgo CDは望ましい状態を読めず、Gitea自身のマニフェストもそのGiteaの中にあります。そのため、GitOpsで管理する領域と手でブートストラップする領域の境界をどこに引くかが、実際の設計問題として現れます。
もう1つあります。このクラスターのGateway APIには、CRDのv1.6.1が必要でした。v1.2ではtlsroutesとreferencegrantsがv1ではなく、Ciliumのゲートウェイコントローラーが起動を拒否しました。CRDのバージョンのようなクラスターの前提条件は、アプリのオーバーレイではなくプラットフォーム層のリポジトリに置き、sync waveを前倒しにしなければならないことを、こうした事故が教えてくれます。
次のラボですること
/root/cgoa-repo/の下にapps/checkout/baseとoverlays/dev|stage|prodを自分で作り、kubectl kustomizeで環境ごとのレンダリング結果がどう分かれるかを確認します。そのあと、Applicationマニフェストとapp-of-appsのルート、AppProjectまでをファイルとして書きます。続くラボでは、同じ構造を実際のクラスターにkubectl apply -kで載せてみます。