1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js の Vercel 依存機能は、Lambda Web Functions で動くのか? ISR・画像最適化・キャッシュを実測した

1
Last updated at Posted at 2026-10-19

Next.js を Vercel 以外で動かすと、ISR(Incremental Static Regeneration)や画像最適化のキャッシュなど、プラットフォームに任せていた部分を自分で用意することになります。AWS で動かすなら、OpenNext のような変換レイヤーが定番でした。

2026年10月に AWS が出した Lambda Web Functions は、ポートを listen する普通の HTTP サーバーをそのまま Lambda に載せられる機能です。OpenNext なしで、Vercel のベンダーロックインになりやすい機能(ISR、画像最適化、キャッシュなど、Next.js がプラットフォームに任せている部分)が Vercel の外でどこまで動くのかを、ほかの主要機能とあわせて実測しました。Cron や Blob のような Vercel 固有のプラットフォーム機能は、Next.js の機能ではないので対象外です。Lambda 以外に使ったのは、S3(コード置き場)、IAM ロール、CloudWatch Logs だけです。

先行して、クラスメソッドさんの記事が、Next.js を Lambda Web Functions に載せる手順を紹介しています。この記事は、載せた後に各機能がどう動くか(実測と、複数環境でのキャッシュ検証)が軸です。

結論(TL;DR)

  • 200 のまま黙って壊れるものが 2 つありました。どちらもエラーになりません
    • タイムアウトでのストリームの打ち切り。既定の 30 秒でも、120 秒に設定しても切れました。終端マーカーを流せば、クライアントで検知できます
    • 約 10 MiB 超の POST の切り詰め。proxy.ts を使っているときだけ起きます(消すと 32 MiB が届きました。ローカルで確認)。Lambda ではなく Next.js の仕様で、ほかのセルフホストでも起きます
  • ストリーミング SSR(サーバーサイドレンダリング)、Server Actions、proxy.ts、after()(アイドル時は未検証)は動きました。ストリーミングは、チャンクがバッファされず、解決した順に流れてきます
  • unstable_cache と use cache のキャッシュは、環境ごとに分かれます(メモリ上と思われます)。環境とは、Lambda が用意してリクエストを処理する「実行環境」のことで、トラフィックに応じて増減します。環境が 2 つあると、revalidateTag を受けた環境だけが更新されました(各 2 ラウンド以上。ISR は複数環境では未検証です)
  • next/image は動きますが、最適化した画像がキャッシュされず、毎回再最適化されます
  • Route Handler の req.url は 0.0.0.0:3000 になります。絶対 URL は Host で作り、x-forwarded-host は使わないでください(公式ドキュメントの注意です。理由は本文にあります)
  • そのほか: WebSocket と HTTP/2 は使えません。大きなレスポンスの転送は、手元からは遅く、AWS 内からは速かったです(原因は未特定)

検証環境と方法

Next.js 16.4.1(App Router、output: "standalone")/ Lambda Web Functions nodejs24.x(arm64)/ 東京リージョンです。Vercel とは比較していません。

アプリは 2 つに分けました。ISR、unstable_cache、next/image などは通常のアプリ、Cache Components(use cache と PPR。PPR は Partial Prerendering の略で、静的な殻を先に返し、動的な部分を後から流す仕組みです)は cacheComponents: true を有効にした別のアプリです。公式ドキュメントによると、Cache Components を有効にすると export const dynamic などの route segment config は使えなくなるので、同じアプリでは動かしていません。

Web Function は、2 通りの方法で作りました。

  • Terraform: AWS プロバイダ(hashicorp/aws 6.69.0 の時点)に Web Function 用のリソースが無かったため、local-exec から aws lambda-web create-web-function を呼びました。S3・IAM・ロググループは Terraform で管理しています。構成全体は、公開するほどの内容ではないので載せず、要になる部分だけ抜粋します
  • aws lambda-web deploy: 動作確認、next/image の確認、use cache の検証に使い、unstable_cache の検証でも同じ結果を再現しました(後述)
Terraform の抜粋(クリックで開く)
resource "terraform_data" "web_function" {
  triggers_replace = { version_id = aws_s3_object.app.version_id }
  input            = { aws_cli = var.aws_cli, name = var.function_name, region = var.region }

  # 作成: スクリプトが create-web-function を呼び、revision が Active になるのを待ち、maxEnvironments を設定する
  provisioner "local-exec" {
    command     = "${path.module}/scripts/create-web-function.sh"
    environment = { BUCKET = aws_s3_bucket.code.id, KEY = aws_s3_object.app.key, VERSION_ID = aws_s3_object.app.version_id, ROLE_ARN = aws_iam_role.exec.arn }
  }

  # 削除: terraform destroy で Web Function も消す。並列実行のレート制限に備えてリトライする
  provisioner "local-exec" {
    when    = destroy
    command = "for i in 1 2 3 4 5; do ${self.input.aws_cli} lambda-web delete-web-function --function-name ${self.input.name} --region ${self.input.region} && break; sleep 10; done; true"
  }
}

スクリプトの中身は、公式ドキュメントにある create-web-function の呼び出しです(--revision-config に S3 のコード置き場と実行ロール、--endpoint-config に authType: ApplicationManaged と autoDeploymentMode: LatestRevision)。公開して動かすだけなら、deploy で足ります。

測定は、2026年10月15〜16日に、1 台のマシンから東京リージョンに対して行いました(aws CLI 2.38.1)。公式の発表は 2026年10月12日(Posted on)で、「is available today」とあるだけで、GA(一般提供)かプレビューかの記述はありませんでした。仕様や性能は、今後変わる可能性があります。特記がない値は、Terraform で作った関数で取ったものです。

検証結果の一覧

機能 結果 メモ
動的 SSR ✅
ストリーミング SSR(Suspense) ✅ バッファされず順に届く
Route Handler のストリーミング / SSE(Server-Sent Events) ✅
Server Actions ✅ 実ブラウザで確認
proxy.ts(旧 middleware) ✅ Node.js ランタイムで動く
next.config の redirects / rewrites / headers ✅
after() ✅ レスポンス後の処理が最後まで走った(アイドル時は未検証)
next/og(ImageResponse) △ レスポンスの確認のみ
runtime = "edge" の Route △ standalone サーバー(Node.js)の中で動く。レスポンスの確認のみ
Draft Mode △ バイパス Cookie が付くことの確認のみ
next/image ⚠️ 動くがキャッシュされない
Cache Components(PPR / use cache) ⚠️ PPR は単一環境のみ。use cache は実測(下記)
ISR / revalidatePath / revalidateTag ⚠️ 単一環境では動作。複数環境は未検証(unstable_cache / use cache の実測は下記)
大きなレスポンスの転送 ❓ 手元からは遅く、AWS 内からは速い。原因は未特定
WebSocket ❌
HTTP/2 ❌ HTTP/1.1 のみ

凡例: ✅ は動作を確認(7 項目)、△ はレスポンスが返ることだけを確認(3 項目)、⚠️ は動くが制約あり(3 項目)、❓ は原因が特定できていない(1 項目)、❌ は動かない(2 項目)。

表の補足:

  • next/image: macOS でビルドすると、sharp の差し替えも必要です

デプロイは 5 行のエントリファイルだけ

Lambda Web Functions は AWS_LAMBDA_HTTP_ENDPOINT(host:port)で待ち受けアドレスを渡してきます。Next.js の standalone は HOSTNAME / PORT を読むので、変換するだけのエントリファイルを置けば足ります。

// index.js
const endpoint = process.env.AWS_LAMBDA_HTTP_ENDPOINT || "0.0.0.0:3000";
const i = endpoint.lastIndexOf(":");
process.env.HOSTNAME = endpoint.slice(0, i);
process.env.PORT = endpoint.slice(i + 1);
require("./server.js");

ビルドから公開までの流れです。

npm run build                                # next.config.ts で output: "standalone"
cp -R public .next/standalone/public         # public/ が無いプロジェクトでは不要
cp -R .next/static .next/standalone/.next/static
cp index.js .next/standalone/index.js
# macOS でビルドした場合は、ここで下の「sharp の差し替え」が必要
aws lambda-web deploy --name my-next-app --code .next/standalone --create

aws lambda-web は、私が aws --version で確認した範囲では、2.38.1 で使えました(2.36.44 には入っていませんでした。最低バージョンは確認していません)。deploy は zip 化、S3 へのアップロード、Web Function の作成までを一度にやってくれて、数十秒で公開用の HTTPS URL が返ります。--create は、公式ドキュメントにある、新規作成時の確認プロンプトを省くオプションです。

起動コマンドの指定は要りません。公式ドキュメントによると、ランタイムは既定でパッケージ直下の index.js を読み込みます。別のファイルを使うときは、環境変数 AWS_LAMBDA_NODEJS_ENTRYPOINT にパスを指定します(私は index.js のまま使ったので、この環境変数は試していません)。

deploy で作った Web Function は、既定で認証なしの URL で公開されます。検証が終わったら、必ず削除してください。認証を付けるなら --auth-type IamAuth ですが、リクエストごとに SigV4(AWS の署名方式 Signature Version 4)の署名が必要になり、ブラウザから直接は開けません(Lambda Web Function endpoint access controlは、CloudFront の OAC(Origin Access Control)を前段に置く構成を案内しています)。

deploy は、Web Function のほかに、S3 バケット awscli-lambdaweb-<account_id>-<region>-an(バージョニング有効)、IAM ロール awscli-lambdaweb-<function_name>、ロググループを作ります。名前は、実際に作られたものを確認した値です。delete-web-function で消えるのは Web Function だけで、バケットとロール、ロググループは残ります。

後片付けの手順(クリックで開く)

バケットは、Creating a Lambda Web Functionでは、同じアカウント・リージョンの他のデプロイと共有されうると書かれています。同じアカウント・リージョンに別の awscli-lambdaweb-* の関数(aws lambda-web list-web-functions で確認できます)が無いことを確かめてから削除してください。私は、自分のデプロイだけの環境で、次の手順で片付けました。

FN=my-next-app
BK=awscli-lambdaweb-<account_id>-ap-northeast-1-an

aws lambda-web delete-web-function --function-name $FN

# バケットはバージョニング有効なので、全バージョンを消してから削除する
# 1,000 件を超える場合は分けて実行する。バケットが空で Versions が null のときは、delete-objects を実行しないこと
aws s3api list-object-versions --bucket $BK \
  --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' --output json > /tmp/del.json
aws s3api delete-objects --bucket $BK --delete file:///tmp/del.json
# delete-bucket が失敗したら、削除マーカーが残っている。次のコマンドで同様に消す
# aws s3api list-object-versions --bucket $BK \
#   --query '{Objects: DeleteMarkers[].{Key:Key,VersionId:VersionId}}' --output json > /tmp/del2.json
# aws s3api delete-objects --bucket $BK --delete file:///tmp/del2.json
aws s3api delete-bucket --bucket $BK

# ロール(付いているポリシーを先に外す)
aws iam list-role-policies --role-name awscli-lambdaweb-$FN
aws iam list-attached-role-policies --role-name awscli-lambdaweb-$FN   # あれば detach-role-policy で外す
aws iam delete-role-policy --role-name awscli-lambdaweb-$FN --policy-name <policy_name>
aws iam delete-role --role-name awscli-lambdaweb-$FN

aws logs delete-log-group --log-group-name /aws/lambda/web/$FN
aws logs delete-log-group --log-group-name /aws/lambda/web/$FN.local   # リージョンごとのロググループ

削除の直後に、Lambda がロググループを書き戻して再作成することがありました。しばらくしてから、もう一度確認してください。

Terraform 版は、S3・IAM ロール・ロググループを Terraform が管理し、Web Function は削除用の provisioner が CLI で消すので、terraform destroy で消えることを実際に確認しました。ただし、次の点に注意してください。

  • 並列で CLI を呼んだときに、レート制限で Web Function の削除だけ失敗したことがありました。そのため、削除用の provisioner にリトライを入れています。ただし、末尾が ; true なので、5 回失敗しても握りつぶします。destroy の後に aws lambda-web list-web-functions で、残りがないか確認してください
  • 削除の直後に、Lambda がロググループ(リージョンごとの .local を含む)を書き戻して、残ることがありました。destroy の後に、ロググループが残っていないかも確認してください

一方、deploy が作ったものは Terraform の管理外なので、上の手順が必要です。

macOS でビルドする場合は sharp を差し替える

standalone に入る sharp のネイティブバイナリは、ビルドしたマシン(darwin)用になります。Lambda は linux/arm64 なので、next/image を使うなら差し替えが必要です。

# 一時ディレクトリで linux/arm64 用の sharp を取得する(バージョンは standalone に入っているものに合わせる)
mkdir /tmp/sharp-linux && cd /tmp/sharp-linux && npm init -y
npm install sharp@<version> --os=linux --cpu=arm64 --libc=glibc
cd -

# standalone 側の node_modules を差し替える
rm -rf .next/standalone/node_modules/@img .next/standalone/node_modules/sharp
cp -R /tmp/sharp-linux/node_modules/@img /tmp/sharp-linux/node_modules/sharp .next/standalone/node_modules/

プロジェクトルートで npm install しても、standalone には反映されません。standalone は、トレースされた依存だけを持つ独自の node_modules を持っているためです。差し替え後は WebP 変換まで動きました。

ストリーミング SSR はバッファされずに届く

Suspense を 3 つ(1 秒・2 秒・3 秒)並べたページで、チャンクの到着時刻を測りました。

+51 ms    HTML シェル
+1058 ms  chunk-1s
+2098 ms  chunk-2s
+3047 ms  chunk-3s

バッファされていれば最後に一気に届くはずですが、解決した順に流れてきます。SSE も 500 ms 間隔のまま届き、Accept-Encoding: gzip を付けると content-encoding: gzip で返ってきますが、チャンクの到着時刻は圧縮なしと同じように逐次でした。

PPR('use cache' と Suspense の動的な穴)でも、静的シェルが先に届き、動的部分が後から埋まります。ウォーム時の計測では、シェルが 35 ms、動的部分(1.5 秒の待ちを入れたもの)を含む完了が 1575 ms でした。ただし、PPR を確認したのは環境が 1 つの状態だけです。

キャッシュは「環境ごと」(unstable_cache と use cache で実測)

この挙動は、Lambda に固有のものではありません。複数のインスタンスで Next.js をセルフホストすれば、既定のキャッシュは同じように分かれるはずです(Self-Hosting ガイドも、同じ趣旨で書かれています)。この記事の価値は、Lambda Web Functions の上で実測した点にあります。

環境のファイルシステムは、コードを置く /var/task が読み取り専用で、書けるのは /tmp だけです。

/tmp        -> writable
/var/task   -> NOT writable: EROFS: read-only file system

Next.js は ISR の結果をディスクに書こうとして失敗し、警告を出します。ただし、環境が 1 つの間は、ISR(revalidatePath も、ビルド時に無かったページのオンデマンド生成も)が動くことを確認しました。ディスクではなく、メモリ上で動いているように見えます(内部の仕組みまでは確認していません)。

Failed to update prerender cache for /isr Error: ENOENT: no such file or directory, mkdir '/var/task/...

警告上のエラーは EROFS ではなく ENOENT でした。私が fs.writeFileSync で試した書き込みは EROFS、存在しない .next/cache の fs.mkdirSync(recursive)は ENOENT でした(ENOENT になる理由は、調べていません)。

問題は環境が増えたときです。Lambda Web Functions は、1 つの環境で複数のリクエストを同時に処理し、足りなければ環境を増やします。1 環境あたりの同時数の上限は、既定で 64 です(maxConcurrencyPerEnvironment、設定できる範囲は 1〜128。実際の同時数は動的に決まります)。

unstable_cache の検証

検証用のページです。期限切れによる再生成が混ざらないよう、タイマーは unstable_cache の 1 つ(revalidate: 3600)だけにしました。各レスポンスに、返した環境の ID と、キャッシュを作った環境の ID・時刻を載せています。revalidateTag を呼ぶ Route Handler も、受けた環境の ID を返します。

// app/cache-test/page.tsx
import { unstable_cache } from "next/cache";
import { headers } from "next/headers";
import { lab } from "@/lib/lab";

export const dynamic = "force-dynamic"; // ページ自体は毎回描画する

const cached = unstable_cache(
  async () => ({
    computedAt: new Date().toISOString(),
    computedBy: lab.instanceId,
  }),
  ["lab-cache-test"],
  { tags: ["cache-test"], revalidate: 3600 },
);

export default async function CacheTest() {
  await headers(); // 動的 API を呼んで、確実に動的描画にする
  const v = await cached();
  const body = JSON.stringify({ servedBy: lab.instanceId, ...v });
  return <pre id="data">{body}</pre>;
}
// lib/lab.ts(抜粋)。プロセスの起動時に一度だけ作る ID。
// route ごとにバンドルが分かれても同じ値になるよう、globalThis に置いています
const g = globalThis as unknown as { __lab?: { instanceId: string } };
export const lab = (g.__lab ??= {
  instanceId: crypto.randomUUID().slice(0, 8),
});

revalidateTag を呼ぶ Route Handler です。Next.js 16 では第 2 引数(プロファイル)が必要で、"max" は stale-while-revalidate(古い値を返しながら、裏で再検証する)の挙動になります(revalidateTag)。

// app/api/revalidate/route.ts(抜粋)
import { revalidateTag } from "next/cache";
import { lab } from "@/lib/lab";

export async function POST(req: Request) {
  const tag = new URL(req.url).searchParams.get("tag")!;
  revalidateTag(tag, "max");
  return Response.json({
    at: new Date().toISOString(),
    handledBy: lab.instanceId,
  });
}

この Route Handler と、後述の /api/slow は、認証なしで誰でも叩けます。コードを流用する場合は、検証が終わったら、必ず関数ごと削除してください。

環境を 2 つにして取得する手順は、次のとおりです。

  1. 環境数の上限 maxEnvironments を、設定できる最小値の 2 にします(aws lambda-web update-web-function-endpoint --function-name <name> --endpoint-name dev --scaling-config '{"maxEnvironments": 2}'。deploy で作った関数にも使えました)
  2. 110 本の同時リクエスト(各 4 秒)を投げて、2 つ目の環境を立ち上げます
  3. 70 本の同時リクエスト(各 3 秒)を居座らせて、2 つの環境の両方が読み取りを受ける状態にします
  4. その状態で、20 リクエストずつ 3 回(revalidateTag の前、後の 1 回目、後の 2 回目)取得します
  5. これを 2 ラウンド行います
追試に使ったスクリプト(要点だけに簡略化したもの。クリックで開く)
// node cache-test.mjs https://<endpoint> 2
const base = process.argv[2];
const rounds = Number(process.argv[3] ?? 2);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function burst(label) {
  // /api/slow は指定ミリ秒だけ待つ。これを居座らせて、2 つの環境の両方に負荷をかける
  const hold = Array.from({ length: 70 }, () =>
    fetch(`${base}/api/slow?ms=3000`).then((r) => r.text()),
  );
  await sleep(700);
  const seen = await Promise.all(
    Array.from({ length: 20 }, async () => {
      const t = await (await fetch(`${base}/cache-test`)).text();
      const body = t.match(/<pre id="data">([^<]*)<\/pre>/)[1]; // ページ内の <pre> の中身だけを取り出す
      return JSON.parse(body.replace(/&quot;/g, '"'));
    }),
  );
  await Promise.all(hold);
  const g = {};
  for (const v of seen) {
    const k = `served=${v.servedBy} cache(by=${v.computedBy} at=${v.computedAt.slice(11, 23)})`;
    g[k] = (g[k] ?? 0) + 1;
  }
  console.log(label, g);
}

// 先に 2 つの環境を立ち上げる
await Promise.all(
  Array.from({ length: 110 }, () =>
    fetch(`${base}/api/slow?ms=4000`).then((r) => r.text()),
  ),
);

for (let i = 1; i <= rounds; i++) {
  await burst("revalidate 前");
  const r = await (
    await fetch(`${base}/api/revalidate?tag=cache-test`, { method: "POST" })
  ).json();
  console.log("revalidateTag at", r.at, "handledBy", r.handledBy);
  await burst("revalidate 後 (1回目)");
  await burst("revalidate 後 (2回目)");
}

/api/slow は、指定したミリ秒だけ待つ Route Handler です。これは通常のアプリ用で、Cache Components を有効にしたアプリでは、export const dynamic = "force-dynamic" の行を消し(ビルドで落ちます)、代わりに await connection()(next/server)を先頭で呼びます。

// app/api/slow/route.ts
export const dynamic = "force-dynamic";

export async function GET(req: Request) {
  const ms = Math.min(
    Number(new URL(req.url).searchParams.get("ms") ?? 1000),
    10000,
  ); // 認証なしで公開するので、待ち時間に上限を付ける
  await new Promise((r) => setTimeout(r, ms));
  return Response.json({ ms });
}

(同時リクエストが多いと 429 を返すことがあるので、実際のスクリプトでは、読み取りをリトライしています。私が見た 429 の種別は Platform.EndpointMaxEnvThrottle でした。)

環境の ID が 0dec… のほうを環境 A、d440… のほうを環境 B と呼びます。表の時刻は、すべて UTC です。

ラウンド revalidateTag を受けた環境 受けた環境のキャッシュ 受けなかった環境のキャッシュ
1(呼び出し 01:17:44) 環境 B 01:17:42 → 01:17:45 に再生成 01:17:42 のまま
2(呼び出し 01:17:54) 環境 A 01:17:42 → 01:17:55 に再生成 01:17:45 のまま
revalidateTag("cache-test") ──▶ 環境 B のメモリ: 再生成される
                                環境 A のメモリ: 古い値のまま(今回の設定なら最大 1 時間)

取得した読み取りは、どちらの環境にも当たっています(revalidateTag の前の 20 件は、環境 A が 10 件、環境 B が 10 件でした)。

どちらのラウンドでも、受けた環境だけが再生成しました。

  • 受けなかった環境: 呼び出しより前に作られた値を、revalidateTag の後に 2 回取得しても返し続けました
  • 受けた環境: 1 回目の取得では古い値が一部返り、2 回目で新しい値だけになりました(stale-while-revalidate の挙動です)
  • どの環境に届くか: revalidateTag がどの環境に届くかは、こちらからは選べません(Lambda が振り分けます)

deploy で作り直した関数でも、同じ結果になりました(別の日の 2 ラウンドで、受けた環境だけが再生成し、受けなかった環境は古い値のままでした)。Terraform 版と deploy 版の各 2 ラウンド、計 4 ラウンドすべて同じ結果でしたが、環境は 2 つだけです。

use cache でも環境ごと

Cache Components の別アプリ(deploy で作った関数)で、'use cache' を使った Route Handler を用意し、同じ検証をしました。cacheLife の revalidate は 3600 秒で、cacheTag("cc-test") を付け、同じタグで revalidateTag を呼びます。

読み取りを 2 つの環境に当てる手順は、unstable_cache と同じです。違いは、居座らせる本数を 70 本ではなく 110 本にしたことだけです。

  • 理由: 最初の試行で、読み取りが片方の環境に偏って見えたため、増やしました(あとで、原因は別だと分かりました)。70 本でも足りた可能性があり、110 本が必要だった証拠はありません
  • 429: 読み取りの 20 本を足すと 128 を超えるので、一部は 429 になり、リトライしています
  • 集計: リトライした読み取りが、同じ環境に当たるとは限りません。表は、取得できたレスポンスだけを集計したものです
// app/api/cc-test/route.ts
import { cacheLife, cacheTag } from "next/cache";
import { connection } from "next/server";
import { lab } from "@/lib/lab";

async function getCached() {
  "use cache";
  cacheLife({ stale: 3600, revalidate: 3600, expire: 86400 });
  cacheTag("cc-test");
  return { computedAt: new Date().toISOString(), computedBy: lab.instanceId };
}

export async function GET() {
  await connection(); // これが無いと、ビルド時に静的に生成される(下記)
  const v = await getCached();
  return Response.json({ servedBy: lab.instanceId, ...v });
}

環境の ID が a0f9… のほうを環境 A、e66f… のほうを環境 B と呼びます。表の時刻は、すべて UTC です。

ラウンド revalidateTag を受けた環境 受けた環境のキャッシュ 受けなかった環境のキャッシュ
1(呼び出し 05:38:38.759) 環境 A 05:38:29 → 05:38:45 に再生成 05:38:38.429 のまま(呼び出しの 0.3 秒前に作られた値)
2(呼び出し 05:38:48.700) 環境 B 05:38:38 → 05:38:52 に再生成 05:38:45 のまま

revalidateTag の前の読み取り 20 件のうち、環境 A が 10 件、環境 B が 9 件で、1 件は取得に失敗しました(429 のリトライ後)。

unstable_cache と同じ結果です。受けた環境だけが再生成し(1 回目の取得では古い値が一部返り、2 回目で新しい値だけになります)、受けなかった環境は古い値を返し続けました。

ハマりどころ:Route Handler がビルド時に固まる

最初の検証は、無効でした。Route Handler に connection() を入れていなかったため、'use cache' を使った GET が、next build のときに静的に生成されていました。返ってきたのは、Lambda の環境ではなく、ビルド時のプロセスの値です(作り直しても同じ ID と時刻が返ってきて、気づきました)。await connection() を入れて、実行時に動くルートにしてから、上の結果を取り直しています。'use cache' を使うルートは、動的 API を呼ばないとビルド時に固まることがあるので、環境ごとの挙動を調べるときは注意してください。

最適化画像は、単一環境でもキャッシュされない

deploy で作った関数で確認しました。next/image のキャッシュ先は .next/cache で、ここも書けません。同じ URL(/_next/image?url=/sample.jpg&w=640&q=75)を 3 回続けて取得すると、x-nextjs-cache は 3 回とも MISS でした。ローカルで next start で動かすと、MISS → HIT → HIT になります。

CloudWatch Logs には、リクエストのたびに次のエラーが出ていました。

⨯ Failed to write image to cache Kf9xQCdn... Error: ENOENT: no such file or directory, mkdir '/var/task/.next/cache'
⨯ unhandledRejection: Error: ENOENT: no such file or directory, mkdir '/var/task/.next/cache'

画像自体は返ってきます。unhandledRejection が出ても、プロセスは再起動していませんでした。別の実験で、画像を 5 回取得する前後に、環境の ID と PID(プロセス ID)が同じだったためです。ただ、同じ画像でも毎回 sharp で再最適化するため、CPU を余分に使います。

回避策として、.next/cache を /tmp へのシンボリックリンクにして、起動時に /tmp/next-cache を作る方法も試しましたが、.next/cache がそもそも存在しないままで効きませんでした(deploy の zip にシンボリックリンクが入らなかったのだと思いますが、原因までは調べていません)。

キャッシュの対策の方向性

キャッシュの置き場を Lambda の外の共有ストア(Redis や S3 など)にするのが基本です。Next.js 公式の Self-Hosting ガイドでも、複数インスタンスではそうするよう案内されています。設定は次のように分かれています。

  • cacheHandler: ISR と Route Handler のレスポンスのキャッシュ用
  • cacheHandlers(複数形): use cache 用
  • images.customCacheHandler: true: 最適化画像のキャッシュに cacheHandler を使うためのオプトインで、Next.js 16.2.0 以降(Custom Next.js Cache Handler の Image Optimization Caching と Version History)
  • images.loader: 外部の画像最適化サービスを使う方法(Image Optimization)

検証に使った 16.4.1 は条件を満たしていますが、いずれも試していません。次に試すなら、ここからです。

200 のまま黙って壊れるもの

エラーにならず、200 のまま内容が欠けるものが 2 つありました。壊れたデータが正常な応答として渡るので、検知しにくいのが危険です。

ストリーミングはタイムアウトで黙って切れる

まず、リクエストタイムアウト(timeoutSeconds)を 120 秒に設定して、130 秒かかる処理と、1 秒間隔で 130 チャンクを流すストリーミングを、同時に投げました。設定できる範囲は 3〜900 秒で、既定は 30 秒です(Request timeout)。

種類 結果
通常のレスポンス 120 秒で 504(Runtime.RequestTimeout)
ストリーミング(130 チャンク) 120 チャンク目で打ち切り。ステータスは 200 のまま、クライアントには正常終了に見える

クライアント(Node.js の fetch)は、エラーなしでレスポンスボディを最後まで読み終え、受け取れたのは 120 チャンクでした。lambda-web-error-type ヘッダも付いていません。

次に、既定の 30 秒のまま(deploy で作った関数の revision の timeoutSeconds は 30 でした)、1 秒間隔で 40 チャンクを流し、最後に終端マーカー DONE を流すルートで確認しました。

ケース 結果
5 チャンク(5 秒) 5 チャンク + DONE。正常に完了
40 チャンク(40 秒) 30 チャンクで打ち切り。ステータスは 200、lambda-web-error-type ヘッダなし、DONE は届かない

既定の 30 秒のまま使うと、30 秒を超える LLM(大規模言語モデル)のストリームは、エラーなしで切れます。ストリームの最後に終端マーカーを流して、クライアント側で届いたかを検査すれば、途中で切れたことを検知できました。設定の上限は 900 秒なので、それより長いストリームは流せないと考えられます(実測はしていません)。

約 10 MiB を超える POST は切れる(Lambda のせいではない)

POST のサイズを変えて、サーバー側で受け取れたバイト数を調べました(ボディはランダムなバイト列です)。

送ったサイズ サーバーが受け取ったバイト数 HTTP ステータス
1 / 4 / 6 / 7 MiB 全部 200
10 MiB(10,485,760 バイト) 全部(10,485,760 バイト) 200
32 MiB(33,554,432 バイト) 10,485,411 バイトで切れる(ローカルでは 10,468,388 バイト) 200

7 MiB(約 7.3 MB)でも 10 MiB(約 10.5 MB)でも届いているので、Lambda の同期呼び出しのペイロード上限(6 MB)の影響は受けていません。6 MB を MB と MiB のどちらの意味で読んでも、この表の 7 MiB 以上は超えています。

切れたボディは、アプリ側では「壊れたデータ」として表に出ます。たとえば、JSON のパースエラーや、壊れたファイルのアップロードです。気づくには、リクエストの Content-Length と、実際に受け取ったバイト数を突き合わせる方法が考えられます(試していません)。

原因は Next.js の proxy.ts です。ローカルの node index.js でも同じく再現したので、ほかのセルフホストでも踏みます。さらに、proxy.ts を消して作り直したアプリをローカルで動かすと、32 MiB(33,554,432 バイト)が最後まで届きました。

公式ドキュメント(proxyClientMaxBodySize)には、次の趣旨の記述があります(意訳です)。proxy.ts があると、リクエストボディを 10MB(公式の表記。この記事では MiB で書いていて、実測は 10 MiB 付近です)までバッファする。上限を超えると、先頭の N バイトだけをバッファし、警告をログに出す。リクエストは失敗せず、エラーも返さない。

10 MiB よりわずかに手前で切れるのは、上限をチャンクの単位で判定しているためではないかと推測しています。10 MiB ちょうどのボディが最後まで届くのも、同じ理由(上限以内に収まっていた)と推測しています。

回避策は、next.config.ts で上限を上げることです。

import type { NextConfig } from "next";

const config: NextConfig = {
  output: "standalone",
  experimental: { proxyClientMaxBodySize: "50mb" },
};

export default config;

これで 32 MiB が最後まで届くことを、ローカルと Lambda の両方で確認しました(Lambda 上でも、受け取ったバイト数が 33,554,432 で、ハッシュも一致しました)。

大きなレスポンスは、手元からは遅く、AWS 内からは速かった(原因は未特定)

  • 手元(日本国内の回線)から: Web Function は 0.3〜1.7 MB/s でした。同じ回線から東京リージョンの S3 は 3.7 MB/s で、Web Function は約 2〜12 倍遅い場合がありました
  • AWS 内から: Web Function から自分自身の公開 HTTPS URL へリクエストすると、8.7〜10.5 MB/s で取得できました。そのため、Web Functions 側に固定の上限があるとは言えません
  • 原因: 手元の回線や経路なのか、エンドポイントまでの経路や挙動なのかは、特定できていません

次の一手として、別の回線(モバイルなど)や別リージョンからの測定、並列接続での測定(私の測定は、HTTP/1.1 の 1 本の接続でした)、通常の Lambda Function URL との比較が考えられます。どれも未実施です。大きなファイルを配信する場合は、実際の利用者に近い回線から測ってから判断してください。

測定の詳細(クリックで開く)

手元から、レスポンスのサイズを変えて転送時間を測りました。

サイズ 所要時間 転送速度(MB/s。1 MB = 106 バイト)
1 MiB(1 回目) 0.7 秒 1.4〜1.5
1 MiB(2 回目) 1.4 秒 0.74
2 MiB(※) 4.2 秒 0.50
3 MiB(※) 7.2 秒 0.44
4 MiB(※) 8.4 秒 0.50
6 MiB 10〜20 秒 0.31〜0.63
20 MiB 62 秒、後日の再測定で 12 秒と 19 秒 0.34、後日 1.7 / 1.1

※ 2〜4 MiB は、同じアプリを作り直す前のデプロイでの値です。

  • 事実: TTFB(最初の 1 バイトが届くまでの時間)は 0.15 秒前後で、本文の転送だけが遅くなっていました。2〜4 MiB ではほぼ一定(0.44〜0.50 MB/s)で、1 MiB や 6 MiB 以上では測るたびにばらつきました。レスポンスの返し方を変えても(単一 Buffer、チャンク分割、静的ファイル)、手元からは同じように遅くなりました
  • 推測: 速度がほぼ一定に見える点は、どこかの経路で帯域が絞られている可能性を示しますが、手元の回線なのか、エンドポイント側なのかは特定できていません

切り分けのために、次の比較もしました。

測定 結果
手元 → 東京リージョンの S3 3.7 MB/s
手元 → Cloudflare 6.5 MB/s
ローカルで同じアプリを動かして取得 20 MiB が 0.02 秒
Web Function の中から、自分自身の公開 URL へ 6 MiB 0.6 秒(10.5 MB/s)
Web Function の中から、自分自身の公開 URL へ 20 MiB 2.4 秒(8.7 MB/s)

AWS 内からの測定は、公開 HTTPS URL(https://….on.aws)へのリクエストで、AWS 内のどの経路を通ったかは確認していません。手元からエンドポイントまでの往復時間(RTT)は、S3 と同程度(0.03〜0.07 秒)でした。

Route Handler の URL と、WebSocket / HTTP/2 の制約

Route Handler の req.url が 0.0.0.0:3000

Lambda 上の Route Handler で req.url を出力すると、次のようになりました。

https://0.0.0.0:3000/api/env

環境変数が効かずにフォールバックしているわけではありません。アプリの中で process.env.AWS_LAMBDA_HTTP_ENDPOINT をそのまま出力すると、0.0.0.0:3000 でした(フォールバックを通さない値なので、Lambda が渡した値です)。standalone サーバーは HOSTNAME の値で URL を組み立てるので、この値が出ます。proxy.ts 内と next.config のリダイレクトは、正しいドメインになりました。

Route Handler で絶対 URL を作るときは、次のようにしてください(x-forwarded-host の実測以外は、公式ドキュメントの記述によるもので、偽装したリクエストでの実測はしていません)。

  • Host ヘッダを使う。Lambda が検証したエンドポイントのドメイン名が入ります(HTTP requests and responses)
  • または、環境変数で固定したオリジンから組み立てる
  • x-forwarded-host は使わない。クライアントが送った値は検証されずに転送される、と公式ドキュメントに書かれていて、リダイレクト、絶対 URL、ホストベースの認可には使わないよう注意されています。使うと、パスワードリセット URL の汚染やオープンリダイレクトにつながります(私の curl はこのヘッダを送っていないのに、エンドポイントのドメインが入っていました。実測はこれだけです)。前段に CloudFront などを置く場合に Host がどうなるかは、未検証です

WebSocket と HTTP/2 は使えない

  • WebSocket: Upgrade ヘッダが除去され、アプリに届きません。リアルタイム通信は SSE にするのが現実的です
  • HTTP/2: HTTP/1.1 のみです。HTTP/1.0 は 505 を返します

確認は次のようにしました。

# WebSocket の Upgrade ヘッダ付きで GET し、アプリ側(/api/ws)が見たヘッダを返させる
curl -s -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZQ==' https://<endpoint>/api/ws
# => {"upgradeHeader":null,"connectionHeader":"close", ...}    Upgrade が消えている

curl -s --http2 -o /dev/null -w 'http_version=%{http_version}\n' https://<endpoint>/api/env
# => http_version=1.1                                          HTTP/2 にならない
curl -s -0 -o /dev/null -w '%{http_code}\n' https://<endpoint>/api/env
# => 505                                                      HTTP/1.0

環境の仕様と挙動

  • Node.js v24 / linux / arm64。Node から見えた CPU は 2 個、メモリは os.totalmem() で約 2.4 GB でした。課金や制限の上では、環境の仕様は 2 vCPU・2 GB で固定です(公式ドキュメント)。見え方の差の理由は、本筋ではないので調べていません
  • コールドスタート: 初期化 552 ms、手元から見た初回リクエストは 1.1〜1.5 秒。ウォーム時は 0.16〜0.25 秒
  • スケールゼロ: 5 分アイドルでは環境が残り、10 分アイドルで新しい環境になりました(各 1 回の観測)。環境ごとのキャッシュは、環境と一緒に消えます。トラフィックが少ないサイトでは、環境が増えることよりも、アイドルで環境が消えてキャッシュが失われることのほうが、先に起きるかもしれません
  • 同時実行: Terraform で作った関数(maxEnvironments は 2)で、1 環境あたり 64 並列(既定の上限)、合計 128 並列でした。なお、環境の数は、設定した maxEnvironments を一時的に超えることがあると、公式ドキュメントに書かれています(同時実行数の話ではありません)
  • 429: 200 本の同時リクエスト(各 3 秒)のうち、128 本が成功し、残りの 72 本が 429(Platform.EndpointMaxEnvThrottle)になりました
  • 上限なしの場合: maxEnvironments を設定しなければ、ドキュメント上はアカウントの vCPU クォータの範囲で増えます(試していません)

まとめ:使い分けの目安

  • 向いている: SSR / API が中心、キャッシュは短命でよい、LLM のようなストリーミング応答がある(timeoutSeconds の調整が前提です)
  • 工夫が要る: 最適化画像、use cache や unstable_cache のキャッシュを複数環境で共有したい(ISR も、同様の可能性が高いですが、未検証です)、revalidateTag を確実に効かせたい。cacheHandler / cacheHandlers に S3 / DynamoDB / Redis などの共有ストアを設定する案がありますが、今回は試していません
  • 向いていない(今のところ): WebSocket。大きなファイルの配信は、利用者の回線から確認してからにしてください。画像や静的アセットは、Next.js が Cache-Control: public, max-age=31536000, immutable を付けて返すので(_next/static で確認)、CloudFront に任せて Lambda の負荷を減らせる可能性があります(未検証)

OpenNext との比較はしていません。OpenNext は ISR 用のキャッシュや画像最適化の仕組みを備えていると言われているので(今回は確認していません)、実際の選択は「Web Functions で共有キャッシュを自前で組む」か「OpenNext を使う」かになりそうです。

検証していないこと

  • after() が、アイドル時(ほかのリクエストが無いとき)に凍結されないか。3 秒後に走らせた処理が最後まで走ったことだけ確認しました
  • ISR(ページ全体)の複数環境での挙動。unstable_cache と use cache のみ実測しました
  • cacheHandler / cacheHandlers による共有キャッシュの実装
  • コスト。Lambda Web Functions は、リクエスト数、アクティブ CPU 時間、環境が存在する間のメモリで課金されます。CloudFront を置かない構成では _next/static の配信も Lambda が受けるため、静的アセットが多いサイトでは負荷と費用に効くはずですが、見積もっていません
  • Vercel 固有のプラットフォーム機能(Cron、Blob、Analytics、Firewall、プレビューデプロイ)。Next.js の機能ではないので対象外です
  • Skew Protection(deploymentId)と、複数環境での Server Actions の暗号鍵(公式ガイドでは、複数インスタンスなら NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を揃えるよう書かれています)
  • CloudFront を前段に置いた構成(独自ドメインや WAF(Web Application Firewall)が必要なら、公式ドキュメントでは CloudFront が前提です)
  • 手元以外の回線からの転送速度

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?