デプロイもランタイムも爆速 AWS Lambda Node.js 向け WEB サーバーおすすめランタイム 「Nitro」
普段、AWS Lambda にて WEB サーバーを構築するときは Rust を使用するのですが、Node.js でないといけない依存パッケージがありました。ここで、どのフレームワークが良いか再度真剣に考えてみて、以前から結論として出ていた Nitro を再評価することになりました。
Nitro とは?
Nitro はコードの変更なしに多くのデプロイ先に合わせてビルドができる WEB フレームワークです。
Nitro は、有名な他の WEB フレームワークと比較してかなり異質なアプローチをとっています。
Nitro はコンパイラマクロベース
一般的な WEB フレームワークは、サーバーのインスタンスを作成し、そのインスタンスにルートを登録してサーバーを起動することが多いです。
しかし、Nitro はルートを定義するコンパイラマクロを記述し、サーバーを起動するコードはユーザーが直接記述しません。
例えば、次のようなコードを記述するだけで、様々なデプロイ先に対してのビルドがすぐにできます。
export default defineEventHandler((event) => { return { message: "Hello, world!" }; });
そして設定ファイルにて preset を設定するだけで各プラットフォームに適したビルドアーティファクトが完成します。
例えば、AWS Lambda 向けにデプロイしたい場合は以下のように設定するのみで良いのです。
export default defineNitroConfig({ srcDir: "server", preset: "aws-lambda" });
VPS, コンテナ, オンプレサーバーにデプロイするときは Node.js で稼働するようにすればよいです。
export default defineNitroConfig({ srcDir: "server", preset: "node_server" });
つまり、Nitro は一度コードを書けばベンダーロックインを恐れずに様々なプロバイダーに適合したアーティファクトを生成できるのです。
ディレクトリ構成がルートパス構成になる
Nitro ではデフォルトでは server/routes/ 以下のディレクトリの構成がそのままルートになるという特徴があります。
例えば以下のようになります:
server/routes/index.ts→ANY /server/routes/greet.get.ts→GET /greetserver/routes/auth/signup.post.ts→POST /auth/signup
軽量 & 最適化
エンタープライズ向けで WEB サーバーを構築するとなると、NestJS + Fastify が無難な選択肢ですが、NestJS はサーバーレスでは非常に起動が遅いという欠点があります。これはスクリプト言語で高度な抽象化を行うと遅くなるという宿命ではありますが、NestJS はユーザー側の抽象化以外にパッケージ側の抽象化も激しいため、JIT コンパイルが効いてくるまで時間がかかります。
対して Nitro は NestJS のように間に Express や Fastify が挟まらないため、それぞれのプラットフォームに対して最適化ができます。Lambda 関数がターゲットであればトランスポートは必要ありませんし、最適化が容易です。
超簡単 - AWS Lambda にデプロイしてみる
以下の手順に従えば、本当にすぐに WEB サーバーを AWS Lambda にデプロイできます。そう、Nitro ならね。
なお、標準的なセットアップガイドは以下のリンクにも書かれています。
プロジェクトの初期化
Nitro のテンプレートがあるため、giget で初期化します。入力したプロジェクト名でディレクトリが作成されていることを確認します。
npx giget@latest nitro <プロジェクト名> --install
ローカルでのサーバーの起動
作成されたディレクトリに移動します。
cd <プロジェクト名>
server/routes/index.ts を次のように書き換えます。
export default defineEventHandler((event) => { return { message: "Hello, world!" }; });
npm スクリプトで開発サーバーを起動します。
npm run dev
http://localhost:3000 にアクセスして、正常にレスポンスが返ってくることを確認します。
AWS Lambda 向けにビルド
AWS Lambda 向けにビルドできるように、設定ファイルを変更します。
export default defineNitroConfig({ srcDir: "server", preset: "aws-lambda" });
npm スクリプト経由でビルドします。
npm run build
デプロイできるように zip にアーカイブします。これで .output/lambda.zip が作成されます。
cd .output/server zip -r ../lambda.zip .
AWS Lambda にデプロイ
以下の設定で Lambda 関数を作成し、zip アーカイブをデプロイしてください。
- ランタイム:
Node.js(バージョンは環境に合わせてください) - ハンドラー:
index.handler
これだけでもう WEB サーバーが完成しました!簡易的に試すには、Function URLs を作成して試してみてください。また、Function URLs と API Gateway のイベントは同じであるため、API Gateway の統合としてもこのまま使用できます。
おまけ: レスポンスストリーム の実装
AWS Lambda では Node.js ランタイムとカスタムランタイムにおいて、HTTP レスポンスストリームがサポートされています。特に昨今の LLM ブームでは、出力を非同期イテレーションで少しずつ取り出すことが多いため、レスポンスストリームは非常に重要です。
ストリームを返すルートの定義
GET /stream にて、ストリームを返すルートを定義します。h3 のドキュメントに例があります。
以下はコピペで稼働するコードです。LLM のレスポンスは非同期イテレータとして返されることが多いため、ジェネレータ関数を挟んでわかりやすくしています。
const sleep = async (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)); async function* streaming(): AsyncGenerator<string> { yield "<ul>"; let counter = 0; while (counter < 20) { await sleep(100); yield `<li>${Math.random()}</li>`; counter++; } yield "</ul>"; } export default defineEventHandler((event) => { setResponseHeader(event, "Content-Type", "text/html"); setResponseHeader(event, "Cache-Control", "no-cache"); setResponseHeader(event, "Transfer-Encoding", "chunked"); const stream = new ReadableStream({ async start(controller) { for await (const value of streaming()) { controller.enqueue(value); } controller.close(); }, }); return sendStream(event, stream); });
ローカルでテスト
ルート定義が間違っていないかを確認します。開発サーバーを起動して http://localhost:3000/stream にアクセスし、一定間隔でレスポンスが返ってきていたら大丈夫です。
npm run dev
もしくは curl でも確認できます。
curl --no-buffer http://localhost:3000/stream
デプロイと Function URLs の注意
デプロイは同じように行っていただいて構いません。ただし、Function URLs を設定する際は、「Invoke mode」を「RESPONSE_STREAM」に設定してください。