🐥

CDK for GoでEKS on EC2のインフラ構成を作ってみた

2022/07/24に公開

1. 前置き

1-1. この記事で説明していること

CDK for Goを利用して下図のインフラを構築する方法を説明しています。
(※ただし、Route53のレコード登録とALBの作成はKubernetes管理下としているため、説明対象外です。)

1-2. この記事で説明していないこと

  • CDKの始め方とその開発環境の構築方法
  • Kubernetesの構成とデプロイ方法(別記事にまとめる予定)

1-3. なぜEKS on EC2の構成を作ったか

Kubernetesの本番運用としてよく選択される構成で、かつ、自分で全てを自由にいじれる環境が欲しいなと思ったからです。Kubernetesの勉強のため、Kubernetes完全ガイドを読み進めていたのですが、途中で「う〜ん、これはわからんし、つまらん。」と思った[1]ので、実際に構築していく中で必要な箇所をピックアップしてKubernetes完全ガイドを参考にしたり、ググったりしながら勉強を進めていこうと方針を切り替えました。そこで、Kubernetesを自由に触れる環境を作ろうと決心し、どうせ作るなら本格的な構成にして、実務で即使えるレベルにまで作りこもうと思い、タイトルにあるインフラ構成の構築に至りました。

1-4. なぜCDK for Goで記述したのか

お金持ちではないので、EKS on EC2を常時立ち上げっぱなしにしておく選択肢はありません(笑)。ですので、Kubernetesを実験する度に作っては壊そうと思い、その利便性からIaCを使うことにしました。IaCと言っても、他にTerraform, pulumi, CloudFormationなどの選択肢がありますが、私自身がクラウドエンジニアやSREではなくソフトウェアエンジニアということもあり、慣れ親しんだプログラミング言語とその開発環境を使いたいというモチベーションがあるので、CDKかpulumiかの2択で考えました。そして、その2つであれば、AWS純正のCDKの方がAWSとの親和性が高いし、CDKを採用している企業数に比例して公開されている記事も多そうと判断し、CDKを採用することにしました。ちなみに、最も多く使われていそうなCDK for TypeScriptではなくCDK for Goを採用したのは単にGoを触りたかったからです😊

2. 本題

2-1. CDKで記述したソースコードのディレクトリ構造

CDKのディレクトリ構造は以下の通りです。

# 本記事に関連する部分のみ抜粋しています
$ tree -a
.
├── .devcontainer # VS Codeのリモートコンテナ機能を利用
│   └── devcontainer.json
├── .editorconfig
├── .gitignore
├── Dockerfile
├── README.md
├── cdk.context.json
├── cdk.json
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── stacks
│   ├── my_eks
│   │   ├── database_cluster.go
│   │   ├── database_cluster_test.go
│   │   ├── db_migrator.go
│   │   ├── db_migrator_test.go
│   │   ├── eks_cluster.go
│   │   ├── eks_cluster_test.go
│   │   ├── hosted_zone.go
│   │   ├── hosted_zone_test.go
│   │   ├── image_builder.go
│   │   ├── image_builder_test.go
│   │   ├── irsa.go
│   │   ├── irsa_test.go
│   │   ├── network.go
│   │   └── network_test.go
│   └── my_eks_stack.go
└── staticcheck.conf # staticcheck(Go言語のlinter)の設定ファイル

本記事では開発環境の構築方法やテストの方法については説明しないので、以下のファイルに絞って説明していきます。

.
├── main.go # Stack構成を記述
└── stacks
    ├── my_eks
    │   ├── database_cluster.go # データベースの構成を記述
    │   ├── db_migrator.go # CodeBuildからDBマイグレートを行うアーキテクチャを記述
    │   ├── eks_cluster.go # EKS Clusterの構成を記述
    │   ├── hosted_zone.go # Route53のホストゾーンの構成を記述
    │   ├── image_builder.go # CodeBuildでイメージビルドを行うアーキテクチャを記述
    │   ├── irsa.go # Pod単位で割り当てるIAM Roleの作成を記述
    │   └── network.go # ネットワークの構成を記述
    └── my_eks_stack.go # 今回構築するStackの構成を記述

2-2. CDKで記述したソースコードの中身

2-2-1. スタック構成

まず、今回構築するスタックにはどういったものがあるのかを説明していきます。main.goの中身は以下の通りです。

main.go
package main

import (
	"os"

	"mycdk/stacks"

	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	jsii "github.com/aws/jsii-runtime-go"
)

func main() {
	app := cdk.NewApp(nil)
	props := &cdk.StackProps{Env: env(),}

	stacks.NewMyEKSStack(app, "MyEKSStack", props)
	
	app.Synth(nil)
}

func env() *cdk.Environment {
	return &cdk.Environment{
	 	Account: jsii.String(os.Getenv("CDK_DEFAULT_ACCOUNT")),
	 	Region:  jsii.String(os.Getenv("CDK_DEFAULT_REGION")),
	}
}

main.goの中身から分かるように、今回構築するスタックはMyEKSStackただ一つです。一つのスタックで構築した方が簡単[2]で、かつ、個人運用を目的としているため分割の必要もないので、今回は一つのスタックに全てを詰め込んでいます[3]

続いて、MyEKSStackの具体的な中身を見ていきます。MyEKSStackの構成はstacks/my_eks_stack.goに記述しており、中身は次のようになっています。

stacks/my_eks_stack.go
package stacks

import (
	myeks "mycdk/stacks/my_eks"

	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	constructs "github.com/aws/constructs-go/constructs/v10"
)

func NewMyEKSStack(scope constructs.Construct, id string, props *cdk.StackProps) (stack cdk.Stack) {
	stack = cdk.NewStack(scope, &id, props)

	vpc, vpcEndpoint := myeks.NewNetwork(stack) // ネットワークの構築
	eksCluster := myeks.NewEksCluster(stack, vpc, vpcEndpoint) // EKSクラスターの構築
	myeks.NewIamRolesForServiceAccounts(stack, eksCluster) // ServiceAccount用のIAM Roleの作成
	dbCluster := myeks.NewDatabaseCluster(stack, eksCluster) // データベースクラスターの構築
	repoMigrate := myeks.NewImageBuilder(stack, props) // イメージをビルドするアーキテクチャの構築
	myeks.NewDBMigrator(stack, repoMigrate, dbCluster, vpcEndpoint, props) // DBマイグレートを行うアーキテクチャの構築
	myeks.NewHostZone(stack) // DNSの構築

	return
}

MyEKSStackに全てを詰め込むと言っても、全てを一つのファイルに押し込めると読む気がおきなくなってしまう💦ので、インフラのまとまり毎にファイルと関数を分割しています。各ファイルと関数の中身については次節以降で説明していきます。

2-2-2. ネットワークの構築

ここでは下図のようなネットワークを構築します。
ネットワークの構築

このようなネットワーク構成を記述しているのがstacks/my_eks/network.goで、コードの中身は以下のようになります。注意点や難しかったことは特になく、NewVpc[4]AddInterfaceEndpoint[5][6]関数だけで図のネットワークをよしなに構築してくれます!さすが、AWS純正のCDKですね👍

stacks/my_eks/network.go
package my_eks

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

func NewNetwork(stack constructs.Construct) (vpc ec2.Vpc, vpcEndpoint ec2.InterfaceVpcEndpoint ) {
	// 2AZにまたがるVPCの作成
	// AZ毎にパブリックサブネットとNATゲートウェイへルートを向けたプライベートサブネットと完全に独立したプライベートサブネットを1つずつ作成
	vpc = ec2.NewVpc(stack, jsii.String("VPC"), &ec2.VpcProps{
		Cidr: jsii.String("10.0.0.0/16"),
		MaxAzs: jsii.Number(2),
		SubnetConfiguration: &[]*ec2.SubnetConfiguration{
			{
				CidrMask: jsii.Number(24),
				Name: jsii.String("subnet-for-eks-cluster-public"),
				SubnetType: ec2.SubnetType_PUBLIC,
			},
			{
				CidrMask: jsii.Number(24),
				Name: jsii.String("subnet-for-eks-cluster-private-with-nat"),
				SubnetType: ec2.SubnetType_PRIVATE_WITH_NAT,
			},
			{
				CidrMask: jsii.Number(24),
				Name: jsii.String("subnet-for-eks-cluster-private-isolated"),
				SubnetType: ec2.SubnetType_PRIVATE_ISOLATED,
			},
		},
		VpcName: jsii.String("vpc-for-eks-cluster"),
	})

	// VPCエンドポイントの作成
	vpcEndpoint = vpc.AddInterfaceEndpoint(jsii.String("VPCEndpoint"), &ec2.InterfaceVpcEndpointOptions{
		Service: ec2.InterfaceVpcEndpointAwsService_ECR(),
		LookupSupportedAzs: jsii.Bool(true),
	})

	return
}

2-2-3. EKSクラスターの構築

ここでは、前節で構築したネットワークの上に下図のようなEKSクラスターを構築しています。赤枠が前節との差分になります。

このようなEKSクラスターの構成を記述しているのがstacks/my_eks/eks_cluster.goで、コードの中身は以下のようになっています。

stacks/my_eks/eks_cluster.go
package my_eks

import (
	ec2 "github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
	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 NewEksCluster(stack constructs.Construct, vpc ec2.Vpc, vpcEndpoint ec2.InterfaceVpcEndpoint) (cluster eks.Cluster) {
	// EKSコントロールプレーンに付与するIAMロールの作成
	masterRole := iam.NewRole(stack, jsii.String("EKSMasterRole"), &iam.RoleProps{
      	AssumedBy: iam.NewServicePrincipal(jsii.String("eks.amazonaws.com"), &iam.ServicePrincipalOpts{}),
      	Path: jsii.String("/"),
      	RoleName: jsii.String("eks-master-role"),
		ManagedPolicies: &[]iam.IManagedPolicy{
			iam.ManagedPolicy_FromAwsManagedPolicyName(jsii.String("AmazonEKSClusterPolicy")),
		},
	})

	// EKSクラスターの作成
	cluster = eks.NewCluster(stack, jsii.String("EKSCluster"), &eks.ClusterProps{
		AlbController: &eks.AlbControllerOptions{
			Version: eks.AlbControllerVersion_V2_4_1(), // v2.4.2が最新のようだが、AlbControllerVersion_V2_4_2()はまだ用意されていない(2022/7/24時点)
		},
		ClusterName: jsii.String("eks-cluster"),
		DefaultCapacity: jsii.Number(0), // デフォルトインスタンスは作らない
		EndpointAccess: eks.EndpointAccess_PUBLIC(),
		MastersRole: masterRole,
		Version: eks.KubernetesVersion_Of(jsii.String("1.22")), // KubernetesVersion_V1_22()はまだ用意されていない(2022/7/24時点)
		Vpc: vpc,
	})

	// ノードグループがプライベートリンクを利用してECRからイメージを取得する
	vpcEndpoint.Connections().AllowFrom(cluster, ec2.Port_AllTraffic(), jsii.String("Allow access to VPC endpoint from EKS cluster"))

	// Nodeに付与するIAMロールの作成
	nodeRole := iam.NewRole(stack, jsii.String("EKSNodeRole"), &iam.RoleProps{
      	AssumedBy: iam.NewServicePrincipal(jsii.String("ec2.amazonaws.com"), &iam.ServicePrincipalOpts{}),
      	Path: jsii.String("/"),
      	RoleName: jsii.String("eks-node-role"),
		ManagedPolicies: &[]iam.IManagedPolicy{
			iam.ManagedPolicy_FromAwsManagedPolicyName(jsii.String("AmazonEKSWorkerNodePolicy")),
			iam.ManagedPolicy_FromAwsManagedPolicyName(jsii.String("AmazonEC2ContainerRegistryReadOnly")),
			iam.ManagedPolicy_FromAwsManagedPolicyName(jsii.String("AmazonEKS_CNI_Policy")),
			iam.ManagedPolicy_FromAwsManagedPolicyName(jsii.String("AmazonSSMManagedInstanceCore")),
		},
	})

	// ノードグループの起動テンプレートを作成
	launchTemplate := ec2.NewLaunchTemplate(stack, jsii.String("EKSNodesLaunchTemplate"), &ec2.LaunchTemplateProps{
		DetailedMonitoring: jsii.Bool(false),
		DisableApiTermination: jsii.Bool(false),
		EbsOptimized: jsii.Bool(false),
		HibernationConfigured: jsii.Bool(false),
		LaunchTemplateName: jsii.String("eks-nodes-launch-template"),
		NitroEnclaveEnabled: jsii.Bool(false),
	})

	// EKSクラスターにNodeグループを追加
	cluster.AddNodegroupCapacity(jsii.String("EKSNodeGroup"), &eks.NodegroupOptions{
		AmiType: eks.NodegroupAmiType_AL2_X86_64,
		CapacityType: eks.CapacityType_SPOT,
		DesiredSize: jsii.Number(2),
		InstanceTypes: &[]ec2.InstanceType{
			ec2.NewInstanceType(jsii.String("m5a.large")),
			ec2.NewInstanceType(jsii.String("m5.large")),
			ec2.NewInstanceType(jsii.String("m5ad.large")),
			ec2.NewInstanceType(jsii.String("m5d.large")),
			ec2.NewInstanceType(jsii.String("m5n.large")),
			ec2.NewInstanceType(jsii.String("m5dn.large")),
		},
		Labels: &map[string]*string {
			"app": jsii.String("practice"),
		},
		LaunchTemplateSpec: &eks.LaunchTemplateSpec{
			Id: launchTemplate.LaunchTemplateId(),
			Version: launchTemplate.LatestVersionNumber(),
		},
		MaxSize: jsii.Number(6),
		MinSize: jsii.Number(2),
		NodegroupName: jsii.String("eks-node-group"),
		NodeRole: nodeRole,
		Subnets: &ec2.SubnetSelection{
			SubnetType: ec2.SubnetType_PRIVATE_WITH_NAT,
		},
		Tags: &map[string]*string {
			"Service": jsii.String("service_name"),
			"Environment": jsii.String("production"),
		},
	})

	// IAMユーザーがクラスターと対話するにはsystem:masters アクセス許可を付与する必要がある。
	// https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/add-user-role.html#aws-auth-users
	user := iam.User_FromUserName(stack, jsii.String("ImportedUserByName"), jsii.String("akari"))
	cluster.AwsAuth().AddUserMapping(user, &eks.AwsAuthMapping{
		Groups: &[]*string{
			jsii.String("system:masters"),
		},
	})

	// AWS CLI利用時にMFA認証必須化するために使用しているロールとsystem:mastersを結びつける
	mfaRole := iam.Role_FromRoleName(stack, jsii.String("ImportedRoleByName"), jsii.String("AdminRole"))
	cluster.AwsAuth().AddRoleMapping(mfaRole, &eks.AwsAuthMapping{
		Groups: &[]*string{
			jsii.String("system:masters"),
		},
	})

	return
}

このファイルでは多くのリソースを作成しているので、以下のセクションに分割して説明していきます。
(1) EKSクラスター
(2) ノードグループ
(3) 権限

(1) EKSクラスター

まず、EKSクラスターのIAMロール(=masterRole)を作成します。ここでは、クラスターロールに必要なAmazonEKSClusterPolicy[7]のみをアタッチしています。

次に、NewCluster[8][9]によって、前節で構築したVPC内にEKSクラスターを作成し、この時に先ほど作成したmasterRoleもEKSクラスターに紐づけています。また、ここではAlbControllerパラメータのバージョンを指定して、作成したEKSクラスターにAWS Load Balancer Controllerをインストールしています。こうすることで、KubernetesのIngressリソースやServiceリソースをデプロイしたときに、それに関連付けられたELBも自動で作成されるようになります。私自身は触ったことがないのですが、eksctlで設定する場合などでは多少の作業が必要そう[10]ですので、属性値一つでAWS Load Balancer ControllerをインストールできるCDKは優秀なようです^^

最後に、作成したEKSクラスターから前節で構築したVPC Endpointへのアクセスを許可し、EKSクラスター上のPodがプライベートリンクを介してECRリポジトリからイメージを取得できるように設定しています。

(2) ノードグループ

まず、ノードに付与するIAMロールを作成します。EKSノードで必須であるポリシーは以下の3つ[11]ですが、

  • AmazonEKSWorkerNodePolicy
  • AmazonEC2ContainerRegistryReadOnly
  • AmazonEKS_CNI_Policy

ここではAmazonSSMManagedInstanceCoreも追加しており、Session ManagerでEKSノードにアクセスできるようにしています。

次に、NewLaunchTemplate[12]によってノードグループ用の起動テンプレートを作成しています。ここでは明示的に起動テンプレートを作成していますが、デフォルトでも自動作成されるので明示的に記述する必要はありません。ここでは、起動テンプレートを作成してそれをノードグループに紐づけることができることの確認のため、あえて記述してみました。

そして、上で作成したEKSクラスターに対してAddNodegroupCapacity[13]メソッドを呼び出すことよって、EKSクラスターにノードグループを追加しています。注意点としては、InstanceTypesにはインターフェイスあたりのIPアドレス数が多いインスタンスタイプを指定した方が良いということです。ノードあたりのIPアドレス数によってそのノード上で稼働できるPod数の上限値が決まってしまう[14]ので、ネットワークの観点からもインスタンスタイプを検討する[15]必要があります。

(3) 権限

実は、上記の手順でEKSクラスターを作成するだけでは、作成したEKSクラスターをマネジメントコンソールやAWS CLIから確認することはできません。というのも、EKSクラスターを作成したIAMエンティティが自身のIAMユーザーではないので、system:mastersアクセス許可を保有していないからです[16]。ですので、権限を与えたいIAMユーザーやロールをEKSクラスターに追加するよう記述する必要があります。ここでは、自身のIAMユーザーとAWS CLI利用時にスイッチロールしているロールをEKSクラスターに追加し、権限を与えています。

2-2-4. ServiceAccount用のIAM Roleの作成

stacks/my_eks/irsa.goでは、Pod単位で割り当てるIAMロール[17]を作成しています。本記事はCDKでのインフラ構築に焦点を当てているので詳細は別記事に記載して説明を割愛しますが、Secrets ManagerからSecretリソースを作成するために必要となるIAMロールとRoute53へレコード登録するために必要となるIAMロールを作成しています。

2-2-5. データベースクラスターの構築

ここではシンプルにデータベースを作成しています。stacks/my_eks/database_cluster.goまでを適用することで、インフラ構成は下図のようになります(差分は赤枠内)。

stacks/my_eks/database_cluster.goのソースコードを以下に示します。構築にあたっての注意点などは特になく、NewDatabaseCluster[18]によってデータベースを作成し、作成後にEKSクラスターからのアクセスを許可する[19]だけです!強いて言うのであれば、SecretName[20]を指定して固定化しておくと、後々Secrets ManagerからSecretリソースを作成する際に便利でした。

stacks/my_eks/database_cluster.go
package my_eks

import (
	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	ec2 "github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
	eks "github.com/aws/aws-cdk-go/awscdk/v2/awseks"
	rds "github.com/aws/aws-cdk-go/awscdk/v2/awsrds"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewDatabaseCluster(stack constructs.Construct, eksCluster eks.Cluster) (dbCluster rds.DatabaseCluster) {
	// DBクラスターの作成
	dbCluster = rds.NewDatabaseCluster(stack, jsii.String("DatabaseCluster"), &rds.DatabaseClusterProps{
		Engine: rds.DatabaseClusterEngine_AuroraPostgres(&rds.AuroraPostgresClusterEngineProps{
			Version: rds.AuroraPostgresEngineVersion_VER_13_6(),
		}),
		ClusterIdentifier: jsii.String("cluster-identifier"),
		InstanceIdentifierBase: jsii.String("db-instance-identifier"),
		Credentials: rds.Credentials_FromGeneratedSecret(jsii.String("postgres"), &rds.CredentialsBaseOptions{
			SecretName: jsii.String("database-secrets"),
		}),
		InstanceProps: &rds.InstanceProps{
			Vpc: eksCluster.Vpc(),
			VpcSubnets: &ec2.SubnetSelection{
				SubnetType: ec2.SubnetType_PRIVATE_ISOLATED,
			},
			AllowMajorVersionUpgrade: jsii.Bool(false),
			AutoMinorVersionUpgrade: jsii.Bool(true),
			DeleteAutomatedBackups: jsii.Bool(false),
			EnablePerformanceInsights: jsii.Bool(false),
			InstanceType: ec2.NewInstanceType(jsii.String("t3.medium")),
			PubliclyAccessible: jsii.Bool(false),
		},
		Instances: jsii.Number(1),
		Port: jsii.Number(5432),
		DefaultDatabaseName: jsii.String("EksDatabaseName"),
		DeletionProtection: jsii.Bool(false),
		RemovalPolicy: cdk.RemovalPolicy_DESTROY,
	})

	// EKSクラスターからDBクラスターへのアクセスを許可する
	dbCluster.Connections().AllowFrom(eksCluster, ec2.Port_Tcp(jsii.Number(5432)), jsii.String("Allow access to Database clster from EKS cluster"))

	return
}

2-2-6. イメージをビルドするアーキテクチャの構築

Dockerイメージをビルドするアーキテクチャは下図の赤枠部分になります。ここでは、CodeBuildがGitHubの特定リポジトリの特定ブランチの更新をトリガーとしてイメージのビルドを開始し、ECRリポジトリへイメージをプッシュするアーキテクチャを採用しています。

上記の構成を作成するstacks/my_eks/image_builder.goのソースコードを以下に示します。簡単に説明すると、前半でアプリケーション用・DBマイグレート用・Nginx用のECRリポジトリを作成し、後半でイメージをビルドしてECRリポジトリへプッシュするCodeBuildプロジェクトを作成しています。CodeBuildがECRリポジトリへイメージをプッシュするために必要なIAM Policyは公式ドキュメントを参考にしています。

stacks/my_eks/image_builder.go
package my_eks

import (
	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	codebuild "github.com/aws/aws-cdk-go/awscdk/v2/awscodebuild"
	ecr "github.com/aws/aws-cdk-go/awscdk/v2/awsecr"
	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 NewImageBuilder(stack constructs.Construct, props *cdk.StackProps) (repoMigration ecr.Repository) {
	// ビルドしたイメージを格納するECRリポジトリの作成
	repoApp := ecr.NewRepository(stack, jsii.String("EKSAppImageRepository"), &ecr.RepositoryProps{
		ImageScanOnPush: jsii.Bool(true),
		LifecycleRules: &[]*ecr.LifecycleRule{{MaxImageCount: jsii.Number(1),},},
		RemovalPolicy: cdk.RemovalPolicy_DESTROY,
		RepositoryName: jsii.String("eks-app"),
	})
	repoMigration = ecr.NewRepository(stack, jsii.String("EKSMigrationImageRepository"), &ecr.RepositoryProps{
		ImageScanOnPush: jsii.Bool(true),
		LifecycleRules: &[]*ecr.LifecycleRule{{MaxImageCount: jsii.Number(1),},},
		RemovalPolicy: cdk.RemovalPolicy_DESTROY,
		RepositoryName: jsii.String("eks-migration"),
	})
	repoWeb := ecr.NewRepository(stack, jsii.String("EKSWebImageRepository"), &ecr.RepositoryProps{
		ImageScanOnPush: jsii.Bool(true),
		LifecycleRules: &[]*ecr.LifecycleRule{{MaxImageCount: jsii.Number(1),},},
		RemovalPolicy: cdk.RemovalPolicy_DESTROY,
		RepositoryName: jsii.String("eks-web"),
	})

	// DockerイメージをビルドしてECRリポジトリへプッシュするIamRoleを作成
	// [ref] https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-push.html#image-push-iam
	pushImagePolicy := iam.NewManagedPolicy(stack, jsii.String("PushImagePolicyForImageBuilder"), &iam.ManagedPolicyProps{
		ManagedPolicyName: jsii.String("push-image-policy-for-image-builder"),
		Document: iam.NewPolicyDocument(&iam.PolicyDocumentProps{
    		Statements: &[]iam.PolicyStatement{
          		iam.NewPolicyStatement(&iam.PolicyStatementProps{
    				Effect: iam.Effect_ALLOW,
    				Resources: &[]*string{repoApp.RepositoryArn(), repoMigration.RepositoryArn(), repoWeb.RepositoryArn()},
    				Actions: &[]*string{
    					jsii.String("ecr:CompleteLayerUpload"),
    					jsii.String("ecr:UploadLayerPart"),
    					jsii.String("ecr:InitiateLayerUpload"),
    					jsii.String("ecr:BatchCheckLayerAvailability"),
    					jsii.String("ecr:PutImage"),
					},
				}),
				iam.NewPolicyStatement(&iam.PolicyStatementProps{
    				Effect: iam.Effect_ALLOW,
    				Resources: &[]*string{jsii.String("*")},
    				Actions: &[]*string{
    					jsii.String("ecr:GetAuthorizationToken"),
					},
				}),
			},
		}),
	})
	role := iam.NewRole(stack, jsii.String("ImageBuilderRole"), &iam.RoleProps{
		AssumedBy: iam.NewServicePrincipal(jsii.String("codebuild.amazonaws.com"), &iam.ServicePrincipalOpts{}),
		Description: jsii.String("Iam Role for CodeBuild Project to push image to ECR repository"),
		Path: jsii.String("/"),
		RoleName: jsii.String("role-codebuild-for-image-builder"),
		ManagedPolicies: &[]iam.IManagedPolicy{pushImagePolicy,},
	})

	// 指定のリポジトリのmainブランチへの更新をトリガーとして、DockerイメージをビルドしECRリポジトリへイメージをプッシュするプロジェクトを作成
	codebuild.NewProject(stack, jsii.String("EKSImageBuildProject"), &codebuild.ProjectProps{
		BuildSpec: codebuild.BuildSpec_FromSourceFilename(jsii.String("buildspec.yml")),
		ConcurrentBuildLimit: jsii.Number(1),
		Environment: &codebuild.BuildEnvironment{
			ComputeType: codebuild.ComputeType_SMALL,
			Privileged: jsii.Bool(true),
		},
		EnvironmentVariables: &map[string]*codebuild.BuildEnvironmentVariable{
			"AWS_ACCOUNT": {Value: props.Env.Account},
			"AWS_REGION": {Value: props.Env.Region},
		},
		ProjectName: jsii.String("EKSImageBuildProject"),
		QueuedTimeout: cdk.Duration_Hours(jsii.Number(1)),
		Role: role,
		Source: codebuild.Source_GitHub(&codebuild.GitHubSourceProps{
			Owner: jsii.String("k-akari"),
			Repo: jsii.String("ent-example"),
			BranchOrRef: jsii.String("main"),
			CloneDepth: jsii.Number(1),
			Webhook: jsii.Bool(true),
			WebhookFilters: &[]codebuild.FilterGroup{
				codebuild.FilterGroup_InEventOf(codebuild.EventAction_PUSH).AndBranchIs(jsii.String("main")),
			},
		}),
		Timeout: cdk.Duration_Minutes(jsii.Number(20)),
	})

	return
}

2-2-7. DBマイグレートを行うアーキテクチャの構築

DBマイグレートを行うアーキテクチャを追加したインフラ構成を下図に示します(差分は赤枠内)。ここでは、プライベートリンクを介してECRリポジトリから取得したイメージをCodeBuildがホストしてDBマイグレートを行うアーキテクチャを採用しています。

上記の構成を記述したstacks/my_eks/db_migrator.goの中身を以下に示します。db_migrator.goでは、最初にDockerイメージをECRリポジトリからプルするIAMロールを作成[21]し、次に、作成したロールを関連付けたCodeBuildプロジェクトを作成し、最後に、作成したCodeBuildプロジェクトからDBクラスターとVPCエンドポイントへのアクセスを許可しています。

stacks/my_eks/db_migrator.go
package my_eks

import (
	cdk "github.com/aws/aws-cdk-go/awscdk/v2"
	codebuild "github.com/aws/aws-cdk-go/awscdk/v2/awscodebuild"
	ec2 "github.com/aws/aws-cdk-go/awscdk/v2/awsec2"
	ecr "github.com/aws/aws-cdk-go/awscdk/v2/awsecr"
	iam "github.com/aws/aws-cdk-go/awscdk/v2/awsiam"
	rds "github.com/aws/aws-cdk-go/awscdk/v2/awsrds"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewDBMigrator(stack constructs.Construct, repo ecr.Repository, dbCluster rds.DatabaseCluster, vpcEndpoint ec2.InterfaceVpcEndpoint, props *cdk.StackProps) {
	// DockerイメージをプルするIamRoleを作成
	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{repo.RepositoryArn()},
    				Actions: &[]*string{
    					jsii.String("ecr:BatchGetImage"),
    					jsii.String("ecr:GetDownloadUrlForLayer"),
					},
				}),
			},
    	}),
	})
	role := iam.NewRole(stack, jsii.String("DBMigratorRole"), &iam.RoleProps{
      	AssumedBy: iam.NewServicePrincipal(jsii.String("codebuild.amazonaws.com"), &iam.ServicePrincipalOpts{}),
		Description: jsii.String("Iam Role for CodeBuild Project to pull image from ECR repository"),
      	Path: jsii.String("/"),
      	RoleName: jsii.String("role-for-db-migrator"),
		ManagedPolicies: &[]iam.IManagedPolicy{pullImagePolicy,},
    })

	// ECRからプルしたイメージを元にDBマイグレートを行うプロジェクトを作成
	project := codebuild.NewProject(stack, jsii.String("DBMigratorProject"), &codebuild.ProjectProps{
		AllowAllOutbound: jsii.Bool(true),
		BuildSpec: codebuild.BuildSpec_FromObject(&map[string]interface{}{
			"version": jsii.String("0.2"),
			"phases": map[string]interface{}{
				"build": map[string]interface{}{
					"commands": []*string{
						jsii.String("whoami"), // 特に必要ないがデバッグのために表示させている
						jsii.String("pwd"), // 特に必要ないがデバッグのために表示させている
						jsii.String("printenv"), // 特に必要ないがデバッグのために表示させている
						jsii.String("/app/cmd"), // DBマイグレートを行うGoバイナリの実行
					},
				},
			},
		}),
		ConcurrentBuildLimit: jsii.Number(1),
		Environment: &codebuild.BuildEnvironment{
			BuildImage: codebuild.LinuxBuildImage_FromEcrRepository(repo, jsii.String("latest")),
			ComputeType: codebuild.ComputeType_SMALL,
			Privileged: jsii.Bool(true),
		},
		EnvironmentVariables: &map[string]*codebuild.BuildEnvironmentVariable{
			"AWS_ACCOUNT": {Value: props.Env.Account},
			"AWS_REGION": {Value: props.Env.Region},
			// [ref] https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/build-spec-ref.html#build-spec.env.secrets-manager
			"DB_HOST": {Value: *dbCluster.Secret().SecretFullArn() + ":host", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
			"DB_PORT": {Value: *dbCluster.Secret().SecretFullArn() + ":port", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
			"DB_PASSWORD": {Value: *dbCluster.Secret().SecretFullArn() + ":password", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
			"DB_USER": {Value: *dbCluster.Secret().SecretFullArn() + ":username", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
			"DB_NAME": {Value: *dbCluster.Secret().SecretFullArn() + ":dbname", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
		},
		ProjectName: jsii.String("DBMigratorProject"),
		QueuedTimeout: cdk.Duration_Hours(jsii.Number(1)),
		Role: role,
		Vpc: dbCluster.Vpc(),
		Timeout: cdk.Duration_Minutes(jsii.Number(20)),
	})

	// DB Migrator(CodeBuildプロジェクト)からDatabase clusterへのアクセスを許可する
	project.Connections().AllowTo(dbCluster, ec2.Port_Tcp(jsii.Number(5432)), jsii.String("Allow access to Database cluster from CodeBuild project"))

	// DB Migrator(CodeBuildプロジェクト)からVPCエンドポイントへのアクセスを許可する
	project.Connections().AllowTo(vpcEndpoint, ec2.Port_AllTraffic(), jsii.String("Allow access to VPC endpoint from CodeBuild project"))
}

上記の構成をCDKで記述するにあたって注意点するべき点は、Secrets Managerに保存されているDBの秘匿情報を環境変数としてCodeBuildプロジェクトに埋め込む部分です。この部分にどういうわけか苦戦した😭ので以下に説明します。

そもそも、Secrets Managerに保存されているシークレットの値を取得するには、下図のように設定する必要があります(あくまで一例です)。

上記の設定を実現しているのが以下のコードになり、*dbCluster.Secret().SecretFullArn()を呼び出すことで、シークレットのARNを指定しています。

EnvironmentVariables: &map[string]*codebuild.BuildEnvironmentVariable{
	"DB_HOST": {Value: *dbCluster.Secret().SecretFullArn() + ":host", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
},

画像では、環境変数の値にシークレットのARN:シークレットキーの文字列を指定することでシークレットの値を取得していますが、この部分はシークレットの名前:シークレットキーの文字列としても同様の結果が得られます[22]。つまり、CDKでは*dbCluster.Secret().SecretName()を呼び出して、以下のように環境変数の値を指定することができるはずで、公式ドキュメントにもそのように記載されているように見えます。

EnvironmentVariables: &map[string]*codebuild.BuildEnvironmentVariable{
	"DB_HOST": {Value: *dbCluster.Secret().SecretName() + ":host", Type: codebuild.BuildEnvironmentVariableType_SECRETS_MANAGER},
},

しかし、実際には、上記のようにシークレットの名前を呼び出すとWarningエラーが発生してそもそもデプロイができませんでした🤔。したがって、最初に示した通り*dbCluster.Secret().SecretFullArn()を呼び出してシークレットのARN:シークレットキーの文字列を記述することで、シークレットの値を取得するようにしています。

2-2-8. DNSの構築

DNSを追加したインフラ構成を下図に示します(差分は赤枠内)。

DNSの追加を記述しているstacks/my_eks/hosted_zone.goの中身は以下の通りです。hosted_zone.goでは、NewPublicHostedZone[23]によってパブリックホストゾーンを作成し、NewCertificate[24]によって証明書をリクエストしています。ALBをプロビジョニングして、ホストゾーンにAレコードを登録する操作はKubernetesから行うので、CDKでの記述はここまでに留めています。

stacks/my_eks/hosted_zone.go
package my_eks

import (
	"os"

	acm "github.com/aws/aws-cdk-go/awscdk/v2/awscertificatemanager"
	route53 "github.com/aws/aws-cdk-go/awscdk/v2/awsroute53"
	constructs "github.com/aws/constructs-go/constructs/v10"
	jsii "github.com/aws/jsii-runtime-go"
)

func NewHostZone(stack constructs.Construct) {
	// パブリックホストゾーンを作成
	hostedZone := route53.NewPublicHostedZone(stack, jsii.String("PublicHostedZone"), &route53.PublicHostedZoneProps{
		ZoneName: jsii.String(os.Getenv("DOMAIN")),
		Comment: jsii.String("free sample domain"),
		CaaAmazon: jsii.Bool(true),
	})

	acm.NewCertificate(stack, jsii.String("Certificate"), &acm.CertificateProps{
		DomainName: jsii.String("mycdk-app-example.tk"),
		Validation: acm.CertificateValidation_FromDns(hostedZone),
	})
}

注意点としては、スタックのデプロイがNewCertificateまで到達すると、ドメインのネームサーバにパブリックホストゾーンのネームサーバが登録されるまでデプロイが止まることです。ボーッとしていると来年までデプロイ待ちしてしまうので、注意しましょう😙。NewCertificateまで到達していれば、AWSのコンソール画面からホストゾーンのネームサーバが確認できるようになっているので、忘れずに、ドメインを取得したサイトの管理画面からネームサーバの設定を変更しましょう!!!

3. まとめ

この記事では、EKS on EC2の構成でアプリケーションを公開するインフラをCDK for Goで構築する方法について説明してみました。なお、本構成では、ALBの作成とRoute53へのレコード登録をKubernetes管理としているので、その部分のCDKでの記述方法に関しては記事内で触れていません。今後は、今回作成したインフラの上にKubernetesをデプロイしてアプリケーションを外部公開し、GitOpsまで実現させる予定です。また内容がまとまりましたら、この続きとして記事化していこうと思います😎

脚注
  1. 本自体は読みやすくてすごく良かったです!!!ただし、使い方が重要で、初心者は1から読み進めるのではなく、手を動かしながら辞書的な使い方をすると理解が捗りそうです。 ↩︎

  2. It's typically easier to keep as many resources in the same stack as possible, so keep them together unless you know you want them separated. (公式ドキュメントからの引用)

    ↩︎
  3. CDKにおけるスタック分割については、この記事が参考になりそうです。 ↩︎

  4. func NewVpc ↩︎

  5. type Vpc ↩︎

  6. type InterfaceVpcEndpointOptions ↩︎

  7. Amazon EKS クラスター の IAM ロール
    ↩︎

  8. func NewCluster ↩︎

  9. type ClusterProps ↩︎

  10. AWS Load Balancer Controller アドオンのインストール ↩︎

  11. Amazon EKS ノードの IAM ロール ↩︎

  12. func NewLaunchTemplate ↩︎

  13. type NodegroupOptions ↩︎

  14. EKSのPod起動数の制限 ↩︎

  15. 各インスタンスタイプのネットワークインターフェイスあたりの IP アドレス数 ↩︎

  16. IAM ユーザーまたはロールを Amazon EKS クラスターに追加する ↩︎

  17. サービスアカウントの IAM ロール ↩︎

  18. func NewDatabaseCluster ↩︎

  19. type Connections ↩︎

  20. type CredentialsBaseOptions ↩︎

  21. ECRリポジトリからイメージをプルするためのポリシー(pullImagePolicy)は、公式ドキュメントを参考にして作成しています。 ↩︎

  22. CodeBuild のビルド仕様に関するリファレンス ↩︎

  23. func NewPublicHostedZone ↩︎

  24. func NewCertificate ↩︎

Discussion