💡

AWS CDKでECS(Fargate) + RDSをマルチAZで作成

2024/05/03に公開

1. 記事の概要

この記事では、AWS初心者の私がCDKを触ってみて、どういうふうにリソースやネットワークの構成を考えながらECS(Fargate) + RDSを構築したかを記します。

1.1. 目標

本記事の主な目標は、AWS CDKを使用してECS(Fargate)とRDSの基本的な構成を理解し、実際に構築する技術を身につけることです。
以下に示すアーキテクチャ図の環境を実際に構築することで、IaCの基礎と開発の流れを体験し、CDK開発への一歩を踏み出すことを目指しています。

1.2. 構成図

今回構築する環境は以下のようになっております。
aws-architecture-diagram.png

ECSはWebアプリケーションサーバーとして機能させ、サーバーへのリクエストはALBを通して行うようにしています。

また、ECR,CloudWatch,S3との接続はNAT Gatewayを使用するのではなく、VPCエンドポイントを使用するようにしました。
VPCエンドポイントを選択した理由としては、NAT GatewayはVPCエンドポイントよりも料金が高いことと、今回実現したい「ECSとECR,CloudWatch,S3の接続」という面ではVPCエンドポイントで機能として十分なためです。

1.3. 手順

上記の構成図を、以下の手順で構築していきます

  1. VPCを作成
  2. セキュリティーグループを作成
  3. ALBを作成
  4. RDSを作成
  5. Secrets ManagerからDB情報を取得
  6. ECSを作成

アプリケーションコードやDockerfileの内容、DockerイメージをECRにpushする手順については、この記事の本質とズレるため記述を省いています。
アプリケーションコードの詳細や、Dockerイメージのpush手順については以下のGitHubリンクに記載しております!

アプリケーションコード
Dockerfile
DockerイメージをECRにpushする手順

2. 実装

2.1. 全体像の共有

具体的な実装に入る前に、今回の全体的な方針を考えます。
今回は、AWSリソースごとにConstructを作成して、StackでそのConstructを連携させる方針で考えています。
そこまで大きな構成ではないため、全てのAConstructをクラス分けせずにStackに書くことも可能ですが、可読性の向上とリソース間の繋がりを明確化する目的でこのような方針を選択しました。
最終的には、以下の感じでStackを通じてConstruct間のやり取りを実現させたいです

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

    // ECR
    const repository = new EcrRepository(/** some properties */)

    // VPC
    const vpc = new Vpc(/** some properties */);

    // Security Group
    const sg = new SecurityGroup(/** some properties */);

    // ALB
    const alb = new Alb(/** some properties */);

    // RDS
    const rds = new Rds(/** some properties */);

    // ECS(Fargate)
    const ecs = new Ecs(/** some properties */);
  }
}

また、ディレクトリ構成としては以下のように考えています

./lib/
├── construct
│   ├── alb.ts
│   ├── ecs.ts
│   ├── ...etc
└── sample-node-app-stack.ts

2.2. VPCを作成

まずは、CDKのL2コンストラクトを使用してVPCを作成します。
上記の構成図の通り、今回はpublicSubnet1つ, privateSubnet2つをそれぞれマルチAZで構成します。

2.2.1. VPC用のConstructを定義

以下のようにVPC用のConstructを定義します

lib/construct/vpc.ts
import { IpAddresses, SubnetType, Vpc as _Vpc } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";

export class Vpc extends Construct {
  // NOTE: 別スタックから参照できるようにする
  public readonly value: _Vpc;
  private readonly ecsIsolatedSubnetName: string;
  private readonly rdsIsolatedSubnetName: string;

  constructor(scope: Construct, id: string, private readonly resourceName: string) {
    super(scope, id);
    this.ecsIsolatedSubnetName = `${this.resourceName}-ecs-isolated`;
    this.rdsIsolatedSubnetName = `${this.resourceName}-rds-isolated`;

    this.value = new _Vpc(this, "Vpc", {
      vpcName: `${this.resourceName}-vpc`,
      availabilityZones: ["ap-northeast-1a", "ap-northeast-1c"],
      // NOTE: ネットワークアドレス部:16bit, ホストアドレス部:16bit
      ipAddresses: IpAddresses.cidr("192.168.0.0/16"),
      subnetConfiguration: [
        {
          name: `${this.resourceName}-public`,
          cidrMask: 26, // 小規模なので`/26`で十分(ネットワークアドレス部: 26bit, ホストアドレス部: 6bit)
          subnetType: SubnetType.PUBLIC,
        },
        // NOTE: ECSを配置するプライベートサブネット
        //       外部との通信はALBを介して行う(NATGatewayを介さない)ので、ISOLATEDを指定(ECRとの接続はVPCエンドポイントを利用する)
        {
          name: this.ecsIsolatedSubnetName,
          cidrMask: 26,
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
        // NOTE: RDSを配置するプライベートサブネット
        {
          name: this.rdsIsolatedSubnetName,
          cidrMask: 26,
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ],
      natGateways: 0,
    });
  }
}

IPアドレス(CIDR)と、サブネットタイプについて解説します。

  • IPアドレス(CIDR)について
    IPアドレス(CIDR)は公式ドキュメントの推奨に従い192.168.0.0/16としています
    (ネットワークアドレス部:16bit, ホストアドレス部:16bit)
    また、極力無駄なプライベートIPアドレスの生成は避けたいのでCIDRマスクは26(ネットワークアドレス部:26bit,ホストアドレス部:6bit)としています。
    実際の運用を考えた場合は、規模の拡大なども考慮してもう少し大きめのCIDRマスクの方が良いかもしれません。

  • サブネットタイプについて
    いずれのprivateSubnetも直接の外部通信は行わなず、NAT Gatewayも必要としないので、サブネットタイプはPRIVATE_ISOLATED を指定しています。
    サブネットタイプについては、以下のドキュメントを参考にしました
    enum SubnetType · AWS CDK

2.2.2. VPCエンドポイントの作成

上記の構成図の通り、S3, CloudWatch, ECR用のVPCエンドポイントを作成していきます。
VPCエンドポイントには、ゲートウェイ型とインターフェース型の2種類があり、上記の3つのうち、S3はゲートウェイ型で、CloudWatchとECRはインターフェース型となるので、それぞれ種類に応じた形で作成していきます。

(各種類については以下のドキュメントを参考にしました)
AWS PrivateLink の概念 - Amazon Virtual Private Cloud

以下のようにコードを修正します

lib/construct/vpc.ts
+ import { GatewayVpcEndpointAwsService, InterfaceVpcEndpointAwsService, IpAddresses, SubnetType, Vpc as _Vpc } from "aws-cdk-lib/aws-ec2";
- import { IpAddresses, SubnetType, Vpc as _Vpc } from "aws-cdk-lib/aws-ec2";

  export class Vpc extends Construct {
       /** 省略 */ 
    constructor(scope: Construct, id: string, private readonly resourceName: string) {
      this.value = new _Vpc(this, "Vpc", {
       /** 省略 */
     });

+    // NOTE: VPCエンドポイントを作成
+    this.value.addInterfaceEndpoint("EcrEndpoint", {
+      service: InterfaceVpcEndpointAwsService.ECR,
+    });
+    this.value.addInterfaceEndpoint("EcrDkrEndpoint", {
+      service: InterfaceVpcEndpointAwsService.ECR_DOCKER,
+    });
+    this.value.addInterfaceEndpoint("CwLogsEndpoint", {
+      service: InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
+    });
+    this.value.addGatewayEndpoint("S3Endpoint", {
+      service: GatewayVpcEndpointAwsService.S3,
+      subnets: [
+        {
+          subnets: this.value.isolatedSubnets,
+        },
+      ],
+    });
   }
 }

2.2.3. サブネットを取得するメソッドを作成

ALBやECSなど他のリソースを作成する際に、簡潔に各サブネットを取得できるするために、外部呼び出し可能なメソッドを用意します。

lib/construct/vpc.ts
+ import type { SelectedSubnets } from "aws-cdk-lib/aws-ec2";

export class Vpc extends Construct {
   /** 省略 */

+ public getPublicSubnets(): SelectedSubnets {
+   return this.value.selectSubnets({ subnetType: SubnetType.PUBLIC });
+ }

+ public getEcsIsolatedSubnets(): SelectedSubnets {
+   return this.value.selectSubnets({ subnetGroupName: this.ecsIsolatedSubnetName });
+ }

+ public getRdsIsolatedSubnets(): SelectedSubnets {
+   return this.value.selectSubnets({ subnetGroupName: this.rdsIsolatedSubnetName });
+ }
}

2.2.4. 作成したConstructクラスをStackでインスタンス化する

ここまでの工程で、Vpc用のConstructが完成しましたので、以下のようにConstructをStackでインスタンス化し、Stackを通じて他のConstructと連携できるようにします。
(ついでに、ECRのリポジトリも作成しておきます)

lib/sample-node-app-stack.ts
import type { StackProps } from "aws-cdk-lib";
import { Stack } from "aws-cdk-lib";
import type { Construct } from "constructs";
import { Repository } from "aws-cdk-lib/aws-ecr";
import { Vpc } from "./construct/vpc";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    // ECR
    const repository = Repository.fromRepositoryName(
      this,
      "EcrRepository",
      resourceName,
    );

    // VPC
    const vpc = new Vpc(this, "Vpc", resourceName);
  }
}

2.3. セキュリティーグループを作成

今回作成するリソースは大きく分けてALB,ECS,RDSの3つなので、それぞれのリソースに対するセキュリティーグループを作成していきます。

2.3.1 セキュリティーグループ用のConstructを実装

通信の流れとしては、ALB → ECS → RDS といった感じになる想定なので、ECSのインバウンド通信はALBからのみ許可し、RDSのインバウンド通信はECSからのみ許可するようにします。

また、今回はアプリケーションサーバー(ECS)のポート番号は80番、DBサーバー(RDS)のポート番号は5432番とします。インバウンド通信はこのポート番号を使用して制御をしていきます。
コード全体は以下の通りです

lib/construct/security-group.ts
import type { Vpc } from "aws-cdk-lib/aws-ec2";
import { Peer, Port, SecurityGroup as _SecurityGroup } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";

interface SecurityGroupProps {
  vpc: Vpc;
  resourceName: string;
}

export class SecurityGroup extends Construct {
  public readonly albSecurityGroup: _SecurityGroup;
  public readonly ecsSecurityGroup: _SecurityGroup;
  public readonly rdsSecurityGroup: _SecurityGroup;

  constructor(scope: Construct, id: string, props: SecurityGroupProps) {
    super(scope, id);
    this.albSecurityGroup = this.createAlbSecurityGroup(props.vpc, props.resourceName);
    this.ecsSecurityGroup = this.createEcsSecurityGroup(props.vpc, props.resourceName);
    this.rdsSecurityGroup = this.createRdsSecurityGroup(props.vpc, props.resourceName);
  }

  /**
   * ALB に関連付けるセキュリティグループを作成する
   * - インバウンド通信: 任意の IPv4 アドレスからの HTTP, HTTPS アクセスを許可
   * - アウトバウンド通信: すべて許可
   */
  private createAlbSecurityGroup(vpc: Vpc, resourceName: string): _SecurityGroup {
    const sg = new _SecurityGroup(this, "AlbSecurityGroup", {
      securityGroupName: `${resourceName}-alb-sg`,
      vpc,
      description: "Allow HTTP and HTTPS inbound traffic. Allow all outbound traffic.",
      allowAllOutbound: true, // すべてのアウトバウンドトラフィックを許可
    });
    sg.addIngressRule(Peer.anyIpv4(), Port.tcp(80), "Allow HTTP inbound traffic");
    sg.addIngressRule(Peer.anyIpv4(), Port.tcp(443), "Allow HTTPS inbound traffic");
    return sg;
  }

  /**
   * ECS に関連付けるセキュリティグループを作成する
   * - インバウンド通信: ALB からの HTTP アクセスを許可
   * - アウトバウンド通信: すべて許可
   */
  private createEcsSecurityGroup(vpc: Vpc, resourceName: string): _SecurityGroup {
    const sg = new _SecurityGroup(this, "EcsSecurityGroup", {
      securityGroupName: `${resourceName}-ecs-sg`,
      vpc,
      description: "Allow HTTP inbound traffic. Allow all outbound traffic.",
      allowAllOutbound: true,
    });
    sg.addIngressRule(this.albSecurityGroup, Port.tcp(80), "Allow HTTP inbound traffic");
    return sg;
  }

  /**
   * RDS に関連付けるセキュリティグループを作成する
   * - インバウンド通信: ECSからの PostgreSQL アクセスを許可(ポート: 5432)
   * - アウトバウンド通信: すべて許可
   */
  private createRdsSecurityGroup(vpc: Vpc, resourceName: string): _SecurityGroup {
    const sg = new _SecurityGroup(this, "RdsSecurityGroup", {
      securityGroupName: `${resourceName}-rds-sg`,
      vpc,
      description: "Allow PostgreSQL inbound traffic. Allow all outbound traffic.",
      allowAllOutbound: true,
    });
    sg.addIngressRule(this.ecsSecurityGroup, Port.tcp(5432), "Allow PostgreSQL inbound traffic");
    return sg;
  }
}

各セキュリティーグループについて解説します。

  • ALBのセキュリティーグループ
    ALBへのインバウンド通信は宛先ポート番号がHTTPを示す80番、もしくはHTTPSを示す443番になっているはずなので、その番号のみ許可しています。
    (今回はSSL/TLS証明書の発行は行わないので、80番のみの許可でも問題なかったです…)

  • ECSのセキュリティーグループ
    上述の通り、ECSへのリクエストはALBのみを想定しているため、インバウンド通信はALBからのみ許可しています。
    ECSへのインバウンド通信は、宛先ポート番号がアプリケーションサーバーを示す80番の通信のみを許可しています。

  • RDSのセキュリティーグループ
    こちらも上述の通り、RDSへのリクエストはECSのみを想定しているため、インバウンド通信はECSからのみ許可しています。
    RDSへのインバウンド通信は、宛先ポート番号がDBサーバーを示す5432番の通信のみを許可しています。

2.3.2. 作成したConstructクラスをStackでインスタンス化する

Vpcと同じようにConstructをStackでインスタンス化します

lib/sample-node-app-stack.ts
+ import { SecurityGroup } from "./construct/security-group";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    // ECR
    const repository = Repository.fromRepositoryName(
      this,
      "EcrRepository",
      resourceName,
    );

    // VPC
    const vpc = new Vpc(this, "Vpc", resourceName);
    
+   // Security Group
+   const { albSecurityGroup, ecsSecurityGroup, rdsSecurityGroup } = new SecurityGroup(this, "SecurityGroup", {
+     vpc: vpc.value,
+     resourceName,
+   });
  }
}

2.4. ALBを作成

2.4.1 ALB用のConstructを実装

上記の構成図の通り、ALBはpublicSubnetに配置します。
今回、ALBのターゲットとなるのはECSになりますが、ALBとECSの通信はHTTPで行うため、プロトコルはHTTPを指定します。
ALBとECSの通信を図にすると以下のようになるかと思います。

alb-ecs.drawio.png

コード全体は以下の通りです

lib/construct/alb.ts
import { Construct } from "constructs";
import type { SecurityGroup, SubnetSelection, Vpc } from "aws-cdk-lib/aws-ec2";
import { ApplicationLoadBalancer, ApplicationProtocol, ApplicationTargetGroup, Protocol, TargetType } from "aws-cdk-lib/aws-elasticloadbalancingv2";

interface AlbProps {
  vpc: Vpc;
  resourceName: string;
  securityGroup: SecurityGroup;
  subnets: SubnetSelection;
}

export class Alb extends Construct {
  public readonly value: ApplicationLoadBalancer;

  constructor(scope: Construct, id: string, props: AlbProps) {
    super(scope, id);

    // NOTE: ターゲットグループの作成
    const targetGroup = new ApplicationTargetGroup(this, "AlbTargetGroup", {
      targetGroupName: `${props.resourceName}-alb-tg`,
      vpc: props.vpc,
      targetType: TargetType.IP,
      protocol: ApplicationProtocol.HTTP,
      port: 80,
      healthCheck: {
        path: "/",
        port: "80",
        protocol: Protocol.HTTP,
      },
    });

    // NOTE: ALBの作成
    this.value = new ApplicationLoadBalancer(this, "Alb", {
      loadBalancerName: `${props.resourceName}-alb`,
      vpc: props.vpc,
      internetFacing: true,
      securityGroup: props.securityGroup,
      vpcSubnets: props.subnets,
    });

    // NOTE: リスナーの作成
    this.value.addListener("AlbListener", {
      protocol: ApplicationProtocol.HTTP,
      defaultTargetGroups: [targetGroup],
    });
  }
}

2.4.2. 外部からターゲットを登録できるようにする

上述の通り今回の構成では、ECSがALBのターゲットになります。
このターゲットの登録をStackを通じて行えるように、ターゲットを登録するためのメソッドを用意しておきます。

lib/construct/alb.ts
+ import type { AddApplicationTargetsProps, ApplicationListener } from "aws-cdk-lib/aws-elasticloadbalancingv2";

export class Alb extends Construct {
  public readonly value: ApplicationLoadBalancer;
+ private readonly listener: ApplicationListener;

  constructor(scope: Construct, id: string, props: AlbProps) {
    /** 省略 */
    
    // NOTE: リスナーの作成
-   this.value.addListener("AlbListener", {
+   this.listener = this.value.addListener("AlbListener", {
      protocol: ApplicationProtocol.HTTP,
      defaultTargetGroups: [targetGroup],
    });
  }
  
+ public addTargets(id: string, props: AddApplicationTargetsProps): void {
+   this.listener.addTargets(id, props);
+ }
}

2.4.2. 作成したConstructクラスをStackでインスタンス化する

以下のようにConstructをStackでインスタンス化します

lib/sample-node-app-stack.ts
+ import { Alb } from "./construct/alb";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    // ECR
    const repository = Repository.fromRepositoryName(
      this,
      "EcrRepository",
      resourceName,
    );

    // VPC
    const vpc = new Vpc(this, "Vpc", resourceName);

    // Security Group
    const { albSecurityGroup, ecsSecurityGroup, rdsSecurityGroup } = new SecurityGroup(this, "SecurityGroup", {
      vpc: vpc.value,
      resourceName,
    });
    
+   // ALB
+   const alb = new Alb(this, "Alb", {
+     vpc: vpc.value,
+     resourceName,
+     securityGroup: albSecurityGroup,
+     subnets: vpc.getPublicSubnets(),
+   });
  }
}

2.5. RDSを作成

ここまでの構築により、インターネットからVCP内のAWSリソースにアクセスできるようになりました。
ここからECSを構築したいのですが、今回のアプリケーションはDBとの接続が必要で、アプリケーション立ち上げ時にDBとの接続確立プロセスが実行される用になっています。
この時に、DBが立ち上がっていない状態だとエラーになってしまうので、ECSより先にRDSを構築していきます。

また、今回の構成ではスケーラビリティを意識して、プライマリインスタンスの他にリードレプリカを用意してみます。

RDSの構築手順は以下の通りです。

  1. Constructを定義
  2. パスワードを生成
  3. プライマリインスタンスを作成
  4. リードレプリカを作成

2.5.1. Constructを定義

まずは、以下のようにRDS用のConstructを定義します

lib/construct/rds.ts
import { Construct } from "constructs";

export class Rds extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
  }
}

2.5.2. パスワードを生成

パスワードの生成を行いたいのですが、CDKのソースコードにハードコーディングするのはセキュリティ的に良くないので、Secrets Managerに保存する方針で考えます。
これを実現するために、Credentials クラスのfromGeneratedSecretメソッドを使用して以下のように実装します。

lib/construct/rds.ts
+ import { Credentials } from "aws-cdk-lib/aws-rds";

+ interface RdsProps {
+   resourceName:string;
+ }

export class Rds extends Construct {
-  constructor(scope: Construct, id: string) {
+  constructor(scope: Construct, id: string, props: RdsProps) {
    super(scope, id);
    
+   // NOTE: パスワードを自動生成してSecrets Managerに保存
+   const rdsCredentials = Credentials.fromGeneratedSecret("cdk_test_user", {
+     secretName: `/${props.resourceName}/rds/`,
+   });
  }
}

2.5.3 プライマリインスタンスの作成

続いて、プライマリインスタンスの実装を行います。
今回は練習用(勉強用)であり、高性能より低価格を求めたいです。その為、Auroraを使用しないPostgreSQLを選択し、インスタンスサイズはMICROを選択しています。
また、AZの構成は、プライマリインスタンスをap-northeast-1a, リードレプリカをap-northeast-1cにおく形で以下のように実装します。

lib/construct/rds.ts
+ import { InstanceClass, InstanceSize, InstanceType, type SecurityGroup, type SubnetSelection, type Vpc } from "aws-cdk-lib/aws-ec2";
+ import { Credentials, DatabaseInstance, DatabaseInstanceEngine, NetworkType, PostgresEngineVersion } from "aws-cdk-lib/aws-rds";
- import { Credentials } from "aws-cdk-lib/aws-rds";

interface RdsProps {
   resourceName:string;
+  vpc: Vpc;
+  securityGroup: SecurityGroup;
+  subnets: SubnetSelection;
}

export class Rds extends Construct {
  constructor(scope: Construct, id: string, props: RdsProps) {
    super(scope, id);
    /** 省略 */
    // NOTE: パスワードを自動生成してSecrets Managerに保存
    const rdsCredentials = Credentials.fromGeneratedSecret("cdk_test_user", {
      secretName: `/${props.resourceName}/rds/`,
    });

+   // NOTE: プライマリインスタンスの作成
+   const rdsPrimaryInstance = new DatabaseInstance(this, "RdsPrimaryInstance", {
+     engine: DatabaseInstanceEngine.postgres({
+       version: PostgresEngineVersion.VER_15_5,
+     }),
+     instanceType: InstanceType.of(
+       InstanceClass.T3,
+       InstanceSize.MICRO,
+     ),
+     credentials: rdsCredentials,
+     databaseName: "cdk_test_db",
+     vpc: props.vpc,
+     vpcSubnets: props.subnets,
+     networkType: NetworkType.IPV4,
+     securityGroups: [props.securityGroup],
+     availabilityZone: "ap-northeast-1a",
+   });
  }
}

2.5.4. リードレプリカの作成

最後に、リードレプリカを作成します。
上述の通り、AZはap-northeast-1cを指定します。

lib/construct/rds.ts
+ import { Credentials, DatabaseInstance, DatabaseInstanceEngine, DatabaseInstanceReadReplica, NetworkType, PostgresEngineVersion } from "aws-cdk-lib/aws-rds";
- import { Credentials, DatabaseInstance, DatabaseInstanceEngine, NetworkType, PostgresEngineVersion } from "aws-cdk-lib/aws-rds";


export class Rds extends Construct {
  constructor(scope: Construct, id: string, props: RdsProps) {
    super(scope, id);
    /** 省略 */

    // NOTE: プライマリインスタンスの作成
    const rdsPrimaryInstance = new DatabaseInstance(this, "RdsPrimaryInstance", {
      /** 省略 */
    });
    
+   // NOTE: リードレプリカの作成
+   new DatabaseInstanceReadReplica(this, "RdsReadReplica", {
+     sourceDatabaseInstance: rdsPrimaryInstance,
+     instanceType: InstanceType.of(
+       InstanceClass.T3,
+       InstanceSize.MICRO,
+     ),
+     vpc: props.vpc,
+     vpcSubnets: props.subnets,
+     networkType: NetworkType.IPV4,
+     securityGroups: [props.securityGroup],
+     availabilityZone: "ap-northeast-1c",
+     autoMinorVersionUpgrade: false,
+   });
  }
}

2.5.5. 作成したConstructクラスをStackでインスタンス化する

以下のようにConstructをStackでインスタンス化します

lib/sample-node-app-stack.ts
+ import { Rds } from "./construct/rds";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    /** 省略 */

    // ALB
    const alb = new Alb(this, "Alb", {
      vpc: vpc.value,
      resourceName,
      securityGroup: albSecurityGroup,
      subnets: vpc.getPublicSubnets(),
    });
    
+   // RDS
+   new Rds(this, "Rds", {
+     vpc: vpc.value,
+     resourceName,
+     securityGroup: rdsSecurityGroup,
+     subnets: vpc.getRdsIsolatedSubnets(),
+   });
  }
}

2.5.6. デプロイ

ECSを作成する前に、いったんここまでの状態でデプロイします。
ここでデプロイする理由は、Secrets ManagerにDB情報が正常に格納されているか確認することと、そのSecrets Managerのarnを確認するためです。

以下のコマンドでデプロイします

cdk deploy

2.6. Secrets ManagerからDB情報を取得

2.6.1. 環境変数にarnの値を格納

デプロイが終了し、Secrets Managerのarnを確認できたら、そのarnの値を環境変数に格納しておきます。

.env
RDS_SECRET_MANAGER_ARN=your-secret-manager-arn

2.6.2. SecretsManager用のConstructを定義

指定したSecret Managerのarnをもとに、RDSのパスワードなどの情報を取得できるようにSecretManager用のConstructを実装します。
今回登録したシークレットの値は、JSON形式で登録されています。
Secret Manager側のConstructでは、「何のリソースのシークレットでどのような値が格納されているか」というのは気にしたくなく、「指定されたarnとkeyを元に値を返す」といった感じにしたいです。

そのため、これを実現するためのメソッドを用意しておきます。

lib/construct/secrets-manager.ts
import { Construct } from "constructs";
import { Secret } from "aws-cdk-lib/aws-secretsmanager";

export class SecretsManager extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);
  }

  public getSecretValue<T extends [string, ...string[]]>(secretKeys: T, arn: string): { [key in T[number]]: string } {
    const secret = Secret.fromSecretAttributes(this, "SecretStrings", {
      secretCompleteArn: arn,
    });

    return secretKeys.reduce((acc, key) => {
      const secretValue = secret.secretValueFromJson(key).unsafeUnwrap();
      if (!secretValue) {
        throw new Error(`Failed to get ${key}`);
      }

      acc[key as keyof { [key in T[number]]: string }] = secretValue;
      return acc;
    }, {} as { [key in T[number]]: string });
  }
}

getSecretValueメソッドについて解説します。
このメソッドは、シークレットのkey(secretKeys)の配列とarnを引数で受け取り、その引数に基づくシークレットの値をkey/valueの形式で返すようにしています。
また、keyが空配列になる事は許容したくないので、ジェネリクスを<T extends [string, ...string[]]>として、引数のsecretKeysが空配列をにならないようにしています。

2.6.3. 作成したConstructクラスをStackでインスタンス化する

以下のようにConstructをStackでインスタンス化します

lib/sample-node-app-stack.ts
+ import { SecretsManager } from "./construct/secrets-manager";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    /** 省略 */

    // RDS
    new Rds(this, "Rds", {
      vpc: vpc.value,
      securityGroup: rdsSecurityGroup,
      subnets: vpc.getRdsIsolatedSubnets(),
    });
    
+   // Secrets Manager
+   const secretManagerArn = process.env.RDS_SECRET_MANAGER_ARN;
+   if (!secretManagerArn) {
+     throw new Error("Failed to get RDS_SECRET_MANAGER_ARN");
+   }
+   const secretsManager = new SecretsManager(this, "SecretsManager");

  }
}

2.7. ECSを作成

RDSを構築し、さらにSecrets Managerからシークレットを取得する準備ができたので、続いてECSを構築していきます。
ECSの構築手順は以下の通りです。

  1. DBのURLを生成
  2. ECSクラスター・タスク定義の作成
  3. タスク定義にECRコンテナを追加
  4. ECSサービスの作成
  5. ALBのターゲットグループにECSを追加

2.7.1. DBのURLを生成
今回作成したアプリケーションでは、環境変数にDBのURLを定義して、その値を読み取っており、その影響でECSのタスク定義を作成する際に環境変数としてDBのURLを指定する必要があります。

また、DBのURLはPostgreSQLサーバーのURLになりますので、その形式に従い、Secrets Managerから取得するシークレットの値を元にURLの生成を行います。

lib/sample-node-app-stack.ts
export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    /** 省略 */
    
    // Secrets Manager
    const secretManagerArn = process.env.RDS_SECRET_MANAGER_ARN;
    if (!secretManagerArn) {
      throw new Error("Failed to get RDS_SECRET_MANAGER_ARN");
    }
    const secretsManager = new SecretsManager(this, "SecretsManager");
+   const keys: ["username", "password", "host", "port", "dbname"] = ["username", "password", "host", "port", "dbname"] as const;
+   const { username, password, host, port, dbname } = secretsManager.getSecretValue(keys, secretManagerArn);
+   const databaseUrl = `postgresql://${username}:${password}@${host}:${port}/${dbname}`;

  }
}

2.7.2. ECSクラスター・タスク定義の作成

以下のコードのようにECS用のConstructを定義し、クラスター及びタスク定義を作成していきます。

lib/construct/ecs.ts
import { Construct } from "constructs";
import { Cluster, CpuArchitecture, FargateTaskDefinition } from "aws-cdk-lib/aws-ecs";
import type { Vpc } from "aws-cdk-lib/aws-ec2";

interface EcsProps {
  vpc: Vpc;
  resourceName: string;
}

export class Ecs extends Construct {
  constructor(scope: Construct, id: string, props: EcsProps) {
    super(scope, id);

    // NOTE: クラスターの作成
    const cluster = new Cluster(this, "EcsCluster", {
      clusterName: `${props.resourceName}-cluster`,
      vpc: props.vpc,
    });

    // NOTE: タスク定義の作成
    const taskDefinition = new FargateTaskDefinition(this, "EcsTaskDefinition", {
      cpu: 256,
      memoryLimitMiB: 512,
      runtimePlatform: {
        cpuArchitecture: CpuArchitecture.ARM64,
      },
    });
  }
}

2.7.3. タスク定義にECRコンテナを追加

続いて、タスク定義にECRコンテナを追加していきます。ここで先ほど生成したDBのURLを環境変数として指定します。
また、ECSのログ情報がCloudWatch Logsに送信されるようにAwsLogDriverクラスを使用します。
今回は本番環境で運用するわけではないので、ログの保持期間は1日と短く設定しておきます。

lib/construct/ecs.ts
+ import { AwsLogDriver, Cluster, ContainerImage, CpuArchitecture, FargateTaskDefinition } from "aws-cdk-lib/aws-ecs";
+ import { RetentionDays } from "aws-cdk-lib/aws-logs";
+ import type { IRepository } from "aws-cdk-lib/aws-ecr";
- import { Cluster, CpuArchitecture, FargateTaskDefinition } from "aws-cdk-lib/aws-ecs";

interface EcsProps {
  vpc: Vpc;
  resourceName: string;
+ ecrRepository: IRepository;
+ env: {
+   databaseUrl: string;
+ };
}

export class Ecs extends Construct {
  constructor(scope: Construct, id: string, props: EcsProps) {
    super(scope, id);
    
    /** 省略 */   
    
    // NOTE: タスク定義の作成
    const taskDefinition = new FargateTaskDefinition(this, "EcsTaskDefinition", {
      /** 省略 */
    });
+   const logDriver = new AwsLogDriver({
+     streamPrefix: "ecs-fargate",
+     logRetention: RetentionDays.ONE_DAY,
+   });
+   taskDefinition.addContainer("EcsContainer", {
+     image: ContainerImage.fromEcrRepository(props.ecrRepository),
+     portMappings: [{ containerPort: 80, hostPort: 80 }],
+     environment: {
+       DATABASE_URL: props.env.databaseUrl,
+     },
+     logging: logDriver,
+   });
  }
}

2.7.4. ECSサービスの作成

ECSクラスター・タスク定義が作成でき、さらにコンテナの追加も行えたので、続いてECSサービスを作成していきます
上記の構成図の通り、今回はECSをマルチAZで構成するので、必要なタスク数は2としています。

lib/construct/ecs.ts
+ import { AwsLogDriver, Cluster, ContainerImage, CpuArchitecture, FargateService, FargateTaskDefinition, TaskDefinitionRevision } from "aws-cdk-lib/aws-ecs";
- import { AwsLogDriver, Cluster, ContainerImage, CpuArchitecture, FargateTaskDefinition } from "aws-cdk-lib/aws-ecs";
+ import type { SecurityGroup, SubnetSelection, Vpc } from "aws-cdk-lib/aws-ec2";
- import type { Vpc } from "aws-cdk-lib/aws-ec2";

interface EcsProps {
  vpc: Vpc;
  resourceName: string;
  ecrRepository: IRepository;
+ securityGroup: SecurityGroup;
+ subnets: SubnetSelection;
  env: {
    databaseUrl: string;
  };
}

export class Ecs extends Construct {
+ public readonly fargateService: FargateService;

  constructor(scope: Construct, id: string, props: EcsProps) {
    super(scope, id);
    
    /** 省略 */   
    
    // NOTE: タスク定義の作成
    /** 省略 */
    taskDefinition.addContainer("EcsContainer", {
      /** 省略 */
    });
    
+   // NOTE: Fargate起動タイプでサービスの作成
+   this.fargateService = new FargateService(this, "EcsFargateService", {
+     cluster,
+     taskDefinition,
+     desiredCount: 2,
+     securityGroups: [props.securityGroup],
+     vpcSubnets: props.subnets,
+     taskDefinitionRevision: TaskDefinitionRevision.LATEST,
+   });

2.7.5. ALBのターゲットグループにECSを追加

最後に、Stack側でECS用のConstructをインスタンス化し、ALBのターゲットに登録します。

lib/sample-node-app-stack.ts
+ import { Ecs } from "./construct/ecs";
+ import { Duration, Stack } from "aws-cdk-lib";
- import { Stack } from "aws-cdk-lib";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);
    
    /** 省略 */
    const databaseUrl = `postgresql://${username}:${password}@${host}:${port}/${dbname}`;

+   // ECS(Fargate)
+   const ecs = new Ecs(this, "EcsFargate", {
+     vpc: vpc.value,
+     resourceName,
+     ecrRepository: repository,
+     securityGroup: ecsSecurityGroup,
+     env: {
+       databaseUrl,
+     },
+     subnets: vpc.getEcsIsolatedSubnets(),
+   });

+   // NOTE: ターゲットグループにタスクを追加
+   alb.addTargets("Ecs", {
+     port: 80,
+     targets: [ecs.fargateService],
+     healthCheck: {
+       path: "/",
+       interval: Duration.minutes(1),
+     },
+   });
  }
}

ここまでで、最初の構成図の通りの構築ができました。
デプロイした後、実際にALBに対してリクエストを送って動作確認をしたいので、ALBのドメイン名を出力するように実装しておきます。

lib/sample-node-app-stack.ts
+ import { CfnOutput, Duration, Stack } from "aws-cdk-lib";
- import { Duration, Stack } from "aws-cdk-lib";

export class SampleNodeAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps, readonly resourceName = "sample-node-app") {
    super(scope, id, props);

    /** 省略 */

    // NOTE: ターゲットグループにタスクを追加
    alb.addTargets("Ecs", {
      /** 省略 */
    });

+   // NOTE: ALBのドメイン名を出力
+   new CfnOutput(this, "LoadBalancerDomainName", {
+     value: alb.value.loadBalancerDnsName,
+   });
  }
}

cdk deploy

デプロイが完了すると、ALBのドメイン名が出力されるので、そこにリクエストを投げてみます。

# `/`にGETリクエストを送信
curl http://<ALBのドメイン名>
Hello World
# `/posts`にPOSTリクエストを送信
curl -X POST http://<ALBのドメイン名>/posts -H "Content-Type: application/json" -d '{"title": "sample post"}'
{"id":"4bc54a3f-9c9d-4662-9d45-856baf434ea2","title":"sample post"}
# `/posts`にGETリクエストを送信
curl http://<ALBのドメイン名>/posts
{"id":"4bc54a3f-9c9d-4662-9d45-856baf434ea2","title":"sample post"}

無事、レスポンスが帰ってきました!

3. まとめ

今回は、AWS CDKを使用してECS(Fargate)とRDSをマルチAZ構成で構築してみました。
私は今回がCDKを触るのが初めてだったのですが、各リソースとのつながりを理解しながら構築でき、とても開発体験が良かったです。
今後は他のAWSリソースについて触れたり、IPv6構成などを試したいと思います。

3.1. 成果物

今回最終的なソースコードは以下になります
https://github.com/ren-yamanashi/ecs-fargate-cdk

3.2. 参考にしたサイト

以下のサイトを参考にさせていただきました!

Discussion