AWS CDKのProps渡しのクロススタック参照で起きる問題と対処方法

AWS CDKでProps渡しのクロススタック参照を使う場合に起きがちな問題と対処方法をご紹介します。
2022.09.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

CX事業本部Delivery部の佐藤智樹です。

今回は、表題の通りAWS CDKでProps渡しのクロススタック参照を行う際によく発生しがちな問題と問題発生時の対処方法について解説します。正確には以下の記事で言及されているCDKの機能の一部であるpropsを経由したスタック間の値やオブジェクトの受け渡し(以降本記事では、props渡しと表現します)で作成されるクロススタック参照についての話をします。

2年ほどCDKを使っているのですが、この問題が現状でも発生しているという話を何箇所かで聞いたので改めて図などを用いて問題の詳細化と対処方法を紹介します。前半はサンプルを用いた問題の説明、後半が問題への対処になっているので対処だけ気になる場合は問題点の部分から読んでください。

2022/09/16 追記
CDKで参照を切り離すための効率的な方法があったので、CDK公式の解決方法として追記しました。問題解決についてはこちらを優先してご確認ください。

サンプルソースコード

今回は以下のソースコードを例に解説してみます。そんなにコード量は多くないので気になった場合は是非動かしてみて、cdk synthで生成されるcdk.outディレクトリのCfnテンプレートファイルを確認してみてください。

本記事では関連するコード部分のみ紹介します。ソースコード内部の lib配下のディレクトリで以下の3つのパターンのサンプルを作成しました。すべてAPI Gatewayスタック、Lambdaスタックを作成してprops渡しによる参照を実装しています。

  1. Lambdaスタック->API Gatewayでprops渡しするパターン
  2. APIGatewayスタック->Lambdaスタックでprops渡しするパターン
  3. 1. と同じ構成で別メソッドを使うパターン

本記事では1番の実装、2番の実装などのように引用します。詳細なコードについては都度掲載します。次節でそれぞれの構成を図で紹介します。

動作環境

項目 バージョン
Node 14.15.0
npm 8.4.0
AWS CDK 2.41.0

1番目の実装

サンプルコードから抜粋して紹介します。概要を図で公開すると以下のようになります。詳細はbin/cross-stack-test.tsのテストケース1とlib/test1配下をご確認ください。

具体的なコード

以下のLambdaのスタックでは単にLambdaをデプロイし、外部から読み取り可能なプロパティとして設定します。

lib/test1/lambda-stack.ts

export class LambdaStack extends cdk.Stack {
  public readonly lambdaA: lambda.Function;
  public readonly lambdaB: lambda.Function;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const stackCrossTest2 = new lambda.Function(this, `StackCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    this.lambdaA = stackCrossTest;
    this.lambdaB = stackCrossTest2;
  }
}

上記のスタックで出力されたプロパティを以下のコードでAPI Gatewayのスタックに受け渡します。

bin/cross-stack-test.ts

// テストケース1. 参照は直感通りになるパターン(APIG->Lambda)
const lambdaStack = new LambdaStack(app, "LambdaStack");
const apiStack = new ApiStack(app, "ApiStack", { lambdaA: lambdaStack.lambdaA, lambdaB: lambdaStack.lambdaB });

API Gatewayのスタックでは、propsで渡されたLambdaをメソッドにセットします。

lib/test1/api-stack.ts

export class ApiStack extends cdk.Stack {
  public readonly stackProps: ApiStackProps;

  constructor(scope: Construct, id: string, props: ApiStackProps) {
    super(scope, id, props);
    const restApi = new apigateway.RestApi(this, "restApi", {
      restApiName: "api-test",
    });
    restApi.root.addMethod("GET", new apigateway.LambdaIntegration(props.lambdaA));
    restApi.root.addMethod("POST", new apigateway.LambdaIntegration(props.lambdaB));
  }
}

export interface ApiStackProps extends cdk.StackProps {
  lambdaA: lambda.Function;
  lambdaB: lambda.Function;
}

今回の場合はCloudFormationの参照が以下のようになります(一部抜粋)

cdk.out/ApiStack.template.json

...
"restApiGETApiPermissionTestApiStackrestApiXXXXXXXXXXXXXXXXXX": {
   "Type": "AWS::Lambda::Permission",
   "Properties": {
    "Action": "lambda:InvokeFunction",
    "FunctionName": {
     "Fn::ImportValue": "LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX"
    },
...

cdk.out/LambdaStack.template.json

...
"Outputs": {
  "ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX": {
   "Value": {
    "Fn::GetAtt": [
     "StackCrossTestFunctionXXXXXXXX",
     "Arn"
    ]
   },
   "Export": {
    "Name": "LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX"
   }
  },
...

改めて図で整理すると以下のようになります。API Gatewayスタック側の実装がLambdaスタックのOutputsに依存するので、両方デプロイする場合Lambdaスタックからデプロイされます。

2番目の実装

こちらもサンプルコードから抜粋して紹介します。詳細はbin/cross-stack-test.tsのテストケース2とlib/test2配下をご確認ください。

lib/test2/api-stack.ts

export class ApiStack extends cdk.Stack {
  public readonly stackProps: ApiStackProps;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const restApi = new apigateway.RestApi(this, "restApi", {
      restApiName: "api-test",
    });
    this.stackProps = { restApi: restApi } as ApiStackProps;
  }
}

export interface ApiStackProps extends cdk.StackProps {
  restApi: apigateway.RestApi;
}

lib/test2/lambda-stack.ts

export class LambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ApiStackProps) {
    super(scope, id, props);

    const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const stackCrossTest2 = new lambda.Function(this, `StackCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    props.restApi.root.addMethod("GET", new apigateway.LambdaIntegration(stackCrossTest));
    props.restApi.root.addMethod("POST", new apigateway.LambdaIntegration(stackCrossTest2));
  }
}

概要を図で公開すると以下のようになります。propsをLambdaスタックに渡しているのでコード上で記載はLambdaスタックからAPI Gatewayスタックを参照してるように見えますが、scopeが変更されていないので1番目の実装と同じ向きの参照になります。スタック間で渡すのがrestApiだけになるので、このパターンで作ることも多いのかと考えます。

3番目の実装

特定の関数だけの事情か確認するため別関数で同じようなprops参照渡しを試しました。詳細はbin/cross-stack-test.tsのテストケース3とlib/test3配下をご確認ください。テンプレートの構成図は以下のようになります。

1番目の実装とテンプレートの参照関係はほぼ変わりませんでした。試した限りでは基本的にCDK内部でscopeが変更されなければ同じような方向で参照されるようです。状況に応じてCDKが生成するCloudFormationのテンプレートをご参照ください。

問題点

本章ではよく起きがちな問題点をまとめます。

デプロイ後、リソース名/IDを変更できない

例えば1番目の実装でLambdaの名前を変更したい、もしくはIDの体系が変わったので変更したいとします。素直に書くと以下のようなコードになるかと思います。

lib/test1/lambda-stack.ts

    const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction-XXXX`, { // 変更点1
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-XXX`, // 変更点2
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });

もし上記のIDかリソース名、もしくは両方を変更しそのままデプロイすると以下のようなエラーになります。

XX:XX:XX | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | LambdaStack
Export LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX cannot be deleted as it is in use by ApiStack

API GatewayのスタックからLambdaスタックへFn::ImportValueの参照が残っているのでエラーになります。以下のように参照自体をCDKのコード上で削除したとしてもCloudFormation上にはテンプレートが残っているので同じエラーが出ます。

lib/test1/api-stack.ts

...
  constructor(scope: Construct, id: string, props: ApiStackProps) {
    super(scope, id, props);
    const restApi = new apigateway.RestApi(this, "restApi", {
      restApiName: "api-test",
    });
    // restApi.root.addMethod("GET", new apigateway.LambdaIntegration(props.lambdaA));
    restApi.root.addMethod("POST", new apigateway.LambdaIntegration(props.lambdaB));
  }
...

ならば、addDependency()を使いAPI Gatewayスタックから先にデプロイして参照を削除しようと試みたとします。

const lambdaStack = new LambdaStack(app, "LambdaStack");
const apiStack = new ApiStack(app, "ApiStack", { lambdaA: lambdaStack.lambdaA, lambdaB: lambdaStack.lambdaB });
lambdaStack.addDependency(apiStack);

すると以下のように循環参照エラーになります。参照元がApiGatewayスタックなので当然の挙動とは言えます。

Error: 'LambdaStack' depends on 'ApiStack' (dependency added using stack.addDependency()). 
Adding this dependency (ApiStack -> LambdaStack/StackCrossTestFunction2/Resource.Arn) would create a cyclic reference.

このように簡単な操作ではリソースの削除ができなくなります。

デプロイ後、リソースを消せない

もう一つはリソースが削除も簡単にはできなくなることです。例えば以下のようにLambdaを1つメソッドごと消そうとします。

lib/test1/lambda-stack.ts

    // const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction`, {
    //   code: lambda.Code.fromAsset(`src/handler`),
    //   functionName: `stack-cross-test`,
    //   handler: "index.handler",
    //   runtime: lambda.Runtime.NODEJS_16_X,
    // });
    const stackCrossTest2 = new lambda.Function(this, `StackCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    // this.lambdaA = stackCrossTest;

lib/test1/api-stack.ts

    const restApi = new apigateway.RestApi(this, "restApi", {
      restApiName: "api-test",
    });
    // restApi.root.addMethod("GET", new apigateway.LambdaIntegration(props.lambdaA));
    restApi.root.addMethod("POST", new apigateway.LambdaIntegration(props.lambdaB));

この場合もリソースの名前を変えるときと同じように、CloudFormation上に参照が残っておりLambdaスタックからデプロイが走るのでエラーになります。

XX:XX:XX | UPDATE_ROLLBACK_IN_P | AWS::CloudFormation::Stack | LambdaStack
Export LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX cannot be deleted as it is in use by ApiStack

リソースを簡単に削除するには複数スタックごと削除が必要になります。

問題点の整理

結局のところ問題点は以下2つだと考えています。

  • CDKだけを見ると参照が切れているはずがCloudFormation上に参照が残り、リソースの名前やID変更による置き換え/削除ができなくなる
  • CDKからCloudFormationの参照を修正する方法が分かりにくい

以下のCloudFormationの記事で言及されている、ImportValueによるクロススタック参照の厳密さとCDKという抽象層を挟むことによる難しさが合わさっているため理解しにくいのかと考えます。

対策(本番デプロイ前)

何人かの話を聞いた上での見解ですが、本番デプロイ前の場合は作り直した方が良いと考えています。まとめてスタックを削除すれば依存関係などを考慮せず簡単に削除することが可能です。別の方法としては参照形式を変えて作成するかスタックをある程度統一するかになります。

以下の記事のようにそもそもスタック分割数を減らすことを検討してください。スタック分割する場合は「3. パラメーターストア経由」のようにParameterStoreを使ってスタック間の値をやり取りした方が、実行順序を完全にコントロールでき自由度も高く構成しやすいのでおすすめです。

またスタック分割でなくとも単純に1つのスタックにリソースを定義する、もしくは以下のようにConstructでリソースを分割すればコードのファイル自体は別けて管理できて可読性は向上しつつスタックを1つに統一することもできます。

lib/test4/lambda-rest-api-stack.ts

import { ApiConstruct } from "./construct/api-construct";
import { LambdaConstruct } from "./construct/lambda-construct";

export class LambdaRestApiStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const lambdaConstruct = new LambdaConstruct(this, "LambdaConstruct");
    new ApiConstruct(this, "ApiConstruct", { lambdaA: lambdaConstruct.lambdaA });
  }
}

lib/test4/construct/lambda-construct.ts

export class LambdaConstruct extends Construct {
  public readonly lambdaA: lambda.Function;
  public readonly lambdaB: lambda.Function;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id);

    const ConstructCrossTest = new lambda.Function(this, `ConstructCrossTestFunction`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `Construct-cross-test`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const ConstructCrossTest2 = new lambda.Function(this, `ConstructCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `Construct-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    this.lambdaA = ConstructCrossTest;
    this.lambdaB = ConstructCrossTest2;
  }
}

lib/test4/construct/api-construct.ts

export class ApiConstruct extends Construct {
  public readonly ConstructProps: ApiConstructProps;

  constructor(scope: Construct, id: string, props: ApiConstructProps) {
    super(scope, id);
    new apigateway.LambdaRestApi(this, `Endpoint`, {
      handler: props.lambdaA,
    });
  }
}

export interface ApiConstructProps extends cdk.StackProps {
  lambdaA: lambda.Function;
}

上記の構成を図で表すと以下のようになります。この場合はCloudFormationのパラメータ数の上限が問題になるので今回サンプルはLambdaで作ったのですが、Lambdaよりパラメータが比較的に少なくなるEC2やECSなどの方が適していると考えます。

対策(本番デプロイ後)

もし本番デプロイまで進んでしまっていて上記のような参照方法の変更やダウンタイムを多く取るスタック削除は難しい場合の対処方法を紹介します。対処方法はLambdaなどのステートレスなサービスを置き換える場合を想定します。ステートフルなサービスの場合はリソースの置き換えにご注意ください。

以前の解決策

この問題の対処方法の1つが以下の記事で解説されています。個人的にはタイトルが好きなので他所で問題発生時はよく紹介しています。

上記記事の対処方法を図解すると以下のようになります。

問題の対処自体は上記の記事のとおりで昔は可能だったのですが、最新のCDKのバージョンで試すとOutputの出力形式が変わっていて細かい部分で機能しなくなっていました。なので、変更になった部分もサンプルで示しつつ紹介します。サンプルには1番目の実装のソースコードを使用します。 (完全に余談ですが、実際騙しているのはCDKでなくCloudFormationの方です。でもこのタイトルのおかげで以前解決に辿り着けたので悩ましい)

解決策

「最初のStackを騙せ!cdk deployを騙せ!」の記事のように、要はOutputsが変更されないようにCloudFormationのテンプレートを偽装すれば変更可能になります。再掲ですが具体的にはLambdaStack.template.jsonの以下の部分です。

cdk.out/LambdaStack.template.json

...
"Outputs": {
  "ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX": {
   "Value": {
    "Fn::GetAtt": [
     "StackCrossTestFunctionXXXXXXXX",
     "Arn"
    ]
   },
   "Export": {
    "Name": "LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX"
   }
  },
...

今回はLambdaを新しいリソースに交換してみます。もしダウンタイムを発生させたくない場合は、以下のように代替となるリソースを一緒に作ってしまえば可能です。1番目の実装を例にリソースを入れ替える方法を紹介します。

lib/test1/lambda-stack.ts

    const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const stackCrossTest2 = new lambda.Function(this, `StackCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    // ---以下が変更点---
    const stackCrossTest3 = new lambda.Function(this, `StackCrossTestFunction3`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-3`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    this.lambdaA = stackCrossTest3; // 参照をstackCrossTest3に切り替える
    this.lambdaB = stackCrossTest2;
    // 擬似的なOutputsの作成;
    // 1個目のメソッドのOutputを偽装する
    new cdk.CfnOutput(
      this,
      "ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX",
      {
        exportName: "LambdaStack:ExportsOutputFnGetAttStackCrossTestFunctionXXXXXXXXXXXXXXXXXX",
        value: stackCrossTest.functionArn, // ここが以前の記事と変わっている
      }
    );

これにより生成されるテンプレートでは、参照が以下のようにstackCrossTest3の方を向くようになります。

cdk.out/ApiStack.template.json

...
"restApiGETApiPermissionApiStackrestApiXXXXXXXXXXXXXXXXXXX": {
   "Type": "AWS::Lambda::Permission",
   "Properties": {
    "Action": "lambda:InvokeFunction",
    "FunctionName": {
     "Fn::ImportValue": "LambdaStack:ExportsOutputFnGetAttStackCrossTestFunction3XXXXXXXXXXXXXXXXXX"
    },
...

CloudFormationのテンプレートのデプロイされる流れは図で書くと以下のような状態になります。

API Gatewayスタックがデプロイされるまでは、既存のstackCrossTestの方に処理が向いており、API Gatewayスタックがデプロイされた後はstackCrossTest3を向くように変更されるのでダウンタイムなしで安全にデプロイできます。いらなくなったstackCrossTestCfnOutputは、既に参照が切れているので、次回のリリースの際は削除しても問題なくデプロイできます。

注意点

CDKが生成するOutputがCDK V1のどこかでのバージョンで変更になったので、「最初のStackを騙せ!cdk deployを騙せ!」のCfnOutputのvalueでは処理が通らなくなりました。単にOutputsがあるだけでなく既存のOutputsと完全に一致させる必要があるので今回はfunctionArnを使ってFn::GetAttなどを再現しています。もしCDKのアップデートで今後Outputsの形式がまた変わった際は、出力形式にあった方法で偽装すれば参照によるリソース削除防止を回避することができます。

もしCDKのコードでOutputを再現できない場合は、CDKが出力したCloudFormationテンプレートを手動修正してデプロイでも、参照の切り離しは可能なのでご検討ください。

CDK公式の解決方法

記事公開後再度調べたところ、公式からCfnのOutputsを効率的に偽装する方法が関数として提供されていたので追記します。内容の詳細は以下の記事やissue、PRも参考にされるとより理解が深まるかと思います。

単純に書くとCfnOutputで擬似的に再現していた記載が以下のようにexportValueで単純に生成できるようになっていました(CDK v1.90.1以降) exportValueに対して、生成したConstructのArnを渡すことで実行できます。

...
this.exportValue(stackCrossTest.functionArn);
...

例えばですが、1番目の実装で一度デプロイ後にexportValueを使うと参照エラーを回避できました。

lib/test1/lambda-stack.ts

    const stackCrossTest = new lambda.Function(this, `StackCrossTestFunction`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const stackCrossTest2 = new lambda.Function(this, `StackCrossTestFunction2`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-2`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    const stackCrossTest3 = new lambda.Function(this, `StackCrossTestFunction3`, {
      code: lambda.Code.fromAsset(`src/handler`),
      functionName: `stack-cross-test-3`,
      handler: "index.handler",
      runtime: lambda.Runtime.NODEJS_16_X,
    });
    this.lambdaA = stackCrossTest3; // API Gatewayからの参照先変更
    this.lambdaB = stackCrossTest2;
    // 擬似的なOutputsの作成(CDK v1.90.1以降):
    this.exportValue(stackCrossTest.functionArn); // 追加部分

参照した記事などを見るとLambdaやS3は少なくとも対応しているようでした。他のリソースもこちらを使うことでCfnOutputを書かなくても済むので作業的にはかなり楽になりそうです。ただCfnOutputの再現部分が簡単になるだけなので、一度参照を切り離すデプロイと切り離した後の掃除用デプロイが必要なのが変わらないことはご注意ください。

所感

1週間で似たような話を3回ほどする機会があり、いまいち問題点や回避方法が浸透してないのかと思い記事にしました。もし使ってしまった場合の回避方法についても記載しているので参考になれば幸いです。