Lambda Web Functions と Lambda Web Adapter で、アセット数多めの Next.js(SSR)ページの配信性能を比べてみた

Lambda Web Functions と Lambda Web Adapter で、アセット数多めの Next.js(SSR)ページの配信性能を比べてみた

同じ Next.js(SSR)アプリを Lambda Web Functions と Lambda Web Adapter(LWA)に載せ、ブラウザの表示と、k6 による 30 秒間のスループットを比べました。ページ/秒は Web Functions が LWA の約 1.3 倍でした。
2026.10.14

はじめに

2026年10月12日、AWS Lambda Web Functions が発表されました。

https://aws.amazon.com/jp/blogs/aws/announcing-aws-lambda-web-functions-for-fast-consistent-web-app-performance-anywhere/

紹介記事では Web Functions の利点として、実行環境が複数のリクエストを同時に処理できること(1 環境あたり最大 128 リクエスト)と、受信トラフィックがバーストしてもコールドスタートが生じにくいことが挙げられています。

今回、Next.js のテストアプリ(SSR)を用意し、新しく利用可能になった Web Functions と、従来の Lambda Web Adapter(LWA)を載せた Lambda 関数に、同じビルド済みのアプリを載せました。ブラウザでの表示と、k6 による 30 秒間のスループットに、どのような違いが現れるか確認したので紹介します。

検証内容

検証アプリと条件

アプリは App Router の Next.js で、SSR のトップページと、固定の JSON を返す API(/api/ping)で構成されています。/api/ping は LWA の readiness check にも使います。トップページには next/dynamic のウィジェットが 150 個あり、1 回の表示で HTML 1 本と JS 156 本(合計 246,645 バイト)を取得します。

環境は Next.js 16.4.0、React 19.3.0、Node.js 24、リージョンは東京です。どちらも認証なしで公開し、同じビルド済みのアプリを載せました。LWA の関数には予約済み同時実行数を設定していないので、同時実行数の上限はアカウント全体の上限(1000)です。両者でそろえられなかった項目は次の表のとおりです。

項目 Web Functions Function URL + LWA
パッケージ形式 zip コンテナイメージ
公開経路 Web Functions のエンドポイント Function URL
環境あたりの同時リクエスト 多重処理(上限の既定値 64) 1 リクエスト
CPU / メモリ 1 環境 2 vCPU(固定)。メモリは設定項目なし メモリ 2048 MB(CPU はメモリに比例し、vCPU 数の設定項目なし)
アーキテクチャ 設定項目なし arm64

Web Functions の環境あたりの同時リクエスト数の上限は maxConcurrencyPerEnvironment で 1〜128 の範囲に指定でき、今回は既定の 64 のままです。1 環境あたり 2 vCPU であることも、次のドキュメントに記載されています。

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-scaling.html

従来の Lambda(LWA 側)では、CPU は設定したメモリに比例して割り当てられます。

https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html

検証アプリのコードと LWA 側の Dockerfile

Widgets.js は W001・W002・W150 のみ掲載し、同じ形の W003〜W149 は省略しています。widgets/w002.js〜widgets/w150.js は w001.js と同じ形で、番号と DATA の文字列だけが違います。DATA には、ウィジェットごとに別の約 8 KB(8,000 文字)のランダムな文字列を置いています(掲載では '...' に置き換えています)。この文字列はビルド後のウィジェットのチャンクには入らず、チャンクは各約 300 バイトで、DATA の長さを表す 8e3 が残るだけでした。

artifacts/app/next.config.mjs

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};

export default nextConfig;

artifacts/app/app/page.js

import Widgets from './Widgets';

// リクエストごとにサーバー側で描画する(SSR)
export const dynamic = 'force-dynamic';

export default function Home() {
  return (
    <main>
      <h1>Hello, world</h1>
      <p>Rendered on the server at {new Date().toISOString()}</p>
      <Widgets />
    </main>
  );
}

artifacts/app/app/Widgets.js

'use client';
import dynamic from 'next/dynamic';

const W001 = dynamic(() => import('./widgets/w001'));
const W002 = dynamic(() => import('./widgets/w002'));
const W150 = dynamic(() => import('./widgets/w150'));

export default function Widgets() {
  return (
    <ul>
      <W001 />
      <W002 />
      <W150 />
    </ul>
  );
}

artifacts/app/app/widgets/w001.js

'use client';
// 検証用ウィジェット 001。チャンクを分けるため、個別のデータ(約 8KB)を持たせる。
const DATA = '...';
export default function W001() {
  return <li>widget 001: {DATA.length} bytes of embedded data</li>;
}

artifacts/app/app/api/ping/route.js

// 対照用の固定の短い JSON。
export const dynamic = 'force-dynamic';

export function GET() {
  return Response.json({ ok: true });
}

artifacts/app/index.js(Web Functions の入口。ビルド後に pkg/ へコピーされます)

// Lambda Web Functions の入口。
// Next.js standalone は HOSTNAME / PORT を読むため、
// Lambda が渡す AWS_LAMBDA_HTTP_ENDPOINT ("host:port") から設定してから起動する。
const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || '0.0.0.0:3000').split(':');
process.env.HOSTNAME = host;
process.env.PORT = port;

require('./server.js');

artifacts/package-next.sh(next build の後に実行し、両方に載せるビルド成果物 artifacts/pkg/ を組み立てます)

#!/usr/bin/env bash
# デプロイパッケージ artifacts/pkg を組み立てる(ビルド後に実行)
set -euo pipefail
cd "$(dirname "$0")/app"
rm -rf ../pkg
cp -a .next/standalone ../pkg
mkdir -p ../pkg/.next
cp -a .next/static ../pkg/.next/static
[ -d public ] && cp -a public ../pkg/public
cp index.js ../pkg/index.js

LWA 側は、同じ pkg/ をコンテナイメージに載せます。次の内容を artifacts/Dockerfile として保存し(pkg/ と同じ階層)、artifacts/ をビルドコンテキストにしてビルドします。

# Lambda Web Adapter(LWA)版。Web Functions 版と同じビルド成果物 pkg/ をそのまま載せる。
FROM public.ecr.aws/docker/library/node:24-bookworm-slim

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 /lambda-adapter /opt/extensions/lambda-adapter

ENV NODE_ENV=production \
    NEXT_TELEMETRY_DISABLED=1 \
    HOSTNAME=0.0.0.0 \
    PORT=8080 \
    AWS_LWA_PORT=8080 \
    AWS_LWA_READINESS_CHECK_PATH=/api/ping \
    AWS_LWA_READINESS_CHECK_HEALTHY_STATUS=200

WORKDIR /var/task
COPY pkg/ ./
CMD ["node", "server.js"]

計測方法

ブラウザの表示は、手元の Chrome の DevTools(Network)で、Web Functions と LWA のページをそれぞれ開いて確認しました。

スループットは、k6(v2.3.0)でページの読み込みを再現して測りました。手順は次のとおりです。

  1. ヘッドレス Chrome(Chromium 155.0.8059.39)でページを開き、取得した 157 本のパスを assets150.json に保存します。
  2. k6 の各 VU は、HTML 1 本とアセット 156 本(ホストあたり同時 6 本)を取得して 1 回の表示とし、これを待ち時間なしで繰り返します。HTTP/1.1 で、キャッシュは使いません。
  3. 先頭 5 秒を助走として集計から外し、続く 30 秒を集計します。
  4. 各ランの前に、関数の環境変数(COLD_TAG)を更新して実行環境を入れ替え、curl で 1 回アクセスして最初の環境を 1 つ立ち上げます。LWA 側で使う --environment は関数の環境変数をすべて置き換えるので、環境変数を設定に持たないこの検証用の関数でだけ使っています。
  5. VU は 10 と 20 です。1 条件 1 回で、実行順は VU 10 が Web Functions → LWA、VU 20 が LWA → Web Functions です。

ページ/秒とリクエスト/秒は、集計 30 秒間に開始した表示の数と、その表示で送ったリクエストの数を、それぞれ 30 で割った値です。30 秒の終了時に進行中だった表示も、完了を待って数えています。ページ完了時間は、HTML のリクエスト開始から最後のアセットの取得完了までです。

手順 1 では、ヘッドレス Chrome 用のコンテナ(下の折りたたみ)を次のコマンドでビルドし、Web Functions のページを 3 回開きます。

docker build -t vs-lwa-chrome:1 artifacts/chrome
URL=https://<Web Functions のエンドポイント>
mkdir -p out
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD/out:/out" vs-lwa-chrome:1 \
  --url "$URL/" --label cap150-wf --sessions 1 --views 3 --out /out
アセット一覧の取得に使ったヘッドレス Chrome のコンテナ(Dockerfile、package.json、measure.mjs)

artifacts/chrome/ に次の 3 ファイルを置きます。

Dockerfile

# ヘッドレス Chrome 計測用コンテナ。認証情報は一切持たない(公開 URL を開くだけ)。
FROM node:24-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends chromium fonts-noto-cjk ca-certificates \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /work
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY measure.mjs ./
ENV CHROME_BIN=/usr/bin/chromium
ENTRYPOINT ["node", "measure.mjs"]

package.json

{
  "name": "chrome-measure",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "puppeteer-core": "25.13.0"
  }
}

measure.mjs

// ヘッドレス Chrome で URL を開き、ネットワークイベントをすべて JSON で出力する。
// 使い方: node measure.mjs --url URL --label NAME --sessions N --views K --out DIR [--api-path /api/ping --api-count M]
// 1 セッション = 1 つの Chrome プロセス。--sessions 個のプロセスを同時に起動し、各セッションが --views 回ページを開く。
import puppeteer from 'puppeteer-core';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const args = Object.fromEntries(
  process.argv.slice(2).reduce((acc, cur, i, arr) => {
    if (cur.startsWith('--')) acc.push([cur.slice(2), arr[i + 1]]);
    return acc;
  }, [])
);
const url = args.url;
const label = args.label ?? 'run';
const sessions = Number(args.sessions ?? 1);
const views = Number(args.views ?? 1);
const outDir = args.out ?? '/out';
const apiPath = args['api-path'];
const apiCount = Number(args['api-count'] ?? 0);
const settleMs = Number(args['settle-ms'] ?? 1000);
fs.mkdirSync(outDir, { recursive: true });

const cpuSnap = () => os.cpus().map((c) => ({ ...c.times }));
const cpuBusy = (a, b) => {
  let busy = 0, total = 0;
  a.forEach((x, i) => {
    const y = b[i];
    const idle = y.idle - x.idle;
    const sum = Object.keys(y).reduce((s, k) => s + (y[k] - x[k]), 0);
    busy += sum - idle; total += sum;
  });
  return total ? busy / total : 0;
};

async function runSession(sid) {
  const prefix = `${label}-s${sid}`;
  const procLog = fs.createWriteStream(path.join(outDir, `${prefix}.chrome-stdio.log`));
  const sessionStartWall = Date.now();
  const sessionStartHr = performance.now();
  const browser = await puppeteer.launch({
    executablePath: process.env.CHROME_BIN,
    headless: true,
    dumpio: false,
    args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', '--disable-crash-reporter'],
  });
  const proc = browser.process();
  proc.stdout?.on('data', (d) => procLog.write(`[stdout] ${d}`));
  proc.stderr?.on('data', (d) => procLog.write(`[stderr] ${d}`));
  const version = await browser.version();
  const result = {
    label, session: sid, url, chromeVersion: version, chromePid: proc.pid,
    launchArgs: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--no-first-run', '--disable-crash-reporter', '--headless'],
    sessionStartWall, launchedAfterMs: performance.now() - sessionStartHr, views: [],
  };

  for (let v = 0; v < views; v++) {
    const page = await browser.newPage();
    const client = await page.createCDPSession();
    await client.send('Network.enable');
    await client.send('Network.setCacheDisabled', { cacheDisabled: true });
    const events = [];
    for (const ev of ['requestWillBeSent', 'responseReceived', 'loadingFinished', 'loadingFailed', 'dataReceived', 'requestServedFromCache']) {
      client.on(`Network.${ev}`, (p) => events.push({ ev, wall: Date.now(), ...p }));
    }
    const consoleMsgs = [];
    page.on('console', (m) => consoleMsgs.push({ wall: Date.now(), type: m.type(), text: m.text() }));
    page.on('pageerror', (e) => consoleMsgs.push({ wall: Date.now(), type: 'pageerror', text: String(e) }));
    let loadEvt = null, dclEvt = null;
    client.on('Page.loadEventFired', (p) => (loadEvt = { wall: Date.now(), ts: p.timestamp }));
    client.on('Page.domContentEventFired', (p) => (dclEvt = { wall: Date.now(), ts: p.timestamp }));
    await client.send('Page.enable');

    const cpu0 = cpuSnap();
    const t0wall = Date.now();
    const t0 = performance.now();
    let navError = null;
    try {
      await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
    } catch (e) { navError = String(e); }
    const t1 = performance.now();
    const t1wall = Date.now();
    await new Promise((r) => setTimeout(r, settleMs));
    const cpuUtil = cpuBusy(cpu0, cpuSnap());

    // API 単発の対照: 同じ Chrome から fetch で呼ぶ
    const apiCalls = [];
    if (apiPath && apiCount > 0) {
      for (let i = 0; i < apiCount; i++) {
        const r = await page.evaluate(async (p) => {
          const s = performance.now();
          let status = -1, err = null;
          try { const res = await fetch(p, { cache: 'no-store' }); status = res.status; await res.text(); } catch (e) { err = String(e); }
          return { startedAtMs: s, durationMs: performance.now() - s, status, err };
        }, apiPath);
        apiCalls.push({ wall: Date.now(), ...r });
      }
    }

    result.views.push({
      view: v, kind: v === 0 ? 'first' : 'warm',
      navStartWall: t0wall, navEndWall: t1wall, navDurationMs: t1 - t0, navError,
      loadEvent: loadEvt, domContentLoaded: dclEvt, hostCpuBusyRatio: cpuUtil,
      events, consoleMsgs, apiCalls,
    });
    await page.close();
  }
  result.sessionEndWall = Date.now();
  await browser.close();
  procLog.end();
  fs.writeFileSync(path.join(outDir, `${prefix}.json`), JSON.stringify(result));
  return result;
}

const t0 = Date.now();
const all = await Promise.all(Array.from({ length: sessions }, (_, i) => runSession(i)));
const summary = all.map((s) => ({
  session: s.session,
  views: s.views.map((v) => ({
    view: v.view, kind: v.kind, navDurationMs: Math.round(v.navDurationMs),
    requests: v.events.filter((e) => e.ev === 'requestWillBeSent').length,
    statuses: v.events.filter((e) => e.ev === 'responseReceived').reduce((m, e) => { const k = e.response.status; m[k] = (m[k] || 0) + 1; return m; }, {}),
    failed: v.events.filter((e) => e.ev === 'loadingFailed').length,
    hostCpuBusyRatio: Number(v.hostCpuBusyRatio.toFixed(3)),
  })),
}));
console.log(JSON.stringify({ label, sessions, views, startedWall: t0, endedWall: Date.now(), chrome: all[0].chromeVersion, cpus: os.cpus().length, summary }, null, 1));

出力の JSON(cap150-wf-s0.json)から、3 回目の表示で取得したパスを取り出します。Python は out/ で実行し、できた assets150.json は artifacts/ に置きます。Python と assets150.json の形は次のとおりです。

import json
d = json.load(open('cap150-wf-s0.json'))
v = d['views'][2]  # 3 回目の表示(キャッシュ無効)
rq = [e for e in v['events'] if e['ev'] == 'requestWillBeSent']
rs = {e['requestId']: e['response']['status'] for e in v['events'] if e['ev'] == 'responseReceived'}
paths = []
for e in rq:
    u = e['request']['url'].split('/', 3)[3]
    paths.append('/' + u)
html, assets = paths[0], [p for p, e in zip(paths[1:], rq[1:]) if rs.get(e['requestId']) == 200]
ua = rq[1]['request']['headers']['User-Agent']
json.dump({'html': html, 'assets': assets, 'headers': {'User-Agent': ua, 'Accept': '*/*'}}, open('assets150.json', 'w'), indent=0)
{
"html": "/",
"assets": [
"/_next/static/chunks/19wg2q9r2_sev.js",
"/_next/static/chunks/0fi453dere7a3.js"
],
"headers": {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36",
"Accept": "*/*"
}
}

assets は 156 本のうち先頭 2 本だけ載せています。artifacts/ に k6-throughput.js と assets150.json を置き、次のコマンドで k6 を実行します。この例は VU 10、Web Functions 向けです。

docker run --rm --network host --user "$(id -u):$(id -g)" \
  -e GODEBUG=http2client=0 -e BASE_URL="$URL" -e VUS=10 \
  -v "$PWD/artifacts:/work:ro" -v "$PWD/out:/out" grafana/k6:2.3.0 run --quiet \
  --summary-export /out/tp-v10-wf.summary.json /work/k6-throughput.js
k6 のスクリプトと、環境の入れ替えを含む実行スクリプト

artifacts/k6-throughput.js

// 30 秒間のスループット測定。各 VU は待ち時間なしで続けて表示する。
// 先頭 5 秒は助走(集計しない)。HTML 1 本 + アセット 156 本、同時 6 本、HTTP/1.1、キャッシュなし。
import http from 'k6/http';
import exec from 'k6/execution';
import { Trend, Counter } from 'k6/metrics';

const spec = JSON.parse(open('/work/assets150.json'));
const BASE = __ENV.BASE_URL;
const VUS = Number(__ENV.VUS);
const params = { headers: spec.headers, timeout: '30s' };

const pageTime = new Trend('page_time', true);
const pages = new Counter('pages');
const reqs200 = new Counter('res_200');
const reqs429 = new Counter('res_429');
const reqs5xx = new Counter('res_5xx');
const reqsErr = new Counter('res_err');
const pagesFailed = new Counter('pages_failed');

export const options = {
  scenarios: {
    warm: { executor: 'constant-vus', vus: VUS, duration: '5s', gracefulStop: '0s' },
    main: { executor: 'constant-vus', vus: VUS, duration: '30s', startTime: '5s', gracefulStop: '35s' },
  },
  batch: 6,
  batchPerHost: 6,
  discardResponseBodies: true,
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
};

export default function () {
  const main = exec.scenario.name === 'main';
  const t0 = Date.now();
  const all = [http.get(BASE + spec.html, params)].concat(
    http.batch(spec.assets.map((p) => ['GET', BASE + p, null, params]))
  );
  if (!main) return;
  let bad = false;
  for (const r of all) {
    if (r.status === 200) reqs200.add(1);
    else if (r.status === 429) { reqs429.add(1); bad = true; }
    else if (r.status >= 500) { reqs5xx.add(1); bad = true; }
    else if (r.status === 0) { reqsErr.add(1); bad = true; }
  }
  if (bad) pagesFailed.add(1);
  pages.add(1);
  pageTime.add(Date.now() - t0);
}

artifacts/run-tp.sh

#!/usr/bin/env bash
# 使い方: run-tp.sh <wf|lwa> <vus> <tag>
# 環境を入れ替え → curl で 1 回ウォームアップ → k6 で 35 秒(助走 5 秒 + 集計 30 秒)。
set -euo pipefail
ART="$(cd "$(dirname "$0")" && pwd)"; ROOT="$(dirname "$ART")"
. "$ART/endpoints.env"   # WF150_URL と LWA150_URL を定義
t=$1; vus=$2; tag=$3
stamp=$(date +%H%M%S)
if [ "$t" = wf ]; then
  url=$WF150_URL
  (cd "$ROOT" && aws lambda-web deploy --name vs-lwa-wf150 --env "COLD_TAG=$stamp" --region ap-northeast-1)
else
  url=$LWA150_URL
  aws lambda update-function-configuration --function-name vs-lwa-lwa150 --environment "Variables={COLD_TAG=$stamp}" --query '[LastUpdateStatus,State]' --output text --region ap-northeast-1
  aws lambda wait function-updated-v2 --function-name vs-lwa-lwa150 --region ap-northeast-1
fi
sleep 5
curl -sS -o /dev/null -w 'status=%{http_code} time_total=%{time_total}\n' --max-time 60 "$url/"
out="$ROOT/out"; mkdir -p "$out"
docker run --rm --network host --user "$(id -u):$(id -g)" \
  -e GODEBUG=http2client=0 -e BASE_URL="$url" -e VUS="$vus" \
  -v "$ART:/work:ro" -v "$out:/out" grafana/k6:2.3.0 run --quiet \
  --summary-export "/out/$tag.summary.json" /work/k6-throughput.js

結果

ブラウザの表示

手元の Chrome の DevTools(Network)でページを開き、数回スーパーリロードして値が安定した時点の画面です。注目するのは Load と、JS 1 本ごとの Time です。リクエスト数 163 にはブラウザ拡張のスクリプト(injected.js など)が含まれます。

LWA の Function URL は Finish 1.43 s、DOMContentLoaded 99 ms、Load 301 ms でした。小さい JS の Time は、ほとんどが 19〜25 ms 程度でした。

LWA の Function URL を DevTools の Network で開いた画面

Web Functions は Finish 1.41 s、DOMContentLoaded 91 ms、Load 168 ms でした。小さい JS の Time は、ほとんどが 10〜17 ms 程度でした。

Web Functions のエンドポイントを DevTools の Network で開いた画面

30 秒間のスループット

VU 対象 ページ/秒 リクエスト/秒 ページ完了の中央値 p95 p99
10 Web Functions 22.9 3,595 439 ms 472 ms 486 ms
10 LWA 17.4 2,727 575 ms 620 ms 782 ms
20 Web Functions 44.9 7,055 447 ms 480 ms 521 ms
20 LWA 34.7 5,443 575 ms 626 ms 738 ms

Web Functions のほうが、VU 10 でも 20 でもページ/秒が約 1.3 倍で、完了時間の中央値は 2 割強短くなりました。Web Functions の p99(VU 10 で 486 ms、VU 20 で 521 ms)は、どちらも LWA の中央値(575 ms)より短い結果です。429、5xx、タイムアウトは 4 ランとも 0 件でした。

集計中の実行環境の初期化(コールドスタート)は、Web Functions が VU 10・VU 20 とも 0 回、LWA が VU 10 で 0 回、VU 20 で 6 回でした。ラン全体で見ると、初期化のほとんどは助走の 5 秒間に起きています。

LWA の関数の最大同時実行数(ConcurrentExecutions の 1 分ごとの最大値)は、VU 10 で 45、VU 20 で 90 でした。Web Functions の稼働中の環境数(EndpointActiveEnvironments の 1 分ごとの最大値)は、VU 10 で 41、VU 20 で 83 でした。k6 の同時接続は、VU 10 で最大 60、VU 20 で最大 120(VU 数 × 6)です。

ログの違い

DevTools の画面を撮った表示(最後にスーパーリロードした表示)について、CloudWatch Logs の記録を抜粋します。表示した時刻は LWA が 14:25:02 UTC、Web Functions が 14:26:58 UTC です。

LWA の関数には、リクエストごとに START、END、REPORT の 3 行が出ます。次は、この表示の先頭のリクエスト(14:25:02.773 UTC に開始)の 3 行です。この表示の各リクエストの 3 行の組は、6 つの実行環境(ログストリーム)に分かれて出ていました。

START RequestId: 12ce6618-289b-4903-87ce-7ab2edfa92ec Version: $LATEST
END RequestId: 12ce6618-289b-4903-87ce-7ab2edfa92ec
REPORT RequestId: 12ce6618-289b-4903-87ce-7ab2edfa92ec	Duration: 23.20 ms	Billed Duration: 24 ms	Memory Size: 2048 MB	Max Memory Used: 137 MB

Web Functions のプラットフォームは、リクエストごとではなく実行環境ごとに記録を出します。次は、最後の表示を受けた実行環境の 1 つ(instanceId の先頭が b4167130)の記録から抜粋した 5 件です。順に、環境の起動(initStart、initReport)、invokeStart、1 分ごとの要約(invokeSummary)、環境の終了時の記録(invokeReport)です。

{"timestamp": "2026-10-13T14:20:56.511Z", "level": "INFO", "type": "platform.initStart", "region": "ap-northeast-1", "record": {"runtimeVersion": "nodejs24.x:nodejs.web24.v14", "runtimeVersionArn": "arn:aws:lambda:ap-northeast-1::runtime/nodejs24.x/version/nodejs.web24.v14", "functionName": "vs-lwa-wf150", "revisionId": "rev-2rmso", "instanceId": "b4167130-a681-4c9a-891c-35aeb658a761", "initializationType": "native-http"}}
{"timestamp": "2026-10-13T14:20:56.861Z", "level": "INFO", "type": "platform.initReport", "region": "ap-northeast-1", "record": {"status": "success", "instanceId": "b4167130-a681-4c9a-891c-35aeb658a761", "initializationType": "native-http", "metrics": {"totalDurationMs": 431.0, "totalCpuTimeMs": 356.0, "maxMemoryUsageMb": 174}}}
{"timestamp": "2026-10-13T14:20:56.862Z", "level": "INFO", "type": "platform.invokeStart", "region": "ap-northeast-1", "record": {"functionName": "vs-lwa-wf150", "revisionId": "rev-2rmso", "instanceId": "b4167130-a681-4c9a-891c-35aeb658a761"}}
{"timestamp": "2026-10-13T14:27:56.849Z", "level": "INFO", "type": "platform.invokeSummary", "region": "ap-northeast-1", "record": {"startTime": "2026-10-13T14:26:56.851507152Z", "instanceId": "b4167130-a681-4c9a-891c-35aeb658a761", "metrics": {"requests": 32, "concurrentRequests": 1, "cpuTimeMs": 153.0, "maxMemoryUsageMb": 209}}}
{"timestamp": "2026-10-13T14:32:23.163Z", "level": "INFO", "type": "platform.invokeReport", "region": "ap-northeast-1", "record": {"status": "success", "instanceId": "b4167130-a681-4c9a-891c-35aeb658a761", "metrics": {"totalCpuTimeMs": 1706.0, "totalDurationMs": 686323.0, "totalRequests": 258, "concurrentRequests": 2, "maxMemoryUsageMb": 209}}}

invokeSummary は、最後の表示を含む 1 分(14:26:56〜14:27:56 UTC)の要約です。この環境が受けたリクエストは 32 件、concurrentRequests は 1 でした。invokeReport は、環境が動いていた約 11 分間の記録で、リクエストは合計 258 件、concurrentRequests は 2 でした。

リクエストごとの行は、アプリで出せます。別に小さな Next.js アプリ(1 回の表示で 50 リクエスト)を用意し、Web Functions に載せました。proxy.js から 1 リクエストにつき 1 行を console.log で出す版です。

import { NextResponse } from 'next/server';

export function proxy(request) {
  console.log(JSON.stringify({ msg: 'access', method: request.method, path: request.nextUrl.pathname }));
  return NextResponse.next();
}

export const config = {
  matcher: ['/:path*'],
};

出力の行には、アプリが何も指定しなくても requestId、endpointName、region が付きました。

{"timestamp": "2026-10-13T05:48:08.420Z", "level": "INFO", "message": "{\"msg\":\"access\",\"method\":\"GET\",\"path\":\"/\"}", "requestId": "JXTL5L5UJXKYTI4Y46USXMARWI", "endpointName": "dev", "region": "ap-northeast-1"}

応答ヘッダ lambda-web-request-id とログの requestId を突き合わせると、1 回の表示の 50 件のどの応答にも、同じ値の行があり、パスも一致しました。

スループット測定の各ランで、CloudWatch Logs に出たログ量です。ログ量と件数は、助走の 5 秒を含むラン全体(k6 の開始の 5 秒前から 45 秒後まで)の値です。リクエスト数は集計 30 秒間の値です。LWA の関数は既定のログ設定のままです。Web Functions は 2 つのロググループ(/aws/lambda/web/vs-lwa-wf150 と /aws/lambda/web/vs-lwa-wf150.local)に出るので、合計を載せています。

VU 対象 ログ量 ログの件数 集計中のリクエスト数
10 Web Functions 0.15 MB(145,110 B) 656 107,859
10 LWA 31 MB(31,007,766 B) 272,103 81,797
20 Web Functions 0.32 MB(321,490 B) 1,410 211,636
20 LWA 61 MB(61,359,202 B) 538,437 163,280

ログ量は、Logs Insights で数えたログ 1 件ごとの本文の長さの合計に、1 件あたり 26 バイトを足した値です。26 バイトは、CloudWatch Logs が取り込み量に 1 件ごとに加算するサイズです。

LWA のログは 1 リクエストにつき 3 件なので、件数を 3 で割ると実行回数(VU 10 で 90,701、VU 20 で 179,479)になります。助走を含むため、表の集計中のリクエスト数より多くなります。ログ量を実行回数で割ると、1 リクエストあたり約 342 バイトで、リクエスト数に比例して増えます。アクセスログを出さない今回の構成では、Web Functions のログ量は LWA の約 1/200 でした。

アクセスログを残さない前提であれば、Web Functions は LWA より CloudWatch Logs の費用も抑えられる可能性があります。

まとめ

Web Functions は、LWA でコールドスタートが起きない状況(VU 10 の集計中は 0 回)と比べても、ページ/秒が約 1.3 倍で、ページ完了の中央値が 2 割強短くなりました。

LWA を載せた従来の Lambda 関数も Web の公開環境として利用できますが、1 つの実行環境は、同時に 1 つのリクエストしか処理できません。同時に来たリクエストの数だけ環境が必要で、足りない分はコールドスタートを待ちます。Provisioned Concurrency で環境を事前に確保すれば緩和できますが、確保した分の費用がかかるうえ、必要な数の見積もりも難しくなります。

https://docs.aws.amazon.com/lambda/latest/dg/lambda-concurrency.html

Web Functions は 1 つの実行環境が複数のリクエストを同時に処理できる設計ですが、今回の負荷では VU 20 で環境数が 83 まで増えました。速さの差が多重処理によるものかは、この結果だけでは分かりません。

従来の Lambda で同時実行数の管理に運用負荷がかかる場合や、安定した性能が必要な場合は、Web Functions が選択肢になります。App Runner の後継として ECS(Fargate、Express Mode)を採用したものの、デプロイや運用が複雑だと感じる場合も同様です。手軽に使える Web 配信環境として、Web Functions をぜひお試しください。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事