Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する
Honoの入力検証、認証、Cookie、SSE、並行処理をAWS上で比較。Lambda Web Functionsのtimeout、同時処理数、台数、環境変数を変え、HTTPの成功だけでは分からない境界を確かめます。
Series: Try Lambda Web Functions (4 articles)
- …
- 3Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する This article
Honoの振る舞いを比べる
Lambda Web Functions上で、Honoの入力検証、認証、並行処理を試しました。SSE(Server-Sent Events)も対象です。HTTP接続でイベントを順に送る方式です。正常応答と失敗、配信途中の終了を比べます。
今回の問いは、HonoのHTTPアプリで、どこまでをAWSの設定に任せ、何をアプリで確かめるかです。 Web Functionsの概要 では、HTTPSの入口と実行環境の管理、trafficに応じた台数調整をAWSが担います。HTTP serverを置く環境の起動・停止を、自分たちで運用する仕事が減ります。入力、認証、配信の完了、requestごとの状態はアプリで確かめます。
測定では、200のSSEが途中で終わり、Honoのtimeout後にも処理が続きました。設定値と実際の動作を対応させて見ていきます。
検証する構成
2026年10月13日、東京リージョンのHomeRegion endpointへ、HTTP/1.1で直接接続しました。同じZIPを5つのrevisionへ配置しています。revisionはコードと実行設定の組です。endpointは認証や1秒あたりの要求数、転送先を決める入口です。
Honoは4.13.13、Node.js Adapterは2.1.4です。runtime指定は
nodejs24.x 、実際の応答はNode.js v24.21.0 、 arm64 でした。CPUは2 vCPU、memoryは2 GBで固定です。 Web Function runtimes 、 Architecture 
初期測定の構成です。実線はHTTP要求・応答とログ出力、破線はZIPの配置を示します。掲載するアプリログは、設定したlog group名に
.local が付いたgroupで観測したものです。revision | timeout | 1環境あたりの同時処理数の上限 | 主な変更 |
|---|---|---|---|
base | 3秒 | 8 | 比較の基準 |
serial | 3秒 | 1 | 並行処理を制限 |
wide | 3秒 | 128 | 同時処理数の上限を拡大 |
slow | 6秒 | 8 | 5秒の処理を完了できる期限 |
tuned | 3秒 | 8 | NODE_OPTIONS、application log levelを変更 |
scale3 はserial revisionを使い、環境数の上限を2から3へ変えます。RateLimitは、1秒あたりの要求数の上限です。 blocked はRateLimitが0です。 appauth だけがApplicationManagedで、残りはIamAuthです。初期測定は300要求です。通常の条件は2回ずつ試しました。並行試験は、serial・base・wide・scale3の4つのendpointへ16件ずつ2回送りました。失敗も数え、自動再試行はせず、status・headers・byte数・hash・時刻を記録しています。
設定切替は先行3要求と追加40要求です。さらに、診断用の6つ目のrevisionで22要求を測定しました。途中終了した先行試験も含め、合計365要求です。
診断用revisionは別ZIPで、timeout 6秒、同時処理数の上限8です。NODE_OPTIONSは
--max-http-header-size=8192 のみ、system log levelはDEBUGです。既存のblocked endpointのRateLimitを100にし、診断用revisionへ一時的に切り替えて測りました。HTTP serverを起動する
Node.js Adapterで、Lambdaが指定したhostとportへbindします。検証コードの抜粋です。
ts
import { createServer } from "node:http";import { getRequestListener } from "@hono/node-server";const address = process.env.AWS_LAMBDA_HTTP_ENDPOINT;if (!address || !/^[A-Za-z0-9.:[\]-]+:\d{1,5}$/.test(address)) {throw new Error("AWS_LAMBDA_HTTP_ENDPOINT must contain a host and port");}const endpoint = new URL(`http://${address}`);const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");const port = Number(endpoint.port);if (!hostname || !Number.isInteger(port) || port < 1 || port > 65535) {throw new Error("invalid AWS_LAMBDA_HTTP_ENDPOINT");}const server = createServer(getRequestListener(app.fetch));server.listen(port, hostname);
app は検証用Honoアプリです。bundleした server.mjs をZIPのrootへ置きます。CloudFormationのリソース型 AWS::Lambda::WebFunctionRevision と AWS::Lambda::WebFunctionEndpoint を、CDKの CfnResource で定義しました。次の設定を比べます。ts
// WebFunctionRevision の ServiceConfig 内TimeoutSeconds: 3,MaxConcurrencyPerEnvironment: 8,EnvironmentVariables: {AWS_LAMBDA_NODEJS_ENTRYPOINT: "server.mjs",LAB_REVISION_MARKER: "base",},// WebFunctionEndpoint の properties 内EndpointType: "HomeRegion",AuthType: "IamAuth",ScalingConfig: { MaxEnvironments: 2 },ThrottleConfig: { RateLimit: 100 },
入力と応答を確かめる
正常な入力と、拒否したい入力を対にしました。以下はclient側の測定記録で、statusは各条件2回とも同じでした。
パターン | 入力・条件 | 観測した結果 |
|---|---|---|
routing | GET/POST/PUT/PATCH/DELETE、path param、query | 200。method・id・先頭のtagを保持 |
JSON | 正常/項目欠落・数値・null・不正JSON/別Content-Type | 200/400/415 |
bodyLimit | 1,023/1,024/1,025 byte | 200/200/413。length付きとchunkedで同じ |
form・multipart | urlencoded、256 byteのfile、file欠落 | 200、fileのhash一致、欠落は400 |
CORS | 許可Origin/不許可Origin | 応答の許可headerに差。詳細は後述 |
Cookie | 署名正常/欠落・改ざん | 200/401。2本のSet-Cookieを保持 |
binary・HEAD | 0〜255のbyte列/同じURLへHEAD | 256 byteとhashが一致/bodyは0 byte |
gzip | 圧縮開始を1,024 byteに設定 | 1,023 byteは非圧縮。1,024/1,025 byteは29 byte |
ETag・redirect | 条件付きGET/相対URLへのredirect | 304でbodyなし/302でLocation保持 |
HTML・CSS・例外 | 固定本文/route内のthrow | 200/500。その後のhealthは200 |
bodyLimitは、アプリが定めた1 KiBの境界です。AWS全体の最大bodyサイズを測った結果ではありません。clientがchunkedで送った場合も、同じ境界で拒否できました。アプリ側で受信したContent-LengthとTransfer-Encodingは記録していません。

1回目のbodyLimit試験のアプリログです。traceの末尾で送信サイズと転送方法を区別しています。
ts
app.post("/body-limit",bodyLimit({maxSize: 1024,onError: (c) => c.json({ error: "body too large" }, 413),}),async (c) => {const bytes = new Uint8Array(await c.req.arrayBuffer());return c.json({ bytes: bytes.byteLength, sha256: digest(bytes) });},);
Cookieの属性はSecure、HttpOnly、SameSite=Lax、Path=/、Max-Age=60です。
gzipの試験では、圧縮前の
Content-Length をhandlerで明示しました。Hono 4.13.13のthreshold判定は、その値を使います。展開後のhashも一致しました。繰り返し文字列を使ったため、29 byteという値は一般のJSONの圧縮率には使えません。なお、CSSはrouteから固定文字列を返す試験です。ファイルシステムからの配信や、大きなuploadの確認は含みません。
認証とCORSを比べる
IamAuthの入口では、AWSのSigV4署名がない要求は403でした。署名付きのhealthは200ですが、Honoで見たAuthorizationの有無は
false でした。これは、Lambdaが署名検証後にAuthorizationを除去する仕様と対応します。 Endpoint access control 入口とアプリの条件 | 結果 |
|---|---|
IamAuth、署名なしのhealth | 403 |
IamAuth、署名あり、Honoの標準Bearer認証route | 401 |
IamAuth、署名あり、専用headerのアプリ認証 | 正しい値は200、欠落・誤りは401 |
ApplicationManaged、署名なしのhealth | 200 |
ApplicationManaged、AuthorizationのBearer認証 | 正しい値は200、欠落・誤りは401 |
アプリ認証には検証専用の固定値を使いました。ApplicationManagedでは、認証を付けていないrouteへも要求が届きます。必要なpathはアプリの認証で覆います。
CORSは、ブラウザへ別Originの応答利用を許可する仕組みです。許可Originは
Access-Control-Allow-Origin に入り、不許可Originにはこのheaderが付きませんでした。ただし、GETは200、OPTIONSは204です。CORSをアクセス拒否の代わりにはできません。署名なしのOPTIONSは、IamAuthではOriginにかかわらず403、ApplicationManagedでは204でした。アプリのCORS設定に加え、入口がpreflightを受けられるかも確かめます。今回はHTTPのheadersを観測し、実ブラウザによるCORSの強制は未検証です。
SSEとtimeoutを比べる
250 ms間隔で4件と
done を送り、clientで最初の受信を約0.33秒後、完了を約1.08秒後に観測しました。入力・条件 | status | 受信したchunk | done | 終了までの時間(2回) |
|---|---|---|---|---|
4件×250 ms、正常 | 200 | 4/4 | あり | 1.084/1.083秒 |
同じ入力、2件目で例外 | 200 | 2/4 | なし | 0.576/0.577秒 |
5件×1秒、timeout 3秒 | 200 | 2/5 | なし | 3.109/3.080秒 |
同じ入力、timeout 6秒 | 200 | 5/5 | あり | 5.109/5.089秒 |

1回目のSSE試験を、アプリ側のresponseとstream-endで追った画面です。3秒timeoutではabortedが1になっています。上表の受信数とdoneの有無は、クライアント側の測定記録で確認しました。
配信途中の例外では、Honoのerrorイベントが届きました。Lambdaの3秒timeoutでは、2件の後にbodyが終わりました。後者には
lambda-web-error-type も付いていません。測定clientはどちらもEOFまで読み終えましたが、アプリの配信は未完了です。一方、応答を始めずに5秒待つrouteは、timeout 3秒で504になりました。
lambda-web-error-type は Runtime.RequestTimeout です。timeout 6秒では約5.09秒後に200となり、 completed: true を返しました。配信開始前後の違いは、 Request timeoutの仕様 とも一致します。検証用SSEの書き込み部分では、データと完了を分けています。
ts
// 各chunkの送信await stream.writeSSE({ event: "chunk", id: String(index), data: String(index) });// 指定件数の送信が終わった場合だけ送信await stream.writeSSE({ event: "done", data: "complete" });
clientは200だけで成功とせず、件数・順序・payloadとdoneを照合します。途中終了後の再接続と再開位置はアプリで設計します。
診断用revisionでは、書き込み件数と完了状態のログを追加しました。timeoutは6秒です。初期の3秒timeoutとは別に、次の3条件を2回ずつ追試しました。
条件 | アプリの書き込み完了数/予定数 | clientの受信数 | streamComplete/done |
|---|---|---|---|
4件×250 ms、正常 | 4/4 | 4 | true/あり |
2件目で例外 | 2/4 | 2 | false/なし |
5件×1秒、正常 | 5/5 | 5 | true/あり |

アプリが記録した予定数、書き込み完了数、完了状態です。completeの1は完了、0は未完了を表します。
各条件の2回とも、同じtraceのログとclient記録が一致しました。書き込みの完了とclientへの到達は別の事象なので、両側の記録を照合しています。
処理の終了をログで追う
Honoの
timeout(1000) に、1,500 ms待つ処理を組み合わせました。クライアントへは約1.08秒後に504が返りました。しかし、CloudWatchには処理終了が後から記録されています。同じtraceのログ | 1回目 | 2回目 |
|---|---|---|
504のresponseログ | 11:29:56.682 | 11:30:29.191 |
work-endログ | 11:29:57.182 | 11:30:29.690 |
差 | 500 ms | 499 ms |

1回目の中断・timeout試験の結果表は4行です。表の上2行がSSEの中断対象、下2行がHono timeoutです。abortedの0は、アプリが中断を検知していないことを示します。画面上部の空のchartは結果表と時刻範囲が異なるため、判定に使っていません。
表の時刻はUTCです。各組でtraceとenvironmentIdが一致しています。work-endのstatusは処理内部の値です。clientへ504を返した後も、処理は続いていました。
clientからの切断も調べました。4件×250 msのSSEを2件受信した時点で接続を閉じます。切断は要求開始から約0.58秒後でした。ところが、同じtraceの
stream-end はresponseログの1,002/1,003 ms後で、 aborted は両方 false でした。requestのAbortSignalとstreamのonAbortを監視しても、この経路では切断直後に停止しませんでした。後続のhealthは別環境から返る場合もあり、元のstreamの停止確認には使えません。
今回の観測は最大5秒の待機で、外部呼び出しや副作用を伴う処理は試していません。この結果から、切断通知だけで処理が止まるとは考えず、処理自身のtimeoutや取消し、完了状態を設計します。
並行処理と状態を比べる
各requestへ異なるidを渡し、500 ms待つ間に16件を並行に送りました。HonoのContextとAsyncLocalStorageの値をawait前後で読み、比較用にアプリで共有する変数も書き換えています。
endpoint | 1環境の上限 | 環境数の上限 | 200件数(各16件) | 429件数 | 観測した環境内の最大重複数 |
|---|---|---|---|---|---|
serial | 1 | 2 | 2/2 | 14/14 | 1/1 |
base | 8 | 2 | 5/13 | 11/3 | 3/7 |
wide | 128 | 2 | 4/4 | 12/12 | 2/2 |
scale3 | 1 | 3 | 3/3 | 13/13 | 1/1 |
429のerror typeは、全92件が
Platform.EndpointMaxEnvThrottle でした。serialでは2環境、scale3では3環境から成功応答を得ています。一方、wideの上限128は、その数の同時処理を保証しません。Lambdaは実際の並行数を動的に調整します。 Scaling behavior baseがwideより多く成功した回もあります。動的な調整を含む小さなburstの結果で、性能順位には使えません。上限の積だけで瞬間的な受理件数を決めないための観測です。
成功した36件では、ContextとAsyncLocalStorageの値はすべて自分のidを保ちました。対して、共有変数は18件で別requestのidになりました。たとえばbaseの1回目では、
item-2 がContextでは維持され、共有変数は item-1 でした。request単位の値はContextなどへ置きます。共有変数は同じ環境の別requestから書き換わります。同時処理数1にも効果はありましたが、状態の永続化や全環境を通した排他制御は別に設計します。
NODE_OPTIONSを確かめる
tunedには
--max-old-space-size=1536 を渡しました。 v8.getHeapStatistics().heap_size_limit は、baseの1,408 MiBからtunedの1,728 MiBへ変わりました。これはV8が返したheap全体の上限です。old-spaceの指定値そのものや、プロセス全体のmemory容量とは区別します。同じNODE_OPTIONSへ
--max-http-header-size=8192 も指定しました。しかし、7,000/9,000 byteのpaddingを持つheaderは、baseでもtunedでも200でした。そこで、headerの受信量を返す診断用revisionを追加しました。設定取得APIでは、NODE_OPTIONSが
--max-http-header-size=8192 であることを確認しています。一方、実行中の node:http.maxHeaderSize は135,168 byteでした。送信したpadding | アプリが返したpaddingBytes | status(2回) |
|---|---|---|
0 byte | 0 | 200/200 |
7,000 byte | 7,000 | 200/200 |
9,000 byte | 9,000 | 200/200 |
17,000 byte | 17,000 | 200/200 |

各サイズの1回目のログです。maxHeaderSizeは実行中のNode.jsの値、paddingBytesは受信したheaderの長さです。
paddingはアプリまで同じ長さで届いていました。設定が受理されても、意図した8 KiBでの拒否は確認できませんでした。135,168 byteになった原因や内部の起動方法は調べていません。この値をAWS全体のheader上限とも扱いません。必要な入力制限は、実際の経路で境界を試して確かめます。
環境変数とログを確かめる
環境変数の仕様 では、環境変数はrevision間で引き継がれません。各revisionへ必要な値を定義します。今回の診断用revisionはmarkerを省略した新規revisionで、応答はアプリの既定値
baseline になりました。system log levelがDEBUGの診断用revisionでは、取得したログに
platform.requestComplete が19件ありました。初期INFOのrevisionでは、同じログ収集範囲にこのeventはありませんでした。別revision・別ZIP・別時刻の観測なので、levelだけを変えた比較にはなりません。revisionとRateLimitを切り替える
RateLimitが0のblocked endpointは、初期測定の2回とも503と
Platform.EndpointDisabled でした。RateLimit 100の処理能力は測っていません。続いて設定を切り替えました。設定取得APIで変更を確認した後も、以前の設定による応答が届きました。入口の設定と、実際のHTTP応答を分けて確認します。revisionは応答本文のmarkerで識別しました。
操作 | 設定確認後の送信時期 | HTTP観測 |
|---|---|---|
blockedのRateLimitを0から100へ変更 | 直後から連続3件 | 200 → 200 → 200 |
RateLimitを再び0へ変更 | 直後、約2秒後、続けて2件 | 200 → 503 → 503 → 200 |
baseとslowのweightを50:50へ変更 | 固定で10秒待った後 | 20要求でbase 8件、slow 12件 |
slow 100%へ変更 | 直後、約2秒後、約4秒後、続けて2件 | base → base → slow → slow → slow |
base 100%へ戻す | 直後から連続3件 | base → slow → slow |
先行の中断試験では、RateLimitを100へ変更して設定を確認した直後の1件が503でした。再測定では上表の200が続き、操作後の最初の応答も一定ではありませんでした。
50:50では両revisionから応答を得ましたが、20件で分配比率の精度は判断しません。slowへの切替では、確認直後の2件がbaseのままでした。baseへ戻したときは、最初がbaseでも続く2件はslowでした。RateLimitを0へ戻したときも、503の後に200を観測しています。送信の待ち時間は試験条件で、反映時間の保証ではありません。

切替後のresponseログです。設定取得APIの記録と時刻を照合し、設定確認後にも新旧revisionの応答が混在したことを確かめました。
切替手順では、管理APIの確認と、一定期間の実トラフィックの観測を分けます。新旧のコードが混ざる間も、同じ入力や保存形式を扱える設計が必要です。RateLimit=0を、操作直後から全要求を止める前提にもできません。今回の少数の観測から、反映時間の上限や、全トラフィックの切替完了は確定していません。
検証した範囲
検証範囲は、東京の直接接続、固定したHono版、小さいbody、最大5秒の処理です。設定の受理、実行時の値、HTTP応答を分けて確認しました。tunedのapplication log levelをERRORへ変える設定は受理されていますが、INFOとの配信差を十分に比較する試験は行っていません。
MultiRegion、VPC接続、独自KMS key、CloudFront・WAF、custom domain、外部IdP、native addon、OTLP転送先、第三者extensionsは未測定です。実ブラウザのCORS、HTTP/2・native gRPC・WebSocket、長時間接続、AWS全体の最大body・headerサイズ、持続的な負荷と料金も今回の結果には含みません。
まとめ
今回の条件では、Honoのrouting、入力制限、Cookie、gzip、binary、SSEをWeb Functions上で扱えました。5秒のSSEは、revision timeoutを3秒から6秒へ変えると完了しました。一方、headerの8 KiB制限や設定切替の完了は、設定値だけでは確かめられませんでした。
SSEには完了イベントを設け、timeoutや切断後の未完了を判断できるようにします。外部処理には独自の期限を持たせます。requestごとの値はContextへ置き、並行数と台数は依存先の容量に合わせて決めます。これらをアプリで守れるHTTP処理なら、実行環境の管理をLambdaへ任せる構成を検討できます。
必要な振る舞いを要求・応答・処理終了の各段階で測り、今回扱わなかった条件は採用前に別途検証します。
WebSocketや双方向streamが必要なら、対応する別の入口と実行基盤を選びます。Web FunctionsのHTTP経路はhalf-duplexで、WebSocketには対応しません。Upgrade付きのHTTP要求は200でしたが、アプリではUpgrade headerの存在を確認できませんでした。これはWebSocket接続の検証ではありません。非対応の判断は、 Networkingの仕様 に基づきます。
Series: Try Lambda Web Functions (4 articles)
- …
- 3Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する This article
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article