maybe daily dev notes

私の開発日誌

CDK Tips: ID=Defaultの使い方

AWS CDK Tipsシリーズです。

昨日の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の産みの親自身、ロジックの一部に後悔があるようですね。

github.com

findDefaultChild メソッドは、 constructs ライブラリの方に実装されています。単純に tryFindChild を呼んでいるだけです。

    const defaultChild = this.tryFindChild('Default');

ここには ID=Resource に関する処理も書かれていますが、こちらは後ほど触れます。

活用方法

ID=Default を活用する方法をいくつか紹介します。 と言っても、こちらの記事でも紹介したとおりの内容です。他になにか思いついた方は教えてください。

tmokmss.hatenablog.com

1. CloudFormationの自動命名を見やすくする

ネストが深いリソースについても論理IDを短縮できる可能性があります。 論理IDの長さは通常気にする必要がないですが、CloudFormationがリソース (S3やIAM Roleなど) の自動命名をする際に利用されることに注意してください。 論理IDを短くすることで、こうした自動命名をわかりやすくすることができます。

2. コンストラクトツリーをリファクタする

あるコンストラクトを他のコンストラクトの子に入れる場合、通常は対応するリソースの論理IDが変化するため、置き換えが発生します。

しかしながら、以下2つのいずれかの方法を採れば、論理IDを保持できます。

  1. 親コンストラクトのIDを Default にし、子コンストラクトのIDをそのままにする
  2. 親コンストラクトのIDをそのままに、子コンストラクトのIDを Default にする

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が含まれるようです。こちらもいつか実現されることを期待したいですね!

github.com

豆知識: 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 で取得可能です。 同じスコープ内に DefaultResource のコンストラクトをそれぞれ配置して合成することは可能ですが、 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 にしたときの挙動やその活用方法をまとめました。 知っているとなにか役立つときがあるかも。 活用方法が他にあれば・見つけたら教えてください!