第2回で構築した Security Hub CSPM の2リージョン構成 — 実はこのデプロイ、一度で成功していません。AWS::Config::ConfigurationRecorder と AWS::Config::DeliveryChannel の相互依存により同じ日に2回失敗し、3回目でようやく成功しました。
本稿はその全現象・原因・エビデンス・解決策を、実際の CloudFormation イベントと AWS CLI 出力(すべて一次記録)で振り返ります。AWS Config を CloudFormation / CDK で有効化しようとする人が必ず通る道なので、同じ場所でハマった方の時間節約になれば幸いです。
この記事でわかること
- CloudFormation で AWS Config を有効化するときに起きる循環デッドロックの正体
- 「エラーメッセージに従って順序を入れ替えたら、今度は別の理由で詰む」実例
- ハングしたとき、CloudFormation の表示ではなく AWS 実体を照会して原因を切り分ける方法
- AwsCustomResource ×3 で API を正しい順序で呼ぶ解決策(コード付き)
シリーズ: ① 概念編 → ② CDK 構築編 → ③ トラブル編(本記事)
1. 何が起きたか — 同じ日に「失敗 → ハング → 成功」
図1: 同じ日の3回のデプロイ。①recorder 先行 → CREATE_FAILED、②channel 先行 → ハング、③AwsCustomResource ×3 → 成功。
1.1 Deploy ① — recorder 先行 → CREATE_FAILED
最初の構成は素直な L1 リソース2つ。delivery channel が recorder に依存する形(recorder が先に作られる)でした。結果、recorder の作成が失敗しスタック全体がロールバック:
aws cloudformation describe-stack-events --stack-name SecurityBaselineStack \
--query "StackEvents[?ResourceStatus=='CREATE_FAILED'].{LogicalId:LogicalResourceId,Type:ResourceType,Reason:ResourceStatusReason}"
{ "LogicalId": "ConfigRecorder...",
"Type": "AWS::Config::ConfigurationRecorder",
"Reason": "Delivery channel is not available to start configuration recorder.
(Service: AmazonConfig; Status Code: 400;
Error Code: NoAvailableDeliveryChannelException; ...)" }
さらにロールバック後、RemovalPolicy.RETAIN を付けていた S3 バケット3個が DELETE_SKIPPED となり、スタック消滅後も「孤児バケット」として残りました(手動削除が必要)。
| リソース(ロールバック後の状態) | 状態 |
|---|---|
| ConfigRecorder(失敗の起点) | CREATE_FAILED → DELETE_COMPLETE |
| Trail / Role / BucketPolicy ×3 など | DELETE_COMPLETE(巻き戻し) |
| RETAIN 付き S3 バケット ×3 | DELETE_SKIPPED — 孤児として残留 |
1.2 修正① — DependsOn を反転(これが罠だった)
エラーメッセージは「recorder を開始しようとしたが delivery channel が無い」と読めます。
ならば delivery channel を先に作ればよい、と依存方向を反転しました:
// 修正①: recorder が delivery channel に依存する形へ(channel が先に作られる)
this.recorder.addOverride("DependsOn", deliveryChannel.logicalId);
1.3 Deploy ② — channel 先行 → CREATE_IN_PROGRESS のままハング
再デプロイでは他のリソース(Security Hub・CloudTrail・バケットポリシー等)は次々にCREATE_COMPLETE になりましたが、DeliveryChannel だけが止まりました。
CLI の進捗表示は (12/15) で停止し、以後イベントが一切進みません:
| ConfigDeliveryChannel... | Resource creation Initiated | CREATE_IN_PROGRESS |
# …このまま何分待っても次のイベントが発生しない
ここで決定的だったのが、ハング中に AWS 実体を照会するという切り分けです:
aws configservice describe-configuration-recorders --region ap-northeast-1
# → { "ConfigurationRecorders": [] } # recorder はまだ無い(DependsOn で待機中なので当然)
aws configservice describe-delivery-channels --region ap-northeast-1
# → { "DeliveryChannels": [] } # ★ channel も無い = 作成 API が通っていない
CloudFormation 上は「作成中」でも、AWS Config 側には delivery channel が存在しない。
つまり PutDeliveryChannel が(recorder 不在で)失敗し続けており、CloudFormation のハンドラがリトライで空回りしている状態でした。このデプロイは自然完了しないため手動で delete-stack し、残骸も再度整理しました。
2. 原因 — 「真の循環依存」
図2: recorder と delivery channel は互いを前提とする。DependsOn をどちらに向けても解決できない。
原因は AWS Config の2つの仕様が組み合わさった、CloudFormation 上の循環デッドロックです:
-
CloudFormation は
AWS::Config::ConfigurationRecorderを作成すると同時に記録を自動開始しようとする。
開始には delivery channel が必要 → channel が無ければNoAvailableDeliveryChannelExceptionで失敗(Deploy ①) -
PutDeliveryChannelAPI は configuration recorder の存在を要求する → recorder が無ければNoAvailableConfigurationRecorderExceptionで作成できない(Deploy ② のハングの正体)
つまり: 「recorder 先行」は①で即死、「channel 先行」は②で永久待機。
L1 リソース2つ + DependsOn という宣言的な表現では、この2つのリソースは絶対にデプロイできません。
必要なのは「停止状態で recorder を作る」という中間状態ですが、L1 のAWS::Config::ConfigurationRecorder にはそれを表現するプロパティがありません。
3. 原因がこれだと判断できたエビデンス
E1 — Deploy ① のエラー原文 × 公式ドキュメントの一致
- 実測:
NoAvailableDeliveryChannelException — Delivery channel is not available tostartconfiguration recorder。 「作成」ではなく「開始(start)」で失敗している点が重要 - 公式(CloudFormation リソースリファレンス):
"AWS CloudFormation starts the recorder as soon as the delivery channel is available."
— CloudFormation が作成時に自動開始することが明記されている
E2 — Deploy ② のハング × PutDeliveryChannel API 仕様の一致
- 実測: ハング中に
describe-delivery-channelsが空(§1.3)— CloudFormation は「作成中」なのに実体が無い = 作成 API が拒否され続けている - 公式(PutDeliveryChannel API リファレンスのエラー定義):
"NoAvailableConfigurationRecorderException — There are no customer managed configuration recorders available to record your resources. Use the PutConfigurationRecorder operation to create the customer managed configuration recorder." - 公式(CloudFormation リソースリファレンス DeliveryChannel): "Before you can create a delivery channel, you must create a configuration recorder."
E3 — 両方向の実測失敗 = 循環の決定的証拠
「recorder 先行」も「channel 先行」も、それぞれ公式仕様どおりの理由で実際に失敗しました。片方向の失敗なら「順序ミス」ですが、両方向が失敗した時点で、これは順序の問題ではなく循環依存だと確定できます。解決には順序の入れ替えではなく、「循環を断ち切る中間状態(停止状態の recorder)」を作れる手段が必要になります。
4. 解決方法 — AwsCustomResource ×3 で API を正しい順序で呼ぶ
図3: 各ステップの前提条件を直前のステップが充足する、デッドロックの無い唯一の順序。削除時は自動的に逆順。
AWS Config の API を直接見ると、デッドロックの無い順序が1つだけ存在します。
PutConfigurationRecorder は recorder を「停止状態」で作成する(開始しない)ため、前提条件が無いのがポイントです:
-
PutConfigurationRecorder— 停止状態で作成(前提: なし) -
PutDeliveryChannel— 前提「recorder の存在」は①で充足済み -
StartConfigurationRecorder— 前提「delivery channel の存在」は②で充足済み
CDK ではこの3コールを AwsCustomResource 3つで表現し、node.addDependency で順序を保証します。削除時は各リソースの onDelete が依存の逆順で実行されるため、Stop → DeleteDeliveryChannel → DeleteConfigurationRecorder と綺麗に畳まれます:
// ── (1) PutConfigurationRecorder(停止状態で作成) ──
const putRecorder = new AwsCustomResource(this, "PutRecorder", {
onCreate: {
service: "@aws-sdk/client-config-service",
action: "putConfigurationRecorder",
parameters: { ConfigurationRecorder: { name: "default", roleARN: role.roleArn,
recordingGroup: props.recordingGroup } },
physicalResourceId: PhysicalResourceId.of("default-recorder"),
},
onDelete: { service: "@aws-sdk/client-config-service",
action: "deleteConfigurationRecorder",
parameters: { ConfigurationRecorderName: "default" } },
/* policy: config:Put/DeleteConfigurationRecorder + iam:PassRole(recorder ロール限定) */
});
// ── (2) PutDeliveryChannel(recorder が存在してから) ──
const putChannel = new AwsCustomResource(this, "PutDeliveryChannel", {
onCreate: { service: "@aws-sdk/client-config-service", action: "putDeliveryChannel",
parameters: { DeliveryChannel: { name: "default", s3BucketName: bucket.bucketName } },
physicalResourceId: PhysicalResourceId.of("default-channel") },
onDelete: { service: "@aws-sdk/client-config-service", action: "deleteDeliveryChannel",
parameters: { DeliveryChannelName: "default" } },
});
putChannel.node.addDependency(putRecorder); // ← 順序保証 ①→②
putChannel.node.addDependency(bucket.policy); // バケットポリシー(Config の書込許可)も先に
// ── (3) StartConfigurationRecorder(delivery channel が存在してから) ──
const startRecorder = new AwsCustomResource(this, "StartRecorder", {
onCreate: { service: "@aws-sdk/client-config-service", action: "startConfigurationRecorder",
parameters: { ConfigurationRecorderName: "default" },
physicalResourceId: PhysicalResourceId.of("default-start") },
onDelete: { service: "@aws-sdk/client-config-service", action: "stopConfigurationRecorder",
parameters: { ConfigurationRecorderName: "default" } }, // 削除時はまず停止
});
startRecorder.node.addDependency(putChannel); // ← 順序保証 ②→③
コスト面の補足: AwsCustomResource は同一スタック内で1つのプロバイダ Lambda(シングルトン)を共有します(CDK ソースで固定 UUID を確認)。3つ使っても追加されるのは Lambda 1個 + IAM ポリシーのみ。
5. この解決で行けると判断したエビデンス(デプロイ前)
E4 — API 前提条件の充足分析(論理)
§2 の失敗条件はそれぞれ「開始時に channel が無い」「作成時に recorder が無い」でした。
新しい順序では、各 API の前提条件がすべて直前のステップで満たされます(図3)。
①には前提が無く、②の前提は①が、③の前提は②が作る。循環の輪に入る箇所が存在しません。
E5 — synth 産物(テンプレート)の実測検証
「コードが意図どおりの API 呼び出しを生成しているか」を、合成された CloudFormation テンプレートで確認しました:
# Custom リソースの Create パラメータ(抜粋)
putConfigurationRecorder: {"ConfigurationRecorder":{"name":"default","roleARN":"...",
"recordingGroup":{"allSupported":true,"includeGlobalResourceTypes":true}}}
putDeliveryChannel: {"DeliveryChannel":{"name":"default","s3BucketName":"..."}}
startConfigurationRecorder: {"ConfigurationRecorderName":"default"}
# DependsOn 連鎖(テンプレート実物)
Custom::ConfigDeliveryChannel: DependsOn=[..., ConfigPutRecorder...] # ①→②
Custom::ConfigRecorderStart: DependsOn=[..., ConfigPutDeliveryChannel...] # ②→③
パラメータ形状が AWS Config API リファレンスの要求と一致し、順序も DependsOn で強制されています。
E6 — 回帰テスト・静的解析
- jest にテンプレートの順序を断言する回帰テストを追加(「channel は recorder に依存」「start は channel に依存」)
- cdk-nag(AwsSolutionsChecks)エラー 0
正直な限界(デプロイ前時点): synth は実 API を呼ばないため、最終確認は実デプロイのみ。
ただし「論理的にデッドロック不可能な唯一の順序」+「テンプレート実測一致」で、前2回より遥かに強い根拠が揃っていました。
6. 結果 — Deploy ③ 成功と実測確認
3回目のデプロイは両スタック(東京 / us-east-1)とも CREATE_COMPLETE。
核心の Config recorder は、両リージョンで実際に記録が動いていることを確認しました:
aws configservice describe-configuration-recorder-status --region ap-northeast-1
# → { "name": "default", "recording": true, "lastStatus": "SUCCESS", ... }
aws configservice describe-configuration-recorder-status --region us-east-1
# → { "name": "default", "recording": true, "lastStatus": "SUCCESS", ... }
| 確認項目 | 結果 |
|---|---|
| CloudFormation スタック(東京 / us-east-1) | CREATE_COMPLETE ×2 |
| Config recorder(両リージョン) | recording:true / lastStatus:SUCCESS / エラーなし |
| recordingGroup | 東京=全タイプ+グローバル / us-east-1=WAFv2・CloudFront 7タイプ限定(設計どおり) |
| 削除時の逆順動作 | onDelete(Stop→DeleteChannel→DeleteRecorder)として実装済み |
| 周辺リソース(Security Hub / 集約 / GuardDuty) | すべて正常(FSBP 単独・集約リンク・ENABLED) |
7. 教訓(まとめ)
-
cdk synthが通ることと、デプロイが成功することは別物 — テンプレートが構文的に正しくても、 リソースの API 前提条件までは CloudFormation は検証しない - エラーメッセージの「表面的な解決」は罠になり得る — 「channel が無い」→「channel を先に」と反転したら、今度は逆方向の前提で詰んだ。両方向が失敗した時点で循環を疑う
- L1 リソースで表現できない中間状態は、AwsCustomResource で API を直接順序制御する —「停止状態の recorder」という中間状態が鍵だった。シングルトン Lambda 共有なのでコストも小さい
-
ハングしたら CloudFormation の表示ではなく AWS 実体を照会する —
describe-delivery-channelsが空だったことが「作成 API が通っていない」ことの決定的証拠になった -
RETAINリソースは失敗ロールバックでも残る(DELETE_SKIPPED)— 再試行前の孤児整理を忘れずに
シリーズ: 第1回 概念編 / 第2回 CDK 構築編 / 第3回(本記事)


