じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

Argo CD ApplicationSet で PR preview の構成を Kind 上で検証した

はじめに

知りすぎていた。

ある 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 自体の考え方を整理するうえでは、関連書籍も参考にしました。

learning.oreilly.com

目指したもの

今回のゴールは 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 のメンテナンスに疲れたからです。

今回いちばん効いたのは、ApplicationSetgit 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 も NamespaceConfigMapServiceDeploymentIngress までに限定しました。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 番号を埋め込まなくてよいので、構成の見通しがかなり良くなりました。

関連ドキュメントは次のとおりです。

本来の運用では PR 作成をトリガーにしたい

ここは誤解のないように書いておきます。今回 ローカルで使ったのは git files generator です。これは「PR を file で模擬する」ための手段であり、本命ではありません。本来やりたいことは、PR を作成したら自動で preview 環境が立つことです。その本命が pullRequest generator です。

pullRequest generator を使えば、PR の numberhead_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.shpreview-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 が対称であること。これが、後から破綻しない設計の最低条件です。 片方だけ綺麗に作って、もう片方を後回しにした仕組みを何度も見てきました。

関連ドキュメントは次のとおりです。

実際に通した確認

Kind 上で ingress-nginx と Argo CD を立て、pr-301pr-302 を作成して確認しました。frontend は preview host の /api を叩き、backend の応答をそのまま画面に表示する単純な構成です。http://pr-301.preview.localtest.me:8080/apipreview=pr-301 commit=a1b2c3d を返します。pr-302 でも同様に preview=pr-302 commit=d4e5f6g を返すところまで見ています。

PR-301 のスクリーンショット

PR-302 のスクリーンショット

画面側も実際にブラウザで確認しました。preview host へアクセスして backend の応答が表示されるところまで見ています。host 名は *.preview.localtest.me を使っているので、ローカル DNS を別でいじらなくても 127.0.0.1 に解決されます。つまり今回の検証は「Application が出た」ではなく、「host 単位で frontend から backend まで通った」ことを確認したものです。

確認コマンドも固定化しました。smoke-test.sh は Kind 起動、preview 作成、ApplicationSynced 待ち、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 の HEADmain を向いていないと checkout が壊れます。ヘルパーを並列に叩くと .git/config.lock 相当の競合も踏みます。今回は HEADrefs/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 に任せるかと、この構成を本当に運用で回せるかです。ローカルで動くことと本番で動くことの間には、溝がある。いつだって、想像より広い。