CDK アプリケーションに適した環境戦略を選択するための包括的ガイド
※本記事は、ドイツの AWS DevTools Hero である Thorsten Höger と、日本の AWS DevTools Hero である私(k.goto)によるコラボ記事『CDK Environment Management: Static vs Dynamic Stack Creation』の和訳記事になります。
目次
1. はじめに:環境管理のジレンマ
AWS CDK でアプリケーションを構築する際、こんな疑問に直面したことがあるでしょう。「複数の環境にデプロイするために、どのようにコードを構造化すべきか?」
この基本的なアーキテクチャ上の決定は、CDK コミュニティで興味深い議論を引き起こし、私たち - Thorsten Höger と 後藤(Kenta Goto / k.goto) - がこの包括的なガイドで協力することになりました。
私たちは数百もの CDK プロジェクトをレビューしてきた中で、あるパターンを発見しました。約 90 %のチームが、その選択がもたらす影響を十分に理解しないまま環境管理のアプローチを決めているのです。一見すると単純な決定に思えます。開発環境、ステージング環境、そして本番環境を用意すればいい、と。しかし、これを実現するための CDK コードの構造化方法は、チームの生産性、デプロイパイプライン、アプリケーションの信頼性に広範な影響を与えます。
このトピックが特に興味深いのは、これから議論する両方のアプローチが有効で、本番環境で広く使用されていることです。普遍的な「正解」はありません。代わりに、それぞれのアプローチが異なるチームのニーズやプロジェクト要件に合わせた独自の利点を提供します。このコラボレーションを通じて、両方の視点を公平に提示し、特定のコンテキストに基づいて情報に基づいた決定を下せるようにすることを目指しています。
このガイドでは、CDK 環境を管理する 2 つの主要なパターン、それらのトレードオフ、そして各パターンをいつ使用すべきかを検討します。最終的には、最初に簡単に見えるものをデフォルトで選ぶのではなく、このアーキテクチャ上の決定を意図的に行うための明確なフレームワークを手に入れることができるでしょう。
2. 2 つのアプローチの概要
2.1 アプローチ A:動的スタック作成
動的アプローチは、どの環境を合成するかを決定するために stage コンテキスト変数(dev や prod)を渡し、この値に基づいてスタックの設定値を分岐させます。
// app.ts const app = new cdk.App(); const stageName = app.node.tryGetContext('stage') || 'dev'; let config: EnvironmentConfig; switch (stageName) { case 'dev': config = { account: '111111111111', instanceType: 't3.micro', minCapacity: 1, maxCapacity: 2, domainName: 'dev.example.com', }; break; case 'prod': config = { account: '222222222222', instanceType: 'm5.large', minCapacity: 3, maxCapacity: 10, domainName: 'example.com', }; break; default: throw new Error(`Unknown stage: ${stageName}`); } new ApplicationStack(app, `MyApp-${stageName}`, { env: { account: config.account, region: 'eu-central-1' }, config, });
チームは通常、このアプローチが直感的に感じられるため採用します。本番環境が必要なときは cdk deploy -c stage=prod を実行し、開発環境には cdk deploy -c stage=dev を実行します。メンタルモデルは単純明快で、「1 つのコマンド = 1 つの環境」となります。
2.2 アプローチ B:静的スタック作成(「一度だけ合成」)
静的アプローチは、合成中にすべての環境のスタック(インスタンス)を、それぞれの環境ごとに異なる設定値で作成します。
// app.ts const app = new cdk.App(); // 開発環境 new ApplicationStack(app, 'MyApp-dev', { env: { account: '111111111111', region: 'eu-central-1' }, config: { instanceType: 't3.micro', minCapacity: 1, maxCapacity: 2, domainName: 'dev.example.com', }, }); // 本番環境 new ApplicationStack(app, 'MyApp-prod', { env: { account: '222222222222', region: 'eu-central-1' }, config: { instanceType: 'm5.large', minCapacity: 3, maxCapacity: 10, domainName: 'example.com', }, });
このアプローチでは、複数の CloudFormation テンプレートを含む単一の CDK クラウドアセンブリにすべての環境を合成します。特定のスタックをデプロイしたい場合は、例えば cdk deploy MyApp-prod のコマンドで本番環境のみのデプロイができます。このアプローチの大事なところは、「合成は一度だけ発生し、すべての環境のアーティファクトを同時に生成する」という点です。
3. 主要なトレードオフ:実践的な比較
3.1 開発者体験
アプローチ A:動的スタック作成
動的アプローチは、開発中に自然に感じられる柔軟性を提供します。各開発者は、単にステージ名を変更することで個人用スタックを立ち上げることができます。
switch (stageName) { case 'dev': config = { account: '111111111111', instanceType: 't3.micro', minCapacity: 1, maxCapacity: 2, domainName: 'dev.example.com', }; break; case 'prod': config = { account: '222222222222', instanceType: 'm5.large', minCapacity: 3, maxCapacity: 10, domainName: 'example.com', }; break; default: // 各開発者用 config = { account: process.env.CDK_DEFAULT_ACCOUNT, instanceType: 't3.nano', minCapacity: 1, maxCapacity: 1, domainName: `${stageName}.example.com`, }; }
# 開発者Aliceのスタック cdk deploy -c stage=alice # 開発者Bobのスタック cdk deploy -c stage=bob
この柔軟性は設定にも及びます。開発者はコードを変更することなくコンテキストを通じて設定をオーバーライドでき、実験が簡単になります。
例えば、このアプローチにより、各開発者は 1 つだけでなく自分用のスタックをいくつも作成できます。彼らは自由にこれらのスタックを作成、更新、そして削除することができ、さまざまな実験を同時に行うことができます。
もう 1 つの利点は、開発者が Lambda 関数などのアプリケーションコードを実験するための一時的な環境を作成し、その機能を自由にデプロイしてテストできることです。複数の開発者が使用する共有開発環境への影響を心配する必要がないため、より高速なラピッドプロトタイピングが可能になります。
アプローチ B:静的スタック作成
静的アプローチは全ての環境を一度に合成するため、開発中にすべての環境について即座にフィードバックを提供します。cdk synth を実行すると、TypeScript がすべての環境の設定を検証します。本番環境の問題を、本番環境のデプロイ中ではなく開発中に発見できます。
例えば、次のコードでは、prod の合成をするときに dev 環境の設定のエラーも捕捉できます。
// Stack export interface ApplicationStackProps extends cdk.StackProps { enforceSSL?: boolean; minimumTLSVersion?: number; } export class ApplicationStack extends cdk.Stack { constructor(scope: Construct, id: string, props: ApplicationStackProps) { super(scope, id, props); new Bucket(this, 'MyBucket', { enforceSSL: props.enforceSSL, minimumTLSVersion: props.minimumTLSVersion, }); } } // App const app = new cdk.App(); new ApplicationStack(app, 'MyApp-dev', { // ValidationError: 'enforceSSL' must be enabled for 'minimumTLSVersion' to be applied enforceSSL: false, minimumTLSVersion: 1.2, }); new ApplicationStack(app, 'MyApp-prod', { enforceSSL: true, minimumTLSVersion: 1.2, });
3.2 CI/CD パイプライン設計
アプローチ A:動的スタック作成
動的アプローチでは、CI/CD パイプラインが各環境に必要なことだけを実行できます。例えば、開発環境パイプラインでは、その環境の合成のみが実行され、本番環境の合成は行われません。これにより合成時間が最小限に抑えられます。さらに、開発環境でのみエラーが発生した場合にも、他の環境には影響せず、その結果として本番 CI/CD が失敗することはありません。
またもう一つの利点は、設定を変更することなく CI/CD パイプラインを使用して各開発者の機能ブランチ用の個別の環境を構築できることです。
アプローチ B:静的スタック作成
静的アプローチは、真の「一度の合成で何度もデプロイ」というパイプラインを可能にします。CI/CD パイプラインは最初に一度合成し、全環境を含む単一のアーティファクトを作成します。
# GitHub Actions の例 jobs: synthesize: runs-on: ubuntu-latest steps: - run: npm ci - run: npx cdk synth - uses: actions/upload-artifact@v3 with: name: cdk-out path: cdk.out deploy-dev: needs: synthesize steps: - uses: actions/download-artifact@v3 - run: npx cdk --app cdk.out deploy MyApp-dev deploy-prod: needs: deploy-dev steps: - uses: actions/download-artifact@v3 - run: npx cdk --app cdk.out deploy MyApp-prod
このアプローチは、開発環境でテストしたものと本番環境にデプロイされるものが同じ時点に生成されていることを保証します。再合成がないということは、環境差異が入り込む機会がないということです。
3.3 チームコラボレーション
アプローチ A:動的スタック作成
動的アプローチでは、各開発者が自分の個人用スタックをデプロイできます。これにより、開発者は自由に独立した環境を使用できるため、お互いに設定の競合を引き起こすことなく様々なシナリオをテストできます。
アプローチ B:静的スタック作成
静的アプローチは、チームがすべての環境を全体的に考えることを強制します。本番環境の設定が変更セットに常に存在するため、コードレビューには自然に本番環境への考慮事項が含まれます。この可視性により、ジュニア開発者は初日から本番環境の要件を理解できます。
4. 各アプローチをいつ使用すべきか
4.1 アプローチ A が向いているケース
単一アカウントでの複数の開発者スタック
チームが共有 AWS アカウントで多数の一時的な環境を必要とする場合、動的アプローチが優れています。
10 人の開発者のチームを考えてみましょう。それぞれがテスト用の独自のスタックを必要としています。前のコード例では、stage に開発者の名前を渡しました。より柔軟な組み合わせを可能にするために、今度は stage と owner を別々に指定してみましょう。
// app.ts const app = new cdk.App(); const stage = app.node.tryGetContext('stage') || 'dev'; const owner = app.node.tryGetContext('owner'); const stackName = owner ? `MyApp-${stage}-${owner}` : `MyApp-${stage}`; let config: EnvironmentConfig; switch (stage) { case 'dev': config = { ... }; // 必要に応じて、`owner`が指定されているかどうかによってドメイン名などの値を変更 break; case 'prod': config = { ... }; break; default: throw new Error(`Unknown stage: ${stage}`); } new ApplicationStack(app, stackName, { // config... });
# 開発者Aliceのスタック cdk deploy -c stage=dev -c owner=alice # 開発者Bobのスタック cdk deploy -c stage=dev -c owner=bob
このアプローチにより、複数の開発者が単一の AWS アカウント内で同じスタックを複製できます。スタック所有者の情報を事前に設定にハードコーディングする必要がないため、アカウントを共有するチームにとってより柔軟なテストが可能になります。
ラピッドプロトタイピング
初期の開発期間中、環境要件が流動的な場合、動的アプローチは絶え間ないコード変更なしに迅速な反復を可能にします。
合成パフォーマンスが重要
合成に大幅な時間がかかる大規模なアプリケーションでは、必要な環境のみを合成することで開発サイクルを高速化できます。
4.2 アプローチ B が向いているケース
エンタープライズアプリケーション
厳格なコンプライアンス要件を持つ本番アプリケーションでは、静的アプローチの決定性は大きなメリットとなるでしょう。すでに合成しているため、何がデプロイされるか正確にわかります。
環境間の依存関係
アーキテクチャに環境間の依存関係が含まれる場合、静的アプローチはこれらの関係を明示的にします。
const devStack = new ApplicationStack(app, 'MyApp-dev', devConfig); const prodStack = new ApplicationStack(app, 'MyApp-prod', prodConfig); // 本番監視スタックは両方の環境に依存 new MonitoringStack(app, 'MyApp-monitoring', { devApiUrl: devStack.apiUrl, prodApiUrl: prodStack.apiUrl, });
明示的なマルチアカウントデプロイ
環境が異なる AWS アカウントにまたがる場合、静的アプローチはアカウントの境界を明示的にします。
new ApplicationStack(app, 'MyApp-dev', { env: { account: '111111111111', region: 'eu-central-1' }, // ... }); new ApplicationStack(app, 'MyApp-prod', { env: { account: '222222222222', region: 'eu-central-1' }, // ... });
5. 実世界での実装のヒント
5.1 コンテキストとルックアップの処理
どのアプローチを選択しても、コンテキストルックアップは最終的な合成の前に行うべきで、CI/CD パイプラインでのデプロイ中には行うべきではありません。また、環境固有のコード内で VPC ルックアップやその他の AWS API コールを実行しないでください。これはエラーの検出遅延や値の検索漏れにつながる可能性があります。
// ❌ 間違い:条件内でのルックアップ if (stageName === 'prod') { const vpc = Vpc.fromLookup(this, 'VPC', { vpcId: 'vpc-123' }); } // ✅ 正しい:一度ルックアップし、設定を使用 const vpcId = props.vpcId; const vpc = Vpc.fromLookup(this, 'VPC', { vpcId });
さらに、ルックアップ結果をcdk.context.jsonに保存し、Git などのバージョン管理システムにコミットしましょう。これにより、チームメンバーと CI/CD 環境間で一貫した合成が保証されます。
詳細は、以前に私、後藤が執筆した『AWS CDK における「cdk.context.json」の必要性』という記事をご覧ください。
5.2 移行の考慮事項
アプローチ A からアプローチ B への移行には慎重な計画が必要です。まず設定を抽出することから始めます。
- 元のコード(アプローチ A)
const app = new cdk.App(); const stage = app.node.tryGetContext('stage') || 'dev'; interface EnvironmentConfig { account: string; region: string; exampleName: string; } let config: EnvironmentConfig; switch (stage) { case 'dev': config = { account: '111111111111', region: 'eu-central-1', exampleName: 'This is dev', }; break; case 'prod': config = { account: '222222222222', region: 'eu-central-1', exampleName: 'This is prod', }; break; default: throw new Error(`Unknown stage: ${stage}`); } new ApplicationStack(app, `MyApp-${stage}`, { env: { account: config.account, region: config.region }, ...config, });
- アプローチ A からアプローチ B への移行ステップ
// ステップ1:switch文から設定を抽出 const configs = { dev: { account: '111111111111', region: 'eu-central-1', exampleName: 'This is dev', }, prod: { account: '222222222222', region: 'eu-central-1', exampleName: 'This is prod', }, }; // ステップ2:静的アプローチを使用して同じスタック名でスタックを定義 new ApplicationStack(app, `MyApp-dev`, { env: { account: configs.dev.account, region: configs.dev.region }, ...configs.dev, }); new ApplicationStack(app, `MyApp-prod`, { env: { account: configs.prod.account, region: configs.prod.region }, ...configs.prod, }); // ステップ3:古いスタック定義とswitch文や`tryGetContext`などの不要なコードを削除 // new ApplicationStack(app, `MyApp-${stage}`, { // env: { account: config.account, region: config.region }, // ...config, // }); // ステップ4:`cdk diff --all`、もしくは特定のスタックに対する `cdk diff MyApp-prod` などを実行し、差分がないことを確認 // 差分がない場合、動的アプローチから静的アプローチへの移行が完了
6. 結論:正しい選択をする
クイック決定マトリックス
| 動的スタック作成を選択する場合 | 静的スタック作成を選択する場合 |
|---|---|
| 多数の一時的な環境が必要 | 固定の環境だけ必要 |
| チームで共有の開発アカウントを使用 | 明示的なアカウント設定を持つマルチアカウント戦略 |
| 合成パフォーマンスが重要 | 環境間におけるデプロイの決定性が重要 |
| 実行時の柔軟性を重視 | 一度の合成で全環境をチェックしたい |
チームへの重要な質問
- 無制限の一時的な環境が必要か、それとも環境セットは固定か?
- 環境間におけるデプロイの決定性と開発の柔軟性や合成パフォーマンス、どちらがより重要か?
- 環境は本当に独立しているか、それとも依存関係を共有しているか?
- チームの CDK と TypeScript の経験レベルはどの程度か?
どちらのアプローチも CDK において有用です。重要なのは、最初に簡単に見えるものをデフォルトで選ぶのではなく、特定の要件に基づいて意図的に選択することです。デプロイパイプラインの要件から始めて、それらに最もよくマッチするアプローチを選択してください。
コード例の付録
完全なアプローチ A の例
// app.ts import * as cdk from 'aws-cdk-lib'; import { ApplicationStack } from './stacks/application-stack'; const app = new cdk.App(); const stage = app.node.tryGetContext('stage') || 'dev'; const owner = app.node.tryGetContext('owner'); interface StageConfig { account: string; region: string; instanceType: string; minCapacity: number; maxCapacity: number; domainName: string; certificateArn?: string; } const baseConfigs: Record<string, StageConfig> = { dev: { account: '111111111111', region: 'eu-central-1', instanceType: 't3.micro', minCapacity: 1, maxCapacity: 2, domainName: owner ? `${owner}.example.com` : 'dev.example.com', }, staging: { account: '111111111111', region: 'eu-central-1', instanceType: 't3.small', minCapacity: 2, maxCapacity: 4, domainName: 'staging.example.com', }, prod: { account: '222222222222', region: 'eu-central-1', instanceType: 'm5.large', minCapacity: 3, maxCapacity: 10, domainName: 'example.com', certificateArn: 'arn:aws:acm:...', }, }; const config = baseConfigs[stage]; if (!config) { throw new Error(`Unknown stage: ${stage}`); } const stackName = owner ? `MyApp-${stage}-${owner}` : `MyApp-${stage}`; new ApplicationStack(app, stackName, { env: { account: config.account, region: config.region }, description: `Application stack for ${stage} environment${owner ? ` (${owner})` : ''}`, ...config, });
完全なアプローチ B の例
// app.ts import * as cdk from 'aws-cdk-lib'; import { ApplicationStack } from './stacks/application-stack'; import { MonitoringStack } from './stacks/monitoring-stack'; import { Stage } from 'aws-cdk-lib'; const app = new cdk.App(); class ApplicationStage extends Stage { public readonly apiUrl: string; constructor(scope: Construct, id: string, props: StageProps) { super(scope, id, props); const stack = new ApplicationStack(this, 'AppStack', props.config); this.apiUrl = stack.apiUrl; } } // dev const devStage = new ApplicationStage(app, 'Dev', { env: { account: '111111111111', region: 'eu-central-1' }, config: { instanceType: 't3.micro', minCapacity: 1, maxCapacity: 2, domainName: 'dev.example.com', }, }); // staging const stagingStage = new ApplicationStage(app, 'Staging', { env: { account: '111111111111', region: 'eu-central-1' }, config: { instanceType: 't3.small', minCapacity: 2, maxCapacity: 4, domainName: 'staging.example.com', }, }); // prod const prodStage = new ApplicationStage(app, 'Prod', { env: { account: '222222222222', region: 'eu-central-1' }, config: { instanceType: 'm5.large', minCapacity: 3, maxCapacity: 10, domainName: 'example.com', certificateArn: 'arn:aws:acm:...', }, }); // すべての環境に対する中央集中監視 new MonitoringStack(app, 'CentralMonitoring', { env: { account: '333333333333', region: 'eu-central-1' }, environments: [ { name: 'dev', apiUrl: devStage.apiUrl }, { name: 'staging', apiUrl: stagingStage.apiUrl }, { name: 'prod', apiUrl: prodStage.apiUrl }, ], });
両機能の比較
// 環境固有の機能フラグ type Features = { betaFeature: boolean; debugMode: boolean; }; interface StackConfig { features: Features; } // 動的アプローチ const features: Record<string, Features> = { dev: { betaFeature: true, debugMode: true }, prod: { betaFeature: false, debugMode: false }, }; const config: StackConfig = { features: features[stage], }; // 静的アプローチ const devConfig: StackConfig = { features: { betaFeature: true, debugMode: true }, }; const prodConfig: StackConfig = { features: { betaFeature: false, debugMode: false }, };
著者について:
Thorsten Höger は AWS DevTools Hero であり、クラウド自動化コンサルタントとして、AWS CDK の初期から取り組んでいます。彼は定期的にエンタープライズ向けの CDK アーキテクチャを構築し、レビューしています。
Kenta Goto は AWS DevTools Hero であり、AWS CDK Top Contributor および Community Reviewer でもあります。さらに、cls3 や delstack などの独自の AWS ツールの OSS 開発者でもあります。