作成日: 2025-12-13 06:39:37

更新日: 2025-12-13 07:09:27

199 views

自作サイトで頑張る Advent Calendar 2025
技術
Web
AWS

React Router v7 を Lambda に載せる

この記事は「自作サイトで頑張る Advent Calendar 2025」の 2 日目です。

遅刻した理由

旅行に行ってましたごめんなさい。

はじめに

このウェブサイトは React Router v7 を AWS Lambda の上で動かしています。通常、Web アプリケーションを Lambda で動作させる方法はいくつか存在しますが、その中でも React Router v7 を使用する場合に特化して解説していきます。

React Router v7 はその依存度に応じて Declarative, Data, Framework の 3 つのモードがあります。今回は、Framework モードで SSR することを前提としています。詳細は 公式ドキュメント を参照してください。

とりあえず Lambda で Web アプリを動かす

イメージを使用する

Lambda はハンドラーのコードを直接アップロードする代わりに、ハンドラー関数に Docker イメージを使用することができます。Lambda Web Adapter を使用すれば、Web アプリケーションをサーバーレスに対応していなくてもほぼ何も変更せずに動作させることができます。

https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/

aws.amazon.com

以下のような構成になります。

Lambda は Function URL を発行し、CloudFront から OAC 経由でアクセスすることを想定します。

しかし、この方法は簡単な反面、イメージを使用する影響でコールドスタートが遅くなります。その遅さは結構致命的で、ドキュメントの読み込みだけで 10 秒以上かかります。その上でさらにハイドレーションがあり、Lambda のリソースがだいぶ持ってかれます。

バケットを使用する

React Router v7 のビルド結果は以下のようになります。

build
├── client
│   ├── assets
│   └── favicon.ico
└── server
    ├── assets
    └── index.js

このように、サーバーコードとクライアントコードが明確に分離されており、クライアントコードに関しては静的ファイルとして配信されます。 よって、client ディレクトリ以下は S3 バケットに配置し、CloudFront から配信することができます。

以下のような構成になります。

ここで、CloudFront の設定について、ちょっと工夫が必要なので、補足しておきます。 ビヘイビアは以下のように設定します。

優先順位パスパターンオリジンキャッシュ
1/assets/*S3有効
2/*.dataLambda無効
3/*.*S3無効
4デフォルトLambda無効
  1. /assets/* : クライアントコードの静的ファイルが入りいます。バンドラーの仕様で、各チャンクのファイル名はビルドごとに変わるため、キャッシュを有効にしても問題ありません。
  2. /*.data : React Router v7 のデータフェッチングで使用されるエンドポイントです。これらは動的に生成されるため、オリジンを Lambda に向け、キャッシュを無効にします。
  3. /*.* : public ディレクトリ以下の静的ファイルが入ります。.data で終わるものは 2 で除外しています。こちらは、更新時に即座に反映される必要があればキャッシュを無効にします。柔軟に設定してください。
  4. デフォルト : それ以外のリクエストはすべて Lambda に向け、キャッシュを無効にします。

Tips: ここで、キャッシュを無効と書いている部分も、TTL を 10 秒など短い時間に設定することで、CloudFront にデータだけ残ります。そうすれば、例えばオリジンが落ちてしまった場合も、CloudFront が自動で期限切れのキャッシュを返してくれるので、可用性が向上します。

このようにバケットを使用することで、Lambda のリソースを節約できます。

イメージから脱却する

やはり、イメージを使用しているうちはコールドスタートが遅いです。そこで、イメージを使用せず、直接ハンドラーコードをアップロードすることでこの問題の解決を目指します。

サーバーの移行

単にビルド成果物を実行するコードを Lambda にアップロードしてもそのままでは動きません。よって、Lambda で Web アプリを動かすために、サーバーレス対応する必要があります。

create react-router コマンドを経由してアプリを作成した場合、何も考えずに配信すると @react-router/serve がサーバーとして使用されます。

https://reactrouter.com/api/other-api/serve

reactrouter.com

ドキュメントにもある通り、これは簡易的なサーバーなので、カスタマイズが効きません。そこで、Express を使用することにします。以下の手順で移行が可能です。

https://reactrouter.com/api/other-api/adapter#migrating-from-the-react-router-app-server

reactrouter.com

Express のサーバーレス化

serverless-http を使用することで、Express アプリをラップするだけで、Lambda で動作させることができます。

https://github.com/dougmoscrop/serverless-http

github.com

ハンドラー関数 (index.mjs) として以下のようなコードを書きます。

import serverless_handler from "serverless-http";
import app from "./server.js";

export const handler = serverless_handler(app);

これでビルドすれば、成果物が Lambda で動作するようになります。

CDK のイメージは以下のようになります。

const lambdaFunction = new lambda.Function(this, "KanarumeWebFunction", {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("../web", {
    exclude: ["app"],
  }),
  timeout: cdk.Duration.minutes(3),
  memorySize: 1024,
  architecture: lambda.Architecture.ARM_64,
});

容量との戦い

Lambda では、イメージを使用すると最大容量が 10 GB である一方、直接コードをアップロードする場合は最大容量が 250 MB に制限されます。アプリ自体はおそらく間に合うのですが、何も工夫をしないと、node_modules が大きくなりすぎてしまいます。

基本的なこと

当然ですが、不要な依存関係は入れないようにします。また、大きなライブラリを使用する場合は、代替ライブラリがないか、独自実装で対応できないか検討します。

依存関係をレイヤーに分離する

Lambda では node_modules をレイヤーとして分離することができます。これにより、本体の Lambda 関数の容量を節約できます。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/nodejs-package.html#nodejs-package-dependencies-layers

docs.aws.amazon.com

しかし、レイヤーも最大容量は 250 MB なので、注意が必要です。

圧縮を工夫する

Lambda レイヤーをアップロードする際、内容を zip で固める必要があります。ここで pnpm を使用している場合、node_modules はシンボリックリンクが多用されているため、何も考えないとそのリンクが展開されてしまい、無駄に解凍後サイズが大きくなってしまいます。CDK の fromAsset を使用するときでも内部的には zip 化されるため、同様の問題が発生します。

そこで、zip 化する際にシンボリックリンクをそのまま保存するようにします。例えば、zip コマンドを使用する場合、-y オプションを指定します。

zip -r9y layer.zip nodejs/node_modules

pnpm の場合は deploy コマンド を使用すると便利です。

バンドラーで最適化する

React Router v7 ではデフォルトだとクライアントコードのみバンドルされます。そのため、サーバーコードに関しては依存関係を node_modules に含める必要があります。しかし、250 MB は結構シビアで、今回もサーバーコードの依存関係だけでオーバーしていました。そこで、サーバーコードに関してもバンドルしたいと思います。

方法は簡単で、vite.config.ts を以下のように変更します。

export default defineConfig(({ isSsrBuild }) => ({
  ...
  ssr: isSsrBuild
    ? {
        noExternal: true,
        external: [
          "@react-router/fs-routes",
          "@react-router/node",
          "@react-router/express",
          "express",
          "compression",
          "isbot",
          "morgan",
          "serverless-http",
          "react",
          "react-dom",
          "react-router",
        ],
      }
    : undefined,
}));

最も重要なのは 5 行目の noExternal: true です。これにより、Vite はすべての依存関係をバンドル対象とします。あとは、external 配列にバンドルから除外したい依存関係を追加します。今回はデフォルトで入っていたものは external としました。サーバーの実行に必要なので。

実運用では、バンドルすると本体の関数の容量を大きくしてしまうようなものは external に含めると良いと思います。

当然ですが、external に含めたものは dependencies に含める必要がありますが、それ以外は devDependencies でも問題ありません。

ちなみに、以上の操作は CDK だと次のようになります。

const layerBucket = s3.Bucket.fromBucketName(
  this,
  "LambdaLayerBucket",
  this.layerBucketName
);

const lambdaLayerVersion = new lambda.LayerVersion(this, "KanarumeWebLayer", {
  code: lambda.Code.fromAsset(
    "../web/layer/layer.zip"
  ),
  compatibleRuntimes: [lambda.Runtime.NODEJS_22_X],
});

const lambdaFunction = new lambda.Function(this, "KanarumeWebFunction", {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("../web", {
    exclude: ["node_modules", "app", "layer"],
  }),
  layers: [lambdaLayerVersion],
  timeout: cdk.Duration.minutes(3),
  memorySize: 1024,
  architecture: lambda.Architecture.ARM_64,
});

ウォーマー関数の導入

これでもコールドスタートはかなり改善されます。しかし、まだまだ高速化の余地があります。 そこで、ウォーマー関数を導入します。ウォーマー関数とは、定期的に本体の Lambda 関数を呼び出すことで、Lambda のインスタンスを温めておく関数です。次のような関数コードを使用します。

import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";
import type { Handler } from "aws-lambda";

const FUNCTION_NAME = process.env.FUNCTION_NAME;
const AWS_REGION = process.env.AWS_REGION || "ap-northeast-1";

const lambdaClient = new LambdaClient({ region: AWS_REGION });

export const handler: Handler = async (event) => {
	console.log("Warmer function triggered", event);

	if (!FUNCTION_NAME) {
		throw new Error("FUNCTION_NAME environment variable is not set");
	}

	try {
		// Lambda 関数を直接呼び出し
		const command = new InvokeCommand({
			FunctionName: FUNCTION_NAME,
			InvocationType: "RequestResponse",
		});

		const response = await lambdaClient.send(command);

		if (response.FunctionError) {
			throw new Error(
				`Function invocation failed: ${response.FunctionError}`,
			);
		}

		console.log("Successfully warmed up the function");

		return {
			statusCode: 200,
			body: JSON.stringify({
				message: "Function warmed up successfully",
			}),
		};
	} catch (error) {
		console.error("Error warming up function:", error);
		throw error;
	}
};

この関数を EventBridge で 5 分おきに実行するように設定します。 これで、多少なりともコールドスタートを防ぐことができます。

最終的には、以下のような構成になります。

非 HEAD/GET リクエストの対応

React Router v7 で POST などのリクエストを扱う場合、Lambda の Function URL を直接使用すると問題が発生します。Function URL では、非 HEAD/GET リクエストに対して x-amz-content-sha256 ヘッダーを要求します。しかし、React Router v7 のサーバーコードはこのヘッダーを生成しないため、リクエストが失敗してしまいます。

そこで、Lambda@Edge を使用して、リクエストボディーの sha256 を計算し、x-amz-content-sha256 ヘッダーに追加する処理を実装します。

import { CloudFrontRequestEvent, CloudFrontRequestHandler } from "aws-lambda";

export const handler: CloudFrontRequestHandler = async (event) => {
  const request = event.Records[0].cf.request;

  if (!request.body) {
    return request;
  }

  const body = Buffer.from(request.body.data, "base64").toString("utf-8");

  // ボディのハッシュを計算
  const encoder = new TextEncoder().encode(body);
  const hashBuffer = await crypto.subtle.digest("SHA-256", encoder);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  // ヘッダーにハッシュを追加
  request.headers["x-amz-content-sha256"] = [
    { key: "x-amz-content-sha256", value: hashHex },
  ];

  return request;
};

この Lambda@Edge 関数を CloudFront のビヘイビアに関連付けることで、非 HEAD/GET リクエストも正しく処理できるようになります。

おわりに

以上の方法で、React Router v7 を Lambda で効率的に動作させることができます。

Next.js では OpenNext などを使わなければいけない一方、この方法を使えば、ほぼそのままのコードでサーバーレスに対応できるのは非常に便利です。ぜひ選択肢の一つとして検討してみてください。