AWS CDK 大規模インフラのスタック設計パターン - 500リソース超えでも破綻しない設計
🚀 はじめに
AWS CDK(Cloud Development Kit)は、プログラミング言語を使ってAWSリソースを定義できる強力なIaCツールです。TypeScriptやPythonなどの使い慣れた言語で記述でき、型安全性や再利用性の高いインフラコードを実現できます。
しかし、プロジェクトが成長し、管理するリソース数が増えていくと、ある日突然こんなエラーに遭遇します:
Stack has more than 500 resources.
Please refactor your stack to reduce the number of resources.
これはCloudFormationスタックの500リソース制限です。この制限は、CloudFormationの根本的な制約であり、単一のスタックに含められるリソース数の上限です。
本記事では、実際に1つの大きなスタックで全リソースを管理していた構成が500リソースを超えた際に、NestedStackを使ってどのように設計を見直したか、その実践的なアプローチを紹介します。
📒 まずは結論
- 「可能な限り1つのスタックにまとめる」ことは大事
- ただし、1スタックあたり500リソースという制約があるため適切な分割方式を選ぶ必要がある
- 私的にはNestedStackによる分割が複雑性を生み出さずに大規模なインフラリソースを構築するのによいと考えた
- NestedStackの分割方針もPJによりけりだが、チーム構成、デプロイ頻度、データの重要性などに応じた設計を選択
- AWS CDKの柔軟性を活かし、それぞれのプロジェクトに最適なスタック設計を見つけることが、スケーラブルで保守性の高いインフラ構築への近道
⚠️ 直面した課題
🧱 初期構成:すべてを1つのスタックに
プロジェクト開始当初は、シンプルさを重視して1つのスタックですべてのリソースを管理していました。
実は、これはAWS CDKの公式ベストプラクティスに沿った正しいアプローチです。AWS CDK Best Practicesでは、以下のように述べられています:
"It's typically more straightforward to keep as many resources in the same stack as possible, so keep them together unless you know you want them separated."
(可能な限り多くのリソースを同じスタックに保つ方が簡単です。分離する明確な理由がない限り、一緒に保つべきです。)
つまり、まずは1つのスタックから始めて、必要になったら分割するというアプローチが推奨されています。過度な分割は、以下のような複雑性を生み出します:
- クロススタック参照の管理
- デプロイの順序制御
- スタック間の依存関係の追跡
私たちの初期構成には、以下の明確なメリットがありました:
- シンプルな構成: 1つのスタックファイルを見れば全体像が把握できる
-
デプロイの一貫性:
cdk deploy一発ですべてがデプロイされる - クロススタック参照の不要: すべてが同一スタック内なので参照が簡単
- 早期の開発速度: 複雑な依存関係を考慮せずに迅速に開発できる
📦 管理していたリソース
私たちのスタックには以下のようなリソースが含まれていました:
- Lambda関数: マイクロサービスアーキテクチャで複数の機能ごとに分割された多数のLambda(50+関数)
- API Gateway: REST APIとHTTP APIのエンドポイント群
- DynamoDB: テーブル、グローバルセカンダリインデックス、ローカルセカンダリインデックス
- VPC関連リソース: サブネット(複数AZ × パブリック/プライベート)、セキュリティグループ、NATゲートウェイ、VPCエンドポイント
- IAMロール・ポリシー: 各Lambdaやサービス用のロール
- ECS/EC2: コンテナベースのワークロードと従来型のEC2インスタンス
- CloudWatch: アラーム、ログストリーム、カスタムメトリクス
一見、よくある構成に思えますが、問題はリソース数のカウント方法にあります。
🧮 500リソースの落とし穴
CloudFormationのリソース数は、見た目よりもはるかに多くカウントされます:
- Lambda関数1つ = 4〜6リソース(関数本体、ログストリーム、IAMロール、IAMポリシー、権限等)
- API Gatewayのエンドポイント1つ = 3〜5リソース(リソース、メソッド、統合、デプロイメント等)
- DynamoDBテーブル1つ = 2〜4リソース(テーブル本体、各インデックス)
- VPCのサブネット1つ = 2〜3リソース(サブネット、ルートテーブル関連付け等)
つまり、「Lambda関数30個程度」と思っていても、実際には150〜200リソース近くになっているのです。
私たちのケースでは:
- Lambda関数: 50個 × 5リソース = 250リソース
- API Gateway: 30エンドポイント × 4リソース = 120リソース
- DynamoDB: 10テーブル × 3リソース = 30リソース
- VPC関連: 80リソース
- ECS/EC2等: 50リソース
- その他(CloudWatch等): 20リソース
合計: 550リソース → 500リソース超過
500リソースを超えると、CloudFormationがスタックの更新を拒否し、デプロイが失敗します。この制限はCloudFormationの仕様上、回避できません。
🔨 解決策の検討
500リソース制限を回避する方法として、主に2つのアプローチがあります:
🧩 1. 複数の独立したスタック
機能やレイヤーごとに完全に独立したスタックに分割する方法です。
メリット:
- 各スタックが完全に独立
- デプロイが並列化できる
- チームごとにスタックを分割しやすい
デメリット:
- クロススタック参照(
Fn::ImportValue)が複雑になる - スタック間の依存関係管理が煩雑
- ルートスタックからの一括管理が困難
🏭 2. NestedStack
親スタック(ルートスタック)の下に、子スタック(NestedStack)を階層的に配置する方法です。
メリット:
- 論理的な階層構造を保持できる
- ルートスタックからの一括デプロイが可能
- クロススタック参照がシンプル(親から子へのパラメータ渡し)
- 親子関係が明確で依存関係を管理しやすい
デメリット:
- NestedStackのテンプレートはS3にアップロードされる(自動処理されるが、S3バケットが必要)
- NestedStackの上限(最大500スタック、最大5レベルの入れ子)
🎯 NestedStackを選択!
私たちはNestedStackを選択しました。主な理由は以下の通りです:
- 論理的な階層を維持: ビジネスロジックやアーキテクチャの階層構造をそのままコードに反映できる
-
一括デプロイの一貫性:
cdk deploy一発で全体をデプロイできるシンプルさを保てる - 段階的な移行: 既存の1つの大きなスタックから、徐々にNestedStackに分割していける
また、この機会にステートフル/ステートレスリソースの分離(AWS公式ベストプラクティス)も実現し、データ保護を強化することにしました。
🏠 NestedStackによる分割戦略
🧭 分割の基本方針
リソースを分割する際、最も重要なのは分割の基準です。主に3つのアプローチがあります:
1. 機能別分割(推奨)
ビジネス機能やドメインごとに分割する方法です。
例:
- 認証スタック(Auth)
- 決済スタック(Payment)
- 通知スタック(Notification)
- ユーザー管理スタック(User)
メリット:
- ビジネスロジックとコードの対応が明確
- チーム分割しやすい(マイクロサービスアーキテクチャと相性が良い)
- 機能単位でのデプロイ・ロールバックが可能
デメリット:
- 機能間で共有するリソース(VPC、共通DBなど)の配置に工夫が必要
2. レイヤー別分割
インフラのレイヤー(階層)ごとに分割する方法です。
例:
- ネットワーク層(Network): VPC、サブネット、ゲートウェイ
- データ層(Data): DynamoDB、RDS、S3
- コンピュート層(Compute): Lambda、ECS、EC2
- API層(API): API Gateway、統合
メリット:
- インフラの依存関係が明確(下位レイヤーは上位レイヤーに依存しない)
- インフラエンジニアにとって理解しやすい
デメリット:
- ビジネス機能が複数のスタックに散在する
- 機能追加時に複数スタックを更新する必要がある
3. リソースタイプ別分割
リソースの種類ごとに分割する方法です。
例:
- Lambdaスタック
- DynamoDBスタック
- IAMスタック
メリット:
- 同じタイプのリソースを一元管理できる
デメリット:
- 機能横断的な参照が多発する
- 依存関係が複雑になりやすい
🧪 推奨される分割戦略:ハイブリッドアプローチ
実際のプロジェクトでは、レイヤー別と機能別のハイブリッドアプローチが最も効果的です。
さらに、AWS公式のベストプラクティスに従い、ステートフル/ステートレスリソースの分離も考慮します。
具体的には、以下のような分割を推奨します:
RootStack (ルートスタック)
├─ NetworkStack (VPC、サブネット、ゲートウェイ等の基盤) ← ステートフル
├─ DataStack (DynamoDB、RDS等の永続化層) ← ステートフル(最重要)
├─ ComputeStack (ECS、EC2等の長時間稼働するコンピュート) ← ステートレス
├─ ApiStack (API Gateway + Lambda統合) ← ステートレス
├─ FunctionsStack (イベント駆動型Lambda関数群) ← ステートレス
└─ MonitoringStack (CloudWatch、アラーム等の監視) ← ステートレス
この構成の利点:
- 基盤リソースを分離: NetworkStackは他のすべてのスタックの基盤となるため、独立させる
-
データ層の保護(重要):
- DataStackを分離することで、誤ってデータベースを削除するリスクを低減
- DataStackのみに
terminationProtection: trueを設定可能 -
removalPolicy: RETAINでデータを保護
- ステートレスリソースの柔軟性: ApiStack、FunctionsStackは気軽に破棄・再作成できる
-
デプロイ戦略の分離:
- ステートフルスタック(Network、Data)は慎重にデプロイ
- ステートレススタック(API、Functions)は頻繁にデプロイ可能
- コンピュートリソースのグループ化: 似た性質のリソースをまとめることで管理しやすくする
- APIとビジネスロジックの分離: API Gateway(インターフェース)とLambda(実装)を分けることで、それぞれの変更を独立させる
🔗 実装における依存関係の設計
NestedStackの大きな利点は、依存関係を明示的に管理できることです。
// ルートスタックで依存関係を明示
const networkStack = new NetworkStack(this, 'Network', { ... });
const dataStack = new DataStack(this, 'Data', { vpc: networkStack.vpc });
const apiStack = new ApiStack(this, 'Api', {
vpc: networkStack.vpc,
table: dataStack.userTable
});
このように、親スタックで子スタックへパラメータを渡すことで、依存関係がコードで明示的に表現されます。
💻 実装
それでは、実際のTypeScript CDKコードで実装を見ていきましょう。
📁 プロジェクト構造
cdk-project/
├── bin/
│ └── app.ts # エントリーポイント
├── lib/
│ ├── root-stack.ts # ルートスタック
│ └── nested-stacks/
│ ├── network-stack.ts # ネットワーク層
│ ├── data-stack.ts # データ層
│ └── api-stack.ts # API層
│ # (必要に応じて他のスタックを追加)
├── package.json
├── tsconfig.json
└── cdk.json
本記事では、ネットワーク層、データ層、API層の3つのスタックを中心に実装例を紹介します。
🏠 ルートスタックの実装
// lib/root-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { NetworkStack } from './nested-stacks/network-stack';
import { DataStack } from './nested-stacks/data-stack';
import { ApiStack } from './nested-stacks/api-stack';
export class RootStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, {
...props,
// 本番環境ではスタック全体に終了保護を有効化(推奨)
// これにより、ステートフルリソース(DataStack)を含むスタック全体が保護される
terminationProtection: true,
});
// 1. ネットワーク基盤(最初に構築)
const networkStack = new NetworkStack(this, 'NetworkStack', {
cidr: '10.0.0.0/16',
maxAzs: 2,
});
// 2. データ層(ネットワークに依存)
// ステートフルリソースを含むため、特に重要なスタック
const dataStack = new DataStack(this, 'DataStack', {
vpc: networkStack.vpc,
});
// 3. API層(ネットワークとデータに依存)
const apiStack = new ApiStack(this, 'ApiStack', {
vpc: networkStack.vpc,
userTable: dataStack.userTable,
});
// スタック間の明示的な依存関係設定
dataStack.addDependency(networkStack);
apiStack.addDependency(networkStack);
apiStack.addDependency(dataStack);
// 出力(他のスタックから参照可能にする)
new cdk.CfnOutput(this, 'ApiEndpoint', {
value: apiStack.api.url,
description: 'API Gateway endpoint URL',
exportName: 'ApiEndpoint',
});
}
}
🌐 NestedStackの実装例
NetworkStack(ネットワーク層)
// lib/nested-stacks/network-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
interface NetworkStackProps extends cdk.NestedStackProps {
cidr: string;
maxAzs: number;
}
export class NetworkStack extends cdk.NestedStack {
public readonly vpc: ec2.Vpc;
constructor(scope: Construct, id: string, props: NetworkStackProps) {
super(scope, id, props);
// VPCの作成
this.vpc = new ec2.Vpc(this, 'MainVpc', {
ipAddresses: ec2.IpAddresses.cidr(props.cidr),
maxAzs: props.maxAzs,
natGateways: 1, // コスト最適化のため1つに
subnetConfiguration: [
{
name: 'Public',
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: 'Private',
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
{
name: 'Isolated',
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
cidrMask: 24,
},
],
});
// VPC Endpointsの作成(コスト削減のためS3とDynamoDBのみ)
this.vpc.addGatewayEndpoint('S3Endpoint', {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
this.vpc.addGatewayEndpoint('DynamoDBEndpoint', {
service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
});
// タグ付け(リソース管理のため)
cdk.Tags.of(this.vpc).add('Layer', 'Network');
cdk.Tags.of(this.vpc).add('ManagedBy', 'CDK');
}
}
DataStack(データ層)
// lib/nested-stacks/data-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
interface DataStackProps extends cdk.NestedStackProps {
vpc: ec2.IVpc;
}
export class DataStack extends cdk.NestedStack {
public readonly userTable: dynamodb.Table;
constructor(scope: Construct, id: string, props: DataStackProps) {
super(scope, id, props);
// ステートフルリソースを含むため、削除保護の設定を推奨
// ルートスタックで terminationProtection を有効化することで、
// 誤ってこのスタックを削除するリスクを低減できる
// DynamoDBテーブルの作成
this.userTable = new dynamodb.Table(this, 'UserTable', {
partitionKey: { name: 'userId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'createdAt', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, // コスト最適化
encryption: dynamodb.TableEncryption.AWS_MANAGED,
pointInTimeRecovery: true, // バックアップ有効化
removalPolicy: cdk.RemovalPolicy.RETAIN, // スタック削除時もテーブルを保持
});
// GSI(Global Secondary Index)の追加
this.userTable.addGlobalSecondaryIndex({
indexName: 'EmailIndex',
partitionKey: { name: 'email', type: dynamodb.AttributeType.STRING },
});
// タグ付け
cdk.Tags.of(this.userTable).add('Layer', 'Data');
cdk.Tags.of(this.userTable).add('ManagedBy', 'CDK');
}
}
ApiStack(API層)
// lib/nested-stacks/api-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
interface ApiStackProps extends cdk.NestedStackProps {
vpc: ec2.IVpc;
userTable: dynamodb.ITable;
}
export class ApiStack extends cdk.NestedStack {
public readonly api: apigateway.RestApi;
constructor(scope: Construct, id: string, props: ApiStackProps) {
super(scope, id, props);
// API Gatewayの作成
this.api = new apigateway.RestApi(this, 'MainApi', {
restApiName: 'Main API',
description: 'Main API for the application',
deployOptions: {
stageName: 'prod',
throttlingRateLimit: 100,
throttlingBurstLimit: 200,
},
defaultCorsPreflightOptions: {
allowOrigins: apigateway.Cors.ALL_ORIGINS,
allowMethods: apigateway.Cors.ALL_METHODS,
},
});
// Lambda関数の作成(API統合用)
const getUserFunction = new lambda.Function(this, 'GetUserFunction', {
runtime: lambda.Runtime.NODEJS_18_X,
handler: 'index.handler',
code: lambda.Code.fromInline(`
exports.handler = async (event) => {
const userId = event.pathParameters.userId;
// DynamoDBからユーザー取得処理
return {
statusCode: 200,
body: JSON.stringify({ userId, message: 'User retrieved' })
};
};
`),
environment: {
TABLE_NAME: props.userTable.tableName,
},
vpc: props.vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
// DynamoDBテーブルへの読み取り権限を付与
props.userTable.grantReadData(getUserFunction);
// API Gatewayエンドポイントの作成
const users = this.api.root.addResource('users');
const user = users.addResource('{userId}');
user.addMethod('GET', new apigateway.LambdaIntegration(getUserFunction));
// タグ付け
cdk.Tags.of(this.api).add('Layer', 'API');
cdk.Tags.of(this.api).add('ManagedBy', 'CDK');
}
}
🔄 クロススタック参照のベストプラクティス
NestedStack間でリソースを参照する際のベストプラクティス:
1. パラメータとして渡す(推奨)
親スタックから子スタックへ、コンストラクタのpropsとしてリソースを渡します。
// ルートスタック
const networkStack = new NetworkStack(this, 'Network', { ... });
const dataStack = new DataStack(this, 'Data', {
vpc: networkStack.vpc, // VPCを渡す
});
メリット:
- 依存関係が明示的
- 型安全
- IDEの補完が効く
2. CfnOutputとImportValueを使う(非推奨)
別のスタックからエクスポートされた値を参照する方法です。
// エクスポート側
new cdk.CfnOutput(this, 'VpcId', {
value: vpc.vpcId,
exportName: 'MyVpcId',
});
// インポート側
const vpcId = cdk.Fn.importValue('MyVpcId');
この方法は個人的には推奨しません。理由は以下の通りです:
- 強い依存関係が生まれる: エクスポートされた値は、それを参照している他のスタックが存在する限り変更・削除できない
- 運用の柔軟性が失われる: スタックAのエクスポート値を変更するには、まずスタックBの参照を削除する必要があり、デプロイの順序制約が発生
- 複雑性が増す: スタック間の依存関係が暗黙的になり、「どのスタックがどの値を参照しているか」の把握が困難
NestedStackを使う場合は、方法1のパラメータ渡しを使うことを強く推奨します。
3. SSM Parameter Storeを使う(特殊なケース)
完全に独立したスタック間で緩い結合を保ちたい場合に使用します。
// エクスポート側
import * as ssm from 'aws-cdk-lib/aws-ssm';
new ssm.StringParameter(this, 'VpcIdParam', {
parameterName: '/myapp/vpc-id',
stringValue: vpc.vpcId,
});
// インポート側
const vpcId = ssm.StringParameter.valueFromLookup(this, '/myapp/vpc-id');
メリット:
- スタック間の直接的な依存関係がなくなる
- 柔軟性が高まる
デメリット:
- デプロイ時に値の存在を保証できない(ランタイムエラーの可能性)
- 型安全性が失われる
NestedStackでは方法1を使うべきです。SSM Parameter Storeは、完全に独立した別のスタックやアカウント間で値を共有する場合に有用です。
⚡ 注意すべきこと
📏 1. NestedStackの制限
NestedStackにはいくつかの制限があります:
- 最大500の子スタック: 1つの親スタックに配置できるNestedStackは最大500まで
- 最大5レベルの入れ子: NestedStackの階層は最大5レベルまで(ルートスタック→子→孫→曾孫→玄孫)
- テンプレートサイズ: 各NestedStackのテンプレートもCloudFormationの制限(51,200バイト)を受ける
これらの制限は十分に余裕があるため、通常のプロジェクトでは問題になりません。もし制限に近づいた場合は、スタックをさらに細分化するか、複数の独立したスタックへの移行を検討してください。
🪣 2. S3バケットの管理
NestedStackのテンプレートは自動的にS3にアップロードされます。
# CDKが自動的に作成するS3バケット
cdk-hnb659fds-assets-{account-id}-{region}
注意点:
- このバケットを誤って削除しないこと
- バケットポリシーを変更しないこと
- ライフサイクルポリシーで古いテンプレートを削除する設定は推奨
⏳ 3. デプロイ時間の増加
NestedStackを使うと、デプロイ時間が増加する可能性があります。
原因:
- 各NestedStackが順次デプロイされるため
- S3へのアップロード時間
- CloudFormationのオーバーヘッド
対策:
- 可能な限り並列にデプロイできるよう、依存関係を最小化する
- 不要な依存関係は削除する
🔁 4. クロススタック参照での循環依存
スタック間で相互に参照すると、循環依存エラーが発生します。
// ❌NG: 循環依存
const stackA = new StackA(this, 'A', { resource: stackB.resource });
const stackB = new StackB(this, 'B', { resource: stackA.resource });
対策:
- 依存関係を一方向にする
- 共通リソースは別のスタックに分離する
// ✅OK: 共通スタックを作成
const commonStack = new CommonStack(this, 'Common');
const stackA = new StackA(this, 'A', { resource: commonStack.resource });
const stackB = new StackB(this, 'B', { resource: commonStack.resource });
🛡 5. リソースの削除保護
重要なリソース(データベースなど)は削除保護を設定しましょう。
new dynamodb.Table(this, 'UserTable', {
// ...
removalPolicy: cdk.RemovalPolicy.RETAIN, // スタック削除時も保持
});
🎉 まとめ
AWS CDKで大規模なインフラを管理する際、スタック設計は重要な検討事項です。本記事では、500リソース制限に直面した実体験をもとに、NestedStackによる解決策を紹介しました。
🧠 基本的なスタック設計の考え方
AWS公式ベストプラクティスでは、**「可能な限り1つのスタックにまとめる」**ことが推奨されています。理由は明確で、シンプルさこそが保守性の鍵だからです。
しかし、プロジェクトの規模が大きくなると、以下のような課題に直面します:
- 500リソース制限による技術的制約
- ステートフルリソースの保護の必要性
- チームやデプロイ要件の違い
このような場合、過度な分割は避けつつ、プロジェクトの特性に合わせた適切な分割戦略を取ることが重要です。
🚀 プロジェクトに合わせた設計を
本記事で紹介したNestedStackによる分割は、あくまで一例です。重要なのは:
- シンプルに始める: まずは1つのスタックから。過度な最適化は不要
- 必要に応じて分割: 制限や課題に直面したら、その時点で分割を検討
- プロジェクトの特性を考慮: チーム構成、デプロイ頻度、データの重要性などに応じた設計を選択
AWS CDKの柔軟性を活かし、それぞれのプロジェクトに最適なスタック設計を見つけることが、スケーラブルで保守性の高いインフラ構築への近道です。
参考リンク: