AWS Builder Center

Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する

Honoの入力検証、認証、Cookie、SSE、並行処理をAWS上で比較。Lambda Web Functionsのtimeout、同時処理数、台数、環境変数を変え、HTTPの成功だけでは分からない境界を確かめます。

Series: Try Lambda Web Functions (4 articles)

  1. …
  2. 3
    Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する This article
Senior Evangelist

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 
東京リージョンの8 endpointから5 revisionのHonoへHTTPを送り、S3からZIPを配置しCloudWatchへログを出す構成

初期測定の構成です。実線は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は記録していません。
CloudWatchの6行のアプリログ。bodyLimitへ送った1,023と1,024 byteは200、1,025 byteは413で、length付きとchunkedの結果が一致している

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秒
CloudWatchの8行のアプリログ。正常SSE、途中例外、timeout 3秒と6秒のresponseとstream-endを並べ、3秒のstream-endだけabortedが1になっている

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/あり
診断用revisionのSSE終了ログ6行。正常時は予定4件または5件を書き込み完了、途中例外では4件中2件でcompleteが0になっている

アプリが記録した予定数、書き込み完了数、完了状態です。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
CloudWatchで同じtraceの応答と処理終了を比較。Hono timeoutの504後にwork-endを記録し、client切断のstream-endではabortedが0だった

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
header診断のアプリログ4行。maxHeaderSizeは135168、受信paddingは0、7000、9000、17000 byteで、statusはすべて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を観測しています。送信の待ち時間は試験条件で、反映時間の保証ではありません。
CloudWatchの8行のアプリログ。slowへの切替後とbaseへの復帰後に、baseとslowのmarkerが混在している

切替後の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)

  1. …
  2. 3
    Lambda Web FunctionsでHonoの実用パターンと設定の境界を検証する This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article