Kubernetes を手を動かして学ぶ (4) ArgoCD による GitOps(Pull 型 CD)
Kubernetes を手を動かして学ぶシリーズの最終回(第4回)です。第3回で組んだ Push 型 CD(GitHub Actions が外からクラスタを kubectl apply で叩く方式)と対になる、Pull 型 CD(GitOps) を ArgoCD で導入します。Application マニフェストやワークフローなど、再現に必要なコードも載せます(リポジトリ名などはプレースホルダなので置き換えてください)。
- 第1回 コンテナ化とローカルクラスタの基礎
- 第2回 AWS EKS で本番相当の構成をつくる
- 第3回 CI/CD パイプラインと可観測性
- 第4回(本記事) ArgoCD による GitOps(Pull 型 CD)
このシリーズを通しての主眼は「デプロイ = クラスタを叩く」から「デプロイ = Git を書き換える」への発想の転換です。
なぜ Pull 型なのか:Push 型の構造的な弱点
第3回で組んだ Push 型 CD には、構造的な弱点があります。
弱点1:クラスタを外に晒す圧力がかかる。 「外部の CI からクラスタの API サーバーに到達できる必要がある」という要求そのものが、クラスタを外部公開する方向に働きます。GitHub-hosted runner の IP は固定できません。一方で EKS のパブリックエンドポイントの許可 CIDR にはクォータ上限(1クラスタあたり 40 件)があり、GitHub が公開する Actions の CIDR(数千件規模)を登録しきれないため、許可リスト方式は現実的に不可能です。結局 endpoint_public_access_cidrs を 0.0.0.0/0 に開放し、防御を IAM + Access Entry + RBAC の認証・認可レイヤーに寄せることになります。
弱点2:git の状態とクラスタの状態が自動では一致しない。 第3回で触れた「kubectl delete した状態が、次の push で勝手に戻る」——これが典型例です。Push 型は「apply した一瞬」だけ両者を合わせるので、その後の乖離は放置されます。
Pull 型(GitOps)では、クラスタ内のエージェント(ArgoCD)が Git を見に行くため、弱点1の「外から叩く」問題自体が消えます。そして弱点2の乖離は「Out of Sync」として可視化され、自動で埋められます。
graph LR
subgraph PUSH["Push 型(第3回)"]
CI1["GitHub Actions"] -->|"kubectl apply<br/>外からクラスタを叩く"| K1["EKS"]
end
subgraph PULL["Pull 型 GitOps(第4回)"]
CI2["GitHub Actions"] -->|"タグを Git に書き戻す"| GIT["deploy ブランチ"]
ARGO["ArgoCD(クラスタ内)"] -->|"Git を pull"| GIT
ARGO -->|"apply"| K2["EKS"]
end
ArgoCD の中核:reconciliation loop
GitOps の原理は、フレームワークの名前(OpenGitOps など)で語られがちですが、動作の実体は reconciliation loop(突き合わせループ) です。
ArgoCD がやっていることは、突き詰めると1つだけです。
Git(desired = あるべき姿)と、live(実クラスタの現状)を常時突き合わせ、ズレていたら live を Git に一致させる。
これを繰り返すだけです。ここが腑に落ちると、ArgoCD の機能がバラバラに見えなくなります。
graph LR
GIT["Git(desired = あるべき姿)"] --> CMP{"差分がある?"}
LIVE["live(実クラスタの現状)"] --> CMP
CMP -->|"ある"| ACT["live を Git に一致させる<br/>作る / 戻す / 消す"]
ACT --> LIVE
CMP -->|"ない"| WAIT["そのまま監視を続ける"]
- auto-sync(Git が変わったら追従)と self-heal(live が勝手に変わったら Git へ戻す)は、トリガーの向きが逆なだけで原理は同一
- prune は「Git から消えたリソースを live からも消す」
「戻す・消す・作る」は別々の機能ではなく、同じ突き合わせの3つの現れ方です。Push 型(イベント駆動 = apply した一瞬だけ)との決定的な差は、「乖離を継続監視して自動で埋め続ける」点にあります。
そして、ここでもシリーズを貫く「宣言と実行者の分離」が再演されます。
- Application(CRD = 宣言) … どの Git のどのパスを管理するか
- application-controller(実行者) … 差分を判定し、実行する
第1回の Ingress ⇔ Ingress Controller、第3回の ServiceMonitor ⇔ Operator ⇔ Prometheus 本体、Grafana の ConfigMap ⇔ サイドカー ⇔ 本体 と、まったく同じ形です。ArgoCD 内部でも、repo-server が kustomize build して desired を生成し、controller が reconcile し、server が UI/API を提供する、という役割分担になっています。
ArgoCD が見るのは「リモートの Git」
ここは勘違いしやすいポイントです。
Q. ArgoCD が見ているのは、手元のローカルファイルか、GitHub 上か?
「ローカル」と答えたくなりますが、正解は GitHub 上(リモートの Git) です。
より正確には、ArgoCD に見えるのは push 済みのリモートの Git だけです。ArgoCD の repo-server はクラスタ内で動いていて、ラップトップのファイルにはアクセスできません。commit して push したものだけが ArgoCD に見えます。
これは “single source of truth” の “Git” が何を指すのかを教えてくれます。それは手元ではなく GitHub 上のブランチ——「チーム全員が見られ、変更が全部履歴に残り、監査できる場所」です。ローカルは「自分にしか見えない truth」であり、source of truth の資格がありません。
この帰結として、private リポジトリでは ArgoCD に認証情報を渡す必要が出てきます。今回は Deploy Key(SSH read-only) を使います。1つのリポジトリだけを read-only で参照する構成では、個人アカウントに紐づかず権限も最小で済むため、シンプルで安全な選択肢です。
なお、ArgoCD は他にも複数の認証方式(HTTPS + アクセストークン、GitHub App など)をサポートしています。複数リポジトリや組織規模で運用するなら、実務では GitHub App 認証(fine-grained な権限で、個人アカウントにも特定リポジトリの鍵にも縛られない)がよく使われます。HTTPS + 個人アクセストークンは手軽ですが、特定の個人に依存しやすい点で GitHub App に劣ります。単一リポジトリの学習・小規模用途なら Deploy Key で十分、という整理です。
deploy ブランチ方式で無限ループを防ぐ
GitOps 化にあたっては、設計判断がいくつかあります。中心になるのが「イメージタグの反映方法」です。
CI がビルドした新しいイメージのタグ(git SHA)を、どこかで Git に書き戻さないと ArgoCD は気づけません。ここでクイズです。
Q. 素朴に main に直接タグを書き戻すと、何が起きる?
答えは 無限ループです。タグ書き換えの push が再び CD を起動し、それがまたタグを書き換えて push し……と止まりません。
解決策として、ソース(main)とデプロイ状態(deploy)を別ブランチに分離します。
main… ソースコードとマニフェストdeploy… レンダリング済みのタグを持つデプロイ状態
CD はワークフロー内で main → deploy を merge してタグを書き換え、deploy に push します。ArgoCD は deploy を追います。無限ループが起きない肝は、書き戻しが deploy への push であり、cd.yaml は main トリガーなので再起動しない点です([skip ci] すら要りません)。
main → deploy の merge でコンフリクトが起きないのも綺麗な点です。deploy と main の唯一の差分は kustomization.yaml の newTag 行で、CD がまさにその行を書き換えるためです。
この方式は実務でも一般的か
この deploy ブランチ方式は場当たり的なものではなく、GitOps でよく知られた考え方に沿っています。
- ArgoCD 公式のベストプラクティスは「アプリのソースとデプロイ設定(マニフェスト)を分ける」ことを推奨しており、その理由の1つに「同じリポジトリに書き戻すと CI が無限ループする」ことを挙げています。今回の
main/deploy分離は、これを1リポジトリ内のブランチ分割で実現した軽量版です。 - さらに「CI で kustomize / helm をレンダリングし、完成した YAML を ArgoCD が追う環境ブランチに commit する」やり方は、Rendered Manifests Pattern として知られる近年広がっているパターンそのものです(ArgoCD にも
sourceHydratorとして native サポートが入りつつあり、Kargo のような promotion ツールもこの上に作られています)。
一方で、これが唯一のベストというわけではありません。 よりスケールする構成では、次がよく使われます。
- 設定を別リポジトリに分ける(app repo と config repo の分離)。アクセス制御や監査が明確になり、複数サービス構成にも向きます(今回は学習のため、1リポジトリのブランチ分割で代用しています)。
- Argo CD Image Updater を使う。イメージタグの更新だけなら、レジストリを監視して Git に書き戻すこのツールで、CD 側の書き戻し処理そのものを無くせます(今回は「素の GitOps」を理解するため、あえて使っていません)。
まとめると、deploy ブランチ方式は学習・小規模には十分実用的で、考え方も実務のパターンに沿っています。 規模が大きくなったら、config リポジトリの分離や Image Updater / Kargo へ発展させる、という位置づけです。
GitOps 化で cd.yaml はどう変わったか
Push 型から Pull 型への移行で、CD パイプラインの中身は大きく変わります。
- 残るもの:
docker build/ push(イメージを作る仕事) - 消えるもの:
aws eks update-kubeconfig/kubectl apply/rollout status(クラスタを直接叩く仕事)は全削除 - 化けるもの:「タグ書き換え」は「
deployブランチへ merge +kustomize edit set image+ push」に
結果として、CI はクラスタに一度も触れません。 実際の apply は、クラスタ内の ArgoCD が実施します。これが Push 型との決定的な対比です。付随して、permissions.contents を read → write(deploy へ push するため)、checkout に fetch-depth: 0(merge に全履歴が要る)、そして第3回で触れた paths-ignore と workflow_dispatch を入れています。
第3回の Push 型 CD と見比べてみてください。後半の「クラスタを叩く」部分がまるごと「Git を書き換える」に置き換わっています。
# .github/workflows/cd.yaml(GitOps 版・後半の抜粋)
permissions:
id-token: write
contents: write # ← deploy ブランチへ push するため read から変更
# ... build & ECR push までは Push 型と同じ ...
- name: Deploy(= Git を書き換える)
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin
# deploy ブランチが無ければ main から作る(初回対策)
if git rev-parse --verify --quiet origin/deploy; then
git checkout deploy
git merge origin/main --no-edit
else
git checkout -b deploy origin/main
fi
cd k8s/overlays/eks
kustomize edit set image \
hello-app=${{ steps.login-ecr.outputs.registry }}/study-k8s:${{ github.sha }}
git commit -am "deploy: ${{ github.sha }}" || echo "no changes"
git push origin deploy # ← kubectl は一度も出てこない
opt-in 管理と、つまずきやすい DNS の地雷
ArgoCD 自体は、監視スタックや ALB Controller と同じく Terraform の helm_release で入れます(Service は ClusterIP に固定し、port-forward で使います。chart のデフォルトに任せて意図せず LoadBalancer になり課金・外部露出する事故を防ぐため)。
# argocd.tf(抜粋)
resource "helm_release" "argocd" {
name = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
namespace = "argocd"
create_namespace = true
values = [yamlencode({
server = { service = { type = "ClusterIP" } }
})]
}
ArgoCD は既存のワークロードを勝手に取り込みません(opt-in 管理)。 「どの Git のどのパスを見るか」は Application という CRD で宣言します。これを作って初めて、そこに書かれたリソースだけを管理し始めます。取り込み時に、live オブジェクトへ argocd.argoproj.io/tracking-id アノテーションを付けて「所有」を主張します。
# argocd/application-eks.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: study-k8s-app
namespace: argocd
spec:
project: default
source:
repoURL: git@github.com:<GITHUB_OWNER>/<REPO>.git
targetRevision: deploy # ← CD が書き戻す deploy ブランチを追う
path: k8s/overlays/eks
destination:
server: https://kubernetes.default.svc
namespace: default
syncPolicy:
automated:
selfHeal: true # live が勝手に変わったら Git に戻す
prune: true # Git から消えたリソースは live からも消す
既に稼働しているアプリに Application を当てると、状態は OutOfSync かつ Healthy になります。「動いている(Health)」と「Git と一致している(Sync)」は独立した軸で、この場合の差分の正体は、まだ付いていない tracking アノテーションです。手動 Sync でアノテーションが付き、Synced になります。
kind(Docker)上では、DNS の地雷も待っています。ArgoCD の repo-server の git fetch が、Could not resolve hostname github.com: Temporary failure in name resolution で2割ほど断続的に失敗する、という現象です。真因は、Kubernetes で有名な「5秒 DNS タイムアウト」問題。A/AAAA の並列クエリが conntrack でレースし、さらに ndots:5 による search ドメイン展開で1回の解決が最大5クエリに増幅されるために起きます。対策は、repo-server に dnsConfig(single-request-reopen + ndots:1 + タイムアウト調整)を効かせること。これで安定します。EKS の CoreDNS + VPC DNS は安定していて、この対策は不要です。
end-to-end:デプロイ = Git を書き換える
最後に、EKS 上での一気通貫の流れを整理します。
- PR を main にマージ
- CD が build → ECR push →
deployブランチにマージコミット SHA を書き戻し(git merge origin/mainもコンフリクトなく成功) - ArgoCD が
deployの新コミットを検知して自動同期 - Pod が新しいイメージで rolling update
- ALB 経由の
curlが新しいレスポンス(Hello, Kubernetes! (GitOps by ArgoCD))を返す
CI はクラスタを一度も叩かず、実 apply は EKS 内の ArgoCD が実施します。 第3回の Push 型と並べると、「誰がクラスタを変更するのか」がはっきり逆転しているのが分かります。
sequenceDiagram participant Dev as 開発者 participant Main as main ブランチ participant CD as GitHub Actions(CD) participant Dep as deploy ブランチ participant Argo as ArgoCD(EKS 内) Dev->>Main: PR をマージ Main->>CD: push トリガー CD->>CD: build → ECR push CD->>Dep: main を merge + newTag 書き換え + push Argo->>Dep: 変更を検知(pull) Argo->>Argo: EKS に apply(rolling update) Note over CD,Argo: CI はクラスタを一度も叩かない

なお、GitOps 化すると後片付けの手順が1つ増えます。auto-sync / self-heal を有効にしたまま kubectl delete すると、ArgoCD が即座に復元してしまい消せません(self-heal の裏返し)。terraform destroy の前に、まず ArgoCD の Application を削除(または auto-sync を止める)してから、第2回で学んだ「ALB Controller に後片付けをさせる」順序に進む必要があります。
シリーズ全体のまとめ
全4回を通して見えてくるのは、Kubernetes は「コマンドを覚える」対象ではなく「いくつかの原理の組み合わせ」だ、ということです。最初はバラバラに見える概念も、たどっていくと同じパターンの繰り返しになっています。
繰り返し登場する原理:
- 宣言と実行者の分離 … Ingress / Controller(第1回)、ServiceMonitor / Operator(第3回)、ConfigMap / サイドカー(第3回)、Application / ArgoCD コントローラ(第4回)。すべて「宣言(CRD)は保存されるだけ、それを見て動く実行者が別にいる」
pod-template-hash… テンプレートが変われば作り直し、変わらなければそのまま(第1回)→ イミュータブルなタグの理由(第3回)- IAM と Kubernetes RBAC の2層構造 … Access Entry が橋渡し(第2回・第3回)
- Push 型 vs Pull 型 … CD(第3回 / 第4回)と監視(第3回)で同じ対立軸
- CR の保存とその利用は独立レイヤー … ラベル忘れは apply が通るのに黙って無視される、というサイレント失敗(第3回で何度も)
この「宣言と実行者の分離」は、シリーズ全体でこれだけ繰り返し現れます。
graph LR A1["Ingress(第1回)"] --> B1["Ingress Controller"] A2["ServiceMonitor(第3回)"] --> B2["Prometheus Operator"] A3["dashboard ConfigMap(第3回)"] --> B3["Grafana サイドカー"] A4["Application(第4回)"] --> B4["ArgoCD controller"]
そして、たくさんの地雷から得た運用の勘所:
- state は「worktree を消しても消えない場所」に置く(第2回)
- コントローラが動的に作る Terraform 管理外リソースは、destroy 順序を間違えると取り残される(第2回)
failurePolicy: Failの admission webhook は、Pod が落ちると対象リソースを誰も作れなくする(第3回)- ヘルスチェック先に副作用を持たせてはいけない(第3回)
terraform planの差分は apply 前に必ず1行ずつ精査する(第2回)
まだ残している課題(graceful shutdown、Secret の実運用管理、metrics ポートの分離、TLS 終端など)もありますが、Kubernetes を「自分でひととおり動かせる」感覚には、だいぶ近づけた実感があります。
同じように手を動かして学ぶ人の、何かの助けになれば嬉しいです。