昨日のCDKカンファレンスで触れた ID=Default
の話に思わぬ反響があった (嬉しい!) ので、詳しくまとめます。
ID=Default
とは
この記事では、AWS CDKのコンストラクトの第2引数のID (Identifier) に Default
という文字を指定することを指します。
// この第2引数に注目 new Bucket(this, "Default");
ID=Default
にしたコンストラクトは、CloudFormationの論理IDを計算する際に特殊な処理が適用されます。以下で挙動を見てみましょう。
挙動
CDKのコンストラクト群はコンストラクトツリーというデータ構造で管理され、論理IDはそのコンストラクトのパスから計算されます。 パスとは、合成されたテンプレートのMetadataにも書かれている、コンストラクトツリー上にコンストラクトがある場所を示すものです。
"Metadata": { "aws:cdk:path": "Stack/Logger/Bucket/Resource" }
ID=Default
のコンスラクトは、論理IDを計算する際に、パスから除外されます。
このため、以下の挙動が観測できます。
ネストしないコンストラクトと、 ID=Default
でネストしたコンストラクトとの論理IDが同じになる
次のCDKコード
{ const stack = new Stack(app, "Stack"); new CfnBucket(stack, "Bucket"); } { const stack = new Stack(app, "StackNest"); const parent = new Construct(stack, "Bucket"); new CfnBucket(parent, "Default"); }
はそれぞれ以下のテンプレートに合成されます:
"Resources": { "Bucket": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "Stack/Bucket" } },
"Resources": { "Bucket": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackNest/Bucket/Default" } },
StackNest
のコードでは、CfnBucket
があるコンストラクトの中にネストされていることに注目してください。
2つのスタックでコンストラクトツリーの構造は異なりますが、CloudFormation テンプレート内の論理IDは2つのスタックで同一 (Bucket
) になります。
このように、ID=Default
にすることで論理IDの計算方法が変わることがポイントです。
ちなみに、CDKで生成される論理IDは通常 BucketDC7D6F65
のようにランダムなハッシュ値が末尾に付きます。
一方上のテンプレートでそれが付いてない (Bucket
) のは、スタック直下のコンストラクトのみハッシュ値を付けないためのようです。
これは ID=Default
の話とは無関係な挙動ですが、念の為補足しておきます。(これはこれで興味深い例外処理ですね!)
Defaultを複数回ネストさせたとき
ID=Default
のコンストラクトを複数ネストさせることもできます。次のコード
const stack = new Stack(app, "StackMoreNest"); const parent = new Construct(stack, "Bucket"); const child = new Construct(parent, "Default"); new CfnBucket(child, "Default");
は、以下のテンプレートに合成されます。
"Resources": { "Bucket": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackMoreNest/Bucket/Default/Default" } },
Default
はすべて切り詰められることがわかります。
Defaultにより論理IDが重複してしまうとき
また、Default
が切り詰められた結果、意図せず論理IDが重複し、エラーになることもありえます。
const stack = new Stack(app, "StackNest"); const con2 = new Construct(stack, "C2"); new CfnBucket(con2, "Bucket"); // C2/Bucket const child = new Construct(con2, "Default"); new CfnBucket(child, "Bucket"); // C2/Default/Bucket // Error: section 'Resources' already contains 'C2BucketAB4DC557'
これは2つ目の CfnBucket
のパスが実質的に C2/Bucket
と等しくみなされるためです。
コンストラクト外に影響を及ぼすものではないためこの挙動で問題が起きる場合は少ないと思われますが、注意すべき挙動でしょう。
node.defaultChild
また、ID=Default
のリソースは、 node.defaultChild
メソッドで取得可能です。
const stack = new Stack(app, "StackNest"); const parent = new Construct(stack, "Bucket"); new CfnBucket(parent, "Default"); console.log(parent.node.defaultChild); // <ref *1> CfnBucket { // ...
Escape hatchを使う時に若干便利かもしれません。
なお他のIDと同じく、一つのscopeには一つの ID=Default
のみが存在できます。
実装を見てみる
挙動は分かったので、どうしてそうなるのか実装を見ていきます。
まずはリソースの論理IDを計算するコードを見てみましょう:
protected allocateLogicalId(cfnElement: CfnElement): string { const scopes = cfnElement.node.scopes; const stackIndex = scopes.indexOf(cfnElement.stack); const pathComponents = scopes.slice(stackIndex + 1).map(x => x.node.id); return makeUniqueId(pathComponents); }
変数名で大体分かりますが、あるリソースのスタック以下のパスを配列にし、 makeUniqueId
関数を呼んでいます。
makeUniqueId
関数は uniqueid.ts
に定義されています。
/** * Resources with this ID are complete hidden from the 論理ID calculation. */ const HIDDEN_ID = 'Default'; // 中略 export function makeUniqueId(components: string[]) { components = components.filter(x => x !== HIDDEN_ID);
この関数ではあるコンストラクトのパス (を /
で分割した配列) からUnique IDを計算します。
関数の1行目で、計算の対象となる配列 components
から ID=Default
が除外されていることが分かります。
その後、 components からハッシュ値を計算し、またハッシュの前の文字列も計算します。
いずれも先程フィルター処理された components
から計算されるので、 ID=Default
はこの関数全体を通じて存在しないものとしてみなされています。
const hash = pathHash(components); const human = removeDupes(components) .filter(x => x !== HIDDEN_FROM_HUMAN_ID) .map(removeNonAlphanumeric) .join('') .slice(0, MAX_HUMAN_LEN);
今回の挙動を理解するためにはこの程度で十分でしょう。コード内のコメントを読むと他の豆知識も拾えるので、興味のある方は深掘りしてみてください。 なぜ末尾にハッシュ値が必要なのか、論理IDが上限長を超える場合はどうするのか、などを理解できます。
ちなみに少し上に書いた、スタック直下のリソースではハッシュ値が末尾に付加されない例外処理も、ここで行われていました。
これらはundocumentedな実装の詳細ですが、おそらく今後も変更が入ることはないでしょう。 論理IDの計算ロジックを変更すると、既存のCDKアプリに強烈な影響を与える破壊的変更になるためです。 このため、以下のようなIssueも見つかります。CDKの産みの親自身、ロジックの一部に後悔があるようですね。
findDefaultChild
メソッドは、 constructs
ライブラリの方に実装されています。単純に tryFindChild
を呼んでいるだけです。
const defaultChild = this.tryFindChild('Default');
ここには ID=Resource
に関する処理も書かれていますが、こちらは後ほど触れます。
活用方法
ID=Default
を活用する方法をいくつか紹介します。
と言っても、こちらの記事でも紹介したとおりの内容です。他になにか思いついた方は教えてください。
1. CloudFormationの自動命名を見やすくする
ネストが深いリソースについても論理IDを短縮できる可能性があります。 論理IDの長さは通常気にする必要がないですが、CloudFormationがリソース (S3やIAM Roleなど) の自動命名をする際に利用されることに注意してください。 論理IDを短くすることで、こうした自動命名をわかりやすくすることができます。
2. コンストラクトツリーをリファクタする
あるコンストラクトを他のコンストラクトの子に入れる場合、通常は対応するリソースの論理IDが変化するため、置き換えが発生します。
しかしながら、以下2つのいずれかの方法を採れば、論理IDを保持できます。
1の方法は複数のコンストラクトを移動することができます。しかしそのスコープですでに Default
が使われている場合は、使えません。
2は単一のコンストラクトを移動できます。通常はこちらのほうが使いやすいと思われます。
// これをリファクタしたい const stack = new Stack(app, "StackRefactor"); new CfnBucket(stack, "Bucket1"); new CfnBucket(stack, "Bucket2"); // 方法1 const parent = new Construct(stack, "Default"); new CfnBucket(parent, "Bucket1"); new CfnBucket(parent, "Bucket2"); // 方法2 const parent = new Construct(stack, "Bucket1"); new CfnBucket(parent, "Default");
これらにより、リソースの置き換えを防ぎながら、コンストラクトの構造をリファクタすることができます。
ちなみに、論理IDを保持するもう一つの方法として、 overrideLogicalId
メソッドがあります。
const parent = new Construct(stack, "AnyIdParent"); const child = new Construct(parent, "AnyIdChild"); const bucket = new CfnBucket(child, "Bucket1"); bucket.overrideLogicalId("Bucket1");
を合成後のテンプレート:
"Resources": { "Bucket1": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackRefactor/AnyIdParent/AnyIdChild/Bucket1" } },
こちらは自動生成の論理IDを無視して任意文字列でオーバーライドする、非常に強力な機能です。 コード内にある種の負債として残り続けてはしまいますが、他にどうしようもないときはこちらも利用できるでしょう。
さらにちなみに、CDKのRFCで新たなリファクタ向け機能が提案されています。
overrideLogicalId
メソッドよりも柔軟に論理IDをオーバーライドするAPIが含まれるようです。こちらもいつか実現されることを期待したいですね!
豆知識: ID=Resource
との違い
実は上のコードにも登場したのですが、 IDを Resource
に設定した場合も、似た挙動を示すようになっています。
このコード
const stack = new Stack(app, "StackResource"); const parent = new Construct(stack, "Bucket"); new CfnBucket(parent, "Resource");
は、以下のテンプレートに合成されます:
"Resources": { "Bucket83908E77": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackResource/Bucket/Resource" } },
論理IDから Resource
という文字列が消えていることに注目してください。
これにより、Default
と同じく論理IDの短縮に利用できます。
Default
との違いは、ハッシュの計算時に無視されないことです。このため Default
とは異なり、意図せぬ論理IDの競合は発生しません。
パスが違えば論理IDも異なるという、分かりやすいモデルが維持されます。
例えば、以下の2つの CfnBucket
はそれぞれ別のリソースとして見なされます。Default
ではエラーになっていたことに注意してください。
const stack = new Stack(app, "StackResource"); const con2 = new Construct(stack, "C2"); new CfnBucket(con2, "Bucket"); // C2/Bucket // このIDが "Default" だとエラーになっていた const child = new Construct(con2, "Resource"); new CfnBucket(child, "Bucket"); // C2/Resource/Bucket
"Resources": { "C2BucketAB4DC557": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackResource/C2/Bucket" } }, "C2BucketC6A4BB8A": { "Type": "AWS::S3::Bucket", "Metadata": { "aws:cdk:path": "StackResource/C2/Resource/Bucket" } },
一方この挙動のために、Default
とは異なりリファクタの用途では使えません。あくまでも論理IDの短縮が使い道になるでしょう。
多くのL2コンストラクトでも、そのコンストラクトのうちメインのリソースのIDが Resource
に設定されています (例: Bucket
に対する CfnBucket
)。
いたずらに論理IDを長くするのを防ぐ配慮だと思われ、コンストラクトライブラリの開発者は意識すると良いでしょう。
また上でも触れた通り、IDが Default
or Resource
のコンストラクトは node.defaultChild
で取得可能です。
同じスコープ内に Default
と Resource
のコンストラクトをそれぞれ配置して合成することは可能ですが、 defaultChild
メソッドを呼ぶとエラーが発生するので、避けるのが無難でしょう。
const stack = new Stack(app, "StackNest"); const parent = new Construct(stack, "Bucket"); new CfnBucket(parent, "Default"); new CfnBucket(parent, "Resource"); // ここまでなら合成できる // defaultChildを呼ぶとエラー parent.node.defaultChild; // Error: Cannot determine default child for StackNest/Bucket. There is both a child with id "Resource" and id "Default"
まとめ
コンストラクトのIDを Default
にしたときの挙動やその活用方法をまとめました。
知っているとなにか役立つときがあるかも。
活用方法が他にあれば・見つけたら教えてください!