はじめに
システム開発推進部 システム開発第4グループの川又です。 最近はめっきり暑くなり、夏本番といった感じですね。
今回はAWS Lambdaに関する内容です。 Lambdaを使ったサーバーレスアーキテクチャは、コストやスケーラビリティの面で非常に魅力的です。 しかし、Lambdaを利用する場合には特有の実装や知識が必要になり、学習コストがかかったり移植性が低くなってしまうことがあります。 慣れ親しんだフレームワークを使って実装しつつ、Lambdaのメリットを享受できたら嬉しいですよね。
そこで今回はAWS Lambda上でWebアプリをほぼそのまま動かすことができるLambda Web Adapter(LWA)を試してみようと思います。 また、コールドスタートを高速化する方法も試してみます。
目次
- Lambda Web Adapter(LWA) とは
- 実際に動かしてみる
- Expressアプリの作成
- Dockerfileの準備
- AWSリソースの作成
- ビルド&プッシュ&デプロイ
- 動作確認
- コールドスタート対策をしてみる
- esbuildでバンドルする
- インポートするパッケージを増やす
- 初期化処理の最適化
Lambda Web Adapter(LWA)とは
Lambda Web Adapter(LWA)は、AWS Lambda上でWebアプリを簡単に実行できるようにするためのツールです。 LWAを使用すると、Express.jsやFlask, Laravelなどの任意のWebフレームワークを使って開発したアプリを、ほぼコードの変更なしでLambda上で実行できます。
LWAはLambda ExtensionとしてWebアプリと同じLambda実行環境上で動作し、LambdaとWebアプリの橋渡し役となりイベントとHTTPリクエスト/レスポンスの変換を行ってくれます。

(LWAのGitHubリポジトリより引用)
処理の流れは以下のようになります。
- API GatewayやLambda関数URL、ALBなどからLambda関数が呼び出される
- LWAがイベントを受け取り、HTTPリクエストに変換して同じ実行環境上で動くWebアプリに送信
- WebアプリはHTTPリクエストを処理し、HTTPレスポンスをLWAに返す
- LWAがHTTPレスポンスをイベントに変換しLambdaへ返す
より詳細な説明は下記の記事に分かりやすく書かれていますので、そちらもご参照いただければと思います。 Lambda Web Adapter でウェブアプリを (ほぼ) そのままサーバーレス化する
使い方ですが、コンテナイメージを使ってデプロイをする場合、Dockerfileに以下の1行を追加するだけです。 とても簡単ですね。
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
Zip Package形式のデプロイにも対応しています。詳細はGitHubリポジトリのREADMEをご参照ください。
実際に動かしてみる
実際にLWAを使ってLambda上でWebアプリを動かしてみます。 今回はNode.jsのExpressを使ってWebアプリを作成します。 なお、使用するコードはClaude Codeを使って生成しました。本記事の趣旨とは逸れてしまうため、生成したコードの詳細については触れません。
Expressアプリの作成
SwaggerのサンプルのPetStore APIを実装します。
検証用なのでDBは使わず、モックデータを返却することにします。
また、LWAはWebアプリが起動完了したことを確認するために定期的にヘルスチェックAPI(デフォルトパスは
/)を呼び出すため、こちらの実装も必要です。
上記の条件をClaude Codeに伝え、以下のような構成で作成してもらいました。 ご丁寧に動作確認用のシェルスクリプトも生成されました。
lwa-sample/
├── src/
│ ├── app.js # Expressアプリ設定
│ ├── server.js # サーバー起動ロジック
│ ├── routes/ # ルーティング定義
│ │ ├── petRoutes.js
│ │ ├── storeRoutes.js
│ │ ├── userRoutes.js
│ │ └── healthRoutes.js # LWAヘルスチェック用
│ ├── controllers/ # ビジネスロジック
│ │ ├── petController.js
│ │ ├── storeController.js
│ │ └── userController.js
│ ├── middleware/ # ミドルウェア
│ │ ├── errorHandler.js
│ │ └── logger.js
│ └── data/ # モックデータ
│ └── mockData.js
├── package.json
└── test-api.sh
ローカルで起動し
$ npm run dev
リクエストを投げたところ正常に動作していることを確認しました
$ curl http://localhost:8080/v2/pet/1
{"id":1,"name":"Buddy","category":{"id":1,"name":"Dogs"},"photoUrls":["http://example.com/photo1.jpg"],"tags":[{"id":1,"name":"friendly"}],"status":"available"}
Dockerfileの準備
今回はコンテナイメージを使ってデプロイするので、Dockerfileを作成します。 先述したLWAを利用するための1行を含む、以下のようなDockerfileになりました。 LWAはデフォルトでは8080ポートにリクエストをします。
FROM public.ecr.aws/docker/library/node:20-slim
# LWAをインストール
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
EXPOSE 8080
CMD ["node", "src/server.js"]
AWSリソースの作成
次にCloudFormationを使って、以下のようなシンプルな構成でAWSリソースを作成します。 今回はLambda関数URLを使って直接Lambda関数を呼び出せるようにします。

例のごとくClaude CodeにCloudFormationのテンプレートを生成してもらい、AWSコンソールからスタックを作成しました。 デプロイするイメージが存在しない状態ではLambda関数の作成ができないため、CloudFormationのパラメータCreateLambda=falseでスタックを作成(ECRのみ作成) -> 後述のステップでイメージをビルド&プッシュ -> CreateLambda=trueでスタックを更新しLambdaを作成する、といった手順で構築します。
AWSTemplateFormatVersion: "2010-09-09"
Description: "Lambda Web Adapter Sample Application - ECR and Lambda with Function URL"
Parameters:
ProjectName:
Type: String
Default: lwa-sample
Description: Project name for resource naming
ImageTag:
Type: String
Default: latest
Description: Docker image tag to deploy
CreateLambda:
Type: String
Default: "false"
AllowedValues:
- "true"
- "false"
Description: Set to true after pushing the first image to ECR
Conditions:
ShouldCreateLambda: !Equals [!Ref CreateLambda, "true"]
Resources:
# ECR Repository
ECRRepository:
Type: AWS::ECR::Repository
Properties:
RepositoryName: !Sub "${ProjectName}-repo"
ImageScanningConfiguration:
ScanOnPush: true
LifecyclePolicy:
LifecyclePolicyText: |
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 10 images",
"selection": {
"tagStatus": "any",
"countType": "imageCountMoreThan",
"countNumber": 10
},
"action": {
"type": "expire"
}
}
]
}
# IAM Role for Lambda
LambdaExecutionRole:
Type: AWS::IAM::Role
Condition: ShouldCreateLambda
Properties:
RoleName: !Sub "${ProjectName}-lambda-role"
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: ECRAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ecr:GetAuthorizationToken
- ecr:BatchCheckLayerAvailability
- ecr:GetDownloadUrlForLayer
- ecr:BatchGetImage
Resource: "*"
# Lambda Function
LambdaFunction:
Type: AWS::Lambda::Function
Condition: ShouldCreateLambda
DependsOn: LambdaLogGroup
Properties:
FunctionName: !Sub "${ProjectName}-function"
Role: !GetAtt LambdaExecutionRole.Arn
PackageType: Image
Code:
ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRRepository}:${ImageTag}"
Timeout: 30
MemorySize: 512
Environment:
Variables:
NODE_ENV: production
PORT: "8080"
# Lambda Web Adapter specific settings
AWS_LWA_PORT: "8080"
AWS_LWA_READINESS_CHECK_PATH: "/health"
AWS_LWA_READINESS_CHECK_PROTOCOL: "http"
Architectures:
- x86_64
# Lambda Function URL
LambdaFunctionUrl:
Type: AWS::Lambda::Url
Condition: ShouldCreateLambda
Properties:
TargetFunctionArn: !Ref LambdaFunction
AuthType: NONE
Cors:
AllowOrigins:
- "*"
AllowMethods:
- GET
- POST
- PUT
- DELETE
AllowHeaders:
- "*"
MaxAge: 86400
# Permission for Function URL
LambdaFunctionUrlPermission:
Type: AWS::Lambda::Permission
Condition: ShouldCreateLambda
Properties:
FunctionName: !Ref LambdaFunction
Principal: "*"
Action: lambda:InvokeFunctionUrl
FunctionUrlAuthType: NONE
Outputs:
ECRRepositoryURI:
Description: ECR Repository URI
Value: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRRepository}"
Export:
Name: !Sub "${AWS::StackName}-ECRRepositoryURI"
ECRRepositoryName:
Description: ECR Repository Name
Value: !Ref ECRRepository
Export:
Name: !Sub "${AWS::StackName}-ECRRepositoryName"
FunctionUrl:
Description: Lambda Function URL
Condition: ShouldCreateLambda
Value: !GetAtt LambdaFunctionUrl.FunctionUrl
Export:
Name: !Sub "${AWS::StackName}-FunctionUrl"
ビルド&プッシュ&デプロイ
次に作成したExpressアプリをAWS Lambdaにデプロイします。 まずDockerイメージをビルドし、ECRにプッシュします。
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin {ECRリポジトリURI}
docker build --platform=linux/x86_64 -t lwa-sample-repo .
docker tag lwa-sample-repo:latest {ECRリポジトリURI}/lwa-sample-repo:latest
docker push {ECRリポジトリURI}/lwa-sample-repo:latest
ECRにプッシュできたら、前のステップで作成したCloudFormationスタックをパラメータCreateLambda=trueで更新し、Lambda関数を作成&デプロイします。
動作確認
それでは実際にLambda関数にリクエストをしてみます。 発行された関数URLに対して、テストスクリプトを使ってすべてのエンドポイントが正常に動作していることを確認します。
$ ./test-api.sh
Testing Petstore API Endpoints
Base URL: https://xxxxxxxxx.lambda-url.ap-northeast-1.on.aws
===========================================
Root endpoint
GET /
HTTP 200
Response:
{
"message": "Petstore API - Lambda Web Adapter Test",
"version": "2.0.0",
"basePath": "/v2",
"endpoints": {
"pet": "/v2/pet",
"store": "/v2/store",
"user": "/v2/user",
"health": "/health"
}
}
Health check endpoint
GET /health
HTTP 200
Response:
{
"status": "healthy"
}
Pet Endpoints
================
Get pet by ID (1)
GET /v2/pet/1
HTTP 200
Response:
{
"id": 1,
"name": "Buddy",
"category": {
"id": 1,
"name": "Dogs"
},
"photoUrls": [
"http://example.com/photo1.jpg"
],
"tags": [
{
"id": 1,
"name": "friendly"
}
],
"status": "available"
}
...(以下省略)...
API testing completed!
===========================================
すべてのAPIでリクエストが成功することを確認できました。 これで構築完了です。
いかがでしょうか? LWAを使うことで、Lambdaをほぼ意識せずにWebアプリを簡単にサーバーレス化できることが分かりました。 また、コンテナイメージで動作するため、Fargateなど他の環境にもすぐに移植できる点も魅力的です。 コスト最適化の方法として、アクセス数が少ない段階ではLambdaを使い、アクセス数が増えてきたらFargateに移行するなどの使い分けもできそうです。(コスト以外の点も考慮する必要はありますが)
コールドスタート対策をしてみる
Lambda特有の問題としてコールドスタートというものがあります。 Lambda関数が呼び出されたときに既存の実行環境がなかったり、既存の実行環境が処理中の場合、Lambdaは新たな実行環境を立ち上げます。 このことをコールドスタートと言い、実行環境の初期化には時間を要するためコールドスタートを伴うリクエストは通常よりもレスポンスが遅くなってしまうという問題があります。
コールドスタート対策としては、プロビジョニング済み同時実行(Provisioned Concurrency)を利用したりEventBridgeで定期的にLambdaを呼び出すなどの方法もありますが、今回はコールドスタートの時間を短くする方向の対策を試してみようと思います。
やることは以下の2つです。
- esbuildでバンドルする
- 初期化処理の最適化
esbuildでバンドルする
Webアプリはフレームワークを使っていたりすべてのエンドポイントが詰め込まれている分、通常のハンドラ関数を使った実装と比べると、依存するパッケージが多かったりファイルサイズが大きくなる傾向があるかと思われます。 以下の記事よるとインポートする依存関係の数が初期化に影響するようです。
関数にインポートされる依存関係とファイルが多いほど、初期化にかかる時間は長くなります。
対策としてesbuildで依存関係のファイルをバンドルしてサイズを小さくしてみます。 例の如くClaude Codeに指示を出してビルドスクリプトbuild.jsとesbuildを利用するバージョンのDockerfileを生成してもらいました。
build.js
const esbuild = require('esbuild');
const path = require('path');
async function build() {
try {
console.log('Building with esbuild...');
await esbuild.build({
entryPoints: [path.join(__dirname, 'src/server.js')],
bundle: true,
platform: 'node',
target: 'node20',
outfile: path.join(__dirname, 'dist/bundle.js'),
minify: true,
sourcemap: false,
external: [], // Bundle all dependencies
format: 'cjs',
loader: {
'.js': 'js'
},
define: {
'process.env.NODE_ENV': '"production"'
}
});
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
}
}
build();
Dockerfile.bundle
# Build stage
FROM public.ecr.aws/docker/library/node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY build.js ./
RUN npm run build
# Production stage
FROM public.ecr.aws/docker/library/node:20-slim AS production
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
WORKDIR /app
COPY --from=builder /app/dist/bundle.js ./
EXPOSE 8080
CMD ["node", "bundle.js"]
このDockerfileをLambdaにデプロイし、esbuildによるバンドルあり/なしでコールドスタートとリクエスト処理にかかる時間を比較します。 ソースコードのサイズはnode_modules込みでバンドル前で15MB、バンドル後で819KBでした。
計測は簡易的に行うため、手元のMacでApach Benchを動かし負荷をかけます。 同時実行数1000、総リクエスト数10000に設定してリクエストをします。 (ただし、Macのスペックの問題で同時実行数は1000には届かず実際には数百程度になりました)
❯ ab -n 10000 -c 1000 https://xxxxxxxxxx.lambda-url.ap-northeast-1.on.aws/
リクエスト後にCloudWatchのログインサイトで対象のロググループ、期間を指定して以下のクエリを実行します。
stats count(@initDuration), count(@duration),
avg(@initDuration), pct(@initDuration, 99), stddev(@initDuration),
avg(@duration), pct(@duration, 99), stddev(@duration)
| filter @message like /^REPORT/
なお集計方法に関しては以下の記事を参考にさせていただきました。 Lambda Web Adapter でウェブアプリを (ほぼ) そのままサーバーレス化する
結果
| 指標 | esbuildなし | esbuildあり |
|---|---|---|
| 平均処理時間(ms) | 3.6215 | 3.5903 |
| 処理時間99パーセンタイル(ms) | 42.356 | 36.1392 |
| 処理時間標準偏差(ms) | 6.4033 | 5.6947 |
| 平均コールドスタート時間(ms) | 397.1417 | 319.5333 |
| コールドスタート時間99パーセンタイル(ms) | 851.1831 | 699.7514 |
| コールドスタート時間標準偏差(ms) | 98.5343 | 81.9605 |
| リクエスト件数 | 10000 | 10000 |
| コールドスタート件数 | 255 | 207 |
処理時間に関してはほぼ同じでしたが、コールドスタートの平均時間がesbuildでバンドルしたほうがわずかに短くなっていることがわかります。 99パーセンタイルでみると、esbuildありのほうが約150ms短くなっています。 また、標準偏差も小さくなっているためesbuildを使ったほうがコールドスタート時間が安定しそうです。
ただ、思ったほど差が出なかったためインポートするパッケージを増やして試してみます。
インポートするパッケージを増やす
Claude Codeに指示をして、適当なパッケージをインストールしapp.jsで読み込むように変更しました。
適当なパッケージをインストール
npm install lodash moment axios uuid crypto-js jsonwebtoken bcryptjs multer helmet compression morgan winston
app.jsのトップレベルで読み込む
const express = require("express");
// 追加パッケージを読み込み
const _ = require("lodash");
const moment = require("moment");
const axios = require("axios");
const uuid = require("uuid");
const crypto = require("crypto-js");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const helmet = require("helmet");
const compression = require("compression");
const morgan = require("morgan");
const winston = require("winston");
// ここまで
const app = express();
...(中略)...
app.get("/", async (req, res) => {
...(中略)...
});
これでバンドル前が約81MB、バンドル後は48KBになりました。 この状態で同じく計測してみます。
結果
| 指標 | esbuildなし | esbuildあり |
|---|---|---|
| 平均処理時間(ms) | 10.9753 | 7.6798 |
| 処理時間99パーセンタイル(ms) | 140.5117 | 99.3655 |
| 処理時間標準偏差(ms) | 26.3615 | 16.5297 |
| 平均コールドスタート時間(ms) | 763.2856 | 484.4411 |
| コールドスタート時間99パーセンタイル(ms) | 1214.9393 | 849.4833 |
| コールドスタート時間標準偏差(ms) | 126.5696 | 83.9551 |
| リクエスト件数 | 10001 | 10000 |
| コールドスタート件数 | 468 | 245 |
コールドスタート時間は顕著に差が出ており、esbuildありのほうが平均時間で約280ms短くなっています。 処理時間についてもesbuildありのほうが処理が早くなっているようです。 依存パッケージが多いとよりesbuildの恩恵が大きいことが分かります。
初期化処理の最適化
初期化処理のコストを下げることもコールドスタート時間の短縮に有効です。 あまり頻繁に使われないパッケージは必要なタイミングで読み込むようにするなどの対策が考えられます。 ただし、初期化時に読み込まない分、リクエスト処理のコストが増えレスポンスが遅れてしまうため、初期化に含めるべきかどうかは十分に検討する必要があります。
直前の検証でapp.jsで読み込んでいたパッケージ群を、ルートパス
/へのリクエストを処理する関数内で読み込むように変更します。
app.get("/", async (req, res) => {
// 関数内で読み込み
const _ = require("lodash");
const moment = require("moment");
const axios = require("axios");
const uuid = require("uuid");
const crypto = require("crypto-js");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const helmet = require("helmet");
const compression = require("compression");
const morgan = require("morgan");
const winston = require("winston");
res.json({
...(中略)...
});
});
結果
| 指標 | 初期化最適化なし | 初期化最適化あり | 初期化最適化+esbuildあり |
|---|---|---|---|
| 平均処理時間(ms) | 10.9753 | 85.7056 | 30.5736 |
| 処理時間90パーセンタイル(ms) | 13.6589 | 56.364 | 17.4128 |
| 処理時間99パーセンタイル(ms) | 140.5117 | 997.1557 | 423.1582 |
| 処理時間標準偏差(ms) | 26.3615 | 266.1987 | 99.0904 |
| 平均コールドスタート時間(ms) | 763.2856 | 463.9265 | 363.8256 |
| コールドスタート時間99パーセンタイル(ms) | 1214.9393 | 940.6558 | 710.3215 |
| コールドスタート時間標準偏差(ms) | 126.5696 | 124.6306 | 78.7664 |
| リクエスト件数 | 10001 | 10000 | 10000 |
| コールドスタート件数 | 468 | 839 | 621 |
初期化の最適化あり/なしと最適化+esbuildありで比較します。
コールドスタート時間は初期化最適化をしたほうが短くなり、さらに最適化+esbuildありが最も短い結果となりました。 しかし、パッケージの読み込みをリクエスト処理のタイミングに回した分、リクエスト処理時間は最適化なしの場合が一番早くなる結果となりました。
処理時間99パーセンタイルを見ると、最適化あり/なしで約7倍の差がありますが、これは初回のrequire()による影響だと考えられます。 次に処理時間90パーセンタイルを見ると、99パーセンタイルよりは差が縮まっています。これはrequire()のキャッシュにより2回目以降の読み込みはある程度高速化されたためと考えれます。 2回目以降は読み込みが早くなるとはいえ、リクエストのたびにパッケージを読み込んでしまうと特に頻繁に使用するパッケージの場合は無駄が多そうです。
今回は極端な例だったので特に顕著に影響が出たと思いますが、初期化の内容は十分に検討する必要があります。
まとめ
Lambda Web Adapterを使うことで、Lambdaを意識することなくWebアプリを簡単にサーバーレス化できることが分かりました。 また、コンテナベースのLambdaにおいても、ソースコードのバンドルや初期化処理の最適化を行うことで、コールドスタート時間を短縮できることを確認できました。 コールドスタートの問題があるため、厳しい性能要件がある場合には注意が必要ですが、アクセス頻度の低い社内向けシステムなどを構築する場合には、費用面や運用面のメリットから有力な選択肢になりそうです。
最後までお読みいただきありがとうございました!
参考
川又康了