🐙

Argo CD Image Updaterを用いてGitOpsを構築してみた

2022/08/08に公開

1. はじめに

1-1. この記事で構築するGitOps

本記事では、下図のような構成でGitOpsを構築します。
CIとしてはGitHub Actionsを利用しており、アプリケーション用リポジトリへのリリースバージョンタグの付与をトリガーとしてイメージをビルドしECRリポジトリへプッシュします。一方で、CDとしてはArgo CDとその拡張であるArgo CD Image Updaterを利用しており、ECRリポジトリへの新しいイメージタグの追加を検知して、新しいイメージをコンテナにデプロイします。

1-2. インフラ構成

上記を動作させるインフラ構成は下図の通りです。基本的にはCDK for Goで記述していますが、ALBの構築とホストゾーンへの証明書インストールのみKubernetes管理としています。

1-3. Kubernetesリソースの構成

Argo CDでデプロイ管理する主要なKubernetesリソースの構成を下図に示します。

2. AWSインフラの構築

1-2節の図のようなインフラをCDK for Goで構築する方法については、CDK for GoでEKS on EC2のインフラ構成を作ってみたという記事の中で説明しているので、詳しくはそちらをご参照ください。ここでは、GitOpsの構築にあたって発生した先述の記事との差分にフォーカスして説明していきいます。

2-1. OIDCを利用してGitHub ActionsからECRへイメージプッシュするためのIAMロールの作成

イメージのビルドとECRへのプッシュを行うCIツールをCodeBuildからGitHub Actionsへと変更しています。変更した理由は、GitHub Actionsの方がイメージのビルド&プッシュにかかる時間が短かったからです。実際、この記事の場合だとGitHub Actionsは1分30秒程度でCIが完了するのですが、CodeBuildの場合だとCIの完了までに3分30秒程度の時間がかかっていました。ビルドする時間は両者で大差ないのですが、CodeBuildではソースコードの取得に時間がかかっているようでした😅

というわけで、先に紹介した記事にあるCDKのコードからイメージビルド用のCodeBuildプロジェクトを作成するロジックを削除しています。代わりに、OIDC認証を通過したGitHubに割り当てるIAMロールをCDKで作成しています。このIAMロールを作成するコードは以下のようになります。

stacks/my_eks/github_actions.go
package my_eks

import (
	iam "github.com/aws/aws-cdk-go/awscdk/v2/awsiam"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewGitHubActions(stack constructs.Construct) {
	// Open ID Connectプロバイダ
	provider := iam.NewOpenIdConnectProvider(stack, jsii.String("Provider"), &iam.OpenIdConnectProviderProps{
		Url: jsii.String("https://token.actions.githubusercontent.com"),
		ClientIds: &[]*string {jsii.String("sts.amazonaws.com")},
		Thumbprints: &[]*string {
			jsii.String("6938fd4d98bab03faadb97b34396831e3780aea1"),
		},
	})

	// 連携したIDプロバイダを表すプリンシパルエンティティ
	// 認証されたユーザに一時的なセキュリティ認証情報を提供するために使用する。
	principalGitHub := iam.NewFederatedPrincipal(provider.OpenIdConnectProviderArn(), &map[string]interface{}{
		"StringLike": map[string]string{"token.actions.githubusercontent.com:sub": "repo:ご自身のGitHubアカウント名/*"},
		"StringEquals": map[string]string{"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
	}, jsii.String("sts:AssumeRoleWithWebIdentity"))

	// GitHub Actionsが任意のECRリポジトリに対してイメージプッシュするためのポリシー
	githubPolicy := iam.NewManagedPolicy(stack, jsii.String("policy-github"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("policy-github"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("*")},
					Actions: &[]*string{
						jsii.String("sts:GetCallerIdentity"),
						jsii.String("ecr:GetAuthorizationToken"),
						jsii.String("ecr:CompleteLayerUpload"),
						jsii.String("ecr:UploadLayerPart"),
						jsii.String("ecr:InitiateLayerUpload"),
						jsii.String("ecr:BatchCheckLayerAvailability"),
						jsii.String("ecr:PutImage"),
					},
				}),
			},
		}),
	})

	// GitHub用のIAMロール
	iam.NewRole(stack, jsii.String("RoleGithub"), &iam.RoleProps{
		AssumedBy: principalGitHub,
		Path: jsii.String("/"),
		RoleName: jsii.String("role-github"),
		Description: jsii.String("Role assumed by GitHub"),
		ManagedPolicies: &[]iam.IManagedPolicy{githubPolicy,},
	})
}

続いて、アプリケーションリポジトリへのリリースバージョンタグの付与をトリガーとして、イメージをビルドしECRへプッシュするGitHub Actionsのワークフローを作成します。この記事の本題から外れるので説明を割愛しますが、以下のようなyamlファイルをアプリケーション用のリポジトリに配置しています。

.github/workflows/build_and_push_image.yml
name: Build and Push Docker Images to ECR Repositories

on:
  push:
    tags:
      - 'v*'

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-region: ap-northeast-1
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT }}:role/role-github
          role-duration-seconds: 1800

      - run: aws sts get-caller-identity

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build and Push images to ECR repositories
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
        run: |
          VERSION=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g")
          docker build -t $ECR_REGISTRY/eks-app:$VERSION -f ./build/app/Dockerfile.prod .
          docker build -t $ECR_REGISTRY/eks-web:$VERSION -f ./build/web/Dockerfile .
          docker build -t $ECR_REGISTRY/eks-migration:$VERSION -f ./build/migration/Dockerfile .
          docker push $ECR_REGISTRY/eks-web:$VERSION
          docker push $ECR_REGISTRY/eks-app:$VERSION
          docker push $ECR_REGISTRY/eks-migration:$VERSION

2-2. DBマイグレートを行うCodeBuildプロジェクトを削除

紹介した記事の中ではCodeBuildからDBマイグレートを行なっていました。今回は、Argo CD Resource Hooksを利用して新しいアプリケーションイメージを自動デプロイする直前にフックをかけて、Jobリソースを起動してDBマイグレートを行う方式に変更しました。ですので、DBマイグレート用のCodeBuildプロジェクトを作成しているロジックをファイル(=stacks/my_eks/db_migrator.go)ごと削除しています。

2-3. IRSA (= IAM Role for Service Account)の作成

ここでは、ServiceAccount用のIAMロールを4つ作成しています。

  1. External Sercets OperatorがSecrets ManagerからSecretリソース作成するためのIAMロール
    (=create-secret-from-secrets-manager-role)
  2. External DNSがRoute53のレコードを作成・更新・削除するためのIAMロール
    (=role-for-external-dns)
  3. ArgoCD Image UpdaterがECRリポジトリのイメージタグ情報を取得するためのIAMロール
    (=role-for-image-updater)
  4. DB Migrateを実行するJobがDockerイメージをプルするためのIAMロール
    (=role-for-db-migrator)

説明すると長くなるので、ソースコードを示すのみに留めます。

stacks/my_eks/irsa.go
package my_eks

import (
	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	ecr "github.com/aws/aws-cdk-go/awscdk/v2/awsecr"
	eks "github.com/aws/aws-cdk-go/awscdk/v2/awseks"
	iam "github.com/aws/aws-cdk-go/awscdk/v2/awsiam"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewIamRolesForServiceAccounts(stack constructs.Construct, cluster eks.Cluster, repoMigration ecr.Repository) {
	// ***************************************************************************
	// External Sercets OperatorがSecrets ManagerからSecretリソース作成するためのIAMロール
	// ***************************************************************************
	principalESO := iam.NewFederatedPrincipal(cluster.OpenIdConnectProvider().OpenIdConnectProviderArn(), &map[string]interface{}{
		"StringEquals": cdk.NewCfnJson(stack, jsii.String("ConditionForAccountToAccessSecrets"), &cdk.CfnJsonProps{
			Value: map[string]string{
				*cluster.ClusterOpenIdConnectIssuer()+":sub": "system:serviceaccount:main:account-to-access-secrets", // system:serviceaccount:ネームスペース:ServiceAccount名
			},
		}),
	}, jsii.String("sts:AssumeRoleWithWebIdentity"))

	secretAccessPolicy := iam.NewManagedPolicy(stack, jsii.String("SecretsManagerAccessPolicy"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("secrets-access-policy"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("*")},
					Actions: &[]*string{
						jsii.String("secretsmanager:GetResourcePolicy"),
						jsii.String("secretsmanager:GetSecretValue"),
						jsii.String("secretsmanager:DescribeSecret"),
						jsii.String("secretsmanager:ListSecretVersionIds"),
					},
				}),
			},
		}),
	})
	
	iam.NewRole(stack, jsii.String("CreateSecretFromSecretsManagerRole"), &iam.RoleProps{
		AssumedBy: principalESO,
		RoleName: jsii.String("create-secret-from-secrets-manager-role"),
		ManagedPolicies: &[]iam.IManagedPolicy{secretAccessPolicy,},
  	})

	// ********************************************************
	// External DNSがRoute53のレコードを作成・更新・削除するためのIAMロール
	// ********************************************************
	principalExternalDNS := iam.NewFederatedPrincipal(cluster.OpenIdConnectProvider().OpenIdConnectProviderArn(), &map[string]interface{}{
		"StringEquals": cdk.NewCfnJson(stack, jsii.String("ConditionForAccountForExternalDNS"), &cdk.CfnJsonProps{
			Value: map[string]string{
				*cluster.ClusterOpenIdConnectIssuer()+":sub": "system:serviceaccount:main:account-for-external-dns",
			},
		}),
	}, jsii.String("sts:AssumeRoleWithWebIdentity"))

	changeRecordSetsPolicy := iam.NewManagedPolicy(stack, jsii.String("ChangeRecordSetsPolicy"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("change-record-sets-policy"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("arn:aws:route53:::hostedzone/*")},
					Actions: &[]*string{
						jsii.String("route53:ChangeResourceRecordSets"),
					},
				}),
			},
		}),
	})
	listRecordSetsPolicy := iam.NewManagedPolicy(stack, jsii.String("ListRecordSetsPolicy"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("list-record-sets-policy"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("*")},
					Actions: &[]*string{
						jsii.String("route53:ListHostedZones"),
						jsii.String("route53:ListResourceRecordSets"),
					},
				}),
			},
		}),
	})

	iam.NewRole(stack, jsii.String("EditRoute53RecordRole"), &iam.RoleProps{
		AssumedBy: principalExternalDNS,
		RoleName: jsii.String("role-for-external-dns"),
		ManagedPolicies: &[]iam.IManagedPolicy{changeRecordSetsPolicy, listRecordSetsPolicy},
  	})

	// *****************************************************************
	// ArgoCD Image UpdaterがECRリポジトリのイメージタグ情報を取得するためのIAMロール
	// *****************************************************************
	principalArgoCDImageUpdater := iam.NewFederatedPrincipal(cluster.OpenIdConnectProvider().OpenIdConnectProviderArn(), &map[string]interface{}{
		"StringEquals": cdk.NewCfnJson(stack, jsii.String("ConditionForAccountForArgoCDImageUpdater"), &cdk.CfnJsonProps{
			Value: map[string]string{
				*cluster.ClusterOpenIdConnectIssuer()+":sub": "system:serviceaccount:argocd:argocd-image-updater", // "system:serviceaccount:<argocd-image-updaterを配置するnamespace>:<argocd-image-updaterに設定するServiceAccount名>"
			},
		}),
	}, jsii.String("sts:AssumeRoleWithWebIdentity"))

	updateImagePolicy := iam.NewManagedPolicy(stack, jsii.String("UpdateImagePolicy"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("argocd-update-image-policy"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{jsii.String("*")},
					Actions: &[]*string{
						jsii.String("ecr:GetAuthorizationToken"),
						jsii.String("ecr:ListImages"),
						jsii.String("ecr:BatchGetImage"),
						jsii.String("ecr:GetDownloadUrlForLayer"),
					},
				}),
			},
		}),
	})

	iam.NewRole(stack, jsii.String("ArgoCDImageUpdaterRole"), &iam.RoleProps{
		AssumedBy: principalArgoCDImageUpdater,
		RoleName: jsii.String("role-for-image-updater"),
		ManagedPolicies: &[]iam.IManagedPolicy{updateImagePolicy,},
  	})

	// ******************************************************
	// DB Migrateを実行するJobがDockerイメージをプルするためのIAMロール
	// ******************************************************
	principalDBMigrator := iam.NewFederatedPrincipal(cluster.OpenIdConnectProvider().OpenIdConnectProviderArn(), &map[string]interface{}{
		"StringEquals": cdk.NewCfnJson(stack, jsii.String("ConditionForAccountForDBMigrator"), &cdk.CfnJsonProps{
			Value: map[string]string{
				*cluster.ClusterOpenIdConnectIssuer()+":sub": "system:serviceaccount:main:account-for-db-migrator",
			},
		}),
	}, jsii.String("sts:AssumeRoleWithWebIdentity"))

	pullImagePolicy := iam.NewManagedPolicy(stack, jsii.String("PullImagePolicyForDBMigrator"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("pull-image-policy-for-db-migrator"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
			Statements: &[]iam.PolicyStatement{
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
					Effect: iam.Effect_ALLOW,
					Resources: &[]*string{repoMigration.RepositoryArn()},
					Actions: &[]*string{
						jsii.String("ecr:BatchGetImage"),
						jsii.String("ecr:GetDownloadUrlForLayer"),
					},
				}),
			},
		}),
	})

	iam.NewRole(stack, jsii.String("DBMigratorRole"), &iam.RoleProps{
		AssumedBy: principalDBMigrator,
		RoleName: jsii.String("role-for-db-migrator"),
		ManagedPolicies: &[]iam.IManagedPolicy{pullImagePolicy,},
	})
}

3. Kubernetesの構築

3-1. マニフェスト用リポジトリのディレクトリ構成

今回作成したk8sマニフェスト用リポジトリのディレクトリ構成は以下のようになっています。

→ tree .
.
├── README.md
├── argocd
│   ├── apps # Argo CDでデプロイ管理するApplication群
│   │   ├── argocd_image_updater.yaml
│   │   ├── external_secrets_operator.yaml
│   │   └── main.yaml
│   └── bootstrap # Argo CDの初期起動時に必要なファイル群
│       ├── init.sh # Argo CDをインストールし、初期設定を行うシェルスクリプト
│       └── manifests
│           ├── application.yaml # Argo CDでのデプロイ管理の起点となるApplication
│           └── project.yaml
├── argocd_image_updater # argocd/apps/argocd_image_updater.yamlの中身
│   └── overlays
│       └── production
│           ├── config_map.yaml
│           ├── deployment.yaml
│           ├── kustomization.yaml
│           └── service_account.yaml
└── main # argocd/apps/main.yamlの中身
    ├── base
    │   ├── db_migrate.yaml
    │   ├── external_dns.yaml
    │   ├── external_secrets.yaml
    │   ├── front.yaml
    │   └── kustomization.yaml
    └── overlays
        └── production
            └── kustomization.yaml

11 directories, 17 files

KubernetesへのデプロイはArgo CDをインストールするところから始まり、インストールしたArgo CDがその他のリソースをデプロイすることで進行します。この一連の作業はargocd/bootstrap/init.shにスクリプト化しているので、このファイルを始まりとして順に説明していきます。

3-2. Argo CDのインストール

前述したように、Argo CDのインストールと初期設定手順はスクリプト化してargocd/bootstrap/init.shに記述しています。これについてはArgo CDのGetting Startedをシェルスクリプト化したで説明していますので、詳しくはそちらをご参照ください。このシェルスクリプトの最後では以下のコマンド2つを実行し、AppProjectリソースとデプロイ起点となるApplicationリソースとを作成しています。

  • kubectl apply -f ./argocd/bootstrap/manifests/project.yaml
  • kubectl apply -f ./argocd/bootstrap/manifests/application.yaml

上記2つのファイルを格納しているargocd/bootstrap/manifestsがArgo CDでのデプロイ管理の起点となるリソースを定義しているディレクトリですので、次にこのディレクトリを説明します。

3-3. Argo CDでのデプロイ起点となるApplicationリソース

前述した通り、Argo CDでのデプロイ管理の起点となるリソースを定義しているディレクトリがargocd/bootstrap/manifestsです。

.
├── argocd
│   └── bootstrap
│       └── manifests
│           ├── application.yaml
│           └── project.yaml

このディレクトリ内のproject.yamlの方でAppProjectリソースを作成し、その中にapplication.yamlで定義したApplicationリソースをデプロイしています。それぞれのファイルの中身を以下に示します。

argocd/bootstrap/manifests/project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: myproject
  namespace: argocd
spec:
  clusterResourceWhitelist:
  - group: '*'
    kind: '*'
  description: Example infrastructure project
  destinations:
  - namespace: '*'
    server: '*'
  sourceRepos:
  - '*'
argocd/bootstrap/manifests/application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myapplication
  namespace: argocd
  finalizers:
  - resources-finalizer.argocd.argoproj.io
spec:
  project: myproject
  source:
    path: argocd/apps
    repoURL: https://github.com/アカウント名/リポジトリ名
    targetRevision: main
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    syncOptions:
    - CreateNamespace=false
    automated:
      selfHeal: false

argocd/bootstrap/manifests/application.yamlの、.spec.source.path.spec.source.repoURLとを見ていただくとわかるように、同じリポジトリ内のargocd/appsディレクトリをApplicationリソースのソース元に定義しています。ですので、次節ではargocd/appsディレクトリを説明していきます。

3-4. Argo CDでデプロイ管理するApplication群

argocd/appsディレクトリは以下のように3つのファイルから構成されています。

.
├── argocd
│   ├── apps
│   │   ├── argocd_image_updater.yaml
│   │   ├── external_secrets_operator.yaml
│   │   └── main.yaml

argocd/appsディレクトリ配下のそれぞれのファイルでは、1つずつApplicationリソースを定義しています。つまり、下図のように、argocd/bootstrap/manifests/application.yamlで定義されたApplicationリソースの配下に3つのApplicationリソースがぶら下がっている構造[1]となっています。これにより、複数のApplicationリソースをArgo CDでのデプロイ管理下に置くことができます。

続いて、argocd/appsディレクトリ内の3つのファイルについて、それぞれ説明していきます。

3-4-1. External Secrets Operator

まず、argocd/apps/external_secrets_operator.yamlについてですが、これはExternal Secrets OperatorというCRDをHelmでインストールするApplicationリソースを定義しています。External Secrets Operator自体はクラウドプロバイダが提供するシークレット管理サービスからSecretリソースを作成するCRDであり、ここではSecrets Managerに格納されているDB秘匿情報をSecretリソース化して、PodからDB接続情報を参照するために使用しています。

以下にargocd/apps/external_secrets_operator.yamlの中身を示します。

argocd/apps/external_secrets_operator.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: external-secrets
  namespace: argocd
  labels:
    app.kubernetes.io/name: external-secrets
  finalizers:
  - resources-finalizer.argocd.argoproj.io
spec:
  project: myproject
  source:
    repoURL: https://charts.external-secrets.io
    targetRevision: 0.5.9
    helm:
      values: |
        installCRDs: true
    chart: external-secrets
  destination:
    server: https://kubernetes.default.svc
    namespace: external-secrets
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true

3-4-2. Argo CD Image Updater

次に、argocd/apps/argocd_image_updater.yamlについてですが、これはArgo CD Image UpdaterというArgo CDの拡張機能をインストールするApplicationリソースを定義しています。

以下にargocd/apps/argocd_image_updater.yamlの中身を示します。

argocd/apps/argocd_image_updater.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: argocd-image-updater
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-image-updater
  finalizers:
  - resources-finalizer.argocd.argoproj.io
spec:
  project: myproject
  source:
    repoURL: https://github.com/アカウント名/リポジトリ名
    path: ./argocd_image_updater/overlays/production
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

.spec.source.repoURL.spec.source.pathとを見ていただければわかるように、同じリポジトリ内の.argocd_image_updater/overlays/productionをソース元に指定しています。ですので、次にこのディレクトリについて説明していきます。

argocd_image_updater/overlays/production配下には以下のように4つのファイルが存在しています[2]

.
├── argocd_image_updater
│   └── overlays
│       └── production
│           ├── config_map.yaml
│           ├── deployment.yaml
│           ├── kustomization.yaml
│           └── service_account.yaml

ここではKustomizeを利用してテンプレート生成を行なっていますので、まずはkustomization.yamlから順番に見ていきます。kustomization.yamlの中身は以下の通りです。

argocd_image_updater/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: argocd
resources:
  - https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml
patchesStrategicMerge:
  - config_map.yaml
  - deployment.yaml
  - service_account.yaml

kustomization.yamlでは、公開されているArgo CD Image Updaterインストール用のマニフェスト(https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yaml)をソース元としています。そして、残りの3つのファイル(config_map.yaml, deployment.yaml, service_account.yaml)でソース元を上書きし、自分用のマニフェストとしてArgo CD Image Updaterをインストールしています。

続いて、service_account.yamlconfig_map.yamldeployment.yamlについてそれぞれ説明していきます。

まず、service_account.yamlを以下に示します。

argocd_image_updater/overlays/production/service_account.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: argocd-image-updater
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::AWSアカウント:role/role-for-image-updater

ここでは、argocd-image-updaterという名前のServiceAccountリソースに対して、role-for-image-updaterというIAMロールを割り当てるように上書きしています。このrole-for-image-updater2-3節で作成したIAMロールであり、Argo CD Image UpdaterのPodがECRリポジトリにログインしてイメージの一覧を取得するのに必要なロールになります。

次に、config_map.yamlを以下に示します。

argocd_image_updater/overlays/production/config_map.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-image-updater-config
data:
  registries.conf: |
    registries:
      - name: ECR
        api_url: https://AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com
        prefix: AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com
        credentials: ext:/app/scripts/ecr-login.sh
        credsexpire: 10h
  ecr-login.sh: |
    #!/bin/sh
    aws ecr --region AWSリージョン get-authorization-token --output text --query 'authorizationData[].authorizationToken' | base64 -d

ここでは、ソース元で定義されているargocd-image-updater-configという名のConfigMapリソースを上書きしてregistries.confecr-login.shとを作成しています。ecr-login.shはECRのプライベートレジストリにアクセスするための認証トークンを取得するシェルスクリプトとなっており、registries.confは、イメージレジストリとそれにアクセスするためのクレデンシャル情報を設定するファイルとなっています。registries.confの方でcredentials: ext:/app/scripts/ecr-login.shと定義されているのは、次に示すdeployment.yamlの方でecr-login.shapp/scriptsにマウントさせているからです。

続いて、deployment.yamlを以下に示します。

argocd_image_updater/overlays/production/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: argocd-image-updater
spec:
  template:
    spec:
      containers:
        - name: argocd-image-updater
          volumeMounts:
            - name: ecr-login-script
              mountPath: /app/scripts
      volumes:
        - name: ecr-login-script
          configMap:
            defaultMode: 0755
            items:
              - key: ecr-login.sh
                path: ecr-login.sh
            name: argocd-image-updater-config
            optional: true

前述した通り、ecr-login.sh/app/scriptsにマウントしており、このDeploymentリソースによって作成されるPodがecr-login.shを実行できるようになっています。また、このdeployment.yamlからは見えませんが、ソース元であるhttps://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/manifests/install.yamlの方で、先に説明したargocd-image-updaterというServiceAccountリソースをこのDeploymentリソースに関連づけているため、ecr-login.shを実行すると実際に認証トークンが発行されるようになっています。

3-4-3. この記事でメインとなるApplicationリソース

最後に、argocd/apps/main.yamlを以下に示します。これが今回Argo CDでデプロイするメインのApplicationリソースになります(メインまでが遠い...😅)。

argocd/apps/main.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: main
  namespace: argocd
  finalizers:
  - resources-finalizer.argocd.argoproj.io
  annotations:
    argocd-image-updater.argoproj.io/write-back-method: argocd
    argocd-image-updater.argoproj.io/write-back-target: kustomization:../../main/overlays/production
    argocd-image-updater.argoproj.io/git-branch: main
    argocd-image-updater.argoproj.io/image-list: app=AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com/eks-app,web=AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com/eks-web
    argocd-image-updater.argoproj.io/app.update-strategy: semver
    argocd-image-updater.argoproj.io/web.update-strategy: semver
spec:
  project: myproject
  source:
    repoURL: https://github.com/アカウント名/リポジトリ名
    path: ./main/overlays/production/
    targetRevision: main
  destination:
    server: https://kubernetes.default.svc
    namespace: main
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true

このApplicationリソースで使われているイメージのタグをArgo CD Image Updaterを利用して自動更新していくので、argocd-image-updater.argoproj.ioから始まるアノテーションを駆使して設定を加えています。ここでは、アプリケーションイメージとNginxイメージとを対象として、新しいイメージを検知した際にgit管理を行わなずに直接Kubernetesを更新する設定としています。また、新しいイメージはセマンティックバージョニングのルールに従って検知していくように設定しています[3]

そして、argocd/apps/main.yamlspec.sourceからわかるように、このApplicationリソースのソース元は/main/overlays/production/ディレクトリとなっています。ですので、次にmainディレクトリについて説明していきます。

まず、mainディレクトリの構成を以下に示します。

.
└── main
    ├── base
    │   ├── db_migrate.yaml
    │   ├── external_dns.yaml
    │   ├── external_secrets.yaml
    │   ├── front.yaml
    │   └── kustomization.yaml
    └── overlays
        └── production
            └── kustomization.yaml

ディレクトリ構成とファイル名からわかるように、メインアプリケーションのテンプレート生成にはKustomizeを利用しており、/main/overlays/production/ディレクトリ配下にはkustomization.yamlの1ファイルのみが格納されています。ですので、argocd/apps/main.yamlのリソース元となっているのはmain/overlays/production/kustomization.yamlであり、このファイルの中身は以下のようになっています。

main/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
commonLabels:
  env: production
resources:
- ../../base

そしてこのファイルでは、ソース元としてさらにmain/base/ディレクトリを相対パスで指定しており、このディレクトリ内のmain/base/kustomization.yamlは以下のようになっています。

main/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- db_migrate.yaml
- external_secrets.yaml
- external_dns.yaml
- front.yaml

つまり、このメインアプリケーションは/main/base/ディレクトリ配下の4つのファイル(db_migrate.yaml, external_secrets.yaml, external_dns.yaml, front.yaml)で定義されていますので、次節にてこれらのファイルを順に説明していきます。

3-5. この記事でメインとなるApplicationリソースの構成

/main/base/ディレクトリ配下の4つのファイルを説明する前に、これらのファイルが定義しているメインアプリケーションの構成について説明します。メインアプリケーションの構成は下図のようになっており、大きく以下の4つにグループ分けしています。

  1. アプリケーションをホストし、外部公開するためのリソース
  2. Secrets Managerに格納されたデータベースの秘匿値を元にSecretを作成するリソース
  3. パブリックホストゾーンにAレコードを追加するためのリソース
  4. DBマイグレートを行うリソース

それぞれグループを1つのファイルでリソース定義しており、これらを順番に説明していきます。

3-5-1. アプリケーションをホストし、外部公開するためのリソース

このグループでは、DeploymentリソースでアプリケーションコンテナとNginxコンテナを保有するPodをデプロイし、そこにServiceリソースとIngressリソースとを接続することでアプリケーションを外部公開しています。このとき、EKSクラスター構築時にAWS LoadBalancer Controllerをインストールしている[4]ので、Ingressリソースのデプロイによって自動的にALBをプロビジョニングし、Ingressリソースとの紐付けを行います。

上記を定義しているのがmain/base/front.yamlであり、これを以下に示します。

main/base/front.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: front-deployment
spec:
  replicas: 2
  selector:
    matchLabels:
      app: front
  template:
    metadata:
      labels:
        app: front
    spec:
      containers:
      - name: app
        image: AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com/eks-app:v0.0.1
        resources:
          requests:
            memory: "64Mi"
            cpu: "200m"
          limits:
            memory: "128Mi"
            cpu: "400m"
        ports:
        - containerPort: 8080
        env: # DB接続のための秘匿情報をSecretリソースから取得して環境変数として設定している。
        - name: DB_HOST
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: host
        - name: DB_PORT
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: port
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: password
        - name: DB_NAME
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: dbname
      - name: web
        image: AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com/eks-web:v0.0.1
        resources:
          requests:
            memory: "64Mi"
            cpu: "200m"
          limits:
            memory: "128Mi"
            cpu: "400m"
        ports: 
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: front-nodeport-service
spec:
  type: NodePort
  ports:
  - name: http-port
    protocol: TCP
    port: 80
    targetPort: 80
  selector:
    app: front
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: alb
spec:
  controller: ingress.k8s.aws/alb
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: front-ingress
  annotations:
    alb.ingress.kubernetes.io/group.name: "main-ingress"
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/target-type: ip # ALBがPod単位でアクセスを振り分けるようにする。
    alb.ingress.kubernetes.io/healthcheck-port: '80'
    alb.ingress.kubernetes.io/healthcheck-protocol: HTTP
    alb.ingress.kubernetes.io/healthcheck-path: '/health_check' # アプリケーション側で/health_checkをヘルスチェック用のパスとして実装している。
    alb.ingress.kubernetes.io/healthcheck-interval-seconds: '15'
    alb.ingress.kubernetes.io/healthcheck-timeout-seconds: '5'
    alb.ingress.kubernetes.io/healthy-threshold-count: '2'
    alb.ingress.kubernetes.io/unhealthy-threshold-count: '2'
    alb.ingress.kubernetes.io/success-codes: '200'
spec:
  ingressClassName: alb
  rules:
  - host: ドメイン名
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: front-nodeport-service
            port:
              number: 80

3-5-2. Secrets Managerに格納されたデータベースの秘匿値を元にSecretを作成するリソース

このグループでは、Secrets Managerに格納されたデータベース接続のための秘匿情報からSecretリソースを作成しています。

まず、取得対象となるシークレットを下図に示します[5]

そして、このシークレットからSecretリソースの作成を定義しているのがmain/base/external_secrets.yamlであり、以下にその中身を示します。

main/base/external_secrets.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::AWSアカウント:role/create-secret-from-secrets-manager-role
  name: account-to-access-secrets
  namespace: main
---
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: db-secrets-store
  namespace: main
spec:
  provider:
    aws:
      service: SecretsManager
      region: AWSリージョン
      auth:
        jwt:
          serviceAccountRef:
            name: account-to-access-secrets
---
apiVersion: external-secrets.io/v1alpha1
kind: ExternalSecret
metadata:
  name: external-secrets
  labels:
    app: front
spec:
  refreshInterval: 1m
  secretStoreRef:
    name: db-secrets-store
    kind: SecretStore
  target:
    name: database-secrets
    creationPolicy: Owner
  data:
  - secretKey: password
    remoteRef:
      key: database-secrets
      property: password
  - secretKey: dbname
    remoteRef:
      key: database-secrets
      property: dbname
  - secretKey: port
    remoteRef:
      key: database-secrets
      property: port
  - secretKey: host
    remoteRef:
      key: database-secrets
      property: host
  - secretKey: username
    remoteRef:
      key: database-secrets
      property: username

3-4-1節で説明した通り、既にExternal Secrets OperatorをインストールしているのでSecretStoreおよびExternalSecretというリソースが使用できる前提でmain/base/external_secrets.yamlを説明します。

まず、2-3節で作成したSecrets Managerから秘匿情報を取得するためのIAMロール(create-secret-from-secrets-manager-role)を紐付けたServiceAccountを作成しています。次に、作成したServiceAccountからSecrets Managerへアクセスを行うためにSecretStoreリソースを作成します。そして、Secrets ManagerのシークレットからどのようにSecretリソースを作成するかをExternalSecretリソースとして定義することで、DB接続情報を保有したSecretリソースを作成しています。

3-5-3. パブリックホストゾーンにAレコードを追加するためのリソース

このグループでは、Ingressリソース作成時に自動生成されるALBをターゲットとしてRoute53のホストゾーンにAレコードを自動追加するリソースを定義しています[6]。これを定義しているファイルがmain/base/external_dns.yamlであり、以下にその中身を示します。

main/base/external_dns.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::AWSアカウント:role/role-for-external-dns # 2-3節で作成したIAMロール。Route53のホストゾーンとそのレコードセット一覧を取得したり、レコードセットを編集する権限を保有したロール。
  name: account-for-external-dns
  namespace: main
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: external-dns
rules:
- apiGroups: [""]
  resources: ["services","endpoints","pods"]
  verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
  resources: ["ingresses"]
  verbs: ["get","watch","list"]
- apiGroups: [""]
  resources: ["nodes"]
  verbs: ["list","watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: external-dns-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
subjects:
- kind: ServiceAccount
  name: account-for-external-dns
  namespace: main
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: main
  labels:
    app: external-dns
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: account-for-external-dns
      containers:
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.12.2 # https://github.com/kubernetes-sigs/external-dns
        args:
        - --source=ingress
        - --source=service
        - --domain-filter=ドメイン名
        - --provider=aws
        - --policy=sync
        - --aws-zone-type=public
        - --registry=txt
        - --txt-owner-id=eks-cluster # EKSクラスター名
      securityContext:
        fsGroup: 65534

ここでやっていることは見かけより単純で、必要な権限を与えたServiceAccountを作成し、そのServiceAccountをPod(Deploymentリソース)に関連づけた上で、Ingressレコードから自動作成したALBをRoute53のホストゾーンのターゲットに追加するコンテナを起動しているだけです。

3-5-4. DBマイグレートを行うリソース

ここでは、Argo CDがマニフェスト用リポジトリと同期する直前に、DBマイグレートを行うためのリソースを定義しています。これを定義しているmain/base/db_migrate.yamlを以下に示します。

main/base/db_migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrator
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  activeDeadlineSeconds: 120
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: db-migrator
        image: AWSアカウント.dkr.ecr.AWSリージョン.amazonaws.com/eks-migration:latest
        imagePullPolicy: Always
        resources:
          requests:
            memory: "64Mi"
            cpu: "200m"
          limits:
            memory: "128Mi"
            cpu: "400m"
        command:
        - "/bin/ash"
        args:
        - "-c"
        - "/app/cmd"
        env:
        - name: DB_HOST
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: host
        - name: DB_PORT
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: port
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: password
        - name: DB_NAME
          valueFrom:
            secretKeyRef:
              name: database-secrets
              key: dbname

4. まとめ

この記事では、Argo CD Image Updaterを利用してGitOpsを構築する方法について説明しました。この記事とCDK for GoでEKS on EC2のインフラ構成を作ってみたArgo CDのGetting Startedをシェルスクリプト化したとを合わせると、EKS on EC2の構築からArgo CDのインストールとGitOpsの構築までを一気通貫で説明したことになります。これらの記事を実践していくと、作業としては(1)cdk deployコマンドの実行と(2)GitHub Actionsでのイメージビルド&プッシュ実行と(3)argocd/bootstrap/init.shスクリプトの実行とで本格的なGitOpsが構築できます。たった3つの作業で自分専用のお砂場GitOps環境が構築できますので、ぜひお試しいただけたらと思います。アプリケーションをデプロイして遊ぶも良し、Kubernetesともっとお友達になるのも良しという感じで、誰かの何かのお役に立てれば幸いです^^

脚注
  1. これをApp of Apps Patternと言います。 ↩︎

  2. この4ファイルの作成にあたってはEKS環境へArgo CD Image Updaterを導入し、デプロイ時間と管理コストを削減した話を参考にさせていただきました。 ↩︎

  3. Annotation Formatの詳細は公式ドキュメントをご参照ください。 ↩︎

  4. CDKでプロビジョニングしたEKSクラスターにAWS LoadBalancer Controllerをインストールする方法はこちらをご参照ください。 ↩︎

  5. ここで作成したシークレットです。 ↩︎

  6. 詳細はAWSの公式ドキュメントをご参照ください。 ↩︎

Discussion