はじめに
知りすぎていた。
ある PR のレビューで preview 環境が動かなくなった。原因を調べると、preview を立ち上げる shell script が別の PR の修正で壊れていた。preview を作る仕組みが、preview とは無関係な変更で壊れる。依存の逆転だった。
気になったのは script のバグではなかった。script が preview の構成を知りすぎていたことだった。namespace の命名規則。Ingress の host 名。backend の引数。全部 script に書いてある。PR が増えるたびに分岐が増え、クリーンアップも script が背負う。動いていた。動いていたから、誰も手を入れなかった。preview 環境というより、script の生態系だった。
作り直すなら、script に構成を持たせたくなかった。Argo CD には ApplicationSet という仕組みがあり、テンプレートと入力データから Application(=デプロイ対象)を自動生成できます。これを使えば、preview の構成を script ではなく Argo CD 側に持たせられるはずでした。
フロントエンドだけなら、PR を作ると自動で preview URL が出るサービスは珍しくありません。ですが、実際の機能追加では backend まで変わることが多く、frontend だけ確認できても安心できません。PR は 1 本ではなく、複数本並ぶことも普通にあります。今回やりたかったのは、PR ごとに backend を含む preview 環境が自動で立ち上がり、script が環境の構成を知らなくて済む仕組みです。
ただし、今回 Kind 上で確かめたのは PR 作成イベントそのものではありません。ローカルでは git files generator に metadata file を流し込みました。本番では pullRequest generator に置き換える前提で、preview の構成と create/delete の挙動を検証しました。ここで確認したかったのは「作れるか」ではなく、「script を主役にせずに Argo CD の機能でどこまで閉じられるか」です。Kind 上で構成を固め、あとから GitHub やクラウド側へ持っていく方が筋が良いと判断しました。
Argo CD 自体の考え方を整理するうえでは、関連書籍も参考にしました。
目指したもの
今回のゴールは 3 つありました。1 つ目は、PR ごとに frontend + backend を同じ host で確認できることです。2 つ目は、preview ごとの差分を shell script ではなく Argo CD の template 側に寄せることです。3 つ目は、create だけでなく delete の挙動まで ローカルで検証できることでした。
ここで重要だったのは、「PR ごとにオーバーレイディレクトリを量産しない」ことです。PR ごとの kustomization.yaml を毎回 script で生成する構成は動きますが、manifest の増殖とクリーンアップの責務まで script に背負わせがちです。今回はそこを避けて、PR ごとの差分を metadata file だけに寄せる構成を選びました。
preview に限らず、「最初は小さな script」が肥大化するパターンには構造的な理由があります。最初の PR preview は 30 行の shell script で動きます。次に並列 PR 対応が入り、その次にクリーンアップが入り、host 名の衝突回避が入る。機能追加のたびに script は太り、しかもその script 自体をテストする仕組みは誰も作りません。script が環境の構成を持っている限り、この膨張は止まりません。構成を宣言側に移すことでしか、この構造は変わらない。少なくとも自分はそう考えました。正しいかは分からない。ただ、太い script を何度か書き直した末の結論ではあります。
最終的な構成
repository は 2 つに分けました。1 つは Kind 設定や Argo CD manifest を置く control repo、もう 1 つは Argo CD が実際に同期する mirror repo です。Argo CD は mirror repo だけを見て、control repo はあくまで検証環境の土台に徹します。
ローカルではこの mirror repo を bare repo に push し、Kind 内の git daemon から配っています。常用構成としては Gitea や Forgejo の方が素直ですが、Argo CD が「別 repo を同期して preview を作る」流れを閉じた環境で確かめるには十分でした。少なくとも、PR preview の基本動作を固めるにはこのくらいの簡素さでよかったです。
実際のブートストラップ設定はかなり小さくしています。cluster 名は argocd-preview、Argo CD は v3.3.6 です。Ingress には Kind 向けの ingress-nginx manifest をそのまま使い、repo-server の cache だけ ローカル向けに短くしました。
CLUSTER_NAME=${CLUSTER_NAME:-argocd-preview} ARGOCD_VERSION=${ARGOCD_VERSION:-v3.3.6} kubectl apply -f \ https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml kubectl patch configmap argocd-cmd-params-cm -n argocd \ --type merge \ -p '{"data":{"reposerver.repo.cache.expiration":"15s","reposerver.default.cache.expiration":"15s"}}'
この 15s は 本番の推奨値ではなく、あくまで ローカルフィードバックを速くするための設定です。ApplicationSet 側の requeueAfterSeconds: 20 と合わせて、metadata file の更新が数分単位で遅れないようにしています。
script を薄くするためにやったこと
正直に言えば、この構成にたどり着くまでに、何本か太い script を書いています。最初は素直に script で namespace を作り、manifest を生成し、kubectl apply していました。動いた。動いたが、2 本目の PR preview を足した時点で script の分岐が倍になり、delete を足した時点でさらに倍になった。script を薄くしたいと思ったのは、美学の問題ではありません。太い script のメンテナンスに疲れたからです。
今回いちばん効いたのは、ApplicationSet の git files generator と templatePatch の組み合わせです。git files generator は Git リポジトリ内の YAML ファイルを入力として読み取り、ファイルごとに Application を生成します。templatePatch は生成された Application ごとに manifest の一部を差し替える仕組みです。PR ごとの差分は preview-metadata/*.yaml にだけ置きます。たとえば 1 件分の入力は次の 2 行だけです。
prNumber: 301 commitSha: a1b2c3d
Argo CD 側ではこの file を拾って、preview 名、namespace、host 名、backend の応答文を template から組み立てます。つまり script がやるのは metadata file の更新までで、preview の構成そのものは Argo CD が持ちます。これが今回の本題で、script で preview を作ったのではありません。Argo CD に preview を作らせるための最小入力だけを script が渡す構成です。
apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: pr-previews namespace: argocd spec: goTemplate: true goTemplateOptions: - missingkey=error generators: - git: repoURL: git://git-mirror.git-mirror.svc.cluster.local/argocd-pr-preview-demo-mirror.git revision: main requeueAfterSeconds: 20 files: - path: preview-metadata/*.yaml values: previewId: "pr-{{ .prNumber }}" host: "pr-{{ .prNumber }}.preview.localtest.me" template: metadata: name: "{{ .values.previewId }}" finalizers: - resources-finalizer.argocd.argoproj.io spec: project: previews source: repoURL: git://git-mirror.git-mirror.svc.cluster.local/argocd-pr-preview-demo-mirror.git targetRevision: main path: base/app destination: server: https://kubernetes.default.svc namespace: "{{ .values.previewId }}" syncPolicy: automated: prune: true selfHeal: true templatePatch: | spec: source: kustomize: namespace: '{{ .values.previewId }}' patches: - target: kind: Namespace name: preview patch: |- - op: replace path: /metadata/name value: {{ .values.previewId | quote }} - target: kind: Ingress name: preview patch: |- - op: replace path: /spec/rules/0/host value: {{ .values.host | quote }} - target: kind: Deployment name: backend patch: |- - op: replace path: /spec/template/spec/containers/0/args/1 value: {{ printf "-text=preview=%s commit=%s" .values.previewId .commitSha | quote }}
ここでは要点だけを抜いていますが、syncPolicy.automated.prune: true を入れておかないと delete の検証になりません。templatePatch 側でも、host だけでなく Namespace 名と backend の応答文を差し替えています。この構成にすると、PR ごとの差分は manifest の directory 構造ではなく、metadata の値として扱えます。git directories generator(ディレクトリ単位で Application を生成する方式)より git files generator の方がしっくりきたのはそのためです。
ただし、この「ほとんどオーバーレイが要らない」は前提に依存しています。今回は同一 image の引数違いで済む構成だから成立しているのであって、PR ごとに backend image 自体を差し替えたいときや、feature flag の組み合わせを変えたいときには足りません。前提が変われば構成も変わる。万能ではないことは書いておく必要があります。
加えて、AppProject 側も preview 用にかなり絞っています。source repo は ローカルミラーと本番用の GitHub mirror だけです。destination は pr-* namespace だけにし、許可 resource も Namespace、ConfigMap、Service、Deployment、Ingress までに限定しました。preview 用の project なので、ここは広げすぎない方が扱いやすいです。
apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: name: previews namespace: argocd spec: sourceRepos: - git://git-mirror.git-mirror.svc.cluster.local/argocd-pr-preview-demo-mirror.git - https://github.com/your-org/your-gitops-mirror.git destinations: - namespace: "pr-*" server: https://kubernetes.default.svc clusterResourceWhitelist: - group: "" kind: Namespace namespaceResourceWhitelist: - group: "" kind: ConfigMap - group: "" kind: Service - group: apps kind: Deployment - group: networking.k8s.io kind: Ingress
ベースマニフェスト側も、PR ごとに変わる部分と変わらない部分をはっきり分けました。変わらないのは frontend と backend の基本構成です。frontend は nginx:1.29-alpine、backend は hashicorp/http-echo:1.0.0 を使います。Ingress は最初は placeholder.preview.localtest.me で定義しておき、実 host 名だけを templatePatch で差し替えます。Namespace も最初は preview というプレースホルダー名で持っておき、PR ごとに pr-301 のような名前へ patch しています。
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: preview spec: ingressClassName: nginx rules: - host: placeholder.preview.localtest.me http: paths: - path: /api pathType: Prefix backend: service: name: backend port: name: http - path: / pathType: Prefix backend: service: name: frontend port: name: http
この形にすると、PR ごとの差分は「metadata file の 2 行」と「ApplicationSet template の patch」に閉じます。ベースマニフェストに PR 番号を埋め込まなくてよいので、構成の見通しがかなり良くなりました。
関連ドキュメントは次のとおりです。
- Git generator: argo-cd.readthedocs.io
- Template: argo-cd.readthedocs.io
- GoTemplate: argo-cd.readthedocs.io
本来の運用では PR 作成をトリガーにしたい
ここは誤解のないように書いておきます。今回 ローカルで使ったのは git files generator です。これは「PR を file で模擬する」ための手段であり、本命ではありません。本来やりたいことは、PR を作成したら自動で preview 環境が立つことです。その本命が pullRequest generator です。
pullRequest generator を使えば、PR の number や head_sha をそのまま template に流し込めます。つまり 本番では「PR 作成・更新を generator が検知し、ApplicationSet が Application を生成し、Argo CD が sync する」という流れに寄せるのが自然です。ローカルでは GitHub token なしで閉じた検証をしたかったので file generator を使いましたが、設計の中心は最初から PR 起点の自動化にありました。
ただし 本番で pullRequest generator を使うなら、実装だけではなく権限設計も必要です。ApplicationSet は一度に多くの Application を作成・削除できます。誰が ApplicationSet を更新できるか、generator の source of truth を誰が触れるかは、先に決めておく必要があります。
このとき ローカルヘルパーがやっていることもかなり限定的です。preview-upsert.sh は preview-metadata/pr-301.yaml のような file を書いて mirror repo に push します。必要なら argocd.argoproj.io/application-set-refresh=true annotation を付けて refresh を促すだけです。運用ではこの部分を GitHub webhook と PR generator に置き換えたいので、ヘルパーには「Argo CD が食べる入力を作る」以上の責務を持たせないようにしました。
実際のヘルパーはこの程度です。PR 番号と commit SHA を file に書き、mirror repo へ commit して push します。Argo CD の ApplicationSet がその結果を読んで Application を作るので、ヘルパー自体は Kubernetes manifest を直接 apply しません。
cat <<EOF >"$PREVIEW_FILE" prNumber: $PR_NUMBER commitSha: $COMMIT_SHA EOF git -C "$MIRROR_REPO_DIR" add "$PREVIEW_FILE" git -C "$MIRROR_REPO_DIR" commit -m "feat(preview): upsert $PREVIEW_ID" "$SCRIPT_DIR/publish-mirror-repo.sh" kubectl annotate applicationset -n argocd pr-previews \ argocd.argoproj.io/application-set-refresh=true --overwrite
delete 側も同じで、preview-metadata/pr-301.yaml を消して push するだけです。この形にしておくと、create と delete の入力が完全に対称になります。manifest の生成と削除をヘルパーが持たないので、ローカル検証コードの責務がかなり明確です。create と delete が対称であること。これが、後から破綻しない設計の最低条件です。 片方だけ綺麗に作って、もう片方を後回しにした仕組みを何度も見てきました。
関連ドキュメントは次のとおりです。
- Pull Request generator: argo-cd.readthedocs.io
- ApplicationSet security: argo-cd.readthedocs.io
実際に通した確認
Kind 上で ingress-nginx と Argo CD を立て、pr-301 と pr-302 を作成して確認しました。frontend は preview host の /api を叩き、backend の応答をそのまま画面に表示する単純な構成です。http://pr-301.preview.localtest.me:8080/api は preview=pr-301 commit=a1b2c3d を返します。pr-302 でも同様に preview=pr-302 commit=d4e5f6g を返すところまで見ています。


画面側も実際にブラウザで確認しました。preview host へアクセスして backend の応答が表示されるところまで見ています。host 名は *.preview.localtest.me を使っているので、ローカル DNS を別でいじらなくても 127.0.0.1 に解決されます。つまり今回の検証は「Application が出た」ではなく、「host 単位で frontend から backend まで通った」ことを確認したものです。
確認コマンドも固定化しました。smoke-test.sh は Kind 起動、preview 作成、Application の Synced 待ち、frontend/backend の rollout 待ち、最後に curl までをひとまとめにしています。
./scripts/bootstrap-kind.sh ./scripts/preview-upsert.sh 301 a1b2c3d kubectl wait -n argocd \ --for=jsonpath='{.status.sync.status}'=Synced \ application/pr-301 \ --timeout=300s kubectl rollout status deployment/frontend -n pr-301 --timeout=300s kubectl rollout status deployment/backend -n pr-301 --timeout=300s curl -H 'Host: pr-301.preview.localtest.me' http://127.0.0.1:8080/ curl -H 'Host: pr-301.preview.localtest.me' http://127.0.0.1:8080/api
Kind では Ingress の都合で Application の Health が Progressing のまま残ることがあります。そのため、ここでは Health の緑表示よりも Synced と frontend/backend の疎通を重視しました。
このとき backend から返ってきたのは preview=pr-301 commit=a1b2c3d でした。frontend 側はその文字列を /api 経由で受け取って画面に表示するだけの簡素なものです。それでも、preview host 単位で frontend と backend が一緒に差し替わることを確認するには十分でした。
ハマったところ
最初にぶつかったのは、Git generator のポーリングと repo-server cache の関係でした。requeueAfterSeconds を短くしても、repo-server 側の revision cache が長いと mirror repo の更新がすぐ見えません。ローカルではフィードバックを速くしたかったので、ポーリング間隔だけでなく cache 側も短めにしました。
次に効いたのは resources-finalizer.argocd.argoproj.io です。これを generated Application に付けないと、delete 時に child resources のクリーンアップが甘くなります。Deployment、Service、Ingress、ConfigMap を Argo CD 側で消し切りたいなら、finalizer はほぼ必須でした。
Git mirror 側にも地味な罠がありました。bare repo の HEAD が main を向いていないと checkout が壊れます。ヘルパーを並列に叩くと .git/config.lock 相当の競合も踏みます。今回は HEAD を refs/heads/main に固定し、ヘルパー側には ロックディレクトリを入れて回避しました。
mirror repo 初期化の細かい設定も、実際にはかなり重要でした。bare repo 側では symbolic-ref HEAD refs/heads/main を明示しないと clone 後の checkout が不安定です。git daemon に渡す repo も update-server-info まで済ませておく必要がありました。ローカルではこのあたりを雑にすると、Argo CD の問題ではなく Git 配信の問題で詰まります。Argo CD を検証しているはずが、git daemon の設定と格闘している。半日かかりました。「ApplicationSet の構成を確認する」が目的だったはずが、bare repo の HEAD がどこを向いているかで午前中が消えた。ローカル検証の宿命です。
ただし、そこで終わりではありませんでした。child resources は消えても destination namespace 自体は残りました。つまり「PR が閉じたら namespace まで完全に消える」とまでは、今回の検証では言い切れませんでした。delete 設計の最後の 1 手は、まだ残っています。
おわりに
preview 環境を作ること自体は、30 分もあればできる。namespace を切って、manifest を apply して、Ingress を向ければ動く。30 分で作れるものを、3 ヶ月壊さない形にすること。それが難しかった。
今回の検証で見えたのは、PR ごとの差分を metadata に閉じ込め、環境の構成は ApplicationSet template で持つという構成が、少なくともこの目的には筋が良いということです。script は metadata file の更新だけを担い、preview の作成も削除も Argo CD 側の責務になります。こうすると script が環境の構成を知らなくて済む。知らなくて済むということは、壊れにくいということです。
一方で、delete の設計は create より難しい。これは今回痛感しました。Application と child resources のクリーンアップは Argo CD にかなり寄せられますが、destination namespace の削除まで含めると、まだ答えが出ていません。preview 環境の本質は create ではなく delete にあります。 作ることは誰でもできる。問題は、いつ、どうやって、確実に消すかです。
冒頭の script は、知りすぎていたから壊れた。今回の構成で、知る範囲はかなり減った。ただし、Argo CD に寄せたぶんだけ、Argo CD への依存は深くなっている。script の複雑さを Argo CD の設定の複雑さに移しただけかもしれない。宣言的に管理できるぶんだけ、見通しは良くなった気がしている。たぶん。
次にやるなら、ローカルの git daemon を Gitea に置き換えること、ポーリングを webhook に寄せること。本番では pullRequest generator を前提にし、namespace のクリーンアップをどこで閉じるかを見極めたい。「PR を作成したら自動で preview が立つ」仕組みの構成は見えました。残っているのは、delete をどこまで Argo CD に任せるかと、この構成を本当に運用で回せるかです。ローカルで動くことと本番で動くことの間には、溝がある。いつだって、想像より広い。
