AWS Builder Center

Lambda Web FunctionsとCloudFront・API Gatewayを連携してみる

Lambda Web Functions+Honoの前段にCloudFrontとAPI Gatewayを置き、認証、endpointへの直接アクセス、cache、timeoutを実測しました。設定を変えた対照から、前段とアプリの責任を分けます。

Senior Evangelist
Lambda Web FunctionsでHonoのHTTPサーバーが動いても、公開の入口は別に考える必要があります。前段にCloudFrontやAPI Gatewayを置くだけで、直接アクセスや個人別の応答まで守れるのでしょうか。
ここでは、前段を通さず、Web FunctionのURLへ送る呼び出しを「直接アクセス」と呼びます。
今回の検証では、CloudFrontのOACと、HTTP APIのHTTP_PROXYを比べました。OACを設定すると、CloudFrontが転送先へのリクエストにIAM署名を付けます。この転送先をオリジンと呼びます。転送先のendpointが、署名を検証します。HTTP_PROXYは、受け取ったHTTPリクエストを別のHTTPサーバーへ転送する方式です。
認証、cache、timeoutを分けて試すと、前段へ任せられる部分と、Honoで守る部分が見えてきました。特に、署名付きの直接アクセスと、日本語を含むURLでは、入口を追加しただけでは済まない結果になりました。

検証する構成

同じWeb Functionに、同じZIPから作った2つのrevisionを配置しました。revisionは、コードと設定を固定した版です。環境変数だけを変え、CloudFront用とAPI Gateway用の処理を分けています。endpointは、Web FunctionへアクセスするURLです。
項目設定
配置先ap-northeast-1。CloudFrontはglobal
Runtimenodejs24.x。実際の応答はNode.js v24.21.0、arm64
CloudFrontの接続先IamAuth endpoint。IAMの署名で認証
HTTP APIの接続先ApplicationManaged endpoint。アプリが認証
Lambdaの上限timeout 6秒。endpointごとの環境数2、環境内の同時処理数4
呼び出しHTTP/1.1。毎秒1件以下で順に送信
比較主な条件を2回ずつ試す。設定を戻した後も確認
Web Functionsの対応runtime一覧 を確認して選びました。Event Functionsのruntime一覧とは区別しています。
前段経由とendpointへの直接アクセス、専用roleの権限を示す検証時の構成
検証時の構成です。青い実線は前段を通るHTTPSリクエスト、赤い実線はendpointへの直接アクセスです。破線はSecretの取得とログ出力を示します。応答は実線の逆向きに返ります。以下の実測画像は、保存したAWSの応答やCloudWatch Logsを、CloudShellの画面へ表示して撮影したものです。

CloudFront OACを試す

OACにはalways-amz-authを指定しました。Web Functionのオリジンでは、この値だけが使えます。利用者のAuthorizationをHonoへ渡しながら、CloudFrontがオリジン向けのIAM署名を付ける設定です。
利用者の識別には、公開してよいダミー値を使いました。Bearer lab-aliceとBearer lab-bobをHonoで照合します。この比較はheaderの転送を確かめるためのものです。実際の利用者に使うJWTの検証は行っていません。
cacheを無効にし、AllViewerExceptHostHeaderで転送すると、aliceとbobをそれぞれ返しました。Bearerがない場合と不一致の場合は401でした。Authorizationを転送しない設定では、正しいダミーBearerを送っても401になりました。
Hostも区別が必要でした。CloudFrontへのHostをそのまま転送する対照では、2回とも502でした。オリジンのドメイン名を使う基準設定では成功しています。AWSの管理origin request policy にも、Web Functions向けのAllViewerExceptHostHeaderが用意されています。

endpointへの直接アクセスを制限する

Web Functionのresource-based policyで、CloudFrontのサービス主体を許可しました。このpolicyは、関数側で呼び出しを許可・拒否する設定です。許可する呼び出し元を、今回のdistributionのaws:SourceArnに限定しています。別のSourceArnにすると403になり、正しい値へ戻すと200へ復旧しました。
ただし、このAllowだけでは直接アクセス経路を閉じられません。同じAWS account内で、専用roleへendpointの呼び出し権限を与えると、署名付きの直接アクセスは200でした。署名なしは403です。
直接アクセスも拒む対照では、対象endpointの呼び出しだけに、次のDeny条件を加えました。
1
2
3
4
5
6
7
8
9
10
11
{
"Effect": "Deny",
"Principal": "*",
"Action": "lambda:InvokeWebFunctionEndpoint",
"Resource": "<CloudFront用endpointのARN>",
"Condition": {
"ArnNotEquals": {
"aws:SourceArn": "<許可するdistributionのARN>"
}
}
}
この条件では、CloudFront経由は2回とも200でした。署名付きと署名なしの直接アクセスは、各2回とも403でした。Denyを外した後は、CloudFront経由の200を再確認しています。
CloudFrontのBearer転送、SourceArnの変更と復旧、直接アクセスの比較結果
CloudFrontからendpointへのIAM署名と、Honoによる利用者のBearer照合は、別に設計します。CloudFrontだけを入口にする場合は、同じaccount内のIAM権限も含めて直接アクセス経路を確認します。endpointのアクセス制御 も、この権限の境界を説明しています。

cacheを分ける

個人別の応答は、CloudFrontのCachingDisabledと、アプリのCache-Control: private, no-store, max-age=0を組み合わせました。利用者を変えても同じURLへ送ります。
alice、bob、alice、bobの順で送ったところ、応答の利用者は一致しました。応答を作るたびに発行するrenderIdも、8件の成功応答で全て異なりました。認証なしと不一致は、計4件とも401でした。CloudWatch Logsでも、これら12件に対応するHonoの開始ログを確認しています。
公開データは、別の/public/*へ分けました。cache key、つまり保存した応答を選ぶ条件に、queryのvを含めます。TTLは最小0秒、既定0秒、最大30秒です。アプリは公開データへpublic, max-age=30を付けます。
同じURLへ送る順序vCloudFrontの結果renderId
1redMissred用を新規発行
2redHit1と同じ
3blueMissblue用を新規発行
4blueHit3と同じ
5redHit1と同じ
6blueHit3と同じ
この並びを別の公開URLでも試し、同じ結果になりました。公開12件は4回のMissと8回のHitでした。公開データへ利用者の情報は入れていません。
公開データのcache Hitと、個人別応答の利用者・renderIdを比較した結果
まず個人別の経路でcacheを無効にし、保存してよい公開データだけを分けると、確認する範囲を絞れます。管理cache policyの仕様 では、最小TTLが0より大きいと、no-storeなどが付いた応答もその時間は保存されます。今回は、この設定による個人別データの混在実験は行っていません。

HTTP APIを接続する

API GatewayはHTTP APIを使い、HTTP_PROXYでApplicationManaged endpointへ接続しました。Lambdaのイベント形式へ変換する連携ではなく、HonoのHTTPサーバーへHTTPSで転送します。
呼び出し元の認証はrouteのAWS_IAMに任せました。API GatewayがIAM署名を検証します。HTTP API側では、Bearerで利用者を見分ける検証はしていません。署名付きは成功し、署名なしのAPI呼び出しは2回とも403でした。
ApplicationManagedでは、Lambdaは認証せず、呼び出し時のresource-based policyも評価しません。そのため、API Gatewayを通さないリクエストもHonoへ届きます。直接アクセスを拒む役割は、アプリに残ります。
今回は、Secrets Managerで専用tokenを生成し、API GatewayがX-Lab-Origin-Tokenを上書きします。Honoは、全routeの前でこの値を照合する実装にしました。
1
overwrite:header.x-lab-origin-token = <専用Secretの値>
値はCloudFormationのdynamic referenceで渡しました。コードやCDK contextへは書いていません。Honoは実行時にSecrets Managerから取得し、短時間だけメモリへ保持します。
入力結果。各条件2回
endpointへ直接アクセス。tokenなし403
endpointへ直接アクセス。誤ったtoken403
endpointへ直接アクセス。正しいtoken200
署名付きAPI呼び出し。呼び出し元が誤tokenを付与上書きされて200。誤tokenは2種類で比較
HTTP APIの署名、オリジンへの直接アクセス、token上書きの比較結果
これは、Secretを知る相手の直接アクセスまで拒む方式ではありません。tokenの保管、ログへ出さない実装、更新時の両側の設定は、運用する側の責任になります。tokenのrotationと無停止の切り替えは、今回の検証には含めていません。
CloudFront以外を前段に置く場合、AWSはアプリでのtoken照合などを案内しています 。

URLと本文を比べる

英数字のpath、重複するquery、日本語を含むJSON本文は、HTTP API経由でも受け取れました。重複queryは、今回のHonoでは2つの値として読めています。
一方、次の日本語pathは400になりました。
1
/echo/%E6%97%A5%E6%9C%AC?q=%E6%97%A5%E6%9C%AC
最初は、overwrite:path = $request.pathを設定していました。parameter mapping で、stage名を除くpathを渡す指定です。書き換えを削除するCDK更新も試しました。しかし、更新後に取得したAPI設定には、同じ値が残っていました。書き換えがない場合の動作は未確認です。
経路・入力結果。各条件2回
IAM署名付きの直接アクセス。同じ日本語path200
CloudFront。同じ日本語path200
HTTP API。path書き換えあり400
HTTP API。削除を試した更新後も、実設定に書き換えが残存400
HTTP API。英数字path、日本語はqueryだけ200
日本語pathとqueryを、直接アクセス・CloudFront・HTTP APIで比較した結果
400にはWeb Functionsのrequest IDが付いていました。どの内部処理で拒まれたかは、この応答から断定できません。HTTP_PROXYという方式だけで、URLが常に同じように届くとは判断できない結果です。
今回の構成を使うなら、日本語をpathへ入れるAPIは採用前に確かめます。queryやJSON本文へ移せる場合も、実際に使う形式で比較します。

timeoutを比べる

前段のtimeoutと、アプリの処理終了は別でした。3秒かかる待機処理を用意し、前段の待ち時間だけを8秒から1秒へ変えました。CloudFrontとHTTP APIの両方へPOSTで送りました。CloudFrontのオリジンへの接続試行は1回にしています。
条件CloudFrontHTTP APIHonoの処理
前段8秒、アプリ期限4秒200。約3.74秒200。約3.12秒約3秒で完了
前段1秒、アプリ期限4秒504。約1.40秒503。約1.08〜1.10秒応答後も続き、約3秒で完了
前段8秒、アプリ期限2秒504。約2.74秒504。約2.10〜2.13秒約2秒で待機を取消し
各条件を2回ずつ試しました。時間はクライアントが送信を始めてから、応答を読み終えるまでです。通信を含む少数の測定なので、前段の速度比較には使いません。
前段のtimeout応答と、同じcase IDの処理完了・取消しログ
アプリの期限には、AbortControllerを待機処理へ渡しました。期限を過ぎると待機を取り消し、アプリ自身が504を返します。CloudWatch Logsでは、前段だけを短くした場合のcompleted=trueと、アプリが止めた場合のcancelled=trueを確認しました。
外部への書き込みがあるアプリでは、timeout後に処理が続く前提で設計します。処理へ伝わる期限と、再送しても重複しない仕組みが必要です。今回、取消しを確かめた対象はタイマーによる待機です。DBや外部HTTP呼び出しの取消しは測っていません。

CORSとstreamを比べる

CORSは、別のoriginからブラウザーが応答を読める条件です。Honoに設定を残す場合は、HTTP API側のCORS設定を空にしました。
Honoの許可originをhttps://app.exampleにすると、CloudFrontとHTTP APIの両経路で、この値のAccess-Control-Allow-Originが返りました。不許可originでもOPTIONSは204、GETは200でしたが、この許可headerは付きません。statusだけでCORSの許可を判断しないようにします。
次にHTTP API側へCORSを設定し、許可originをhttps://gateway.exampleへ変えました。OPTIONSには、認証不要の専用routeも設けています。HonoとGatewayの両方へ異なる設定を置く対照です。許可headerはGateway側の値になり、Honoで許可したapp.exampleには付かなくなりました。設定を戻すと、Hono側の許可originへ戻りました。CORSの設定を管理する場所は、一方に決めます。
応答を少しずつ返すstreamも比べました。Honoは250ミリ秒間隔で3つのJSON行を返します。直接アクセスとCloudFrontでは、受信の間隔も約250ミリ秒でした。HTTP APIでは、2回とも3行が同じ受信時刻にまとまりました。
CORSの許可headerと、3つのJSON行が届いた時刻の比較
ここで測ったのはHTTP応答のheaderと、クライアントでの受信時刻です。ブラウザーによるCORSの強制は測っていません。ブラウザーからSigV4で呼ぶ場合は、署名に使うheaderの許可も別に確かめる必要があります。HTTP APIの結果を、REST APIのresponse streaming へ一般化しないようにします。

まとめ

CloudFrontでは、OAC、header転送、cacheを別々に決めます。利用者のBearerをHonoへ渡し、個人別の応答はcacheを無効にします。CloudFrontだけを入口にするなら、IAM署名付きの直接アクセスも確認します。
HTTP APIでは、呼び出し元のIAM署名に加え、ApplicationManaged endpointへ届く専用tokenを照合します。今回の構成では、日本語pathの400と、短いstreamがまとまる挙動も採用判断に残りました。
公開データのcacheや逐次の受信が必要な経路は、今回成功したCloudFrontの構成が候補になります。HTTP APIを使う経路は、呼び出し元の認証と、endpointでのtoken照合を設けます。そのうえで、実際のURL、本文、応答形式を比べます。どちらでも、前段のtimeoutに処理の取消しまで任せず、アプリの期限と再送時の扱いを決めます。
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