はじめに
CDKの勉強がてら、VPC / ALB / ECS Fargate / Aurora の3層 Web アプリ基盤を TypeScript で構築しました。
構築の方針としては、最初からフル構成を作るのではなく、v1.0 の最小構成から段階的に機能を追加して v1.6 まで育てています。
この記事では各バージョンで 何を追加し、なぜそう設計したか を振り返ります。
以下が今回構築したAWS構成図の最終断面(v1.6)です。
ソースコード: GitHub
CDK の構成
今回のCDK構成は Construct によるファイル分割で各コンポーネントを独立させる構成としました。
lib/
├── app-stack.ts # ルートスタック
└── constructs/
├── network.ts # VPC / Subnet / SG / VPC Endpoint
├── compute.ts # ALB / ECS Fargate / Canary
├── database.ts # Aurora Serverless v2
├── ecr.ts # ECR リポジトリ
└── github-actions-role.ts # OIDC ロール
スタック分割を採用しなかった理由としては以下になります。
- リソース間の参照が多い(SG → ECS → ALB → Route53 など)。
- マルチスタックにすると
CfnOutput/Fn::ImportValueが大量に必要になり、可読性が下がる
- マルチスタックにすると
- 単一スタックなら props 渡しだけで完結する。循環依存も発生しない
- 構成の規模が小さくスタック分割するメリットがないため。
なお、Construct 間の依存関係は以下となります。
Construct 間の依存関係:
NetworkConstruct → DatabaseConstruct → ComputeConstruct
v1.0 ベース構成 — まず動くものを作る
最小構成となるv1.0のAWS構成図は以下となります。
サブネット設計
3層に対応する3種類のサブネットを用意しました。
| サブネット | 用途 | インターネット到達性 |
|---|---|---|
| Public | ALB | あり |
| Private (with egress) | ECS Fargate | NAT GW 経由で外向きのみ |
| Isolated | Aurora | なし |
サブネットタイプ Private (with egress) と Isolated の違い
- Private (with egress) : インターネットへEgress通信する場合のみ許可。外部からの接続を許可しない場合のみ使用する
- Isolated : Egress通信を提供しないよう設計されているもの
SG 設計: SG 参照で IP レンジを使わない
SG のインバウンドルールは、IP レンジではなく SG 参照で書くのが原則です。
// ECS SG: ALB SG からのみ受け付ける
this.ecsSg.addIngressRule(
ec2.Peer.securityGroupId(this.albSg.securityGroupId),
ec2.Port.tcp(80),
"from ALB",
);
// RDS SG: ECS SG からのみ受け付ける
this.rdsSg.addIngressRule(
ec2.Peer.securityGroupId(this.ecsSg.securityGroupId),
ec2.Port.tcp(5432),
"from ECS",
);
こうすると、ALB → ECS → RDS の通信経路が SG の定義だけで読み取れます。
IP レンジで書くと、サブネットの CIDR が変わったときに追従が必要になります。
ハマりポイント: NAT Gateway が勝手に作られる
CDK の Vpc L2 コンストラクトは、natGateways を省略すると AZ 数分の NAT Gateway を自動作成 します。
1 AZ あたり約 $45/月なので、2 AZ で $90/月が課金されます。
this.vpc = new ec2.Vpc(this, "Vpc", {
maxAzs: 2,
natGateways: 0, // 明示的に 0 を指定しないと勝手に作られる
// ...
});
v1.0 時点では NAT Gateway を使っていましたが、v1.4 で VPC エンドポイントに置き換えて廃止しています。
v1.1〜v1.2 Route53 + HTTPS — 本番に近づける
Route53
parameter.ts にはサブドメイン名だけを書きます。
ホストゾーンは既存のものを fromLookup で参照する設計としています。
const hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", {
domainName: props.hostedZone,
});
new route53.ARecord(this, "AliasRecord", {
zone: hostedZone,
recordName: props.domainName, // サブドメインだけ指定
target: route53.RecordTarget.fromAlias(
new route53targets.LoadBalancerTarget(this.alb),
),
});
HTTPS 化
ACM 証明書を作成し、ALB の HTTPS リスナーにアタッチ。
HTTP でのアクセスを恒久的に許可しない設計のため、HTTPからきた通信は 301 にリダイレクトさせています。
※メンテナンスページへの一時転送時など、一時的に移動する場合は 302 を使用する。
const certificate = new acm.Certificate(this, "Certificate", {
domainName: `${props.domainName}.${props.hostedZone}`,
validation: acm.CertificateValidation.fromDns(hostedZone),
});
// HTTPS リスナー
this.alb.addListener("HttpsListener", {
port: 443,
open: false,
certificates: [
elbv2.ListenerCertificate.fromArn(certificate.certificateArn),
],
});
// HTTP → HTTPS リダイレクト
this.alb.addListener("HttpListener", {
port: 80,
open: false,
defaultAction: elbv2.ListenerAction.redirect({
protocol: "HTTPS",
port: "443",
permanent: true, // true → 301, false → 302
}),
});
alb.addListenerについて、open: false を指定しているのは、ALB SG で許可する CIDR を明示的に制御しているためです。
仮に、open: true にすると CDK が自動で 0.0.0.0/0 のインバウンドルールを追加してしまいます。
v1.3〜v1.4 ECR + 閉域化 — NAT Gateway を消す
v1.3 DockerHub → ECR
DockerHub のレート制限(匿名: 100 pulls/6h)に引っかかるリスクがあるため、DockerHub からではなくECRからの pull するようにしました。
v1.4 NAT Gateway 廃止
環境の閉域化、つまり、NAT Gateway を廃止するには、ECS タスクが外向き通信で使っていたサービスすべてに VPC エンドポイントを用意する必要があります。
必要な VPC エンドポイントは以下の通り。
S3GatewayEndopointが必要になるのは正直盲点でした。
| エンドポイント | タイプ | 用途 |
|---|---|---|
| ECR API | Interface | ECR API コール |
| ECR Docker | Interface | イメージ pull |
| S3 | Gateway | イメージレイヤー取得(ECR は内部で S3 を使う) |
| CloudWatch Logs | Interface | ログ送信 |
| Secrets Manager | Interface | DB 認証情報取得 |
エンドポイントのタイプが Interface型の場合は、ループさせているのが実装のポイントです。
const endpointServices = [
ec2.InterfaceVpcEndpointAwsService.ECR,
ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
];
endpointServices.forEach((service) => {
this.vpc.addInterfaceEndpoint(service.shortName, {
service,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [this.vpcEndpointSg],
});
});
// S3 は Gateway 型(無料)
this.vpc.addGatewayEndpoint("S3Endpoint", {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
Interface 型と Gateway 型のエンドポイントの違いは以下の通り。
| Interface | Gateway | |
|---|---|---|
| 料金 | $0.014/h + データ処理料 | 無料 |
| 対応サービス | ほぼ全サービス | S3 / DynamoDB のみ |
| 仕組み | ENI をサブネットに配置 | ルートテーブルにエントリ追加 |
S3 は Gateway 型が使えるので無料。
ECR のイメージレイヤーは S3 に保存されているため、ECR の Interface エンドポイントだけでは pull できません。S3 Gateway も必須です。
コスト比較
| 構成 | 月額概算(2 AZ) |
|---|---|
| NAT Gateway × 1 | ~$45 + データ処理料 |
| Interface Endpoint × 4 | ~$40($0.014 × 730h × 4) |
ほぼ同額ですが、NAT Gateway はデータ処理料($0.062/GB)が加算されるため、通信量が増えると差が開きます。
閉域化によるセキュリティ向上も考慮すると、VPC エンドポイントのほうが合理的です。
v1.5 外形監視 — CloudWatch Synthetics
CloudWatch Synthetics Canary でECSコンテナに対する外形監視を追加しました。
Canary スクリプトの実装方法は2パターンあります。
| 方法 | メリット | デメリット |
|---|---|---|
fromInline |
1ファイルで完結 | 複雑なスクリプトに不向き |
fromAsset |
別ファイル管理、テスト可能 | ディレクトリ構成の制約あり |
スクリプトが育ったときにテストしやすいのと、CDK コードとテストコードの責務を分離できるため、fromAsset を採用しました。
new synthetics.Canary(this, 'EcsCanary', {
schedule: synthetics.Schedule.rate(Duration.minutes(5)),
test: synthetics.Test.custom({
code: synthetics.Code.fromAsset(path.join(__dirname, 'canary')),
handler: 'index.handler',
}),
runtime: synthetics.Runtime.SYNTHETICS_NODEJS_PUPPETEER_9_1,
environmentVariables: {
TARGET_URL: `https://${props.domainName}.${props.hostedZone}`,
},
});
v1.6 GitHub Actions CI/CD — OIDC 認証
GitHub Actionsを使ってみたいというだけの理由で追加改修しました。
なお、GitHub Actions から ECR にイメージを push するための認証は OIDC 認証を使いました。
アクセスキーを発行しないので、漏洩リスクがありません。
OIDC 認証の仕組みは以下の通りです。
- GitHub Actions が実行されるたびに GitHub が一時トークンを発行
- そのトークンで AWS に「この GitHub Actions ですよ」と証明
- AWS が IAM ロールを一時的に貸し出す(有効期限あり)
const provider = new iam.OpenIdConnectProvider(this, "GitHubOidcProvider", {
url: "https://token.actions.githubusercontent.com",
clientIds: ["sts.amazonaws.com"],
});
const role = new iam.Role(this, "GitHubActionsRole", {
assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:<GITHUB_OWNER>/<GITHUB_REPO>:ref:refs/heads/main",
},
}),
});
IAM ポリシーは最小権限で設計しています。
ecr:GetAuthorizationToken だけはリソース指定ができない(* が必須)ため、別の PolicyStatement に分離しています。
// GetAuthorizationToken はリソース指定不可
role.addToPolicy(new iam.PolicyStatement({
actions: ["ecr:GetAuthorizationToken"],
resources: ["*"],
}));
// ECR push は対象リポジトリに限定
role.addToPolicy(new iam.PolicyStatement({
actions: [
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
],
resources: [props.repository.repositoryArn],
}));
全体を通してのハマりポイント
desiredCount: 0 にしないと初回デプロイで失敗する
ECS Service の desiredCount をデフォルト(1)のままにすると、初回デプロイ時に ECR にイメージがまだ push されていないためタスク起動に失敗し、デプロイが一生終わりません。
this.service = new ecs.FargateService(this, "Service", {
desiredCount: 0, // 初回は 0。イメージ push 後に 1 以上に変更
circuitBreaker: { rollback: true }, // デプロイ失敗時の自動ロールバック
enableExecuteCommand: true, // ECS Exec でコンテナにログイン可能にする
});
circuitBreaker は必ず有効にする
デプロイ失敗時にタスクが延々と再起動を繰り返す状態を防ぎます。rollback: true にしておけば、失敗時に前のバージョンに自動で戻ります。
enableExecuteCommand を入れておく
障害調査時に aws ecs execute-command でコンテナにログインできます。
本番では読み取り専用にするなどの要件がある場合があるため、一概には言えませんが基本的には入れておいた方がいいと思います。
まとめ
v1.0 → v1.6 の変遷:
| バージョン | 追加内容 |
|---|---|
| v1.0 | VPC / ALB / ECS / Aurora のベース構成 |
| v1.1 | Route53 ドメインルーティング |
| v1.2 | HTTPS 化(ACM + ALB リスナー) |
| v1.3 | DockerHub → ECR に変更 |
| v1.4 | NAT Gateway 廃止 → VPC エンドポイント |
| v1.5 | CloudWatch Synthetics 外形監視 |
| v1.6 | GitHub Actions OIDC 認証 |
「最初からフル構成を作る」のではなく、段階的に育てることで各コンポーネントの役割と依存関係が明確になるので、学習にはおすすめです。
ソースコードはGitHubで公開してるので良ければ参考にしてください。
次回は CodePipeline + CodeDeploy による ECS Blue/Green デプロイを書きます。





