AWS CDKでECS(Fargate) + RDSをマルチAZで作成
1. 記事の概要
この記事では、AWS初心者の私がCDKを触ってみて、どういうふうにリソースやネットワークの構成を考えながらECS(Fargate) + RDSを構築したかを記します。
1.1. 目標
本記事の主な目標は、AWS CDKを使用してECS(Fargate)とRDSの基本的な構成を理解し、実際に構築する技術を身につけることです。
以下に示すアーキテクチャ図の環境を実際に構築することで、IaCの基礎と開発の流れを体験し、CDK開発への一歩を踏み出すことを目指しています。
1.2. 構成図
今回構築する環境は以下のようになっております。
ECSはWebアプリケーションサーバーとして機能させ、サーバーへのリクエストはALBを通して行うようにしています。
また、ECR,CloudWatch,S3との接続はNAT Gatewayを使用するのではなく、VPCエンドポイントを使用するようにしました。
VPCエンドポイントを選択した理由としては、NAT GatewayはVPCエンドポイントよりも料金が高いことと、今回実現したい「ECSとECR,CloudWatch,S3の接続」という面ではVPCエンドポイントで機能として十分なためです。
1.3. 手順
上記の構成図を、以下の手順で構築していきます
- VPCを作成
- セキュリティーグループを作成
- ALBを作成
- RDSを作成
- Secrets ManagerからDB情報を取得
- 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を定義します
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
以下のようにコードを修正します
+ 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など他のリソースを作成する際に、簡潔に各サブネットを取得できるするために、外部呼び出し可能なメソッドを用意します。
+ 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のリポジトリも作成しておきます)
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番とします。インバウンド通信はこのポート番号を使用して制御をしていきます。
コード全体は以下の通りです
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でインスタンス化します
+ 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の通信を図にすると以下のようになるかと思います。
コード全体は以下の通りです
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を通じて行えるように、ターゲットを登録するためのメソッドを用意しておきます。
+ 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でインスタンス化します
+ 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の構築手順は以下の通りです。
- Constructを定義
- パスワードを生成
- プライマリインスタンスを作成
- リードレプリカを作成
2.5.1. Constructを定義
まずは、以下のようにRDS用のConstructを定義します
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
メソッドを使用して以下のように実装します。
+ 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におく形で以下のように実装します。
+ 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を指定します。
+ 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でインスタンス化します
+ 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の値を環境変数に格納しておきます。
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を元に値を返す」といった感じにしたいです。
そのため、これを実現するためのメソッドを用意しておきます。
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でインスタンス化します
+ 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の構築手順は以下の通りです。
- DBのURLを生成
- ECSクラスター・タスク定義の作成
- タスク定義にECRコンテナを追加
- ECSサービスの作成
- ALBのターゲットグループにECSを追加
2.7.1. DBのURLを生成
今回作成したアプリケーションでは、環境変数にDBのURLを定義して、その値を読み取っており、その影響でECSのタスク定義を作成する際に環境変数としてDBのURLを指定する必要があります。
また、DBのURLはPostgreSQLサーバーのURLになりますので、その形式に従い、Secrets Managerから取得するシークレットの値を元にURLの生成を行います。
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を定義し、クラスター及びタスク定義を作成していきます。
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日と短く設定しておきます。
+ 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としています。
+ 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のターゲットに登録します。
+ 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のドメイン名を出力するように実装しておきます。
+ 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. 成果物
今回最終的なソースコードは以下になります
3.2. 参考にしたサイト
以下のサイトを参考にさせていただきました!
- VPC ConstructのAPI Reference
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Vpc.html - SubnetTypeのAPI Reference
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.SubnetType.html#members - VPCエンドポイントの種類についての公式ドキュメント
https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/concepts.html#concepts-vpc-endpoints - VPC CIDRブロックについての公式ドキュメント
https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/vpc-cidr-blocks.html - RDSのパスワードを自動生成する方法
https://dev.classmethod.jp/articles/automatically-generate-a-password-with-cdk/
Discussion