見出し画像

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上で完結するデプロイフローを構築しています。ざっくりとした流れは以下の通りです。

  1. アプリケーションリポジトリにコミットがpushされる

  2. Argo CDがpushを検知すると自動でArgo CD ApplicationのSyncが開始される

  3. Kanikoを実行し、DockerfileをビルドしてECRにpushする

  4. コンテナイメージのタグの変更がSyncされPodが置き換わる


図1: KanikoによるKubernetes上で完結するデプロイフロー

図にすると上の通りです。いくつかポイントについて解説します。

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上からターゲットリビジョンを変えるだけで上記デプロイフローをトリガーできます。

図2: Argo CD ApplicationのSync状況

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のマニフェストを設定する)

今回構築した構成の大まかな流れとしては、

  1. デプロイして確認したいPRにpreviewラベルを付与する

  2. Argo CDが検知しApplicationSetによってApplicationが作成される

  3. Namespaceが用意され、Kanikoによってイメージがビルドされる

  4. Podが起動してALBにぶら下がる

  5. 特定のHTTPヘッダーを付与してそのPodでアクセスできるようになる

といった形です。

図3: Argo CD Pull Request GeneratorによるPRごとのデプロイ環境

ざっくり図にすると上の通りです。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にはルールの数などいくつかのクォータがありますが、数環境あれば事足りる現段階では気にしなくて良さそうです。

図4: 複数のIngressのruleがマージされたALBが作成される

あとはブラウザの拡張機能などを用いて特定のヘッダーを付与して環境にアクセスするだけです。開発フロー上はPRにラベルをつけ、ヘッダーの値を修正するだけで良いのでかなりお手軽です。

また、PRがcloseされたりラベルが外されるとApplicationが自動で削除されるため、環境が増え続けることもありません。

図: PRにラベルが付与されるとApplicationが作成され(左)、closeされると削除される(右)。

所感

KanikoとHelmでデプロイフローを構築していたため、Pull Request Generatorは案外すんなり導入することができました。実際の運用はこれからですが、少なくともdev環境が1つのみだったころよりは開発者体験は上がっていると思います。

また、コストに関してもPRごとにPodが1つ必要になる程度なので問題はなさそうです。かなり手軽で費用対効果が良いと思いますので、同様の構成の方がいましたらぜひ参考にしていただけると嬉しい限りです。

この記事が気に入ったらサポートをしてみませんか?