AWS CDKで1つのスタックにLambdaを21個以上作る場合に起きる問題と対処方法の紹介

AWS CDKで1つのスタックに21個以上Lambdaを作った場合に起きる問題と対処方法について記載します。問題の詳細と対処方法(スタック分割)について記載しています。
2020.07.27

はじめに

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

今回はタイトルの通りAWS CDKで1つのスタックに21個以上Lambdaを作った場合に起きる問題と対処方法について記載します。現在はissueで対応中のようです。コメント内で紹介されているcdk.jsonにパラメータを追加する方法だとまだうまく行かなかったので、スタックの分割で対応する方法を紹介します。

2020/10/23 追記 CloudFormationのサービスクォータの制限引き上げがサポートされたため申請すればある程度上限を回避できるようになりました。詳しくは以下の記事をご参照ください。 AWS CloudFormation now supports increased limits on five service quotas

本記事は、表題の問題が起きている方やこれから起きそうな方の参考になるように構成しています。今問題が起きていない方も今後参考になるかと思います。

環境情報

名称 バージョン
cdk 1.46.0

実際に起きた問題と原因

問題自体はCDKのコードからCloudFormationテンプレートが作成されて、CloudFormationのデプロイ中に発生します。実際のエラー内容は以下の通りです。

[スタック名] failed: Error [ValidationError]: Template format error: Parameter count 63 is greater than max allowed 60

CloudFromationの制約でパラメータは61個以上設定できないためエラーとなります。

なぜパラメータが61個以上も生成されるのか確認するため、まずサンプルとしてLambdaだけを定義したスタックをデプロイして確認します。

以下のスタックを定義してデプロイしてみます。サンプルなのでCORSや認証設定は省いています。

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as apigateway from '@aws-cdk/aws-apigateway';

export class StackDevideTestStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const region = cdk.Stack.of(this).region;

    const stackDevideFunction = new lambda.Function(
      this,
      `StackDivideTestFunction`,
      {
        code: lambda.Code.fromAsset(`handlers`),
        environment: {
          REGION: region,
          TZ: 'Asia/Tokyo',
        },
        functionName: `stack-divide-test`,
        handler: 'task.handler',
        runtime: lambda.Runtime.NODEJS_12_X,
        tracing: lambda.Tracing.ACTIVE,
        timeout: cdk.Duration.seconds(10),
      },
    );

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `devide-api`,
      deployOptions: {
        stageName: 'v1',
      },
    });

    const userResource = restApi.root.addResource('text');

    userResource.addMethod(
      'GET',
      new apigateway.LambdaIntegration(stackDevideFunction),
    );
  }
}

デプロイしたスタックをWebコンソールのCloudFormationのパラメータ画面から以下のように確認できます。

上記のように1Lambdaで3つのパラメータが作成されていることが確認できます。このパラメータはCDKが生成するCloudFormationのテンプレートでも確認できます。

以下はCludFotmationテンプレートの抜粋です。

  "Parameters": {
    "AssetParameters5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8edS3Bucket4E4E0455": {
      "Type": "String",
      "Description": "S3 bucket for asset \"5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8ed\""
    },
    "AssetParameters5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8edS3VersionKeyCF111D35": {
      "Type": "String",
      "Description": "S3 key for asset version \"5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8ed\""
    },
    "AssetParameters5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8edArtifactHashF18406D2": {
      "Type": "String",
      "Description": "Artifact hash for asset \"5eccc816461729302bc683af45757ef4d2b03de32cae4d3ecb3e5d8ae7ccf8ed\""
    }

それぞれ、S3バケット(bootstrap)用、S3バージョンキー(デプロイするLambdaのソース)用、artifact hash(パラメータ名などに付与するハッシュ値)用に3種類のパラメータが設定れていることがわかります。これらがLambdaごとに生成されることで、Lambdaの作成数が21を超えるとちょうどパラメータ63個となってCloudFormationの上限に引っかかります。

実際に試す場合は先ほどのコードなどを使用して、Lambdaを21個以上複製すると確認できるので試してみてください。

対処方法

対処方法としては以下の3点があると思います。

  1. Lambdaの実装スタックを複数に分離して、API GatewayのスタックでArnを使用してLambdaを呼び出す
  2. Lambdaの実装スタックを複数に分離して、クロススタック参照で呼び出す
  3. issue内の解決方法(スタック生成方法変更)を行う

3番を最初に試したのですが、報告にあるようにまだ現状では良い解決策とはなっていないです。(実際に試して他の開発メンバーがデプロイできなくなり冷や冷やしました…試す場合はbootstarpが変更になるので個人アカウントなどで試した方が良さそうです)

2番に関しては、クロススタック参照にするとスタック間が密結合になり削除や変更が一部難しくなるので今回は採用しませんでした。

最終的には1番のArnで別スタックのLambdaを参照する方法を選択しました。

実践

方針としては、API GatewayとLambdaのスタックを分割してAPI GatewayのスタックからLambdaのArnを使って参照します。最終的にはPermissionなどを作成してLambdaが実行可能になることを確認します。

まずは以下のように単体でLambdaを定義したスタックとLambdaのソースを作成します。

lib/cdk-api-lambda-stack.ts

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';

export class ApiLambdaStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const region = cdk.Stack.of(this).region;

    new lambda.Function(
      this,
      `StackDivideTestFunction`,
      {
        code: lambda.Code.fromAsset(`handlers`),
        environment: {
          REGION: region,
          TZ: 'Asia/Tokyo',
        },
        functionName: `stack-divide-test`,
        handler: 'task.handler',
        runtime: lambda.Runtime.NODEJS_12_X,
        tracing: lambda.Tracing.ACTIVE,
        timeout: cdk.Duration.seconds(10),
      },
    );
  }
}

handlers/task.ts

export const handler = async (
    event: any,
): Promise<any> => {
    return { statusCode: 200, body: JSON.stringify({id:'123456'}) };
}

次にLambdaを呼び出すAPIスタックを準備します。1つのスタックで同時に呼び出す場合と違って、インポートしたLambdaをAPIのConstructに渡すことになります。この場合はLambda呼び出しのPermisiionが自動追加されないため手動で追加しています。

(IFunction内にaddPermissionがありそちらも試したのですが、既存のLambdaをインポートした場合はCloudFormationにPermissionが出力されませんでした。既存のLambdaをインポートした場合は、別アカウントのLambdaを参照して権限を付与しないためにaddPermissionが権限を生成せず終了するようになっています。今回はアカウントの管理下にあるLambdaなのでCfnPermissionを使用して権限を追加しています。)

2020/10/27 追記 CDKのversion 1.64.0で同一のAWSアカウント内のLambdaをimportした場合Permissionが自動生成されるようになったので下記のCfnPermissionの記述は不要になりました。詳しくはCDKのリリースノートをご参照ください。 AWS CDK v1.64.0 Release Note

lib/cdk-api-stack.ts

import * as cdk from '@aws-cdk/core';
import * as lambda from '@aws-cdk/aws-lambda';
import * as apigateway from '@aws-cdk/aws-apigateway';

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

    const accountId = cdk.Stack.of(this).account;
    const region = cdk.Stack.of(this).region;

    // 別スタックのLambdaをArnで呼び出し
    const stackDevideFunction = lambda.Function.fromFunctionArn(
      this,
      'StackDevideFunction',
      `arn:aws:lambda:${region}:${accountId}:function:stack-divide-test`,
    );

    const restApi = new apigateway.RestApi(this, 'RestApi', {
      restApiName: `devide-api`,
      deployOptions: {
        stageName: 'v1',
      },
    });

    const userResource = restApi.root.addResource('text');

    userResource.addMethod(
      'GET',
      new apigateway.LambdaIntegration(stackDevideFunction),
    );

    // Permisiionの追加(既存のLambdaをインポートした場合、権限が自動生成されないため追加)
    new lambda.CfnPermission(this, `${stackDevideFunction}Permission`, {
      principal: 'apigateway.amazonaws.com',
      action: 'lambda:InvokeFunction',
      functionName: stackDevideFunction.functionName,
      sourceArn: restApi.arnForExecuteApi(
        'GET',
        '/text',
        restApi.deploymentStage.stageName,
      ),
    });

  }
}

次にこちらのスタックをbin配下のAppに記述します。

bin/cdk-lambda-stack.ts

#!/usr/bin/env node
import * as cdk from '@aws-cdk/core';
import { ApiLambdaStack }from '../lib/cdk-api-lambda-stack'
import { ApiStack }from '../lib/cdk-api-stack'

const app = new cdk.App();
new ApiLambdaStack(app, 'ApiLambdaStack');
new ApiStack(app, 'ApiStack');

設定が終わったのでデプロイしてみます。デプロイに関してはAPI GatewayとLambdaを分離したことによってLambda、API Gatewayの順番でデプロイしないと失敗するため注意してください。

$ npm tun build
$ cdk deploy ApiStack 
$ cdk deploy ApiLambdaStack

最後ちゃんとAPIが動作するかリクエストして確認してみると正常に動作することが確認できます。

$ curl  https://iv6xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/v1/text
{"id":"123456"}%

後念のためAPI Gateway用のスタックをCloudFormationのWebコンソールで確認してみるとパラメータがなくなっていることが確認できます。

感想など

Permissionが生成されていないことに気づいてどこに設定すれば良いのか確認するのに時間がかかってしまったので、他にもスタック分割が必要になりそうな方は参考にしてもらいたいと思っています。ただCfnPermissionを使用して意図していないLambdaに権限を追加しないよう注意は必要です。

issue自体は1年ぐらい前からあってまだ未解決なので、一旦この方法で回避するのが良いかもしれないです。また1つのスタックにLambdaが数十個ある場合はデプロイ粒度も大きくなるので、ついでに分割してみるのも良いかと思います。

今回の方法だとLambdaの削除が一時的に必要になるためそこは注意してください。本番採用しなかったのですが、CDKでLambdaを削除せずに別スタックに移行する方法についても今後別記事で紹介します。