個人開発アプリのAPIをDockerのままECSからLambdaに載せ替えたときのハマりどころ
昨今の円安により、月あたりの請求が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を採用してみることにしました。
Serverless Frameworkでフレームワークを使おうとする場合、ブートストラップファイルに多少なりとも細工が必要ですが、こちらを使うと、アプリケーションコードに一切手を加えること無く、Dockerfileに数行追記するだけで、DockerコンテナをそのままLambdaに載せれるという点が魅力的です。ただ、やはりコンテナなので、Lambdaのコールドスタートのレイテンシは気になるところ。
...前略
FROM node:20-alpine as production-hosting
ARG APP_NAME
COPY /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 /home/node/${APP_NAME}/package*.json ./
COPY /home/node/${APP_NAME}/dist ./dist
RUN npm ci --production
EXPOSE 3000
CMD ["npm", "run", "start:prod"]
一部省略していますが、基本的には矢印で示した3行を追記しただけです。
Serverless FrameworkでLambdaを作るための設定
結局使うんかい!と言われそうですが、一番慣れているので…
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;
特に重要なところをピックアップします。
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
でビルドに必要なコンテキスト等を設定しておきます。
image: {
name: 'app',
},
memorySize: 1769,
timeout: 25,
Lambda FunctionとDockerイメージを紐づけています。
メモリサイズを1769MB
としているのは、AWS公式のLambdaクォータページにある、「Lambda は、設定されたメモリの量に比例して CPU パワーを割り当てます。[メモリ (MB)] 設定を使用して、関数に割り当てられたメモリと CPU パワーを増減できます。1,769 MB の場合、1 つの vCPU に相当します。」という記述を参考にしています。
メモリサイズによるvCPU増減についてまとめられている偉大な先達によると、CPUパワーの割当は段階的ではなく、メモリサイズによってシームレスに制限がかけられているようです。
したがって、1,769MBから1MB増やすと2vCPUにはなりますが、実際に使えるのは1vCPUとほんのちょっと、といった動きになります。
Nodeアプリは基本的にはシングルスレッド動作ですから、1vCPUで速度は頭打ちになるはず。また、1,769MBより大きいメモリ容量が必要になるような処理は行っていないので、1vCPUを丸々占有できる(はずの)1,769MBを採用しています。
これはアプリの作りによって最適値が異なるので、いつか簡単に検証できればいいですね。Lambda Compute Optimizerなるものもある模様。
タイムアウト設定は、API Gatewayの制限値30秒からちょっと引いて25秒にしています。
environment: {
HOME: '/tmp',
...後略
},
これが割とハマりました。Dockerイメージを利用したLambdaでは、コンテナ内のファイルシステムは読み取り専用となります。
したがって、コンテナ内でログを吐いたりしているようなシステムは動作できません。こういった書き込み動作を行いたい場合、/tmp
にマウントされているエフェメラルストレージを使用する必要があります。
今回の場合、CMD ["npm", "run", "start:prod"]
でnpmが吐くログがこれに引っかかってしまったため、HOME
環境変数自体を/tmp
に設定することでこれを回避しました。(npmは$HOME/.npm
以下を作業ディレクトリとして使うため)
events: [
{
httpApi: '*',
},
],
HTTP型のAPI Gatewayをとりあえず採用しました。後でREST型にするかも。
今回はNestJS側にルーティング機能をもたせるので、API Gatewayでは特にルーティングをしません。すべてのリクエストをバックエンドに送信します。
RDS Proxyを噛ませる
ここまでで、DockerコンテナをLambdaに載せ、API GatewayからAPIにリクエストを投げることができるようになりました。
がしかし、何回かRDSが絡むリクエストを送信していると、接続数が爆発してLambdaがエラーを返すようになってしまいました。
緑線の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
のソースコードまで潜りました。
NestJSのonApplicationShutdown()
というフックによって、明示的にコネクションが閉じられていることを確認できました。
では、このフックはというと…
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コンテナを立ち上げるところまでは課金対象ではないと思っていたのですが、どうやらそうではないようです。
コールドスタートとレイテンシの関係について記載されているAWS公式のブログによれば、実行時のステップは4つに分かれており、Dockerコンテナの立ち上げはそのうちのExecute initialization code
の方に含まれてしまっているようです。
Node.jsで書かれたコードを実行する際は、Node.jsのバイナリが含まれた実行環境をStart new execution environment
の時点で用意してくれるため、Billed Duration
は完全にNode.jsコード依存になり、それほど大きくならないということなんですね。
ということで、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以外の著名フレームワークにも対応できるよう機能拡充されたものになります。
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;
}
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();
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用コードを記述します。
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