AWS CDKのAPIGateway/Lambdaスタック分割の落とし穴と解消法

この記事では、cdkでスタック分割を利用してAPIGateway, Lambda の構成を作る際に起こる問題と解消法について紹介しています。 スタック分割構成を利用している方、検討している方の参考になる情報となっています。
2024.04.06

はじめに

概要

この記事では、cdkでスタック分割を利用してAPIGateway, Lambda の構成を作る際に起こる問題と解消法について紹介しています。

理想的な形はスタックを分割せずに同一スタックで管理することですが、CloudFormationのリソース数上限(500個)やファイル上限(51,200バイト)などの制限により、スタック分割の構成を選択することがあります。

スタック分割の構成ではcdk deploy時に以下のような問題が起こります。

  • スタック分割を行うと新たにAPIのパスを追加した際に、自動デプロイされない問題
  • ステージ設定を変更した際に昔のデプロイ状態に戻される

実際にスタック分割を細かく行なっている環境で大変悩まされたので、同じようなことで困っている方向けに共有となります。

対象読者

  • AWS CDK を利用している方
  • APIGatewayとLambda(Resource)でスタック分割を行なっている、検討している方
  • APIGatewayとLambda(Resource)でスタック分割で手動デプロイが必要になっている方
  • スタック分割している構成から一つのスタックにまとめたいが、何かしら制約があり出来ない方
  • サーバーレス構成をよく使う方

この記事に書くこと

  • APIGatewayの基本構成を紹介
  • スタック分割で起こる問題点とその解消法
  • 問題が起こるコード、問題が起こらないコードの共有
  • おまけ: 問題が起こる原因を探るためCDKの内部コードを追う
  • おまけ: CDK本体にPRを作成してみた

※今回対象としているのは、APIGatewayのRestAPIになります。HTTP APIやWebSocketAPIでは未確認です。

APIGatewayの自動デプロイとは何か?

APIGatewayの構成(Stage, Resource, Deployment)について整理

今回の事象を理解するためには、APIGatewayへの理解が必要なので、ここで整理していきます。

Resource

解説:

ResourceはAPI Gateway内で定義されるエンドポイントです。これは、APIが提供する具体的な機能やデータにアクセスするためのパスを表します。

例えば、/users/orders などがResourceに該当します。ResourceはHTTPメソッド(GET、POST、PUT、DELETEなど)と組み合わせて使用され、特定の操作を定義します。

関係性:

ResourceはAPIの構造を形成し、Deploymentを通じて外部に公開されます。 StageはこれらのDeploymentを管理し、異なる環境(開発、ステージング、本番など)でのAPIの振る舞いを定義します。

Deployment

解説:

DeploymentはAPIの特定のスナップショットであり、APIの設定とResourceのコレクションを含みます。Deploymentを作成することで、API Gatewayはその時点でのAPIの状態をキャプチャします。

関係性:

DeploymentはResourceの変更を含むAPIのバージョンを表し、これをStageに関連付けることでAPIを利用可能にします。

Stage

解説:

StageはDeploymentを特定の環境に関連付けるための概念です。Stageは、APIのライフサイクルの中で異なるフェーズ(例えば、開発、テスト、本番)を表し、それぞれの環境でのAPIの設定(ステージ変数、レート制限、アクセスログ設定など)を管理します。Stageを使用することで、同じAPI定義を異なる設定で複数の環境にデプロイできます。

関係性:

StageはDeploymentを環境にマッピングする役割を持ち、APIの公開と管理を容易にします。Stageを通じて、異なるDeploymentのバージョンを異なる環境でテストし、本番環境に適用することができます。

自動デプロイで行われることを整理する

以上を踏まえて、CDKでAPIGatewayの自動デプロイが行われる際に何が起こっているのか整理します。すでに存在するAPIGatewayに新たなリソース(パス)を追加した時を考えてみましょう。

  1. Resource(/users)を追加する
  2. Method(GET)にLambda関数を紐づける
  3. Deploymentを作成する
  4. StageにDeploymentを紐づける

実際にイメージを掴むために、マネジメントコンソールで操作する流れを画像で紹介します。

1. Resourceを追加する

2. Method(GET)にLambda関数を紐づける

3. Deploymentを作成する

4. StageにDeploymentを紐づける

自動デプロイが行われるとき、このような流れになっています。

自動デプロイが行われる構成 (スタックが1つ)

まずは図をご覧ください。

1つのスタックにAPIGatewayのリソースがまとまっている時、自動デプロイが行われます。 具体的には、RestApiリソースを定義しているスタックで、 RestApi.addMethod() を利用してLambda関数との紐付けを行なっている場合、自動デプロイとなります。

これはスタック分割をせずにCDKを利用していると、特に意識することなく実現できます。

サンプルコードは以下のようになります。

/lib/api-stack.ts

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

    // ~~ Lambdaの定義などは割愛 ~~

    const api = new apiGateway.RestApi(this, "base-api", {
      deploy: true,
      cloudWatchRole: true,
    });

    api.root.addMethod("any");
    api.root.addResource("hoge").addMethod("GET", hogeIntegration); // ⭐ addResource, addMethod の利用
  }
}

自動デプロイが行われる構成 (スタック分割、APIGatewayStackでLambdaを参照する)

スタック分割を行い、APIGatewayStackで addResource addMethod など定義する方法です。 依存の関係が APIGatewayStack <- LambdaStack になります。 詳細はこちらのブログを参照ください。

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

自動デプロイが出来ない構成 (スタック分割、LambdaStackでResource, Stage, Deploymentを定義する)

こちらも、図をご覧ください。

APIGatewayを定義するスタックとResourceを定義するスタックが分割されている例です。 冒頭でも説明した通り、スタックのリソース数上限やファイルサイズ上限を避けるためにスタック分割が行われるケースがあります。

サンプルコードは以下のようになります。

/lib/api-stack.ts

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

    const api = new RestApi(this, "base-api", {
      deploy: true, // ⭐ trueに設定することで自動デプロイが行われる
      cloudWatchRole: true,
      deployOptions: {
        accessLogDestination: new LogGroupLogDestination(logGroup),
        accessLogFormat: AccessLogFormat.clf(),
      },
    });

    api.root.addMethod("any");
  }
}

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

    // ~~ Lambdaの定義などは割愛 ~~

    // ⭐ APIGatewayをfromXXXXAttributeで取得する
    const apiGateway = RestApi.fromRestApiAttributes(this, "api", {
      restApiId: props.apiId,
      rootResourceId: props.rootId,
    });
    apiGateway.root.addResource("hoge").addMethod("GET", hogeIntegration);
  }
}

現在(2024/04/05)、公式ドキュメントで紹介されているスタック分割のサンプルコードも同様の問題を抱えています。

スタック分割と自動デプロイの両立方法

注意点

今回の構成は、いくつかの制約があり選択した構成になります。 RestApiの定義とResource,Stageなどを分割すると様々な検討事項が増え構成が複雑になるので、最初は以下の構成どちらかの検討をオススメします。

自動デプロイを可能にする構成と自動デプロイできない構成を比較

自動デプロイできない構成(スタック分割)

自動デプロイ可能な構成(スタック分割)

自動デプロイ可能な構成では、StageとDeploymentのリソースをLambdaStackの作成後に持ってきています。 そうすることで、Resourceの変更が反映された後にDeploymentの作成を行うことができます。

実際のCDKコードを紹介していきます。

自動デプロイを可能にする構成のコードを紹介

コードの全体像は以下になります。 ※説明上割愛できる箇所は割愛しているので、コードの全容はGitHubリポジトリを参照してください。

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

    const api = new RestApi(this, "base-api", {
      deploy: false, // 1️⃣
      cloudWatchRole: true,
    });

    api.root.addMethod("any");
  }
}

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

    // api-stackで定義したAPIGateway
    const apiGateway = RestApi.fromRestApiAttributes(this, "api", {
      restApiId: props.apiId,
      rootResourceId: props.rootId,
    });

    // ~~ Lambda, LambdaIntegrationの定義は割愛

    // APIGatewayにメソッド追加
    apiGateway.root.addResource("hoge").addMethod("GET", hogeIntegration);
  }
}

// ApiDeploymentStack
export class ApiDeploymentStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: ApiDeploymentStackProps) {
    super(scope, id, props);

    // api-stackで定義したAPIGateway
    const apiGateway = RestApi.fromRestApiAttributes(this, "api", {
      restApiId: props.apiId,
      rootResourceId: props.rootId,
    });

    // 2️⃣ Deploymentを定義
    const deployment = new Deployment(this, "base-api-deployment", {
      api: apiGateway,
      retainDeployments: true,
    });
    deployment.addToLogicalId(new Date().toISOString()); // 3️⃣ Deploymentを更新

    // Stageを定義
    const stage = new Stage(this, "base-api-stage", {
      deployment,
      stageName: "prod",
    });
  }
}

細かく紹介していきます。

1️⃣ deploy: false に設定することでStageとDeploymentを自動作成しなくなります。

2️⃣ Deploymentを定義します。

3️⃣ addToLogicalId を利用することで、毎回デプロイメントを新規作成します。後ほどaddToLogicalIdについては後ほど少し説明します。

addToLogicalId では、ResourceやMethod関連の変更を検知するための値を入れておくことが本来の使い方ですが、差分を検知するには複雑な実装が必要になるため、毎回Deploymentを更新するように実装しています。

このような実装は、 aws-cdk のIssueで議論でワークアラウンドとして話されている実装になります。

ただ毎回デプロイが作成されてしまうので、回避策についても以下で少し紹介します。

デプロイメントを必要な時のみ作成する回避策: LambdaStackの差分を検出する

ファイルのハッシュ値を取得する関数を作成し、 addToLogicalId にハッシュ値を渡してあげる実装です。

/lib/utils/create-files-hash.ts

export const createFilesHash = async (paths: string[]): Promise<string> => {
  try {
    const fileContents = await Promise.all(
      paths.map((path) => fs.readFile(path, "utf8"))
    );
    return createHash("sha256").update(fileContents.join()).digest("hex");
  } catch (error) {
    console.error(`Error reading files: ${error}`);
    throw new Error(`Error reading files: ${error}`);
  }
};

DeploymentStack

/lib/api-deployment-stack.ts

    const deployment = new Deployment(this, "base-api-deployment", {
      api: apiGateway,
      retainDeployments: true,
    });
    createFilesHash(["lib/lambda-stack.ts"])
      .then((fileHash) => deployment.addToLogicalId(fileHash))
      .catch((error) => {
        throw new Error(`Error reading files: ${error}`);
      });

createFilesHash でResource, Methodの作成などAPIGatewayのデプロイが必要なリソースを管理するファイル(スタック)を対象にハッシュ値を生成します。

これにより、ファイルに変更があった場合のみAPIGatewayのデプロイを行うように実装できます。

デメリットは、対象とするファイルが増えたときに、createFilesHashの引数にもファイルを増やす必要があることです。

addLogicalIdについて

addLogicalIdを呼ぶことでCDK内部では最終的に以下のように論理IDを上書きします。

this.overrideLogicalId(Lazy.uncachedString({ produce: () => this.calculateLogicalId() }));

https://github.com/aws/aws-cdk/blob/2814011fdbafad87af9f7a1cad143a19eae30a05/packages/aws-cdk-lib/aws-apigateway/lib/deployment.ts#L144

論理IDはCloudFormationで言うと、以下の部分になります。

リソース - AWS CloudFormation

"Resources" : {
    "Logical ID" : {
        "Type" : "Resource type",
        "Properties" : {
        }
    }
}

Logical IDが更新されることで、別のリソースとみなされるためデプロイメントが新規作成されることになります。

カスタムドメインを設定する際の注意点

カスタムドメインを設定する場合にも、注意が必要です。

RestApiでカスタムドメインを定義すると以下のようにdomainNameの設定を入れる必要があります。

    const api = new RestApi(this, "base-api", {
      deploy: false,
      cloudWatchRole: true,
      domainName: {
        domainName: "api.example.com",
        certificate: Certificate.fromCertificateArn(this, "id", "arn"),
      },
    });

しかし、ここで設定をしてしまうと、Stageが存在しない状態なのでStageに紐づけることができません。そのため、設定したカスタムドメインでAPI実行できなくなってしまいます。

図にすると以下の構成になります。

Stageを紐付けたい場合、Stageの作成後にカスタムドメインを作成する必要があります。

一部ですが、カスタムドメインを紐づけるためのDeploymentStackの実装を紹介します。

/lib/api-deployment-stack.ts

    const stage = new Stage(this, "base-api-stage", {
      deployment,
      stageName: "prod",
      accessLogDestination: new LogGroupLogDestination(logGroup),
      accessLogFormat: AccessLogFormat.clf(),
    });

    const domainName = new CfnDomainName(this, "api-domain-name", {
      domainName: DOMAIN,
      regionalCertificateArn: props.certificateArn,
      endpointConfiguration: {
        types: ["REGIONAL"],
      },
    });

    const basePathMapping = new CfnBasePathMapping(this, "base-path-mapping", {
      domainName: DOMAIN,
      restApiId: props.apiId,
      stage: stage.stageName,
    });
    basePathMapping.addDependency(domainName);

CDKのコードを読み解く

なぜ同一スタックだと自動デプロイが可能なのか、別スタックだと不可能なのか。

CDKコードの中身を追ってみようと思います。

Resource.addMethod()の挙動を追っていきます。

addMethod()の中で Methodのコンストラクタを呼び出します。

https://github.com/aws/aws-cdk/blob/b82320b08ebcda98b85be8ceb56a5a4b39511d4a/packages/aws-cdk-lib/aws-apigateway/lib/resource.ts#L186

  public addMethod(httpMethod: string, integration?: Integration, options?: MethodOptions): Method {
    return new Method(this, httpMethod, { resource: this, httpMethod, integration, options });
  }

Methodのコンストラクタの中で、紐づける対象のAPIGatewayの latestDeploymentを取得し、latestDeployment が存在すれば addToLogicalId を利用して Deployment を更新します。

https://github.com/aws/aws-cdk/blob/b82320b08ebcda98b85be8ceb56a5a4b39511d4a/packages/aws-cdk-lib/aws-apigateway/lib/method.ts#L237

    const deployment = props.resource.api.latestDeployment;
    if (deployment) {
      deployment.node.addDependency(resource);
      deployment.addToLogicalId({
        method: {
          ...methodProps,
          integrationToken: bindResult?.deploymentToken,
        },
      });
    }

これにより、紐づいているAPIGatewayのデプロイメントが更新されることになります。

では、なぜ別スタックでaddMethod()をした場合に、デプロイメントが更新されないのでしょうか?

ポイントは、別スタックでAPIGatewayを参照する際にRestApi.fromRestApiAttributes() を使っているところです。

    const apiGateway = RestApi.fromRestApiAttributes(this, "api", {
      restApiId: props.apiId,
      rootResourceId: props.rootId,
    });

RestApi.fromRestApiAttributes()で返却される IRestApi オブジェクトは、APIGatewayを特定するのに必要最低限の情報しか持っていません。

https://github.com/aws/aws-cdk/blob/b82320b08ebcda98b85be8ceb56a5a4b39511d4a/packages/aws-cdk-lib/aws-apigateway/lib/restapi.ts#L784

  public static fromRestApiAttributes(scope: Construct, id: string, attrs: RestApiAttributes): IRestApi {
    class Import extends RestApiBase {
      public readonly restApiId = attrs.restApiId;
      public readonly restApiName = attrs.restApiName ?? id;
      public readonly restApiRootResourceId = attrs.rootResourceId;
      public readonly root: IResource = new RootResource(this, {}, this.restApiRootResourceId);
    }

    return new Import(scope, id);
  }

そのため、 addMethod() を実行しても、latestDeploymentが取得できず新たなDeploymentが作成されません。

所感

元々スタック分割されており依存が絡み合った構成を修正するために色々調査した結果をまとめました。

最終的に自動デプロイできるように解決できましたが、このような問題が起こらないようにするために RestApi を定義するスタックの中で addMethodaddResource を定義するように修正する方針で進めた方が最終的に良い構成になっただろうな、という感想です。

また、RestApi Construct がよしなに作ってくれるリソースを徐々に剥がしていく作業になってしまったので、最終的にはL2 Constructの良さを全く活かせない構成になってしまったと思います。

多少時間をかけてでも、スタック1つの構成にするか、ApiGateway側のスタックでLamdbaを参照する構成にした方が良かったかな、と感じました。

実装していく中で、CDK内部構成やConstructについての知識も付いたので勉強になり良かったです。 今回の実践を活かして、より良いCDK構成を考えていきたいと思います。