はじめに
こんにちは!CDK が好きな社会人2年目のエンジニアです。
筆者が当記事を書いている本日(2026/7/11)で、CDK は7周年だそうです。おめでとうございます!
先日 CDK に「Source Tracing for L1 Property Mutations」という機能が加わりました。
CDK_DEBUG=1 という環境変数を付けて cdk synth を実行すると、L1 コンストラクトのプロパティが「どのコードによって」書き換えられたか、のスタックトレースが synth 結果に記録されるという機能で、CDK v2.258.0 以降で利用できます。
本記事の検証
CDK を使っていると、複数の Aspect が同じ L1 プロパティを書き換えようとする場面が稀に出てきます。
たとえば「組織全体に Permission Boundary を強制する Aspect」と「特定リソースだけ個別に Permission Boundary を上書きする apply() 呼び出し」が競合する、というケースです。
かなりニッチな機能ではありますが、Source Tracing for L1 Property Mutations が上記のような Aspect 競合の調査に利用できるのか、実際に手を動かして検証してみました。
本記事の内容は 2026年7月 の情報に基づいて記載しています。最新情報は公式ドキュメントをご参照ください。
Source Tracing for L1 Property Mutations とは
CDK の L1 コンストラクトの各プロパティセッターと addPropertyOverride() には、内部的に traceProperty() という関数が仕込まれています。この関数が、今このプロパティを書き換えたコードの呼び出し元スタックトレースを synth 結果(cdk.out)に記録します。
記録されたトレースは cdk.out/<スタック名>.metadata.json を開くと確認できます。"aws:cdk:propertyAssignment" というキーで、コンストラクトパスごとに整理されています。
CDK_DEBUG= の値には、1、on、true という3つの種類があります。
今回の記事のメインテーマである、Source Tracing for L1 Property Mutations は CDK_DEBUG=1 の時に記録されます。
基本的な使い方
CDK_DEBUG=1 cdk synth の実行
通常の cdk synth にそのまま環境変数を追加するだけです。
CDK_DEBUG=1 npx cdk synth --output cdk.out.debug
出力先を変えておくと、CDK_DEBUG なしの通常 synth と diff を取れるので便利です。
npx cdk synth --output cdk.out.normal
CDK_DEBUG=1 npx cdk synth --output cdk.out.debug
# metadata.json だけ diff を取る
diff cdk.out.normal/MyStack.metadata.json cdk.out.debug/MyStack.metadata.json
CDK_DEBUG なしで実行した時、 "aws:cdk:propertyAssignment" は 0 件です。
CDK_DEBUG=1 にすると、L1 プロパティへの書き込みが発生した分だけエントリが追加されます。
L1 プロパティへの直接代入
まず一番シンプルなケースとして、L1 コンストラクトのプロパティをコンストラクタ外で直接書き換える例を示します。
// lib/my-stack.ts
const cfnBucket = new s3.CfnBucket(this, 'MyBucket');
cfnBucket.bucketName = 'my-bucket'; // ← ここが追跡される
CDK_DEBUG=1 cdk synth を実行すると、cdk.out/MyStack.metadata.json に以下のようなエントリが追加されます。
{
"/MyStack/MyBucket": [
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "BucketName",
"stackTrace": [
"...CfnBucket.set bucketName in aws-cdk-lib...",
"new MyStack (/path/to/lib/my-stack.ts:8:25)",
"<anonymous> (/path/to/bin/app.ts:6:1)",
"...node internals, ts-node..."
]
}
}
]
}
| 表示形式 | 意味 |
|---|---|
...CfnBucket.set bucketName in aws-cdk-lib... |
aws-cdk-lib 内の連続フレームをまとめたもの(セッター名が分かる) |
...node internals, ts-node... |
Node.js 内部や実行ランタイムのフレームをまとめたもの |
new MyStack (/path/to/lib/my-stack.ts:8:25) |
ユーザーのコード(ここが調査したい部分) |
stackTraceの中で今回注目すべきは、new MyStack (/path/to/lib/my-stack.ts:8:25)の行です。
ユーザーのコードは 関数名 (ファイルパス:行:列) の形式で表示されます。この例では my-stack.ts の 8 行目 25 列目が該当箇所です。
TypeScript のソースマップが有効になっていれば(tsconfig.json に "inlineSourceMap": true と "inlineSources": true)、コンパイル後の JS ではなく元の .ts ファイルの行番号で表示されます。
(検証)Aspect 競合ケースで試す
GHSA-qc59-cxj2-c2w4 で発行された Permissions Boundary の優先順位問題
CDK v2.172.0 で Aspect に「優先度(priority)」の概念が導入されました。
このとき PermissionsBoundary.of().apply() が内部で使う Aspect の priority が DEFAULT(500) から MUTATING(200) に変更されました。数値が小さいほど先に実行されます。
この変更の影響で、「組織全体に共通の Permissions Boundary を強制するカスタム Aspect」と「特定リソースだけ個別に Boundary を上書きする apply() 呼び出し」の勝敗が意図せず逆転するという障害が実際に報告されました。
v2.189.1 の修正にて、feature flag "@aws-cdk/core:aspectPrioritiesMutating" を明示的に true にしない限り、組み込みAPIが使う Aspect も DEFAULT(500) になるよう変更されました。
今回は、この実際に生じた事例に対し、Source Tracing for L1 Property Mutations でどちらが後から書いたかを追えるかどうか検証します。
PermissionsBoundary.apply() の実装を先に確認する
カスタム Aspect を実装する前に、PermissionsBoundary.apply() が内部でどのようにプロパティを書いているかを確認します。
// aws-cdk-lib/aws-iam/lib/permissions-boundary.js(抜粋)
public apply(boundaryPolicy: IManagedPolicy) {
Aspects.of(this.scope).add({
visit(node: IConstruct) {
if (
CfnResource.isCfnResource(node) &&
(node.cfnResourceType == CfnRole.CFN_RESOURCE_TYPE_NAME || node.cfnResourceType == CfnUser.CFN_RESOURCE_TYPE_NAME)
) {
node.addPropertyOverride('PermissionsBoundary', boundaryPolicy.managedPolicyArn);
}
},
}, {
priority: mutatingAspectPrio32333(this.scope),
});
}
apply() は addPropertyOverride() を使っています。
これが検証内容に影響してきます。(補足セクションで触れます。)
検証コードの構成
Aspect の呼び出し元を階層ごとに追跡できるよう、ファイルを分割して実装します。
lib/
aspects/
custom-permissions-boundary-aspect-override.ts ← addPropertyOverride で書くカスタム Aspect
constructs/
boundary-default-function.ts ← apply() なし Lambda (functionA)
boundary-override-function.ts ← apply() あり Lambda (functionB)
verify-stack.ts ← 2 つの Construct を束ねる Stack
カスタム Aspect(addPropertyOverride を利用)
Aspects を用いて、addPropertyOverrideでPermissionsBoundaryを上書きするコードを実装します。
// lib/aspects/custom-permissions-boundary-aspect-override.ts
export class CustomPermissionsBoundaryAspectOverride implements cdk.IAspect {
visit(node: IConstruct): void {
if (node instanceof iam.CfnRole) {
node.addPropertyOverride(
'PermissionsBoundary',
'arn:aws:iam::123456789012:policy/OrgDefaultBoundary'
);
}
}
}
cdk.json の feature flag
cdk.jsonの feature flag "@aws-cdk/core:aspectPrioritiesMutating" を true にすることで、priority 順で Aspects の Mutating 処理が実行されるように設定します。
"@aws-cdk/core:aspectPrioritiesMutating": true
functionA のコンストラクト(apply() なし)
apply() の呼び出しを一切行わないシンプルな Lambda コンストラクトです。
Aspect によって Boundary を設定されることを想定しています。
// lib/constructs/boundary-default-function.ts
constructor(scope: Construct, id: string) {
super(scope, id);
this.function = new lambda.Function(this, 'Function', { ... });
// apply() なし。Aspect で OrgDefaultBoundary が付与される想定
}
functionB のコンストラクト(apply() で個別指定)
iam.PermissionsBoundary.of で個別の Boundary を付与します。
// lib/constructs/boundary-override-function.ts
constructor(scope: Construct, id: string) {
super(scope, id);
this.function = new lambda.Function(this, 'Function', { ... });
// このコンストラクタ内で個別の Boundary を apply()
iam.PermissionsBoundary.of(this.function.role!).apply(
iam.ManagedPolicy.fromManagedPolicyArn(
this, 'SpecificBoundary',
'arn:aws:iam::123456789012:policy/SpecificBoundaryForB'
)
);
}
Stack(priority 未指定による、GHSA バグ再現用)
明示的に priority を指定しないことにより、 500 の priority で適用されるよう実装します。
// lib/verify-stack.ts(バグ再現ケース)
new BoundaryDefaultFunction(this, 'BoundaryDefaultFunction');
new BoundaryOverrideFunction(this, 'BoundaryOverrideFunction');
// priority 未指定 → DEFAULT(500)
cdk.Aspects.of(this).add(new CustomPermissionsBoundaryAspectOverride());
Stack(priority 200 に揃えたGHSA バグ修正ケース)
明示的に priority を指定し、functionB と同じく 200 の priority で適用されるよう実装します。
// lib/verify-stack.ts(修正ケース)
cdk.Aspects.of(this).add(new CustomPermissionsBoundaryAspectOverride(), {
priority: cdk.AspectPriority.MUTATING,
});
synth の実行
CDK_DEBUG=1 npx cdk synth --output cdk.out.debug
結果
テンプレートの最終値
まず、synthされたテンプレートの PermissionsBoundary を確認します。
| ロール | バグ再現(DEFAULT=500) | 修正後(MUTATING=200) |
|---|---|---|
functionA のロール(apply() なし) |
OrgDefaultBoundary |
OrgDefaultBoundary |
functionB のロール(apply() あり) |
OrgDefaultBoundary ← バグ |
SpecificBoundaryForB ← 正しい |
このように、priority で適用される PermissionsBoundary の種類が異なっており、バグ再現ケースでは、apply() で指定した SpecificBoundaryForB がカスタム Aspect の OrgDefaultBoundary に上書きされていることがわかります。
priority を MUTATING(200) に変えると、意図どおり SpecificBoundaryForB が勝ちます。
Source Tracing のトレース内容
次に、cdk.out/BugStack.metadata.json の中身を見ます。
functionA のロール(apply() なし)
書き換えた主体が、Stack に適用している Aspects ひとつなのでシンプルです。
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...CfnRole.addPropertyOverride in aws-cdk-lib...",
"CustomPermissionsBoundaryAspectOverride.visit (.../lib/aspects/custom-permissions-boundary-aspect-override.ts:10:12)",
"...aws-cdk-lib..."
]
}
}
custom-permissions-boundary-aspect-override.ts の 10 行目まで正確に遡れます。
functionB のロール(apply() あり)
バグ再現ケースです。配列の順序が実行順序に対応しています。
[
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
},
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...CfnRole.addPropertyOverride in aws-cdk-lib...",
"CustomPermissionsBoundaryAspectOverride.visit (.../lib/aspects/custom-permissions-boundary-aspect-override.ts:10:12)",
"...aws-cdk-lib..."
]
}
}
]
- エントリ[0]
...aws-cdk-lib...のみ、ユーザーコードなし →apply()が先に実行(MUTATING=200) - エントリ[1]
CustomPermissionsBoundaryAspectOverride.visit→ カスタム Aspect が後に実行(DEFAULT=500)
Aspects では小さい priority の方が先に実行され、後に実行された処理で上書きされてしまうため、バグ再現ケースでは OrgDefaultBoundary が勝ちます。
functionB のロール — 修正ケース(MUTATING=200)
priority を変えると、トレースの順序が逆転します。
[
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...CfnRole.addPropertyOverride in aws-cdk-lib...",
"CustomPermissionsBoundaryAspectOverride.visit (.../lib/aspects/custom-permissions-boundary-aspect-override.ts:10:12)",
"...aws-cdk-lib..."
]
}
},
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
}
]
同一の priority となることで、実行順に適用されるためです。
末尾が apply() 側(aws-cdk-lib)になり、SpecificBoundaryForB が勝ちます。
Source Tracing for L1 Property Mutations の機能で、しっかり実行順序が可視化できることがわかります!
プロパティの直接代入で書いた場合との比較
これまで、Aspects でのプロパティ書き換えに addPropertyOverride を利用していましたが、対象の L1 Construct が保持している値に対しては、直接プロパティを上書きすることもできます。
型付きで IDE 補完が効くため、直接プロパティを上書きできるケースでは addPropertyOverride を利用せず、直接プロパティを変更(以下、直接代入と略す)することが推奨されます。
直接代入に書き直す
export class CustomPermissionsBoundaryAspect implements cdk.IAspect {
visit(node: IConstruct): void {
if (node instanceof iam.CfnRole) {
// addPropertyOverride を利用せず、直接上書きする
node.permissionsBoundary = 'arn:aws:iam::123456789012:policy/OrgDefaultBoundary';
}
}
}
トレースの変化
直接代入した場合でも aws:cdk:propertyAssignment エントリは記録されます。
addPropertyOverride 版と同じく priority によってトレースの順序が変わるため、両方を並べて示します。
DEFAULT=500(バグ再現ケースと同じ priority 設定)
// エントリ[0]:apply() が先に実行(MUTATING=200)
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
}
// エントリ[1]:カスタム Aspect が後に実行(DEFAULT=500)← 末尾
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...CfnRole.set permissionsBoundary in aws-cdk-lib...",
"CustomPermissionsBoundaryAspect.visit (.../lib/aspects/custom-permissions-boundary-aspect.ts:10:31)",
"...aws-cdk-lib..."
]
}
}
MUTATING=200(修正ケースと同じ priority 設定)
// エントリ[0]:カスタム Aspect が先に実行(MUTATING=200)
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...CfnRole.set permissionsBoundary in aws-cdk-lib...",
"CustomPermissionsBoundaryAspect.visit (.../lib/aspects/custom-permissions-boundary-aspect.ts:10:31)",
"...aws-cdk-lib..."
]
}
}
// エントリ[1]:apply() が後から実行(MUTATING=200)← 末尾
{
"type": "aws:cdk:propertyAssignment",
"data": {
"propertyName": "PermissionsBoundary",
"stackTrace": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
}
addPropertyOverride を利用した時と同じく、priority を変えるとトレースの順序が逆転します。stackTrace の 1 行目だけ異なります。
addPropertyOverride: "...CfnRole.addPropertyOverride in aws-cdk-lib..."
直接代入: "...CfnRole.set permissionsBoundary in aws-cdk-lib..."
テンプレート結果の変化
しかし、テンプレートの最終値は priority を変えても変わりません。
| ロール | DEFAULT=500 | MUTATING=200 |
|---|---|---|
functionA(apply() なし) |
OrgDefaultBoundary |
OrgDefaultBoundary |
functionB(apply() あり) |
SpecificBoundaryForB |
SpecificBoundaryForB |
トレースの stackTrace を見る限り、直接代入でも addPropertyOverride 版と同じようにエントリの並び順が記録されます。
ところが、直接代入と addPropertyOverride では、記録された呼び出し順序と、実際にテンプレートへ反映されるタイミングが一致しません。直接代入で設定した値はリソースの基本プロパティとして扱われるのに対し、addPropertyOverride で設定した値は「上書き情報」として別に保持され、最終的なテンプレート生成の直前に基本プロパティへ後乗せされる、という2段階の仕組みになっているためです。
Source Tracing はいつ・どのコードがプロパティを書き換えたかを正確に見せてくれますが、その順序がそのまま最終結果の勝敗を決めるとは限らない、という点は使う上で覚えておきたいポイントです。
おわりに
Source Tracing for L1 Property Mutations を使うと Aspects の実行順序をトレースできること、また Source Tracing が追いきれない限界についても実際に確認することができました。
一方、どのファイルのどの Aspect が、最後にこのプロパティを書き換えたかを cdk.out のファイルを確認するだけで分かるのは、デバッグ体験としてとても価値があると感じたので、是非 CDK_DEBUG=1 を設定して cdk synth してみて下さい。
最後に、ここまで読んでくださった皆様、誠にありがとうございました!