AWS CDKにおけるLazyの使い所

AWS CDK の Lazy の具体的な使い方・使い所についてまとめました。

目次

目次


Lazy とは

AWS CDK には Lazy という、便利だけれどもあまり知られていない機能があります。


Lazy とは「遅延処理」のための機能(クラス)です。

例えば、Construct のとあるプロパティに値を指定する際、指定した時点ではまだ値を決定させず、合成(synth)が完了するタイミングで値を決定(解決)させたいケースに使用できます。

主に以下のメソッドを使用して遅延処理を実現します。

  • Lazy.any()
  • Lazy.list()
  • Lazy.number()
  • Lazy.string()

docs.aws.amazon.com


具体的には、以下のコードのような書き方をします。

let actualValue: number;

new AutoScalingGroup(this, 'Group', {
  desiredCapacity: Lazy.number({
    produce() {
      return actualValue;
    },
  }),
});

actualValue = 10;


AutoScalingGroupdesiredCapacity(number 型) に値を直接指定せず、Lazy.numberメソッド(の戻り値)を指定しています。

またこのLazy.numberメソッドには、number 型の値を返すproduceメソッドを持つオブジェクトを渡しています。


そして事前に定義したactualValueproduceメソッドで返すようにし、AutoScalingGroupインスタンスの生成よりも後、この例では最後の行でactualValue10という値を設定しています。

これによって、desiredCapacityに指定したLazy.numberの呼び出し時点ではactualValueの値は未確定ですが、このdesiredCapacityには、最終的にactualValueに設定された10という値が設定されます。


このように、呼び出し時・定義時ではなく、後から値を決定(解決)したいケースで Lazy は有用です。

これはあくまで Lazy を説明するための簡単な例でしたが、では実際に CDK 開発をする際にどのような使い方ができるのか、いくつか例を挙げてみます。


Lazy の実用的な具体例

1. add メソッドとの併用

まず 1 つ目は、CDK コントリビューションにおいて Construct の内部実装で特によく見られるケースです。


例えば MyConstruct という Construct の中で、SomeConstructという Construct を呼び出すとします。

そしてそのSomeConstructの props には配列プロパティ(someArray)があり、それに渡すためのプロパティ(myArray)をMyConstructの props(MyConstructProps)に用意しています。

export interface MyConstructProps {
  readonly myArray: string[];
}

export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    new SomeConstruct(this, 'SomeConstruct', {
      someArray: props.myArray,
    });
  }
}


ただこのような場合に、配列プロパティに値を追加するaddXxxのようなメソッドが用意されている Construct を見たことがある方もいるのではないでしょうか。

このメソッドがあることで、Construct のインスタンス生成時にその props に渡すだけでなく、後からこのメソッドを呼ぶことでも値を設定できるようになります。

これにより Construct の使い方がより広がるケースがあります。

export class MyConstruct extends Construct {
  private readonly myArray: string[];

  // ...
  // ...

  public addMyArray(value: string): void {
    this.myArray.push(value);
  }
}

しかし先ほどは、MyConstructの中で呼び出すSomeConstructにはprops.myArrayを渡していたので、それだけでは add メソッドを呼ぼうが呼ばまいが、props で渡した値しか渡されません。

new SomeConstruct(this, 'SomeConstruct', {
  someArray: props.myArray,
});

そんな時にこそのLazyです。

addXxxメソッドによって内部プロパティのmyArrayに値を追加し、SomeConstructにはLazyを通して渡すことで、Construct 生成時の値(props の値)ではなく、addXxxメソッドを呼んだ後の値を渡すことができます。

export interface MyConstructProps {
  readonly myArray: string[];
}

export class MyConstruct extends Construct {
  private readonly myArray: string[];

  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    // myArrayにpropsで渡した値を設定
    this.myArray = props.myArray;

    new SomeConstruct(this, 'SomeConstruct', {
      // addメソッドを呼んだ後の値が渡される
      someArray: Lazy.list({
        produce: () => this.myArray,
      }),
    });
  }

  public addMyArray(value: string): void {
    this.myArray.push(value);
  }
}


2. 参照順序の逆転・循環依存

2 つ目ですが、Lazy によって Construct 間の参照順序を逆転させたり、循環依存させることができるケースがあります。


以下の例のように、MyFunctionという Lambda 関数を持つ Construct と、MyApiという API Gateway を持つ Construct があるとします。

MyFunctionではarnForExecuteApiという、この CDK コード上ではMyApiが返すはずの ARN を要求しています。

一方で、MyApiではfuncという、この CDK コード上ではMyFunctionが返す Lambda 関数を要求しています。

const fn = new MyFunction(this, 'MyFunction', {
  // APIのARNが欲しい(でもAPIにこのfnが要求されている)
  arnForExecuteApi: api.arnForExecuteApi, // まだ`api`は未定義なのでエラーになる
});

const api = new MyApi(this, 'MyApi', {
  // Functionが欲しい(でもFunctionにこのAPIのARNが要求されている)
  func: fn.function,
});


このようにそれぞれが互いに参照しあっているケースでも、Lazy によって参照順序を逆転させ、循環依存させることができます。

let arnForExecuteApi: string;

const fn = new MyFunction(this, 'MyFunction', {
  // APIのARNが欲しい(でもAPIにこのfnが要求されている)
  arnForExecuteApi: Lazy.string({
    produce: () => arnForExecuteApi,
  }),
});

const api = new MyApi(this, 'MyApi', {
  // Functionが欲しい(でもFunctionにこのAPIのARNが要求されている)
  func: fn.function,
});

arnForExecuteApi = api.arnForExecuteApi;


ただし注意点として、CloudFormation テンプレート上で循環依存になるものはそもそもエラーになってしまい解決不可能です。

あくまで CloudFormation テンプレート上では循環依存にならないが CDK の Construct 上では循環依存の形をすることが可能なケースがある、というような捉え方をしていただくと良いかもしれません。


上記の例では、MyFunctionarnForExecuteApi(つまり API の ARN)は Lambda の環境変数に使用し、MyApifuncAPI Gateway のメソッドとして使用する想定でした。

つまり、CDK コードとしては循環参照となっていますが、CloudFormation テンプレートとしては AWS::ApiGateway::Method->AWS::Lambda::Function->AWS::ApiGateway::RestApi という関係性になるため循環参照にならず、エラーにならないため実現が可能となるわけです。


3. IAM PolicyStatement の統合

3 つ目は 1 つ目の例に近い使い方ですが、こちらは配列プロパティでなく IAM のステートメントを対象としたものになります。

まずは以下の例をご覧ください。

export class ... {
  // ...
  // ...
  public grantInvokeFromVpcEndpointsOnly(vpcEndpoint: ec2.IVpcEndpoint): void {
    this.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*'],
        effect: iam.Effect.DENY,
        conditions: {
          StringNotEquals: {
            'aws:SourceVpce': vpcEndpoint,
          },
        },
      }),
    );
    this.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*'],
        effect: iam.Effect.ALLOW,
      }),
    );
  }


上記は API Gateway の Construct を想定したコード例ですが、「VPC エンドポイント経由からのみ接続を許可する」ためのメソッドです。

一方で、API Gateway に複数の VPC エンドポイントから接続したいケースもあるでしょう。その場合、grantInvokeFromVpcEndpointsOnlyメソッドを複数回呼ぶことになります。

api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint1);
api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint2);


しかし、こちらで生成する IAM ステートメントのうち肝心なDENYの部分は以下のようになっています。これは、指定したエンドポイント 「以外」 を拒否するステートメントです。

{
  principals: [new iam.AnyPrincipal()],
  actions: ['execute-api:Invoke'],
  resources: ['execute-api:/*'],
  effect: iam.Effect.DENY,
  conditions: {
  StringNotEquals: {
      'aws:SourceVpce': vpcEndpoint,
  },
}


先ほどの例のようにこのメソッドを 2 回呼んだ場合、1 回目の呼び出しではvpcEndpoint1以外を拒否するステートメントが生成され、2 回目の呼び出しではvpcEndpoint2以外を拒否するステートメントが生成されます。

つまりこの場合、vpcEndpoint1vpcEndpoint2のどちらからの接続も拒否されてしまうことになります。

api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint1); // vpcEndpoint2は拒否される
api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint2); // vpcEndpoint1は拒否される


例えば、メソッドの引数をエンドポイントの配列にしてまとめて渡すことで 1 つのconditionsにまとめるという手もありますが、それでもメソッドが複数回呼ばれた場合、お互いを拒否する IAM ステートメントが生成されてしまうのは少々使い勝手が落ちてしまうでしょう。


そこで、Lazyを使ってこのような場合にも対応できるようにしてみます。(また次の例は、先ほど述べた配列の引数に変更したものになっています。)

export class ... {
  // ...
  // ...
  private _allowedVpcEndpoints: Set<ec2.IVpcEndpoint> = new Set();

  public grantInvokeFromVpcEndpointsOnly(vpcEndpoints: ec2.IVpcEndpoint[]): void {
    vpcEndpoints.forEach(endpoint => this._allowedVpcEndpoints.add(endpoint));

    const endpoints = Lazy.list({
      produce: () => {
        return Array.from(this._allowedVpcEndpoints).map(endpoint => endpoint.vpcEndpointId);
      },
    });

    this.addToResourcePolicy(new iam.PolicyStatement({
      principals: [new iam.AnyPrincipal()],
      actions: ['execute-api:Invoke'],
      resources: ['execute-api:/*'],
      effect: iam.Effect.DENY,
      conditions: {
        StringNotEquals: {
          'aws:SourceVpce': endpoints,
        },
      },
    }));
    this.addToResourcePolicy(new iam.PolicyStatement({
      principals: [new iam.AnyPrincipal()],
      actions: ['execute-api:Invoke'],
      resources: ['execute-api:/*'],
      effect: iam.Effect.ALLOW,
    }));
  }


まず、Set<ec2.IVpcEndpoint>型の_allowedVpcEndpoints変数を作成し、該当のメソッドが呼ばれるたびにこちらにエンドポイントを追加していきます。

Set型にしているのは、同じものが渡された場合は重複を排除してくれるためです。

  private _allowedVpcEndpoints: Set<ec2.IVpcEndpoint> = new Set();

  public grantInvokeFromVpcEndpointsOnly(vpcEndpoints: ec2.IVpcEndpoint[]): void {
    vpcEndpoints.forEach(endpoint => this._allowedVpcEndpoints.add(endpoint));


そして、_allowedVpcEndpointsの値をLazy.listメソッド内でvpcEndpointIdの配列に変換しています。その後、PolicyStatementconditionsにはLazy によって遅延実行されたendpoints、つまりそのメソッドが複数回呼ばれた後のエンドポイントの配列が渡されています。

これにより、該当メソッドが複数回呼ばれたとしても、全てのエンドポイントの配列が単一の IAM ステートメントconditionsにまとめられるようになります。

const endpoints = Lazy.list({
  produce: () => {
    return Array.from(this._allowedVpcEndpoints).map((endpoint) => endpoint.vpcEndpointId);
  },
});

this.addToResourcePolicy(
  new iam.PolicyStatement({
    principals: [new iam.AnyPrincipal()],
    actions: ['execute-api:Invoke'],
    resources: ['execute-api:/*'],
    effect: iam.Effect.DENY,
    conditions: {
      StringNotEquals: {
        'aws:SourceVpce': endpoints,
      },
    },
  }),
);


実際に Lazy で対応した参考 PR

実はこの 3 つ目の例は、実際に AWS CDK に出された PR (by クライヤーさん)で、私がレビューをした際にコメントした内容をアウトプットしておこうと書き起こしたものになります。

github.com


PolicyStatement が統合される仕組み

ここで一つ、本題の Lazy とは少し逸れた話をします。

先ほどのコードをよく見てみると、該当のメソッドが複数回呼ばれた際、毎回PolicyStatementが生成されて、そのたびにaddToResourcePolicyに渡されるはずです。


しかし、最終的に生成される CloudFormation テンプレートでは Deny Statement は 1 つだけ生成され、その Condition に複数のエンドポイントがまとめられています。

"Condition": {
  "StringNotEquals": {
   "aws:SourceVpce": [
    {
     "Ref": "Vpc1VpcEndpoint1A8BD3278"
    },
    {
     "Ref": "Vpc2VpcEndpoint2898EEC0D"
    }
   ]
  }
},


この理由は、aws-iam モジュールのPolicyDocumentにおけるPolicyStatementは、重複した・もしくは統合できるステートメントを合成時に自動で統合してくれるためです。

今回の例では該当メソッドが 2 回呼ばれることで 2 つのPolicyStatementが生成されますが、どちらのconditionsにもLazyによって遅延実行されるendpointsが渡されています。そして、その 2 つのステートメントendpointsはどちらも最終的に同じ値に解決されるため、合成時に 1 つの Statement にまとめられるのです。


統合処理の CDK 内部コード

ちなみに、この IAM ステートメントの統合が CDK の内部コードのどこでどのように行われているかというと、CDK における合成の最終段階で、通常のポリシーとトークンのポリシーとで別々で処理されています。(トークンとは何かについては後述)


通常のポリシーのステートメントの統合は、PolicyDocumentクラスのresolveメソッドで行われ、このメソッドから最終的にmergeStatementsという関数が呼ばれて統合処理が走ります。


一方で、トークンのポリシーのステートメントの統合は、PostProcessPolicyDocumentクラスのpostProcessメソッドで行われます。

このPostProcessPolicyDocumentは、前者のPolicyDocumentクラスのresolveメソッドの最後に生成され、合成処理の最後あたりのタイミングでまとめて実行されます。


今回の Lazy によって渡されたエンドポイントはトークンなので、後者のPostProcessPolicyDocumentによって統合処理が走っています。

export class PostProcessPolicyDocument implements cdk.IPostProcessor {
  // ...
  // ...
  public postProcess(input: any, _context: cdk.IResolveContext): any {
    // ...
    // ...
    for (const statement of input.Statement) {
      const jsonStatement = JSON.stringify(statement);
      if (!jsonStatements.has(jsonStatement)) {
        uniqueStatements.push(statement);
        jsonStatements.add(jsonStatement);
      }
    }


バリデーション

Lazy による遅延処理で使用するプロパティに対してバリデーションをするときは注意が必要です。

というのも、普段 CDK でバリデーションをする際は、おそらく constructor 内で直接 if 文を使ってバリデーションをしているかと思います。


例えば、上記 1. の例にて、myArrayに渡す値が空の場合はエラーにしたいとします。そんなとき、まずは以下のようなコードを書くかもしれません。

constructor(scope: Construct, id: string, props: MyConstructProps) {
  super(scope, id);

  this.myArray = props.myArray;

  if (this.myArray.length === 0) {
    throw new Error('myArray must not be empty');
  }
}


一方で、props のmyArrayからは 1 つも値を渡さなくても、後から add メソッドで渡すケースは正常パターンです。しかしそんな正常パターンでも、上記のようなバリデーションコードでは add メソッドが呼ばれる前にバリデーションが実行されるため、エラーになってしまいます。


そんなときに使うのがNodeクラスに用意されたaddValidationメソッドです。

こちらはバリデーションを遅延実行するためのメソッドです。つまり、add メソッドが呼ばれた後に実行されるバリデーションになります。

  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    this.myArray = props.myArray;

    // addValidationには、validateメソッドを持つIValidation interface型のオブジェクトを渡す
    this.node.addValidation({ validate: () => this.validateMyArray() });
  }

  // 可読性のため、実際のバリデーションの内容はメソッドを分けて記述している
  private validateMyArray(): string[] {
    const errors: string[] = [];
    if (this.myArray.length === 0) {
      errors.push('myArray must not be empty');
    }
    return errors;
  }


詳細は、以前 builders.flash に寄稿させていただいた「AWS CDK におけるバリデーションの使い分け方を学ぶ」という記事をご覧ください。

aws.amazon.com


トーク

トークンのバリデーション

Lazy によって返される値はトークンという種類の値になります。


トークンとは後から解決される値のための特殊な形式の値であるため、Lazyで返された値をそのまま比較してバリデーションすると想定していない挙動を引き起こす可能性があります。

そのため、バリデーションの際は、比較する値がトークンでないことを確認してから比較を行う必要があります(つまり、基本的にはトークンに対するバリデーションは行えません)。具体的には、Token.isUnresolvedというメソッドを使用してトークンかどうかの判別ができます。

if (!cdk.Token.isUnresolved(props.lifecycleDays) && props.lifecycleDays > 400) {
  throw new Error('ライフサイクル日数は400日以下にしてください');
}

詳しくは、こちらも以前 builders.flash に寄稿させていただいた「AWS CDK における単体テストの使い所を学ぶ」の「5. バリデーションテスト」をご覧ください。

aws.amazon.com


またトークンについて詳しくは公式ドキュメントをご覧ください。

docs.aws.amazon.com


トークンの解決

基本的にはトークンは CDK 内部で合成のタイミングで自動で解決されますが、自分で任意のタイミングで値を解決することも可能です。

そのためには、Stackクラスのresolveメソッドを使用します。これによって、特殊な形式であるトークンから本来の値を取得することができます。

export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    // ...
    // ...

    Stack.of(this).resolve(myToken);
  }
}


しかし、CDK コードを書く中でトークンを自分で解決して何かをするケースはほとんどないかと思いますので、あまり使用するケースはないでしょう。CDK コントリビューションにおいては稀に使用することがあるため、知っていると何かの時に役に立つかもしれません。


最後に

CDK における Lazy はあまり馴染みのない機能かもしれませんが、使い方やシチュエーションによっては非常に便利な機能です。

また、CDK コントリビューションの際には知っていないと対応できないケースもあります。

この記事を通してぜひ Lazy に対する理解を深めていただければ幸いです。