KanikoとArgo CD Pull Request Generatorで作るPRごとのデプロイ環境
本投稿はSRE Advent Calendar 2023の20日目のエントリになります。
こんにちは。CyberZでSREをしている@toro_ponzです。普段はEKSの運用やキャパシティプランニング、サービス開発などに取り組んでいます。
今回はCyberZの1プロジェクトで現在試験導入中の「Pull Requestごとのデプロイ環境」についてお話しいたします。
モチベーション
このプロジェクトでは主に開発に使うdev環境、および本番同等であるステージング環境がEKS上に構築されています。開発の都合上dev環境にはmainブランチ以外の変更をデプロイして動作確認したり他のエンジニアに共有したりすることがしばしばありますが、チームメンバーの増加や並行する開発案件などによって複数のdev環境が欲しいケースが増えてきました。
別のプロジェクトでは、dev、dev02、dev03といった形で個別のk8sマニフェストを用意していたのですが、マニフェストの管理が煩雑だったりデプロイがしづらいといったこともあり本プロジェクトの開始時にはひとまず複数環境の用意をしなかった経緯があります。
Argo CDにはこの課題を解決できるPull Request Generatorという機能があるので、今回検証してみることにしました。
Kanikoによるデプロイフロー
Argo CD Pull Request Generatorの構成に入る前に、既存のデプロイフローについて触れておきます。
Kanikoはコンテナの中でコンテナイメージを作成するためのOSSです。Docker in Dockerなどのセキュリティの懸念がある方式を採る必要がないため、Kubernetes上でDockerfileをビルドする際などに便利なツールになっています。また、リモートキャッシュにも対応しているためビルド時間が短縮されることも期待できます。
https://github.com/GoogleContainerTools/kaniko
本プロジェクトではKanikoを用いてKubernetes上で完結するデプロイフローを構築しています。ざっくりとした流れは以下の通りです。
アプリケーションリポジトリにコミットがpushされる
Argo CDがpushを検知すると自動でArgo CD ApplicationのSyncが開始される
Kanikoを実行し、DockerfileをビルドしてECRにpushする
コンテナイメージのタグの変更がSyncされPodが置き換わる
図にすると上の通りです。いくつかポイントについて解説します。
k8sマニフェストを動的に生成する
厳密なGitOpsをしようとすると開発環境などへのデプロイの度にマニフェストの変更が必要になってしまい、マニフェストリポジトリが自動コミットばかりになってしまいます。もちろんそれでも良いですが、このプロジェクトではArgo CDとHelmチャートを組み合わせることでGitHub上のマニフェストの変更なくデプロイがされるようにしています。
具体的には、Argo CDにはマニフェストビルド時に使える変数がいくつか用意されています。このビルド変数を利用することでk8sにapplyされるマニフェストを動的に変更しています。
Applicationの定義としては以下のように指定して、Helmに渡すパラメータを上書きしています。image.tagにはHEADのコミットハッシュを、builder.branchにはブランチ名がセットされます。アプリケーションリポジトリにHelmチャートを入れることで、コミットする度にマニフェストのリビジョンが変わり差分がSyncされます。
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: xxxxx-web
spec:
# 中略
source:
helm:
valueFiles:
- values/dev.yaml
parameters:
- name: image.tag
value: $ARGOCD_APP_REVISION
- name: builder.branch
value: $ARGOCD_APP_SOURCE_TARGET_REVISION
path: charts/web
repoURL: https://github.com/xxxxx/xxxxx-web.git
targetRevision: main
PreSyncフェーズでKanikoを実行する
コンテナイメージタグが書き変わっても、肝心のイメージがなければPodは起動しません。そのためにはPodを起動する前にコンテナイメージを作成しておく必要がありますが、今回はArgo CDのSyncフェースを用いて解決しています。Syncの前に実行されるPreSyncフェーズにKanikoのJobを実行することで、コンテナイメージが正常に作成されたあとに各種マニフェストのapplyが実行されるようになっています。
apiVersion: batch/v1
kind: Job
metadata:
name: xxxxx-web-builder
namespace: xxxxx-web
annotations:
argocd.argoproj.io/hook: PreSync # Podの置き換えフェーズよりも前に実行されるように
また、Kaniko自体はGitHubからソースコードを持ってくる機構を持っていないため、kubernetes/git-syncをinitContainerとして実行することでgit pullをしています。pullされるリビジョンはHelmのパラメータによって動的に変わります。
initContainers:
- image: registry.k8s.io/git-sync/git-sync:v3.6.7
name: git
args:
- "--repo={{ .repo }}"
- --root=/workspace
- --ssh=true
- --ssh-key-file=/etc/git-secret/ssh.key
- "--branch={{ .branch }}"
- "--rev={{ $.Values.image.tag }}"
- --depth=1
- --one-time=true
volumeMounts:
- name: git-repo
mountPath: /workspace
- name: git-secret
mountPath: /etc/git-secret/ssh.key
subPath: ssh.key
readOnly: true
その後メインコンテナではpullしたソースコードでKanikoを実行し、作成したコンテナイメージをECRにpushします。
containers:
- name: builder
image: gcr.io/kaniko-project/executor:v1.18.0
args:
- --dockerfile=Dockerfile
- "--context=dir:///workspace/{{ $.Values.image.tag }}"
- --build-arg=AWS_WEB_IDENTITY_TOKEN_FILE=$(AWS_WEB_IDENTITY_TOKEN_FILE)
- --build-arg=AWS_ROLE_ARN=$(AWS_ROLE_ARN)
- --build-arg=AWS_DEFAULT_REGION=$(AWS_DEFAULT_REGION)
- --build-arg=AWS_REGION=$(AWS_REGION)
- "--destination={{ include "web.image" $ }}"
- --cache=true
- --use-new-run=true
env:
- name: AWS_EC2_METADATA_DISABLED
value: "true"
- name: AWS_SDK_LOAD_CONFIG
value: "true"
volumeMounts:
- name: git-repo
mountPath: /workspace
- name: builder-config
mountPath: /kaniko/.docker/config.json
subPath: docker.json
ここまでの処理が正常に終了すれば、Podのコンテナイメージタグが変更されデプロイが完了します。
以前はGitHub Actionsでイメージを作成しArgo CDでタグの変更をSyncして、という二段階になっていてデプロイ状況がわかりづらかったのですが、Argo CD単体でのデプロイフローにすることで状況がわかりやすくなりました。Argo CDを見ればどのブランチ・どのコミットがデプロイされているか一目瞭然ですし、別ブランチをデプロイする際もWebUI上からターゲットリビジョンを変えるだけで上記デプロイフローをトリガーできます。
Argo CD Pull Request Generatorの導入
それでは本題です。Argo CD Pull Request Generatorは、GitHubやGitLabなどのPull Request(以下PR)の状態を監視してOpenな状態のものを検知してくれます。この状態をArgo CD ApplicationSetに同期させ、PRごとにApplicationを作成することができる代物です。Argo CDに組み込まれているため、ApplicationSetを作成するだけで導入することができます。
https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-Pull-Request/
具体的には、generators[].pullRequestを指定したApplicationSetリソースを定義することでPRを監視できます。以下の設定では60秒おきにPRを取得し、その内のpreviewラベルが付いたものに関してApplicationリソースを作成します。
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: xxxxx-web-reviewapps
spec:
generators:
- pullRequest:
github:
owner: xxxxx
repo: xxxxx-web
appSecretName: github-app-repo-creds
labels:
- preview
requeueAfterSeconds: 60
template: # (kind: Applicationのマニフェストを設定する)
今回構築した構成の大まかな流れとしては、
デプロイして確認したいPRにpreviewラベルを付与する
Argo CDが検知しApplicationSetによってApplicationが作成される
Namespaceが用意され、Kanikoによってイメージがビルドされる
Podが起動してALBにぶら下がる
特定のHTTPヘッダーを付与してそのPodでアクセスできるようになる
といった形です。
ざっくり図にすると上の通りです。Argo CD Pull Request Generatorの設定自体は複雑なところはないので割愛して、今回採用したフローのポイントについてご紹介します。
Helmのparameterを動的に変更しNamespaceごと作成
アプリケーションリポジトリにあるマニフェストはHelmを用いているため、パラメータで柔軟な制御が行えます。既存の構成を大きく変えたりHelmチャートにこの対応専用の定義などを入れたりしたくなかったので、今回はNamespaceごと別で作成する方式にしてみました。名前が重複さえしなければほとんど気にすることがないので、リソース的な制約がなければこの方式が楽かなと思います。
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: xxxxx-web-reviewapps
spec:
generators: # 中略
template:
metadata:
name: "xxxxx-web-pr-{{number}}"
spec:
source:
helm:
valueFiles:
- values/dev.yaml
parameters:
- name: nameOverride
value: "xxxxx-web-reviewapp{{number}}"
- name: image.tag
value: "{{head_sha}}"
- name: builder.branch
value: "{{branch}}"
余談ですが、NamespaceやServiceAccount名が変わるとIAMロールの信頼関係の条件に合致しなくなるため注意が必要です。今回はServiceAccountのチェックをStringEqualsではなくStringLikeのワイルドカード指定でするようにしたIAMロールを作成し対応しました。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::000000000000:oidc-provider/oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX:aud": "sts.amazonaws.com"
},
"StringLike": {
"oidc.eks.ap-northeast-1.amazonaws.com/id/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX:sub": "system:serviceaccount:xxxxx-web-reviewapp*:xxxxx-web-reviewapp*"
}
}
}
]
}
IngressGroupを使ってALBを1つに集約
Namespaceごとほぼ全てのリソースを作成していますが、ALBだけは共通化しています。ALBも環境ごとに用意し専用のドメインをExternalDNSで払い出すこともできるのですが、ドメインが変わるとCORSなどの各種アプリケーション対応が必要だったことと純粋にALBのコストが懸念でした。そのため今回はドメインは据え置きで、ヘッダーの値によるルーティングをしています。
AWS Ingress ControllerにはIngressGroupという複数のIngressを一つのALBにまとめる機能があります。それを活用することで、動的なALBルールを既存のIngressに触れることなく追加しています。
Ingressのアノテーションにingress.annotations.alb.ingress.kubernetes.io/group.nameを指定することでALBが集約されます。group.orderに適当な番号をつけ、デフォルトの環境(mainブランチのPod)のルールよりも先に評価されるようにしておきます。こちらもHelmのパラメータで上書きしています。
また、ルールの条件にヘッダーの一致を追加しておきます。以下の設定ではX-PULLREQUEST-IDというヘッダーにPR番号が指定されていればPRごとの環境にルーティングされます。
spec:
source:
helm:
parameters:
# 中略
- name: ingress.annotations.\alb\.ingress\.kubernetes\.io/group\.name
value: xxxxx-web
- name: ingress.annotations.\alb\.ingress\.kubernetes\.io/group\.order
value: '10'
forceString: true
- name: ingress.annotations.\alb\.ingress\.kubernetes\.io/conditions\.xxxxx
value: '[{"Field":"http-header","HttpHeaderConfig":{"HttpHeaderName":"X-PULLREQUEST-ID","Values":["#{{number}}"]}}]'
実際に作成されるALBのルールは以下のようになります。ALBにはルールの数などいくつかのクォータがありますが、数環境あれば事足りる現段階では気にしなくて良さそうです。
あとはブラウザの拡張機能などを用いて特定のヘッダーを付与して環境にアクセスするだけです。開発フロー上はPRにラベルをつけ、ヘッダーの値を修正するだけで良いのでかなりお手軽です。
また、PRがcloseされたりラベルが外されるとApplicationが自動で削除されるため、環境が増え続けることもありません。
所感
KanikoとHelmでデプロイフローを構築していたため、Pull Request Generatorは案外すんなり導入することができました。実際の運用はこれからですが、少なくともdev環境が1つのみだったころよりは開発者体験は上がっていると思います。
また、コストに関してもPRごとにPodが1つ必要になる程度なので問題はなさそうです。かなり手軽で費用対効果が良いと思いますので、同様の構成の方がいましたらぜひ参考にしていただけると嬉しい限りです。