AWS CDKでAPI Gateway+Lambdaを作成する際のベストなスタック構成について

AWS CDKでAPI Gateway、Lambdaのスタック構成について本気出して考えてみた
2020.12.11

はじめに

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

この記事は、Serverless Advent Calendar 2020の11日目の記事です。

今回はAPI GatewayとLambdaをAWS CDKで作成する際のスタック構成についてまとめました。

表題のサービスを使ったスタック構成は、最初はシンプルでも問題ないのですがサービスが大きくなった段階で必ず問題が出ます。

運用が始まっていると構成変更時にダウンタイムが発生してしまう可能性が高いので、もしこれから上記のAWSサービスをCDKで構築される方は見ていただくと参考になるかと思います。

約1年弱ほどCDKで上記の構成のアプリケーションを作ってきた経験から、様々な構成の中でそれぞれにどんな利点/欠点があるのかを実体験から紹介します。

前提

今回はAPI GatewayとLambdaの構成に絞った内容になります。DynamoDBやKinesis Data Streams など別のAWSサービスにを使った場合の構成についてはまた別途記事にしたいと思います。

スタックの構成について、現状は以下の構成が有力な候補になるかと思います。

  • 1スタックで完結
  • 手動スタック分割(Arn経由で連携)
  • ネストスタック
  • CDKの自動クロススタック参照

下3項目は、1つのAPI Gatewayスタックに対して、単数~複数のLambdaスタックを紐付ける構成です。

個人的には手動スタック分割が一番悩まされることが少なく便利だと感じています。

ただ色々運用して試しましたが、どんな状況でもこれがベストプラクティスというものはないです。 上記のどれもそれぞれに利点/欠点があるので考慮の上で設計を選択してください。

1スタックで完結

API GatewayとLambdaを1つのスタックで構成するパターンです。一般的なサンプルなどに記載されているパターンで、例えば形で考えるサーバーレス設計のサンプルコードで以下のように記載されています。

cdk-stack.ts

export class CdkStack extends Stack {
    constructor(scope: App, id: string, props: StackProps) {
        super(scope, id, props);

        new CfnParameter(this, 'AppId');
        
        (中略)
        // This is a Lambda function config associated with the source code: put-item.js
        const putItemFunction = new lambda.Function(this, 'putItem', {
            description: 'A simple example includes a HTTP post method to add one item to a DynamoDB table.',
            handler: 'src/handlers/put-item.putItemHandler',
            runtime: lambda.Runtime.NODEJS_10_X,
            code,
            timeout: Duration.seconds(60),
            environment,
        });
        // Give Create/Read/Update/Delete permissions to the SampleTable
        table.grantReadWriteData(putItemFunction);

        const api = new apigateway.RestApi(this, 'ServerlessRestApi', { cloudWatchRole: false });
        api.root.addMethod('GET', new apigateway.LambdaIntegration(getAllItemsFunction));
        api.root.addMethod('POST', new apigateway.LambdaIntegration(putItemFunction));
        api.root.addResource('{id}').addMethod('GET', new apigateway.LambdaIntegration(getByIdFunction));
    }
}

参考元ファイル

利点

1つのスタックだけで完結するので初めて取り組む方には分かりやすく、試しやすいです。また1つのファイルを読むだけでLambdaとAPI Gatewayの繋がりが分かるので、引き継ぎやコードを読み直す際も分かりやすく構成できます。

欠点

CDKはCloudFormationを経由してデプロイしています。なのでデプロイ時はCloudFormationの制約を受けます。以前記事で書いたのですがデフォルト状態のサービスクォータの制限だと、Lambdaを21個以上紐付けた段階でパラメータの上限に当たりデプロイできなくなります。

最近CloudFormationのサービスクォータの制限開放ができるようになったので、パラメータとリソースの上限を開放すればLambdaを60個ぐらいまでは紐付けられるようになります。

ただ、Lambdaを細かく監視するために対してCloudWatchLogsのサブスクリプションフィルターなどを紐づけていくとします。するとさらにリソース数を消費するため、現実ではもう少し早く上限に到達しそうです。

また別の欠点としてはデプロイ単位がかなり大きくなるので、どんどんデプロイ速度が遅くなりダウンタイムが気になってくると思います。なので、ある一定の規模(Lambda20個ぐらい?)まで拡大する予定の場合は以降で説明するスタック構成の方が良いかと思います。

リソース限界が来るたびにAPI Gateway+Lambdaのスタックを新しく作っていく方法もあるかと思います。しかし、デプロイ粒度とスタックの機能単位がどんどん解離してくるので処理の可読性が悪くなるというデメリットもあります。

後1点ですが、最初にこの構成を選択して後から分割しようとすると一度スタックの削除などが必要になります。構成にもよりますがダウンタイムが発生する可能性が高いので出来れば最初から以降で説明する構成にした方が良さそうです。

手動スタック分割(Arn経由で連携)

API GatewayとLambdaのスタックを分けて、API Gateway側のスタックでLambdaをArn経由で呼び出すパターンです。以前の記事で上げていた例を引用します。

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),
      },
    );
  }
}

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),
    );
  }
}

利点

API GatewayとLambdaのスタックが完全に分離するので、CloudFormationのパラメータやリソースなどの上限制約から開放されます。 またCloudFormationやAWS CDK側の自動参照に頭を悩ませる必要がなくなります。例えば後述する自動クロススタック参照の場合は、Lambdaをスタック間で移動する場合にトリッキーな操作が必要になります。自分も少し悩まされました。

またデプロイ粒度をコントロールできるので、特定のスタックだけ変更したい場合に最小限のスタックのデプロイだけで済みます。

Arnの記述が冗長に感じたらこちらの記事のように命名をまとめることもできます。

後、以前はAPI GatewayからLambdaを実行するPermissionが必要で記述が多くなってました。しかし、CDKのversion 1.64.0 で同じアカウント内のLambdaをimportした場合はPermissionが生成されるようになったので記述が少なくなりました。 詳細

色々試しましたが、個人的には現状で一番ベストな選択肢かと思っています。

欠点

API GatewayのスタックでLambdaをインポートしている関係上、デプロイの順序がLambda->API Gatewayの順序で固定されます。

なのでLambdaを追加した際に、誤ってAPI Gatewayのスタックからデプロイするとエラーになります。

ステージングや本番環境では、CircleCIやGitHub ActionsなどのCI/CDツールで順序をコントロールすれば問題ないですが、開発環境で手動デプロイがある場合は注意が必要です。

また誤ってLambdaのスタックを削除してしまった場合、再度Lambdaスタック作成->API Gatewayスタックデプロイとしても再作成されたLambdaとAPI Gatewayが紐付かなくなりAPIリクエスト時にエラーになります。その場合は、API Gatewayのスタックを再度削除してからLambda->API Gatewayの順でデプロイする必要があります。

上記のように開発時でスタック操作を誤るとうまくデプロイできなくなるので、構成に関する理解がある程度必要になります。

ネストスタック

API Gatewayをルートスタックとして、Lambdaをネストスタックにいれて構成するパターンです。こちらは、AWS CDKのドキュメントで紹介されています。

// ルートスタック
class RootStack extends Stack {
  constructor(scope: Construct) {
    super(scope, 'integ-restapi-import-RootStack');

    const restApi = new RestApi(this, 'RestApi', {
      deploy: false,
    });
    restApi.root.addMethod('ANY');

    const petsStack = new PetsStack(this, {
      restApiId: restApi.restApiId,
      rootResourceId: restApi.restApiRootResourceId,
    });
    (中略)
  }
}

// ネストスタック
class PetsStack extends NestedStack {
  public readonly methods: Method[] = [];

  constructor(scope: Construct, props: ResourceNestedStackProps) {
    super(scope, 'integ-restapi-import-PetsStack', props);

    const api = RestApi.fromRestApiAttributes(this, 'RestApi', {
      restApiId: props.restApiId,
      rootResourceId: props.rootResourceId,
    });

    const method = api.root.addResource('pets').addMethod('GET', new MockIntegration({
      integrationResponses: [{
        statusCode: '200',
      }],
      passthroughBehavior: PassthroughBehavior.NEVER,
      requestTemplates: {
        'application/json': '{ "statusCode": 200 }',
      },
    }), {
      methodResponses: [{ statusCode: '200' }],
    });

    this.methods.push(method);
  }
}

利点

1スタックで構成する場合と比べて、スタックが分割されるのでCloudFormationの制約からある程度開放されます。Lambda単体のスタックは制約でリソース上限に入る可能性はあります。こちらは処理に応じてネストスタックをいくつかに分割していくと回避できます。

また最近ネストスタックの変更内容が見れるようになったので、分割の選択肢に入ってきそうです。

欠点

ネストしたスタックは毎回まとめてデプロイされます。分割したデプロイができないので構成が大きくなるとどんどん、デプロイ速度が遅くなる懸念はあります。この部分が許容できるならネストスタックの詳細な変更も見れるようになったので選択肢に入ると思います。

実はあまりこの設計を使い込んでいないので深く書けないですが、使う機会があれば再度加筆したいと思います。

CDKの自動クロススタック参照

最後はCDK自体の機能にスタック間の参照を任せるパターンです。サンプルとしては、API Gateway+Lambdaの良い例が見つからなかったんで、S3を呼んでいる公式ドキュメントか最低限のサンプルを以下に書いたので確認してください。

bin/cross_stack_test.ts

import * as cdk from '@aws-cdk/core';
import { ApiStack} from '../lib/api-stack';
import { LambdaStack} from '../lib/lambda-stack';

const app = new cdk.App();
const apiStack = new ApiStack(app, 'ApiStack');
// StackPropsで別スタックのAPI GatewayのConstructsを呼ぶ
new LambdaStack(app, 'LambdaStack',{restApi: apiStack.stackProps.restApi});

lib/api-stack.ts

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

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

  constructor(scope: cdk.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/lambda-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'
import { ApiStackProps } from './api-stack'

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.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_12_X,
      },
    );
    // bin配下のファイルを経由して受け取ったAPI GatewayのConstructsにLambdaを紐づける
    props.restApi.root.addMethod("GET", new apigateway.LambdaIntegration(stackCrossTest));
  }
}

利点

CDK内部の機能を使って自動でスタックが分かれてPermissionなどを作成するため非常に便利です。Arnで呼ぶ場合と比べるとLambdaの名前を複数箇所に書かなくて良くなるのでより簡潔に書くことができます。

欠点

自動でOutputsが貼られているため、APIのパスやLambdaの構成を変更する場合に問題がでます。

例えば、あるAPI Gateway用スタックにLambdaAスタック,LambdaBスタックが紐付いているとします。

LambdaAスタックからLambdaBスタックにLambdaを移動すると、LambdaAスタック側にAPI Gatewayスタックと紐づくOutputsが残ってデプロイできません。

デプロイ順序も固定されるためそのままでは構成変更ができなくなります。

CDKのBlackBeltのQ&Aにも書かれていますが、クロススタック参照に関する制約は CloudFormation と同じです。変更するには依存する方のスタックから先に削除するか以下の記事のようにOutputsを偽装するようなトリッキーな操作が必要になります。

また削除する場合もAPI Gatewayスタック->Lambdaスタックの順番でしか削除できません。

最初に展開する分には便利なのですが、後から構成をリファクタリングしたい場合につまづく部分が多々ありました。

所感

3ヶ月ほど前に書こうと思ってから長くなりそうで後回しにしていた内容がようやく書けたのでよかったです。

API Gateway+Lambdaの構成でどんどん増築してた場合は、必ず上記の問題にぶつかるのでPJが段々大きくなってどうしようか悩んでいる人に届けば幸いです。