AWS CDK の開発に必要な CloudFormation の基礎についてまとめています。

1. CloudFormation スタックの基本原則

CloudFormation の挙動を理解する上で、基盤となる 2 つの原則があります。

  • 原子性(アトミシティ)
    • スタック操作(作成・更新・削除)はトランザクションとして扱われます。結果は「すべて成功」か「すべて失敗(ロールバック)」のいずれかとなり、中途半端な状態でリソースが残るのを防ぎます
    • 例外もあります。cdk deploy --no-rollback を指定した場合や、削除に失敗して DELETE_FAILED になった場合は、途中の状態が残ります
    • 置換で不要になった旧リソースの削除は、更新の最後(UPDATE_COMPLETE_CLEANUP_IN_PROGRESS)に行われます。ここで削除に失敗してもロールバックされず、スタックは UPDATE_COMPLETE になり、旧リソースが残ります
    • DeletionPolicy: Retain(CDK の RemovalPolicy.RETAIN)のリソースや、カスタムリソースが AWS 外に起こした副作用も、ロールバックでは元に戻りません
  • 参照制約(依存関係の自動解決)
    • リソース間の参照関係を解析して依存関係グラフを構築して作成・更新・削除の順序を適切に解決します

参照制約における 2 つの制約・仕様

  • 循環参照の禁止(同一スタック内・スタック間の両方)
    • 組み込み関数(!Ref, !GetAtt)や !ImportValue で他リソースを参照できます。リソース間で互いに参照し合う 循環参照 が発生すると順序を解決できずにエラーになります(詳細は「4. 循環参照」を参照)
  • 他スタックからの参照(クロススタック参照)
    • あるスタックの出力を他スタックが参照している(!ImportValue)場合、CloudFormation はデータの整合性を保つため、参照されている Export の削除や変更をロックします

2. 原則が崩れる場合:ロールバック失敗

原子性 によって元の状態に戻りますが、ロールバック自体が失敗した場合(ROLLBACK_FAILED や UPDATE_ROLLBACK_FAILED 状態)、リソースが中途半端に作成・変更された状態でスタックの処理が止まります。

主な失敗原因:AWS外のリソース競合、手動操作による構成ドリフト、またはリソース削除権限の不足など。

ただし、復旧手段はあります。

状態 発生場面 復旧方法
UPDATE_ROLLBACK_FAILED 更新のロールバックに失敗 原因を解消して continue-update-rollback を実行する。原因を解消できないリソースは --resources-to-skip でスキップする
ROLLBACK_FAILED 作成のロールバックに失敗 スタックを削除する。削除に失敗して DELETE_FAILED になったリソースは、--retain-resources を指定して再削除する
aws cloudformation continue-update-rollback \
  --stack-name  \
  --resources-to-skip  \
  --profile default

CloudFormation スタックのロールバックは スタックのロールバック を参照してください。

3. クロススタック参照の挙動と制約

ロックの対象は リソース ではなく Export(名前と値)

クロススタック参照の補足
Outputs:
  Foo: # <--- Output の論理 ID
    Description: example outputs.
    Value:
      Ref: foo
    Export:
      Name: ExportStack-Foo
  ......
Resources:
  BarXXX: # <--- Import の論理 ID
    Type: AWS::XXX::XXX
    Properties:
      BarFoo:
        Fn::ImportValue: ExportStack-Foo
  ......

NOTE
ロックの対象は Output の名前(例:Foo.Export.Name)と値(例:Foo.Value)の両方。つまり一方でも変更されたら Export 側のスタック更新は失敗します。

※ Export 側の Output の論理 ID(例:Foo)が変更された場合の挙動は要調査。

CloudFormation がロックをかけるのは他から参照されている Export の 名前(Name)/ 値(Value)を削除・変更しようとした時です。
リソースの更新自体がすべて拒否されるわけではありません。

成功するケースの具体例:EC2 インスタンスのタグ変更

  • スタック A(Export 側): MyEC2InstanceId という名前で、EC2 の インスタンス ID(i-xxxxxxxxxxxxxxxx)を Export
  • スタック B(Import 側): !ImportValue MyEC2InstanceId で Import

この状態で スタック A の EC2 の Name タグを変更しても Output の Value である インスタンス ID は変更されません。
Output の Name も変わらないので、CloudFormation は Import 側(スタックB)に影響がないと判断し、スタックA の更新を正常に承認・完了させます。

失敗するケースの具体例:変更がブロックされるケース

  • エクスポートの削除: Outputs から該当の Output(Export) を消す
  • エクスポート値の変更: 値(Value)が変わるような変更
  • エクスポート名の変更:名前(Name)が変わるような変更
  • スタック自体の削除: 参照されている Export を持つスタックを丸ごと削除する

AWS CDK における最大のリスク

CDK では、論理ID(id)の変更や一部のプロパティ変更によって、裏側でリソース置換(Replace)が発生します。 置換されるとリソースの物理 ID(ARN など)が変わるため、そのリソースを参照している Export の値も変わります。 これがクロススタック参照のブロックに引っかかり、Export 側のスタック更新が失敗します。

多くの場合、更新はロールバックされて UPDATE_ROLLBACK_COMPLETE で終わり、スタックが壊れることはありません。 本当の問題は、参照を外さない限り何度デプロイしても同じ理由で失敗し、身動きが取れなくなること(いわゆる deadly embrace)です。

CDK でよくあるのは、Import 側が参照をやめたときのパターンです。

  1. Import 側のコードから参照を削除すると、CDK は Export 側のテンプレートから Export を自動で削除する
  2. デプロイ順は Export 側が先のため、Import 側がまだ !ImportValue している状態で Export の削除が拒否される

この場合は、Export 側で this.exportValue() を使って Export を一時的に残し、Import 側のデプロイ後に削除する 2 段階のデプロイで回避します(後述の「解決策 1」の注意を参照)。

4. 循環参照:同一スタック・スタック間の両方で発生する

循環参照は、リソース同士が互いに参照し合い、作成順序を決められない状態です。 クロススタック参照かどうかに関係なく発生し、発生するとデプロイできません。

発生場所 検出タイミング エラー例
同一スタック内 cdk deploy 時(CloudFormation のテンプレート検証) Circular dependency between resources: [A, B]
スタック間 cdk synth 時(CDK が検出) Adding this dependency (...) would create a cyclic reference.

同一スタック内の循環参照

!Ref / !GetAtt や DependsOn(CDK の node.addDependency())で互いに依存すると発生します。 CDK の synth は通るため、デプロイして初めて気づくことが多いです。

よくある例:

  • Cognito ユーザープールのトリガー Lambda の環境変数に、そのユーザープールの ID を渡す
  • API Gateway と統合している Lambda の環境変数に、その API の URL を渡す
// NG: userPool → trigger(fn) → 環境変数(userPool.userPoolId) → userPool
const fn = new lambda.Function(this, 'PreSignUp', {
  // ...
  environment: { USER_POOL_ID: userPool.userPoolId },
});
userPool.addTrigger(cognito.UserPoolOperation.PRE_SIGN_UP, fn);

解決策:

  • 実行時に取得できる値は、環境変数で渡さない(Cognito トリガーならイベントに userPoolId が含まれる)
  • 参照の片方を別リソースに切り出す(例:SG のインラインルールを AWS::EC2::SecurityGroupIngress に分ける)
  • 値を SSM パラメータに置き、実行時に読み込む

スタック間の循環参照

2 つのスタックが互いに Export / !ImportValue し合うと、どちらも先に作成できません。 CDK は synth 時に検出してエラーにするため、デプロイ前に気づけます。

注意すべきなのは、コード上は一方向に見えても、CDK のメソッドが逆向きの参照を生成するケースです。

// AppStack → DataStack:Lambda の環境変数でバケット名を参照
const fn = new lambda.Function(this, 'Fn', {
  // ...
  environment: { BUCKET_NAME: props.bucket.bucketName },
});

// DataStack → AppStack:バケット通知の送信先に AppStack の Lambda を指定
bucket.addEventNotification(s3.EventType.OBJECT_CREATED, new s3n.LambdaDestination(props.fn));

grant*()、addEventNotification()、addToResourcePolicy() などは、引数に渡したリソースを参照するリソースやポリシーを生成します。 生成先のスタックによっては、意図しない逆向きの依存ができます。

解決策:

  • 依存の向きを一方向にそろえる。逆向きの参照を生むリソース(上の例ではバケット通知)を、依存する側のスタック(AppStack)に移す
  • SSM パラメータストアを介して直接の参照を断つ(後述の「解決策 2」を参照)
  • 互いに強く依存しているなら、そもそもスタックを分けない

既存スタックの循環を解消するときは、Export の削除がブロックされます(「3. クロススタック参照の挙動と制約」を参照)。 exportValue() で Export を一時的に残しながら、段階的にデプロイしてください。

循環の調べ方

  • エラーメッセージに含まれるリソース名・スタック名から、参照をたどる
  • cdk synth 後の cdk.out/*.template.json で、Fn::ImportValue と Outputs の Export を検索し、スタック間の参照の向きを確認する

5. 同一スタック内更新 vs クロススタック参照

同一スタック内での !Ref や !GetAtt による参照は、クロススタック参照のような「値が変わるからブロック」という硬直化(ロック)は起きません。しかし、別のリスクが存在します。

同一スタック内における置換の連鎖(カスケード)

親リソースの置換が発生すると、それを参照している子リソース、さらに孫リソースへと更新・置換が連鎖(カスケード)します。 参照先が置換されても、参照側が必ず置換されるとは限りません。参照側のプロパティが更新可能なら更新で済み、更新不可なら置換になります。

[MyVpc (VPC)] ──(CIDR の変更などで置換が必要)
│
▼ !Ref: MyVpc
[MySubnet / MySecurityGroup] ──(VpcId が変わるため置換が必要)
│
▼ !Ref / !GetAtt
[MyEcsService] ──(Subnet や SG の ID が変わるため更新が必要。更新できないプロパティなら置換)

この連鎖の途中で1つでもエラーが発生すると、ロールバックも含めてスタック全体が UPDATE_ROLLBACK_FAILED するリスクがあります。

比較まとめ

項目 同一スタック(!Ref / !GetAtt) クロススタック(!ImportValue)
参照中のリソース更新 原則可能(連鎖的な置換・更新) 値が変わる更新はブロックされる
参照中のリソース削除 依存関係の順に自動処理 Export が使われている間は削除不可
循環参照 デプロイ時にエラー synth 時にエラー(CDK が検出)
主なリスク 置換連鎖の失敗によるスタック破壊 依存関係が解けず、身動きが取れなくなる

したがって、クロススタック参照はシステム全体を硬直化(デッドロック)させやすいと言えます。

6. 設計アンチパターンと解決策

アンチパターン1:過度なスタック分割

単に「リソースの種類(ネットワーク、DB、アプリ)」だけで機械的に分割すると、スタック間の結合度が密になり逆効果です。 解決策:「管理境界(誰が管理するか)」と「変更頻度(どれくらいの周期で変わるか)」を基準に分割します。

アンチパターン2:手動作業の混在による構成ドリフト

IaC 管理画面以外からリソースを直接触ると、CloudFormation の管理情報と乖離し、ロールバック失敗の直接原因になります。 ドリフト検出(CloudFormation のドリフト検出機能)で、乖離を定期的に確認します。

アンチパターン3:密結合な相互参照(クロススタック参照の多用)

スタック間で安易にオブジェクトを渡し合うと、暗黙的な !ImportValue が大量に生成され、リファクタリングが不可能なシステムになります。

解決策 1:依存の向きを一方向にして、参照を意識する

CDK で別スタックのリソースを props で渡すと、実際に参照された値(VPC ID、サブネット ID など)が、裏側で自動的に Export / !ImportValue になります。 オブジェクトを渡すこと自体が問題なのではなく、暗黙の !ImportValue が生成されて依存関係に気づきにくいことが問題です。

やりがちな例(参照した値ごとに、暗黙の Export が増えていく):

const networkStack = new NetworkStack(app, 'NetworkStack');
// vpc の中で参照された値ごとに、Export / !ImportValue が自動生成される
const dataStack = new DataStack(app, 'DataStack', {
  vpc: networkStack.vpc
});

対策:

  • 依存の向きは「変更頻度が低い基盤 → 変更頻度が高いアプリ」の一方向のみにする
  • 参照する値を必要最小限にする
  • 参照される側で、論理 ID の変更や置換を起こすプロパティ変更をしない。cdk diff で確認する
  • 参照を確実に切りたい場合は、解決策 2 を採用する

注意(exportValue と Fn.importValue): CDKの stackA.exportValue() は、Export を明示的に残したいとき(参照元を先に外して段階的に移行する場合など)に使います。Fn.importValue() で手動記述しても、生成される CloudFormation テンプレート上は !ImportValue の密結合のままです。真に疎結合にするには、次の解決策 2 を採用してください。

解決策2:SSM Parameter Store や環境変数ファイルを介した疎結合化

スタック間の直接参照を断ち切り、静的な設定値や動的なリソースIDの受け渡しを分離します。

  • 静的な値(ドメイン名、環境名など): TypeScriptによる環境変数ファイルから注入する
  • 動的な値(VPC ID、共通 SG ID など): SSM パラメータストアを経由して受け渡す
// 参照される側(NetworkStack)
new ssm.StringParameter(this, 'VpcIdParam', {
  parameterName: '/myapp/network/vpc-id',
  stringValue: vpc.vpcId,
});

// 参照する側(DataStack)
const vpcId = ssm.StringParameter.valueForStringParameter(this, '/myapp/network/vpc-id');

SSM 経由にすると、CloudFormation のロックは無くなりますが、依存関係の検知もされなくなります。

  • デプロイ順(参照される側が先)は、dataStack.addDependency(networkStack) などで自分で保証する
  • パラメータの値が変わっても、参照する側は次回デプロイまで反映されない

7. CDKにおける環境変数・設定管理のベストプラクティス

環境ごとの静的な設定値は、.env ではなく TypeScriptファイル で管理します。 解決策 2 の「静的な値を注入する」側の実装にあたります。

// config/env.ts
export interface EnvConfig {
  readonly envName: 'dev' | 'prod';
  readonly domainName: string;
  readonly desiredCount: number;
}

export const config: Record<string, EnvConfig> = {
  dev: { envName: 'dev', domainName: 'dev.example.com', desiredCount: 1 },
  prod: { envName: 'prod', domainName: 'example.com', desiredCount: 2 },
};

なぜ TypeScript ファイルで管理すべきなのか:

  • 型安全性の確保
    • コンパイル時点で「文字列と数値の混同」や「必須キーの不足」といった初歩的なミスを検知できます
  • IDE の強力なサポート
    • 自動補完(コードコンプリーション)が効くため、設定値のタイポや参照ミスを未然に防げます
  • 柔軟なロジック定義
    • 単純なキー&バリューだけでなく、環境に応じた型定義の拡張が安全に行えます

8. AWS CDK での管理に注意が必要なリソース(IaC 境界線の設計)

すべてのリソースをAWS CDK(IaC)で画一的に管理すると、かえってデプロイ効率の低下やセキュリティリスクを招くことがあります。リソースの特性や更新頻度に応じて、IaCの管理対象から「切り離す」「外出しする」「参照に留める」といった判断が必要です。

8.1 アプリケーションコードとコンテナイメージ

方針:インフラ(土台)のみをCDKで作成し、アプリケーションのデプロイは専用のCI/CDパイプラインに委譲する。

  • 理由1:更新頻度と関心の分離 インフラの変更頻度(週〜月単位)と、アプリケーションの変更頻度(1日〜週に複数回)は大きく異なります。アプリの軽微な修正のために、毎回CDK全体の差分チェック(cdk diff)やスタック全体の更新を走らせるのは非効率です。
  • 理由2:タスク定義の巻き戻りの防止 CDK 側のタスク定義には dummy などのイメージタグが入っています。CDK でタスク定義に変更が入る(CPU・メモリや環境変数の変更など)と、dummy のイメージで新しいリビジョンが作られ、CI/CD でデプロイ済みのアプリが巻き戻る恐れがあります。
  • 補足:デプロイ戦略について ECS のローリングアップデートは CloudFormation の標準機能で動きます。CodeDeploy を使った ECS の Blue/Green や、Lambda のカナリアデプロイ(LambdaDeploymentGroup)も CDK で構成できます。ただし、アプリのリリースのたびにインフラのデプロイを通すと、上記の 2 つの問題が起きるため、分離が推奨されます。

💡 Q. ECS Service のコンテナイメージタグは AWS CDK で管理しないということ? A. はい、原則として管理しません。 CDK側には dummy や latest(または初期構築用の特定のタグ)を設定してサービスを起動するだけに留めます。その後のコンテナイメージの更新は、GitHub Actions等のCI/CDパイプラインから ecspresso などの専門ツールを使用してデプロイします。AWS CLI で行う場合は、aws ecs register-task-definition で新しいイメージタグのタスク定義リビジョンを作成し、aws ecs update-service --task-definition でそのリビジョンを指定します。update-service 単体ではイメージタグを変更できません。

8.2 セキュリティ統制・基盤ネットワークリソース

対象:組織共通の IAM リソース(権限境界、共通ロールなど)、VPC(コアネットワーク)、WAFなど 方針:共通基盤(プラットフォーム)スタックとして完全に分離するか、組織によっては手動・専用パイプラインで管理する。

  • 理由:広範囲への影響度と組織の職務分掌 これらのリソースは、システムの「土台」であり、一度作成するとめったに変更されません。しかし、万が一変更を誤った場合の被害(全システム通信停止、権限の全損など)が甚大です。 アプリケーション開発用のCDKスタックにこれらを含めてしまうと、アプリ側の意図しない変更(論理IDの変更によるVPCの再作成など)に巻き込まれてシステムが崩壊するリスクがあります。また、組織における「セキュリティ管理者」と「アプリ開発者」の権限を分離(職務分掌)するためにも、スタックを完全に分けるべきです。
  • 補足:タスクロールなど、アプリ固有の IAM ロールは、アプリのスタックに含めます。

8.3 ドメイン・外部認証連携に関わるリソース

対象:Route 53(DNSレコード)、Certificate Manager(ACM証明書)、SES(ドメイン認証)など 方針:初回構築時のみCDKで管理するか、あらかじめ手動(または共通基盤)で作成したリソースをCDK側で「参照」する。

  • 理由:外部手続きによるデプロイのブロッキング 例えば、ACM証明書の検証(外部 DNS での DNS 検証や、Eメール検証)や、他社管理ドメインからRoute 53へのネームサーバー切り替えは、AWS外部での操作や数分〜数時間の待ち時間(伝播待ち)を伴います。 これらをアプリのCDKスタックに組み込むと、検証が完了するまでCDKのデプロイ処理自体が途中で待機状態になり、最悪の場合はタイムアウトでデプロイエラーになります。依存関係をクリーンに保つため、ドメイン基盤は事前に外側で用意しておくのが鉄則です。
  • 補足:同一アカウントの Route 53 ホストゾーンなら、CertificateValidation.fromDns(hostedZone) で DNS 検証が自動化されるため、この問題は起きにくくなります。
  • 補足:ALB 用の証明書は ALB と同じリージョンで、CloudFront 用の証明書は us-east-1 で発行します。

8.4 機密情報(シークレット値)

対象:KMSキー、Systems Manager パラメータストア、Secrets Managerなど 方針:メタデータ(箱)はIaCで作成し、実際の「秘密値(シークレット)」はAWSに自動生成・運用を委譲するか、手動で後入れする。

機密情報の管理は、「AWSに生成・ローテーションまで委譲できるか」によって方針が変わります。共通の鉄則として、「IaCのソースコード、デプロイを実行するターミナル、生成されるテンプレート(cdk.out や CloudFormation のテンプレート)に、平文のパスワードを絶対に載せない」という点があります。

パターンA:AWSに秘密値の生成・管理を委譲する場合(推奨)

  • 例:RDSのマスターパスワード管理など
  • 設計:CDKで Secrets Manager のリソース(箱)を作成する際、AWS側に「ランダムな文字列を自動生成する」ように指示します(CDK の SecretStringGenerator や、RDS の Credentials.fromGeneratedSecret() などを使用)。
  • メリット:コード上には一切文字列が現れず、AWS内で完結するため安全です。定期的なローテーション(自動更新)もAWSに安全に委譲できます。

パターンB:外部の機密情報をAWSに埋め込む必要がある場合

  • 例:外部SaaSのAPIトークン、サードパーティ製ツールのライセンスキーなど
  • 設計:あらかじめマネジメントコンソール等から手動(または専用のセキュアなスクリプト)で Secrets Manager にシークレット値を書き込んでおきます。CDK側ではそのシークレットのリソース(ARN や名前)を参照するコードのみを記述します(Secret.fromSecretNameV2() など)。
  • メリット:機密情報がGitリポジトリ(コード)に混入したり、CDKのビルドログ(キャッシュ)にプレーンテキストで出力されるリスクを完全に排除できます。

注意:SSM パラメータストアの SecureString

CloudFormation と CDK では、SSM パラメータストアの SecureString を作成できません(String と StringList のみ)。 SecureString はコンソールや CLI で作成し、CDK からは StringParameter.fromSecureStringParameterAttributes() などで参照します。機密値は Secrets Manager に置くのが扱いやすいです。