😇

個人開発アプリのAPIをDockerのままECSからLambdaに載せ替えたときのハマりどころ

2023/10/25に公開

昨今の円安により、月あたりの請求が1万円を超えてきてしまい、ECSの上で動いているDockerコンテナをLambdaに載せ替えられないかとひとしきり研究をした記録になります。

目標

  • TypeScript (NestJS)で作られたAPIをALB+ECSからAPI Gateway+Lambdaに載せ替える
    • 現在1vCPU 2GBのFargate Spotコンテナ1台により、$10/月程度で動作している
    • 背後のRDSはdb.t4g.micro
  • 仕様上、多重並列リクエストを受けるため、1台ではレイテンシが増大することから、これの解消も目指す
  • 適宜周辺環境も見直し、RDS周りを除いて完全に(狭義の)サーバーレスで構成できないか模索する

やったこと

Lambda Web Adapterの導入

TypeScriptでLambdaといえば、まずServerless Frameworkが思い浮かぶところですが、今回はまずLambda Web Adapterを採用してみることにしました。

https://github.com/awslabs/aws-lambda-web-adapter

Serverless Frameworkでフレームワークを使おうとする場合、ブートストラップファイルに多少なりとも細工が必要ですが、こちらを使うと、アプリケーションコードに一切手を加えること無く、Dockerfileに数行追記するだけで、DockerコンテナをそのままLambdaに載せれるという点が魅力的です。ただ、やはりコンテナなので、Lambdaのコールドスタートのレイテンシは気になるところ。

Dockerfile
...前略

FROM node:20-alpine as production-hosting

ARG APP_NAME

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.1 /lambda-adapter /opt/extensions/lambda-adapter <-- ココ
ENV PORT="3000"  <-- ココ
ENV READINESS_CHECK_PATH="/health-check"  <-- ココ

ENV TZ="Asia/Tokyo"
ENV NODE_ENV="production"
USER node
RUN mkdir /home/node/${APP_NAME} 
WORKDIR /home/node/${APP_NAME}
COPY --from=production-build --chown=node:node /home/node/${APP_NAME}/package*.json ./
COPY --from=production-build --chown=node:node /home/node/${APP_NAME}/dist ./dist
RUN npm ci --production
EXPOSE 3000
CMD ["npm", "run", "start:prod"]

一部省略していますが、基本的には矢印で示した3行を追記しただけです。

Serverless FrameworkでLambdaを作るための設定

結局使うんかい!と言われそうですが、一番慣れているので…

serverless.ts
import type { AWS } from '@serverless/typescript';

const system = 'sotetsu-lab-v3' as const;

const serverlessConfiguration: AWS = {
    service: `${system}-api`,
    frameworkVersion: '3',
    plugins: ['serverless-deployment-bucket'],
    provider: {
        name: 'aws',
        stage: 'prod',
        region: 'ap-northeast-1',
        stackName: '${param:prefix}-cfstack',
        stackTags: {
            System: system,
            Stage: '${sls:stage}',
            Serverless: 'true',
        },
        deploymentBucket: {
            name: '${param:prefix}-sls-deployment-bucket',
            serverSideEncryption: 'AES256',
        },
        vpc: {
            subnetIds: {
                'Fn::Split': [
                    ',',
                    '${ssm:sotetsu-lab-v3-api-subnet-ids-param}',
                ],
            },
            securityGroupIds: [
                '${ssm:sotetsu-lab-v3-api-security-group-id-param}',
            ],
        },
        ecr: {
            images: {
                app: {
                    path: '.',
                    file: 'Dockerfile',
                    platform: 'linux/amd64',
                },
            },
        },
        httpApi: {
            name: '${param:prefix}-apigw',
        },
        iam: {
            role: {
                name: '${param:prefix}-lambda-role',
            },
        },
    },
    // import the function via paths
    functions: {
        app: {
            name: '${param:prefix}-lambda',
            image: {
                name: 'app',
            },
            memorySize: 1769,
            timeout: 25,
            // reservedConcurrency: 1000,
            environment: {
                HOME: '/tmp',
                NODE_ENV: 'production',
                CORS_HEADER_ORIGIN: 'https://v3.sotetsu-lab.com',
                DATABASE_URL:
                    'postgresql://${self:custom.databaseSecrets.username}:${self:custom.databaseSecrets.password}@${ssm:sotetsu-lab-v3-database-rds-proxy-host-param}/sotetsu_lab_v3',
                COGNITO_USERPOOL_ID:
                    '${ssm:sotetsu-lab-v3-auth-cognito-userpool-id}',
            },
            events: [
                {
                    httpApi: '*',
                },
            ],
        },
    },
    package: { individually: true },
    params: {
        default: {
            prefix: `${system}-\${sls:stage}-api`,
        },
    },
    custom: {
        databaseSecrets:
            '${ssm:/aws/reference/secretsmanager/sotetsu-lab-v3-database-rds-secrets}',
        deploymentBucket: {
            blockPublicAccess: true,
        },
    },
};

module.exports = serverlessConfiguration;

特に重要なところをピックアップします。

provider
vpc: {
    subnetIds: {
	'Fn::Split': [
	    ',',
	    '${ssm:sotetsu-lab-v3-api-subnet-ids-param}',
	],
    },
    securityGroupIds: [
	'${ssm:sotetsu-lab-v3-api-security-group-id-param}',
    ],
},
ecr: {
    images: {
	app: {
	    path: '.',
	    file: 'Dockerfile',
	    platform: 'linux/amd64',
	},
    },
},

RDSに接続する必要があるので、当然ながらVPC Lambdaとする必要があります。また、Dockerfileをデプロイ時にビルドするため、ecrでビルドに必要なコンテキスト等を設定しておきます。

functions
image: {
    name: 'app',
},
memorySize: 1769,
timeout: 25,

Lambda FunctionとDockerイメージを紐づけています。
メモリサイズを1769MBとしているのは、AWS公式のLambdaクォータページにある、「Lambda は、設定されたメモリの量に比例して CPU パワーを割り当てます。[メモリ (MB)] 設定を使用して、関数に割り当てられたメモリと CPU パワーを増減できます。1,769 MB の場合、1 つの vCPU に相当します。」という記述を参考にしています。

https://qiita.com/takuma818t/items/a25e22fec1863707be08
https://stackoverflow.com/questions/66522916/aws-lambda-memory-vs-cpu-configuration
https://web.archive.org/web/20220629183438/https://www.sentiatechblog.com/aws-re-invent-2020-day-3-optimizing-lambda-cost-with-multi-threading?utm_source=reddit&utm_medium=social&utm_campaign=day3_lambda

メモリサイズによるvCPU増減についてまとめられている偉大な先達によると、CPUパワーの割当は段階的ではなく、メモリサイズによってシームレスに制限がかけられているようです。
したがって、1,769MBから1MB増やすと2vCPUにはなりますが、実際に使えるのは1vCPUとほんのちょっと、といった動きになります。

Nodeアプリは基本的にはシングルスレッド動作ですから、1vCPUで速度は頭打ちになるはず。また、1,769MBより大きいメモリ容量が必要になるような処理は行っていないので、1vCPUを丸々占有できる(はずの)1,769MBを採用しています。
これはアプリの作りによって最適値が異なるので、いつか簡単に検証できればいいですね。Lambda Compute Optimizerなるものもある模様。

タイムアウト設定は、API Gatewayの制限値30秒からちょっと引いて25秒にしています。

functions
environment: {
    HOME: '/tmp',
    ...後略
},

これが割とハマりました。Dockerイメージを利用したLambdaでは、コンテナ内のファイルシステムは読み取り専用となります。
したがって、コンテナ内でログを吐いたりしているようなシステムは動作できません。こういった書き込み動作を行いたい場合、/tmpにマウントされているエフェメラルストレージを使用する必要があります。
今回の場合、CMD ["npm", "run", "start:prod"]でnpmが吐くログがこれに引っかかってしまったため、HOME環境変数自体を/tmpに設定することでこれを回避しました。(npmは$HOME/.npm以下を作業ディレクトリとして使うため)

functions
events: [
    {
        httpApi: '*',
    },
],

HTTP型のAPI Gatewayをとりあえず採用しました。後でREST型にするかも。
今回はNestJS側にルーティング機能をもたせるので、API Gatewayでは特にルーティングをしません。すべてのリクエストをバックエンドに送信します。

RDS Proxyを噛ませる

ここまでで、DockerコンテナをLambdaに載せ、API GatewayからAPIにリクエストを投げることができるようになりました。
がしかし、何回かRDSが絡むリクエストを送信していると、接続数が爆発してLambdaがエラーを返すようになってしまいました。

RDS接続数爆発の図
緑線のLambdaの実行時間が25秒に達してしまっています。

この当時はdb.t4g.microで検証を行っていたため、最大接続数は70ちょいです。しかし、Lambdaは平然と80を超えるインスタンスを同時実行してくるので、あっという間にコネクションを使い果たしてしまったようです。

取り急ぎ、インスタンスタイプを1段上のdb.t4g.smallに変更するとともに、RDS Proxyの導入をしてみることにしました。

resource "aws_db_proxy" "main" {
  name                   = local.rds_proxy_name
  engine_family          = "POSTGRESQL"
  role_arn               = aws_iam_role.rds_proxy_role.arn
  vpc_subnet_ids         = var.subnet_ids
  vpc_security_group_ids = [aws_security_group.for_rds_proxy.id]
  idle_client_timeout    = 1

  auth {
    auth_scheme = "SECRETS"
    secret_arn  = aws_secretsmanager_secret.main.arn
    iam_auth    = "DISABLED"
  }
}

resource "aws_db_proxy_default_target_group" "main" {
  db_proxy_name = aws_db_proxy.main.name

  connection_pool_config {
    max_connections_percent      = 80
    max_idle_connections_percent = 0
  }
}

resource "aws_db_proxy_target" "main" {
  db_proxy_name          = aws_db_proxy.main.name
  target_group_name      = aws_db_proxy_default_target_group.main.name
  db_instance_identifier = aws_db_instance.main.identifier
}

Terraformで記述しましたが、特段難しいところはないのでメイン部分のみ。

導入してはみましたが、予想通りRDS Proxyはあくまでもコネクションのバッファでしか無く、RDSの最大接続数を超えるコネクション接続要求がごく短時間に集中した場合は、Proxyによって他のコネクションが空くのを待つ状態となり、結局Lambdaのタイムアウトに達してしまう、という挙動になりました。

idle_client_timeout = 1の設定は、こういった場合にアイドル状態なコネクションを1分間で切断する=できるだけ素早くコネクションを空けるという設定です。
ただ、これでも追いつかなくなることは容易に想像がつくので、そうなったらもうRDSのスペックを上げろ、ということなんでしょうね。

API Gatewayではなく、ALBからLambdaに繋ぐようにすれば、タイムアウト問題はある程度緩和できるとは思うのですが、起動時間課金なALBは廃止したいため、不採用。
また、Lambda側の設定で、「予約された同時実行数」を使うことで、同時に実行されるインスタンスの最大数を設定できますが、それを超えると問答無用でAPI Gatewayにエラーが返ってくるので、これもちょっと微妙な挙動です。

ここでふと、「そういえばシャットダウン時って明示的にコネクションを切ってるんだろうか?」というところに行き当たりました。
あまりにもRDS ProxyのClientConnectionsが長時間減らなさすぎなためです。

残存するコネクションの図

ORMとしてTypeORMを使用しているため、@nestjs/typeormのソースコードまで潜りました。

https://github.com/nestjs/typeorm/blob/36ccf21bb0200c3b1376cef20f6192fad19accc6/lib/typeorm-core.module.ts#L147

NestJSのonApplicationShutdown()というフックによって、明示的にコネクションが閉じられていることを確認できました。
では、このフックはというと…

https://docs.nestjs.com/fundamentals/lifecycle-events#application-shutdown

Shutdown hook listeners consume system resources, so they are disabled by default. To use shutdown hooks, you must enable listeners by calling enableShutdownHooks():

デフォルト無効なんかーーーーーーい!!!!

というわけで、ドキュメント通りapp.enableShutdownHooks()main.tsに追記してデプロイしたところ…

アグレッシブになるコネクションの図

メトリクスがだいぶアグレッシブに動くようになりました!
一部180近くに達しているところもありますが、ひとまず大半のトラフィックはさばけるかな?といったところまで来ました。

API Gateway + Lambdaから、CloudFront + Lambda Function URLに移行する

タイムアウト制限の問題であったり、CloudFrontを前段に置いたときのキャッシュポリシー/オリジンリクエストポリシーの噛み合いが良くなかったりで、あまりフレームワークとの組み合わせに向いてないと感じたので、Lambda Function URLをCloudFrontのオリジンとして指定することにより、API Gatewayを廃止しました。

serverless.tsに書きましたが、ほぼCloudFormationなので詳細は割愛。

@h4ad/serverless-adapterを導入する

APIとしてきちんと動作するところまで持ってくることはできましたが、ふとコールドスタート時のログをみると、Billed Durationの値が異様に高いことが気になってきました。

REPORT RequestId: dcc3c2e2-e122-4322-be43-5bf704fa82df	Duration: 3.51 ms	Billed Duration: 9254 ms	Memory Size: 1769 MB	Max Memory Used: 240 MB	Init Duration: 9249.76 ms	

Dockerコンテナを立ち上げるところまでは課金対象ではないと思っていたのですが、どうやらそうではないようです。

https://aws.amazon.com/blogs/compute/operating-lambda-performance-optimization-part-1/

コールドスタートとレイテンシの関係について記載されているAWS公式のブログによれば、実行時のステップは4つに分かれており、Dockerコンテナの立ち上げはそのうちのExecute initialization codeの方に含まれてしまっているようです。

Node.jsで書かれたコードを実行する際は、Node.jsのバイナリが含まれた実行環境をStart new execution environmentの時点で用意してくれるため、Billed Durationは完全にNode.jsコード依存になり、それほど大きくならないということなんですね。

https://github.com/H4ad/serverless-adapter
https://github.com/H4ad/serverless-adapter-examples

ということで、Lambda Web Adapterを卒業し、@h4ad/serverless-adapterを導入します。
「This library was a refactored version of @vendia/serverless-express」とREADMEにあるように、LambdaでExpress.jsを利用するための有名なライブラリである@vendia/serverless-expressがリファクタリングされ、Express.js以外の著名フレームワークにも対応できるよう機能拡充されたものになります。

app.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import helmet from 'helmet';
import moment from 'moment-timezone';
import { AppModule } from './app.module';
import { validationPipeOptions } from './core/configs/validator-options';

moment.tz.setDefault('Asia/Tokyo');

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault('Asia/Tokyo');

export async function createApp(): Promise<INestApplication> {
    const app = await NestFactory.create(AppModule, new ExpressAdapter());

    app.enableShutdownHooks();
    // app.use(compression());
    app.use(helmet());
    app.enableCors({
        origin: process.env.CORS_HEADER_ORIGIN || '*',
    });
    app.useGlobalPipes(new ValidationPipe(validationPipeOptions));
    // app.useGlobalFilters(new ErrorFilter());
    // app.useGlobalFilters(new HttpExceptionFilter());

    return app;
}
handler.ts
import { ServerlessAdapter, ServerlessHandler } from '@h4ad/serverless-adapter';
import { ApiGatewayV2Adapter } from '@h4ad/serverless-adapter/lib/adapters/aws';
import { ExpressFramework } from '@h4ad/serverless-adapter/lib/frameworks/express';
import { LazyFramework } from '@h4ad/serverless-adapter/lib/frameworks/lazy';
import { DefaultHandler } from '@h4ad/serverless-adapter/lib/handlers/default';
import { PromiseResolver } from '@h4ad/serverless-adapter/lib/resolvers/promise';
import { createApp } from './app';

async function bootstrap(): Promise<any> {
    const app = await createApp();

    await app.init();

    return app.getHttpAdapter().getInstance();
}

const express = new ExpressFramework();
const framework = new LazyFramework(express, bootstrap);

export const main: ServerlessHandler<any> = ServerlessAdapter.new(null)
    .setFramework(framework)
    .setHandler(new DefaultHandler())
    .setResolver(new PromiseResolver())
    .addAdapter(new ApiGatewayV2Adapter()) // Lambda Function URLはAPIGW v2と同じイベント形式なので流用が可能
    .build();
main.ts
import { createApp } from './app';

async function bootstrap(): Promise<void> {
    const app = await createApp();
    await app.listen(3000);
}
bootstrap();

main.tsに書かれていた初期化処理を一旦app.tsに外出ししたあと、handler.tsでLambda用コードを記述します。

serverless.ts
app: {
    handler: `dist/handler.main`,
    name: '${param:prefix}-lambda',
    memorySize: 1769,
    timeout: 60,
    url: true,
    package: {
        patterns: ['!**', 'dist/**'],
    },
},

serverless.tsではhandlerにビルド後のファイル名および関数名を指定します。
また、zipファイルの肥大化を防ぐため、distフォルダ以外がzipに含まれないよう、package.patternsを指定しておきます。(別途Lambda Layersの用意は必要です。今回はserverless-layersというプラグインを用いました)

"deploy": "npm run build && serverless deploy",

ビルドしてからデプロイするようにスクリプトも修正しておきます。

REPORT RequestId: ed216ad5-9edf-4202-9f3c-f2472630b916	Duration: 409.46 ms	Billed Duration: 410 ms	Memory Size: 1769 MB	Max Memory Used: 198 MB	Init Duration: 2397.35 ms	

結果、Billed Durationは数百msまで下げることができました。
Init Durationが2.5秒程度発生しているので、コールドスタート時のレイテンシはありますが、明らかにDockerの時より減っているし、実行時間課金を削減できてホクホクです。

利点 / 弱点 / 今後の展望

懸案であった「多重並列リクエスト時のレイテンシ」については、やはりLambdaの並列実行の効果が出て、体感で感じられるほど速くなりました。

ただ、お気づきかとは思いますが、ここまでRDSのスペックを1段上げたり、RDS Proxyを追加したり、思ったよりLambdaの実行時間課金がかさむなどしているため、コストメリットは全く享受できていませんw
本格採用となればALBとECSが廃止され、計$27程度コストが減るわけなんですが、それを補ってあまりあるほどのコスト…

今後の流れとしては、できるだけAPI自体の実行時間を減らすために、前段にCloudFrontを噛まして適切にキャッシュし、そもそもオリジンにリクエストする件数を減らす、API側では余計な処理をせずにレイテンシ改善する、といったチューニングを行うことになりそう。

→CloudFrontを前段に挟んでみました。今後エンドポイントごとに適切なキャッシュTTL設定を行っていきたい。

また、コールドスタート時間が4~5秒程度かかっているのも課題です。Dockerイメージを使用していることによるものなのであれば、Serverless Frameworkに全乗っかりしてみるのも手ですが、そもそもNestJSという比較的重いフレームワークを使っているので、それを行ったところで改善効果が見込めるかどうかは微妙なところかなーと思っています。

→Dockerをやめたことで明らかに改善されました。ExpressやめてFastifyにしたらもっと早くなるかも?

感想

ここまでするぐらいならフルサーバーレスでAPI自体作り直そうや。

Discussion