AWS CDKでECS環境を構築する

この記事は[オープンロジアドベントカレンダー](https://qiita.com/advent-calendar/2021/openlogi)の19日目の記事です。

## はじめに
[AWS Cloud Development Kit (AWS CDK) v2 の一般提供開始](https://aws.amazon.com/jp/about-aws/whats-new/2021/12/aws-cloud-development-kit-cdk-generally-available/)
12月のre:InventでAWS CDK v2の一般提供が開始されました。
今回はCDK v2を使ってAWS ECS環境のアプリケーションからRDSへの接続までを構築していきたいと思います！

また、情報が古くなる可能性があるので実際のご利用の際は[公式ドキュメント](https://docs.aws.amazon.com/cdk/api/v2/)をご参照ください

## 前提環境
cdkコマンドをDockerコンテナから叩いています。言語はTypeScriptを利用しました。
[Dockerfile](https://github.com/sasaron/ecs-cdk-sample/blob/main/Dockerfile)
[package.json](https://github.com/sasaron/ecs-cdk-sample/blob/main/package.json)

コマンドの実行

```bash:deploy.sh
$ docker run --rm -v $(pwd):/app --env-file=".env" --env ENV=Dev -it ecs-sample cdk synth
$ docker run --rm -v $(pwd):/app --env-file=".env" --env ENV=Dev -it ecs-sample cdk deploy
```
また[リポジトリ](https://github.com/sasaron/ecs-cdk-sample)も公開しているのでご参照ください。

## Step1. 単純なECSサービスの作成

[step1のコード](https://github.com/sasaron/ecs-cdk-sample/tree/step-1-tutorial)

```typescript:ecs-sample.ts
import { Stack, StackProps, aws_ecs as ecs, aws_ecs_patterns as ecsp} from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class EcsSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    new ecsp.ApplicationLoadBalancedFargateService(this, 'SampleWebService', {
      taskImageOptions: {
        image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
      },
      publicLoadBalancer: true
    });
  }
}
```

[AWS CDKを使用してAmazon ECS の開始方法](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/tutorial-ecs-web-server-cdk.html)とほとんど同じ方法で開始できます。

v2への移行の変更点は、

- パッケージのimport元が`aws-cdk-lib`になった。
- 上記の影響で`npm install @aws-cdk/aws-ecs-patterns`のインストール作業が不要になった。

の２点です。
チュートリアルでは`aws_ecs_patterns`を利用しています。
`aws_ecs_patterns`は公開されているECSのテンプレートパターンを利用してECS環境を構築するモジュールになります。

## Step2. VPCのカスタマイズ
[step2のコード](https://github.com/sasaron/ecs-cdk-sample/tree/step-2-vpc-customize)

チュートリアルのコードでは、VPCに単一サブネットだったのでMultiAZの3層Subnet構成(Public,Private,Protected)に変更したいと思います。

### VPCの作成

```typescript:vpc-sample-stack.ts
export class VPCSampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    this.vpc = new ec2.Vpc(this, 'ECSSampleVPC', {
      cidr: "192.168.0.0/21",
      maxAzs: 2,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'public',
          subnetType: ec2.SubnetType.PUBLIC
        },
        {
          cidrMask: 24,
          name: 'private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_NAT
        },
        {
          cidrMask: 24,
          name: 'protected',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        }
      ],
      vpnRoutePropagation: [
        {
          availabilityZones: [],
        }
      ]
    });
    Tags.of(this.vpc).add('Name', 'VPCSampleStack');
 }
}
```
VPCを作成します。今回はprivateにapplication, protectedにアプリケーションから参照されるデータリソース(RDS)を配置します

### クロススタックの設定
VPCやアプリケーションのインフラとしてのライフサイクルが違うので、意図しないタイミングでの変更を防ぐためにクロススタック(スタック間でのリソース共有)での生成を行いたいと思います。

```typescript:vpc-sample-stack.ts#L5
export class VPCSampleStack extends Stack {
  public readonly vpc : ec2.IVpc;
```

VPCのスタックのリードオンリーのメンバ変数としてvpcを登録します。

```typescript:ecs-sample.ts#L9
new EcsSampleStack(app, 'EcsSampleStack', vpcStack.vpc, {});
```
ECSのスタックのコンストラクタを拡張してVPCを設定すれば、Fn::ImportValueでのデータ参照ができるようになります。

あとは、[ECS Clusterを作成してFargateサービス](https://github.com/sasaron/ecs-cdk-sample/blob/step-2-vpc-customize/lib/ecs-sample-stack.ts#L13-L18)に渡してあげれば、Step1のFargate ECSが今回生成したVPCに作成されます。

この時、PublicにLoadBalancer, PrivateにECS Clusterを自動的に配置してくれるようです。


## Step3. RDSの作成
[step3のコード](https://github.com/sasaron/ecs-cdk-sample/tree/step-3-rds)

認証情報の管理はSecretManagerの方が管理などの面で便利だと思いますが、簡素化のためParameterStoreを使います。

### SSM Parameter Storeへの認証情報の登録
ECSで利用するのでSSM Parameter Storeに認証情報の登録を行います。

```bash:store-ssm-parameter.sh
$ aws ssm put-parameter --name "/ECSSample/Dev/RDS/Password" --value "xxxxxxxxx" --type "SecureString"
{
    "Version": 1,
    "Tier": "Standard"
}
$ aws ssm put-parameter --name "/ECSSample/Dev/RDS/User" --value "user" --type "String"
{
    "Version": 1,
    "Tier": "Standard"
}
$ aws ssm put-parameter --name "/ECSSample/Dev/RDS/Rotation" --value "1" --type "String"
{
    "Version": 1,
    "Tier": "Standard"
}
$ aws ssm put-parameter --name "/ECSSample/Dev/RDS/Database" --value "testDB" --type "String"
{
    "Version": 1,
    "Tier": "Standard"
}
```


### Aurora MySQL8.0でのインスタンス作成
```typescript:rds-sample-stack.ts
export class RDSSampleStack extends Stack {
  public readonly rdsCluster: rds.IDatabaseCluster;
  constructor(scope: Construct, id: string, vpc: ec2.IVpc, props?: StackProps) {
    super(scope, id, props);
    const user = ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/User');
    const rotation = ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/Rotation'); // あまり良くないかも
    const password = SecretValue.ssmSecure('/ECSSample/Dev/RDS/Password', rotation);
    this.rdsCluster = new rds.DatabaseCluster(this, 'SampleAuroraCluster', {
      engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_3_01_0 }),
      credentials: rds.Credentials.fromPassword(user, password),

      instanceProps: {
        instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MEDIUM),
        vpcSubnets: {
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        },
        vpc,
      },
    });
  }
}
```
先程の認証情報をもとにAurora MySQLの作成をします。
インスタンスタイプは`t4g.midium`インスタンス、MySQL Versionは8.0です。

Deployを行うとライターインスタンス,リーダインスタンス構成のAurora MySQLが生成されます。
![スクリーンショット 2021-12-19 10.57.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78113/6ff3de0c-1b81-c19c-6107-689976ea1817.png)

## Step4. SecurityGroupの設定
[step4のコード](https://github.com/sasaron/ecs-cdk-sample/tree/step-4-securitygroup)

現状ではRDSのSecurityGroupがフルオープンの状態となってしまっているので、Security系の設定をしていきます。
### SecurityGroupの設定
今回は各スタックで生成したSecurityGroupを繋げていきます。

```diff
 export class RDSSampleStack extends Stack {
   public readonly rdsCluster: rds.IDatabaseCluster;
+  public readonly rdsSecurityGroup: ec2.ISecurityGroup;
   constructor(scope: Construct, id: string, vpc: ec2.IVpc, props?: StackProps) {
     super(scope, id, props);
+
     const user = ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/User');
     const rotation = ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/Rotation'); // あまり良くないかも
     const password = SecretValue.ssmSecure('/ECSSample/Dev/RDS/Password', rotation);
+
+    this.rdsSecurityGroup = new ec2.SecurityGroup(this,'RDSSecurityGroup', {vpc});
     this.rdsCluster = new rds.DatabaseCluster(this, 'SampleAuroraCluster', {
       engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_3_01_0 }),
       credentials: rds.Credentials.fromPassword(user, password),
```
RDSとECSのスタックにSecurityGroupの生成を記述します。
各スタックからExportし、AssignSecurityGroupクラスでルールの繋ぎ込みを行います。

```typescript:assign-security-group.ts
import { aws_ec2 as ec2 } from 'aws-cdk-lib';

export class AssignSecurityGroup {
  private ecsSecurityGroup: ec2.ISecurityGroup;
  private rdsSecurityGroup: ec2.ISecurityGroup;
	constructor(ecsSecurityGroup: ec2.ISecurityGroup, rdsSecurityGroup: ec2.ISecurityGroup ) {
    this.ecsSecurityGroup = ecsSecurityGroup;
    this.rdsSecurityGroup = rdsSecurityGroup;
	}
  assign(){
    this.ecsSecurityGroup.connections.allowTo(
      this.rdsSecurityGroup,
      ec2.Port.tcp(3306),
      'Allow ECS Connection'
    );
  }
}
```

RDS => ECS 方向のみの依存にするためAllowToで後からIngressを作ってます。
デプロイし、SecurityGroupが紐づいていることを確認できました。

## Step5. 動作確認
[step5のコード](https://github.com/sasaron/ecs-cdk-sample/tree/step-5-check-application)


検証用のコンテナを作成します。 [Dockerfile](https://github.com/sasaron/ecs-cdk-sample/blob/step-5-check-application/sample-app/Dockerfile)
コンテナの中身はGolangでRDSにPingを送った後にHttp HandlerでALBのヘルスチェックを80ポートで受け付けるようにしてます。

```bash
$ cd ./sample-app
$ docker build -f ./Dockerfile -t rds-connect-test . 
$ aws ecr create-repository --repository-name rds-connect-test 
```
コンテナをビルドして、ECRにリポジトリを作成しpushします。
イメージのpushについては下記をご参照ください。

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/docker-push-ecr-image.html

Imageのオプションを変更し環境変数をコンテナに渡します。

```diff
diff --git a/lib/ecs-sample-stack.ts b/lib/ecs-sample-stack.ts
index 9b0d863..c84d689 100644
--- a/lib/ecs-sample-stack.ts
+++ b/lib/ecs-sample-stack.ts
@@ -3,7 +3,9 @@ import {
   StackProps,
   aws_ecs as ecs,
   aws_ec2 as ec2,
-  aws_ecs_patterns as ecsp
+  aws_ecs_patterns as ecsp,
+  aws_ssm as ssm,
+  aws_ecr as ecr
 } from 'aws-cdk-lib';
 import { IVpc } from 'aws-cdk-lib/aws-ec2';
 import { Construct } from 'constructs';
@@ -14,12 +16,27 @@ export class EcsSampleStack extends Stack {
     super(scope, id, props);
     const cluster = new ecs.Cluster(this, 'SampleEcsCluster', { vpc })
     this.ecsToRDSSecurityGroup = new ec2.SecurityGroup(this,'ECStoRDSSecurityGroup', {vpc});
+    const rotation = Number(ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/Rotation'));
+    const repo = ecr.Repository.fromRepositoryName(this, 'RDSConnectTestRepo', 'rds-connect-test')
+    
 
     new ecsp.ApplicationLoadBalancedFargateService(this, 'SampleWebService', {
       cluster,
       taskImageOptions: {
-        image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
-      },
+        image: ecs.ContainerImage.fromEcrRepository(repo),
+        environment: {
+          ENDPOINT: ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/Endpoint'),
+          USER: ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/User'),
+          DATABASE: ssm.StringParameter.valueForStringParameter(this, '/ECSSample/Dev/RDS/Database'),
+        },
+        secrets: {
+          PASS: ecs.Secret.fromSsmParameter(
+            ssm.StringParameter.fromSecureStringParameterAttributes(this, 'RDSPassWord',{
+              parameterName: '/ECSSample/Dev/RDS/Password',
+              version: rotation
+            }))
+        },
+    },
       publicLoadBalancer: true,
       securityGroups: [ this.ecsToRDSSecurityGroup ] ,
       deploymentController: {
```

ECRのエンドポイントを変更して、SSM ParameterをTask定義に渡しました。

https://docs.aws.amazon.com/ja_jp/AmazonECR/latest/userguide/vpc-endpoints.html

またVPCにECRとSSMのVPCEndPointを作成しました。

![スクリーンショット 2021-12-20 2.24.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/78113/09ae51c8-6f87-7815-751f-f0519dc5e4ec.png)

ここまででECS FargateからRDSへの疎通確認まで出来たと思います。

## 最後に
CloudFormationだとyamlやjsonのパラメータを細かく調べるコストがかかるところを、CDKがある程度自動的に作成してくれることで簡易に構築することができました。

チームで利用する際は意図しない構成で作成されてしまうことを避けるため、テストコードなどで動作を担保する必要があるのかもしれません。

業務ではインフラは触らないのですが、今後もキャッチアップしていきたいと思います。

## 参考

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/tutorial-ecs-web-server-cdk.html

https://docs.aws.amazon.com/cdk/api/v2/docs

https://github.com/aws/aws-cdk