AWS CDK の Aspects の仕様が変わりました

目次

目次


先にまとめ

  • 2024/12/7 にリリースされた AWS CDK の v2.172.0 で Aspects の仕様が変更された
  • 「優先度(priority)」 と 「Stabilization ループ」 という 2 つの機能が導入された
    • Stabilization ループは、機能フラグが true の場合に有効になる(優先度は機能フラグに関わらず効く)
    • ただしこの機能フラグは通常の機能フラグと違い、既存プロジェクトでも該当 CDK バージョンに挙げた際に自動的に true になる
  • Aspects に 「更新(Mutating)」 と 「読み取り専用(Readonly)」 という 2 種類の概念が生まれた


Aspects とは

docs.aws.amazon.com

Aspects とは、あるスタックやコンストラクトといった、特定のスコープ内の全ての構成に操作を適用する方法です。

例えば、「スタック内の全ての S3 バケットにバージョニング設定が適用されているか」などのようなバリデーションを行いたい場合に使うことができます。

※本記事では、Aspects 自体の概念や機能を「Aspects」、Aspects の実装・クラスを「Aspect」と表記しています。

import { App, Aspects, IAspect, Stack, Tokenization } from 'aws-cdk-lib';
import { CfnBucket } from 'aws-cdk-lib/aws-s3';
import { IConstruct } from 'constructs';

// AspectはIAspectインターフェースを実装することで実現する
export class BucketVersioningChecker implements IAspect {
  // visitメソッドにバリデーションの内容を記述する
  public visit(node: IConstruct) {
    // Aspectによって渡されたConstructの内部のリソース全てにvisitメソッドが適用されるので、CfnBucketリソースの場合にのみ実行されるよう条件分岐をする
    if (node instanceof CfnBucket) {
      // バケットのバージョニング設定が無効の場合、エラーを発生させる
      if (
        !node.versioningConfiguration ||
        (!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
      ) {
        throw new Error('バージョニングが有効になっていません');
      }
    }
  }
}

const app = new App();
const stack = new Stack(app, 'MyStack');

// MyStack内の全てのリソースに対してBucketVersioningCheckerを適用
Aspects.of(stack).add(new BucketVersioningChecker());


CDK のライフサイクル

AWS CDK のライフサイクルには、以下の 4 つのフェーズがあります。

  • Construct フェーズ
  • Prepare フェーズ
  • Validate フェーズ
  • Synthesize フェーズ


ユーザーの書く CDK コードのほとんど(リソースのインスタンスの生成など)は Construct フェーズで実行されます。

Aspects は Construct フェーズが終わった後の Prepare フェーズで実行され、Aspect クラスの visit メソッド、つまり Aspect の処理が走ります。


そのため、Aspect インスタンスが生成されたタイミングでは Aspect の処理は実行されないことに注意が必要です。(Aspect インスタンス生成よりも後のリソースのインスタンス生成などが全て終わった後に実行される。)

ライフサイクルに関する詳細は以下の記事をご覧ください。

aws.amazon.com


仕様変更の背景(問題点)

今回の Aspects の仕様変更が行われるまでは、以下の 2 つのケースで問題がありました。


問題 1. Aspect 内で上位ノードに新しいリソースを作成するケース

1 つ目の問題とは、「上位ノード(Construct から見たスタックなど)にリソースを追加する Aspect」と「ツリー全体のリソースを走査する Aspect」とがある場合に、前者の Aspect で追加されたリソースが、ツリー全体を辿って走査する後者の Aspect で走査されない、という問題でした。


具体的には、以下のようなケースで発生していました。

  • 新しいリソースをスタックに追加する Aspect を、スタック内の Construct に適用
  • 全てのリソースを走査する別の Aspect を、スタックに適用


というのも、今までの Aspects は、コンストラクトツリーの最上位のノードに適用された Aspect から、ツリーを辿るように上のノードから順番にそれぞれのノードの Aspect を実行していくような内部実装になっていました。

※当時の CDK 本体の該当コード:

github.com


つまり、後者の Aspect はより上位であるスタックに適用されているため先に実行され、全てのリソースを走査し終えた後に前者の Aspect によって新しいリソースが追加されるため、このような問題が発生していました。

またこの事象には、Aspect によって既存のコンストラクトツリーに新しいノード(リソース)を追加しても、他の Aspect ではその新しいノードが追加される前のコンストラクトツリーを辿るということも関係しています。


コード例としては、以下のようなケースが挙げられます。(※後述する issue に記載のコード例を少し変更して使用しています。)

import { Aspects, Tags, IAspect, Stack, StackProps } from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct, IConstruct } from 'constructs';

export class CdkIssueStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // スタック全体を走査してタグを追加(タグを追加する Aspect がスタックに適用)
    // これが最初に実行される(add先が一番上のノードであるスタックのため)
    Tags.of(this).add('test-tag', 'test-value');

    new MyConstruct(this, 'MyConstruct');
  }
}

class MyConstruct extends Construct {
  constructor(scope: IConstruct, id: string) {
    super(scope, id);

    const stack = Stack.of(scope);
    // スタック直下に作成
    new Bucket(stack, 'BucketWithTags');

    // リソースを上位ノードであるスタックに追加する Aspect をこの Construct に適用
    // これが 2 番目に実行される(add先がスタックより下位ノードであるConstructのため)
    Aspects.of(this).add(new MyDynAspect());
  }
}

class MyDynAspect implements IAspect {
  private processed = false;

  public visit(node: IConstruct): void {
    // 一度だけ呼び出すための条件分岐
    if (!this.processed) {
      // 上位ノードであるスタックの直下に Bucket を作成
      const stack = Stack.of(node);
      new Bucket(stack, 'BucketWithoutTagsThatShouldHave');

      this.processed = true;
    }
  }
}


これは上記で説明したライフサイクルに沿って実行されます。具体的には Construct フェーズで BucketWithTags が生成され、Prepare フェーズでタグの付与と BucketWithoutTagsThatShouldHave の生成が実行されます。これは、Tags は内部的に Aspects が使用されていているのと、Aspects は Prepare フェーズで実行されるからです。


このコードによる合成後のツリーは以下のようになります。

  • Stack
    • BucketWithTags
      • タグ有
    • BucketWithoutTagsThatShouldHave
      • ã‚¿ã‚°ç„¡

しかし本来は BucketWithoutTagsThatShouldHave にもタグが適用されるべきである、というような問題提起がありました。


問題 2. 複数の Aspect が同じコンストラクトに適用されるケース

上記の問題 1 は、コンストラクトツリーにおける各ノードの走査順序(と、Aspect で追加されたリソースは別の Aspect では走査されない点)に起因する問題でした。

一方で、こちらの問題 2 で挙げる問題は、Aspect の呼び出し順序に起因する問題です。つまり、複数の Aspect が同じコンストラクトに対して適用されている場合に、それらの Aspect の呼び出し順序に関する問題になります。


以下の Aspect 構成のようなケースがあったとします。


このケースでは、以下のような問題が発生する可能性があります。

  • ValidateEncryptionAspect が最初に実行されるが、DefaultEncryptionAspect がまだ適用されていないため失敗する
  • DefaultEncryptionAspect は EnvironmentBasedEncryptionAspect によって上書きされる
  • ValidateEncryptionAspect は EnvironmentBasedEncryptionAspect によって設定された最終的な暗号化構成を確認できない


これまでの Aspects は上記のように適用された順に実行されていましたが、より柔軟にコントロールできる要素があると良いはずです。例えば上記の例でいうと、ValidateEncryptionAspect を一番最初に add するけど、一番最後に実行したいケースを実現することです。


新しい仕様

2024/12/7 にリリースされた v2.172.0 で、上記の問題を解決するために Aspects の仕様が変更されました。

その Aspects の仕様の変更点は 2 つあります。


仕様 1. 優先度(priority)の導入

「優先度(priority)」 という機能が導入され、Construct ツリー全体に渡って、優先度の高い(priority の数値が小さい) Aspect が優先度の低い(priority の数値が大きい) Aspect よりも先に適用されることが保証されるようになりました。

これは、主に上記で挙げた問題 2 を解決するために導入された、Aspect が適用される順番を指定できるようにするための仕様です。(※問題の順番、仕様の順番が逆なので注意)


またこの「優先度(priority)」に併せて、様々な Construct ライブラリ間の一貫性を担保するために、Aspects に「更新(Mutating)」と「読み取り専用(Readonly)」の 2 種類の概念が新たに生まれました。

ここでいう 「更新(Mutating)」 とは、コンストラクトツリーに新しいノード(リソース)を追加するか既存のノードを変更するもの、「読み取り専用(Readonly)」 はツリーの変更はしない検査(検証)などのものです。


この 2 種類の概念の実装として、デフォルトの優先度、また更新用 Aspect や読み取り専用 Aspect の優先度としての数値がAspectPriorityクラスの static プロパティ(CDK 本家では Enum-Like Class と呼ばれます)として定義されています。

具体的な値は以下のように定義されており、「更新(MUTATING)」→「デフォルト(DEFAULT)」→「読み取り専用(READONLY)」の順に適用され、優先度を指定しない場合は更新用 Aspect の後・読み取り専用 Aspect の前に実行されることになります。

github.com

/**
 * Default Priority values for Aspects.
 */
export class AspectPriority {
  /**
   * Suggested priority for Aspects that mutate the construct tree.
   */
  static readonly MUTATING: number = 200;

  /**
   * Suggested priority for Aspects that only read the construct tree.
   */
  static readonly READONLY: number = 1000;

  /**
   * Default priority for Aspects that are applied without a priority.
   */
  static readonly DEFAULT: number = 500;
}


さらに問題 2 のような「同じコンストラクトに対する複数の Aspect の適用順序」だけでなく、Construct ツリー全体を通しての Aspect の適用順序をコントロールすることができます。

例えば以前までの仕様だと、以下のように、「リソースを検査する Aspect(読み取り専用)」を上位ノードであるスタックに適用し、「リソースを更新する Aspect(更新)」を下位ノードであるコンストラクトに適用した場合、コンストラクトツリーの上位である前者のリソース検査の Aspect が最初に走ってしまいました。


しかし今回導入された優先度を利用して、前者のリソース検査 Aspect の優先度を、後者のリソース更新 Aspect よりも低く(READONLY は MUTATING より優先度が低い/数値は大きい)してあげることで、上位ノードに適用された読み取り専用であるリソース検査の Aspect を最後に走らせることができるようになりました。

import { Aspects, IAspect, Stack, StackProps, Tokenization, AspectPriority } from 'aws-cdk-lib';
import { Bucket, CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Construct, IConstruct } from 'constructs';

export class CdkIssueStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    new MyConstruct(this, 'MyConstruct');

    Aspects.of(this).add(new VersioningCheckAspect(), { priority: AspectPriority.READONLY });
  }
}

class MyConstruct extends Construct {
  constructor(scope: IConstruct, id: string) {
    super(scope, id);

    new Bucket(this, 'BucketWithTags');

    Aspects.of(this).add(new VersioningAspect(), { priority: AspectPriority.MUTATING });
  }
}

class VersioningCheckAspect implements IAspect {
  public visit(node: IConstruct) {
    if (node instanceof CfnBucket) {
      if (
        !node.versioningConfiguration ||
        (!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
      ) {
        throw new Error('バージョニングが有効になっていません');
      }
    }
  }
}

class VersioningAspect implements IAspect {
  public visit(node: IConstruct): void {
    if (node instanceof CfnBucket) {
      if (
        !node.versioningConfiguration ||
        (!Tokenization.isResolvable(node.versioningConfiguration) && node.versioningConfiguration.status !== 'Enabled')
      ) {
        node.versioningConfiguration = {
          status: 'Enabled',
        };
      }
    }
  }
}


仕様 2. Stabilization ループの導入

Stabilization ループとは、上記で挙げた問題 1 を解決するためのもので、Aspects の呼び出し時にコンストラクトツリーへの適用を複数回実行する仕組みのことです。

主に以下の特徴があります。

  • コンストラクトに適用された Aspect でそのコンストラクトよりも上位のノードに新しいリソースを作成した場合も、先に実行される上位ノード(スタックなど) に適用された Aspect でその追加リソースの検査ができる
  • 他の Aspect によって作成された Aspect も実行できる(Aspect のネスト実行)


特に前者が問題 1 の対応に当たります。

一方、後者に関してですが、実は今までは Aspect のネスト実行はできなかったのです。

後者の Aspect のネスト実行の例として、以下のようなコードが挙げられます。

class MyConstruct extends Construct {
  constructor(scope: IConstruct, id: string) {
    super(scope, id);

    Aspects.of(this).add(new ParentAspect());
  }
}

class ParentAspect implements IAspect {
  public visit(node: IConstruct): void {
    Aspects.of(node).add(new MyDynAspect());
  }
}

class MyDynAspect implements IAspect {
  private processed = false;

  public visit(node: IConstruct): void {
    if (!this.processed) {
      const stack = Stack.of(node);
      new Bucket(stack, 'BucketWithoutTagsThatShouldHave');

      this.processed = true;
    }
  }
}


上記では Stabilization ループとはコンストラクトツリーへの適用を複数回実行する仕組みという説明をしましたが、同じリソースに同じ Aspect は一度しか適用(実行)されないので安心してください。

Aspect の実行順序は、以下の画像(RFC で提示された画像)が参考になります。


補足

Aspect 情報の取得と優先度の上書き

Aspect を適用した後に、そのスコープに適用された Aspect の一覧とその情報をAspects.of(scope).appliedで取得できます。

それらはAspectApplicationクラスのインスタンスで、aspect、construct、priorityの 3 つのプロパティがあります。

またそのインスタンスを通して、Aspect 宣言後に優先度の値の上書きもできます。上記 3 つのプロパティをよしなに使って比較などをして優先度の上書きに使うのが良いでしょう。

const app = new App();
const stack = new MyStack(app, 'MyStack');

Aspects.of(stack).add(new MyAspect());

let aspectApplications: AspectApplication[] = Aspects.of(stack).applied;

for (const aspectApplication of aspectApplications) {
  // The aspect we are applying
  console.log(aspectApplication.aspect);
  // The construct we are applying the aspect to
  console.log(aspectApplication.construct);
  // The priority it was applied with
  console.log(aspectApplication.priority);

  // Change the priority
  aspectApplication.priority = 700;
}


機能フラグ

上記で挙げた 2 つの仕様変更点のうち、2 つ目の 「Stabilization ループの導入」は機能フラグが true の場合に適用されます。(※1 つ目の「優先度」機能は機能フラグの有無に関わらず適用される。)

※機能フラグに関しては、公式ドキュメントを参照してください。


通常、機能フラグは、新規 CDK プロジェクト(該当機能フラグが導入された以降のバージョンの CDK でcdk initをする場合)ではデフォルトで true になり、既存の CDK プロジェクトでは明示的にその機能フラグを true で指定しない限り false 扱いになるので、その機能を有効にするためには自分で指定する必要があります。


しかし、この Stabilization ループのための機能フラグは通常の機能フラグと違い、既存プロジェクトでも該当 CDK バージョンに挙げた際に「自動的に」true 扱いになるので、注意が必要です。

※ソースは以下のリンクになります。

github.com


つまり今回の Stabilization ループは、新規プロジェクトでも既存プロジェクトでも CDK バージョンを上げると自動的に有効になり、従来通りの 1 回のみのコンストラクトツリーの走査に切り替えるためには明示的に機能フラグを false にする必要があります。


既存の CDK プロジェクトで AWS CDK のバージョンを v2.172.0 以上にし、かつ「Stabilization ループの導入」の機能フラグを「オフに」設定したい場合、以下のようにします。(有効にしたい場合、CDK バージョンを上げる以外に何もする必要はありません。)

  • cdk.jsonのcontextに"@aws-cdk/core:aspectStabilization": falseを追加する
{
  // ...
  // ...
  "context": {
    // ...
    // ...
    "@aws-cdk/core:aspectStabilization": false
  }
}


参照

今回の Aspects の仕様変更に関して、元となった issue や、それに対して提出された RFC、そして PR があります。ご興味がありましたらぜひご覧ください。

issue

github.com

PR

github.com

RFC

github.com


最後に

Aspects は CDK の中でも特に便利な機能ですが、今回の仕様変更によって、より柔軟にコンストラクトツリーを走査できるようになりました。

そのため、Aspects を使っているコードを見かけたら、ぜひこの記事を参考にしてみてください。