WebアプリをAWS Lambdaにデプロイしよう!

AWS, AWS Lambda, webアプリ

はじめに

システム開発推進部 システム開発第4グループの川又です。
最近はめっきり暑くなり、夏本番といった感じですね。

今回はAWS Lambdaに関する内容です。
Lambdaを使ったサーバーレスアーキテクチャは、コストやスケーラビリティの面で非常に魅力的です。
しかし、Lambdaを利用する場合には特有の実装や知識が必要になり、学習コストがかかったり移植性が低くなってしまうことがあります。
慣れ親しんだフレームワークを使って実装しつつ、Lambdaのメリットを享受できたら嬉しいですよね。

そこで今回はAWS Lambda上でWebアプリをほぼそのまま動かすことができるLambda Web Adapter(LWA)を試してみようと思います。
また、コールドスタートを高速化する方法も試してみます。

目次

  1. Lambda Web Adapter(LWA) とは
  2. 実際に動かしてみる
    1. Expressアプリの作成
    2. Dockerfileの準備
    3. AWSリソースの作成
    4. ビルド&プッシュ&デプロイ
    5. 動作確認
  3. コールドスタート対策をしてみる
    1. esbuildでバンドルする
    2. インポートするパッケージを増やす
    3. 初期化処理の最適化

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リクエスト/レスポンスの変換を行ってくれます。
image-20250728095914341.png
(LWAのGitHubリポジトリより引用)

処理の流れは以下のようになります。

  1. API GatewayやLambda関数URL、ALBなどからLambda関数が呼び出される
  2. LWAがイベントを受け取り、HTTPリクエストに変換して同じ実行環境上で動くWebアプリに送信
  3. WebアプリはHTTPリクエストを処理し、HTTPレスポンスをLWAに返す
  4. LWAがHTTPレスポンスをイベントに変換しLambdaへ返す

より詳細な説明は下記の記事に分かりやすく書かれていますので、そちらもご参照いただければと思います。
Lambda Web Adapter でウェブアプリを (ほぼ) そのままサーバーレス化する

使い方ですが、コンテナイメージを使ってデプロイをする場合、Dockerfileに以下の1行を追加するだけです。
とても簡単ですね。

Zip Package形式のデプロイにも対応しています。詳細はGitHubリポジトリのREADMEをご参照ください。

実際に動かしてみる

実際にLWAを使ってLambda上でWebアプリを動かしてみます。
今回はNode.jsのExpressを使ってWebアプリを作成します。
なお、使用するコードはClaude Codeを使って生成しました。本記事の趣旨とは逸れてしまうため、生成したコードの詳細については触れません。

Expressアプリの作成

SwaggerのサンプルのPetStore APIを実装します。
検証用なのでDBは使わず、モックデータを返却することにします。
また、LWAはWebアプリが起動完了したことを確認するために定期的にヘルスチェックAPI(デフォルトパスは /)を呼び出すため、こちらの実装も必要です。

上記の条件をClaude Codeに伝え、以下のような構成で作成してもらいました。
ご丁寧に動作確認用のシェルスクリプトも生成されました。

ローカルで起動し

リクエストを投げたところ正常に動作していることを確認しました

Dockerfileの準備

今回はコンテナイメージを使ってデプロイするので、Dockerfileを作成します。
先述したLWAを利用するための1行を含む、以下のようなDockerfileになりました。
LWAはデフォルトでは8080ポートにリクエストをします。

AWSリソースの作成

次にCloudFormationを使って、以下のようなシンプルな構成でAWSリソースを作成します。
今回はLambda関数URLを使って直接Lambda関数を呼び出せるようにします。

image-20250728124038931.png

例のごとくClaude CodeにCloudFormationのテンプレートを生成してもらい、AWSコンソールからスタックを作成しました。
デプロイするイメージが存在しない状態ではLambda関数の作成ができないため、CloudFormationのパラメータCreateLambda=falseでスタックを作成(ECRのみ作成) -> 後述のステップでイメージをビルド&プッシュ -> CreateLambda=trueでスタックを更新しLambdaを作成する、といった手順で構築します。

ビルド&プッシュ&デプロイ

次に作成したExpressアプリをAWS Lambdaにデプロイします。
まずDockerイメージをビルドし、ECRにプッシュします。

ECRにプッシュできたら、前のステップで作成したCloudFormationスタックをパラメータCreateLambda=trueで更新し、Lambda関数を作成&デプロイします。

動作確認

それでは実際にLambda関数にリクエストをしてみます。
発行された関数URLに対して、テストスクリプトを使ってすべてのエンドポイントが正常に動作していることを確認します。

すべてのAPIでリクエストが成功することを確認できました。
これで構築完了です。

いかがでしょうか?
LWAを使うことで、Lambdaをほぼ意識せずにWebアプリを簡単にサーバーレス化できることが分かりました。
また、コンテナイメージで動作するため、Fargateなど他の環境にもすぐに移植できる点も魅力的です。
コスト最適化の方法として、アクセス数が少ない段階ではLambdaを使い、アクセス数が増えてきたらFargateに移行するなどの使い分けもできそうです。(コスト以外の点も考慮する必要はありますが)

コールドスタート対策をしてみる

Lambda特有の問題としてコールドスタートというものがあります。
Lambda関数が呼び出されたときに既存の実行環境がなかったり、既存の実行環境が処理中の場合、Lambdaは新たな実行環境を立ち上げます。
このことをコールドスタートと言い、実行環境の初期化には時間を要するためコールドスタートを伴うリクエストは通常よりもレスポンスが遅くなってしまうという問題があります。

コールドスタート対策としては、プロビジョニング済み同時実行(Provisioned Concurrency)を利用したりEventBridgeで定期的にLambdaを呼び出すなどの方法もありますが、今回はコールドスタートの時間を短くする方向の対策を試してみようと思います。

やることは以下の2つです。
1. esbuildでバンドルする
2. 初期化処理の最適化

esbuildでバンドルする

Webアプリはフレームワークを使っていたりすべてのエンドポイントが詰め込まれている分、通常のハンドラ関数を使った実装と比べると、依存するパッケージが多かったりファイルサイズが大きくなる傾向があるかと思われます。
以下の記事よるとインポートする依存関係の数が初期化に影響するようです。

AWS Lambda の Node.js 依存関係を最適化

関数にインポートされる依存関係とファイルが多いほど、初期化にかかる時間は長くなります。

対策としてesbuildで依存関係のファイルをバンドルしてサイズを小さくしてみます。
例の如くClaude Codeに指示を出してビルドスクリプトbuild.jsとesbuildを利用するバージョンのDockerfileを生成してもらいました。

build.js

Dockerfile.bundle

このDockerfileをLambdaにデプロイし、esbuildによるバンドルあり/なしでコールドスタートとリクエスト処理にかかる時間を比較します。
ソースコードのサイズはnode_modules込みでバンドル前で15MB、バンドル後で819KBでした。

計測は簡易的に行うため、手元のMacでApach Benchを動かし負荷をかけます。
同時実行数1000、総リクエスト数10000に設定してリクエストをします。
(ただし、Macのスペックの問題で同時実行数は1000には届かず実際には数百程度になりました)

リクエスト後にCloudWatchのログインサイトで対象のロググループ、期間を指定して以下のクエリを実行します。

なお集計方法に関しては以下の記事を参考にさせていただきました。
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で読み込むように変更しました。

適当なパッケージをインストール

app.jsのトップレベルで読み込む

これでバンドル前が約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で読み込んでいたパッケージ群を、ルートパス /へのリクエストを処理する関数内で読み込むように変更します。

結果

指標 初期化最適化なし 初期化最適化あり 初期化最適化+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においても、ソースコードのバンドルや初期化処理の最適化を行うことで、コールドスタート時間を短縮できることを確認できました。
コールドスタートの問題があるため、厳しい性能要件がある場合には注意が必要ですが、アクセス頻度の低い社内向けシステムなどを構築する場合には、費用面や運用面のメリットから有力な選択肢になりそうです。

最後までお読みいただきありがとうございました!

参考