APIGateway + Lambdaにserverless-expressを採用することでコールドスタートヒットが減るかもしれない話

2022.11.02

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

APIGateway + Lambdaにserverless-expressを採用すると、複数のURLパス+HTTPメソッドの組み合わせを1つのLambda関数にまとめることができます。

これによりAPIコール時のLambdaコールドスタートのヒット率が減るのではないかと思い、簡単にではありますが検証してみました。

サンプルとして次のようなラーメンの具を取得するAPIを作って試していきます。

GET /ramen-gu/jiro # 二郎系ラーメンの具を返す
GET /ramen-gu/iekei # 家系ラーメンの具を返す

環境

  • macOS Monterey
  • node 16.15.0
  • typescript 4.7.4
  • aws-cdk-lib 2.46.0
  • constructs 10.1.43
  • source-map-support 0.5.21
  • moment 2.29.4
  • lodash 4.17.21
  • @vendia/serverless-express 4.10.1
  • express 4.18.2

serverless-expressを使わない場合

比較のためにserverless-expressを使う場合、使わない場合両方のAPIを作ります。

まず使わない場合で、次のCDKコードを用意します:

// lib/apigateway-stack.ts

import { Construct } from "constructs"
import * as cdk from "aws-cdk-lib"

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

    // APIGW Lambda関数
    const jiroFn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "jiroFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_16_X,
      entry: "src/lambda/jiro-handler.ts",
    })
    const iekeiFn = new cdk.aws_lambda_nodejs.NodejsFunction(this, "iekeiFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_16_X,
      entry: "src/lambda/iekei-handler.ts",
    })

    // APIGW
    const api = new cdk.aws_apigateway.RestApi(this, "api", {
      deployOptions: {
        tracingEnabled: true,
        stageName: "api",
      },
    })
    const ramenGuResource = api.root.addResource("ramen-gu")
    ramenGuResource
      .addResource("jiro")
      .addMethod("GET", new cdk.aws_apigateway.LambdaIntegration(jiroFn))
    ramenGuResource
      .addResource("iekei")
      .addMethod("GET", new cdk.aws_apigateway.LambdaIntegration(iekeiFn))
  }
}

Lambda関数のコード:

// src/lambda/jiro-handler.ts

import { APIGatewayProxyResult } from "aws-lambda"
import "source-map-support/register"
import "lodash"
import "moment"

export const handler = async (
  event: APIGatewayProxyResult
): Promise<APIGatewayProxyResult> => {
  return {
    statusCode: 200,
    body: JSON.stringify({ message: "もやしキャベツ" }),
  }
}
// src/lambda/iekei-handler.ts

import { APIGatewayProxyResult } from "aws-lambda"
import "source-map-support/register"
import "lodash"
import "moment"

export const handler = async (
  event: APIGatewayProxyResult
): Promise<APIGatewayProxyResult> => {
  return {
    statusCode: 200,
    body: JSON.stringify({ message: "ほうれん草" }),
  }
}

実戦的なバンドルサイズに近づけるため、著名かつサイズが大きいlodashとmomentをimportしています。

serverless-expressを使う場合

続いてserverless-expressを使う場合です。CDKコード:

import { Construct } from "constructs"
import * as cdk from "aws-cdk-lib"

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

    // APIGW Lambda関数
    const ramenGuFn = new cdk.aws_lambda_nodejs.NodejsFunction(
      this,
      "ramenGuFn",
      {
        runtime: cdk.aws_lambda.Runtime.NODEJS_16_X,
        entry: "src/lambda/ramen-gu-handler.ts",
      }
    )

    // APIGW
    const api = new cdk.aws_apigateway.RestApi(this, "api", {
      deployOptions: {
        tracingEnabled: true,
        stageName: "api",
      },
    })
    api.root.addProxy({
      defaultIntegration: new cdk.aws_apigateway.LambdaIntegration(ramenGuFn),
    })
  }
}

Lambda関数コード:

// src/lambda/ramen-gu-handler.ts

import "source-map-support/register"
import "lodash"
import "moment"
import serverlessExpress from "@vendia/serverless-express"
import express from "express"

const app = express()
app.use(express.json())

app.get("/ramen-gu/jiro", (_, res) => {
  res.status(200).send({
    message: "もやしキャベツ",
  })
})

app.get("/ramen-gu/iekei", (_, res) => {
  res.status(200).send({
    message: "ほうれん草",
  })
})

export const handler = serverlessExpress({ app })

確認手順

手元で以下のcurlコマンドを連続で実行し、要した時間を計測します。

/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/jiro
/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/jiro
/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/jiro
/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/iekei
/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/iekei
/usr/bin/time -l curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/ramen-gu/iekei

結果

レスポンス完了までに要した時間と、出力されたJSコードのバンドルサイズが以下です。

serverless-expressを使わなかったAPI

API リクエスト回数 時間
/api/ramen-gu/jiro 1回目 380ms
/api/ramen-gu/jiro 2回目 25.0ms
/api/ramen-gu/jiro 3回目 14.0ms
/api/ramen-gu/iekei 1回目 392ms
/api/ramen-gu/iekei 2回目 26.0ms
/api/ramen-gu/iekei 3回目 36.0ms

serverless-expressを使ったAPI

API リクエスト回数 時間
/api/ramen-gu/jiro 1回目 598ms
/api/ramen-gu/jiro 2回目 29.0ms
/api/ramen-gu/jiro 3回目 30.0ms
/api/ramen-gu/iekei 1回目 34.0ms
/api/ramen-gu/iekei 2回目 21.0ms
/api/ramen-gu/iekei 3回目 31.0ms

バンドルサイズ比較

ケース サイズ
serverless-expressを使わなかった場合 437.2kb
serverless-expressを使った場合 2.1mb

まとめ

serverless-expressを使わなかったAPIでは、初回リクエストとコールするURLパスが変わったタイミングで約380ms要し、コールドスタートを引いていると思われます。

一方serverless-expressを使ったAPIでは、初回リクエストのみコールドスタートを引いたと思われる598msですが、URLパスが変わった際は34msであり、ウォームスタートを維持できているように見えます。したがって、serverless-expressを使うことでコールドスタートを引く回数が減少する可能性はあると思われます。

ただ、コールドスタート発生時は、バンドルサイズの増大が響いたのか後者のほうが時間がかかる結果となりました。そのため、手放しで後者の方がパフォーマンスが良いかというと微妙かもしれません。実戦ではコード量も多く依存ライブラリの数も増えるわけですが、それによりこの差が縮まるかもしれませんし、逆に広がるかもしれません。

以上参考になれば幸いです。