はじめに
ソフトウェアエンジニアの森山です。
CloudFront で API と静的ファイルを別オリジンで扱い Single Page Application(以下 SPA)をホスティングする構成について解説します。
プライベート API を遮断する設定を追加する際に CloudFront のカスタムエラーレスポンスで躓きました。しかし CloudFront Functions を活用することで上手く切り抜けることができました。
構成

リクエストのパスを元にCloudFrontでオリジンへのアクセスを振り分けています。
S3でSPAのアセットをホスティングし、ALB経由でECSでバックエンドのAPIへ疎通しています。
ハマりポイント
/api/private/*へのリクエストのみALBで遮断する設定を施しました。しかし、何度APIコールしてもステータスコード200が返却され遮断できていないように見えました。試しにWAFで遮断する設定をしても結果は同じくステータスコード200が返却されました。
AWSの設定反映が遅延していることを疑いましたがWAFには/api/private/*のリクエストはブロックしているアクセスログが記録されていました。
原因
原因は以下のCloudFrontのカスタムエラーレスポンスでした。

上記はオリジンからのステータスコード403,404のレスポンスをステータスコード200に上書きしindex.htmlを返却します。
カスタムエラーレスポンスを設定しているとCloudFrontがレスポンスを返却する直前に上書きするのでALBでブロックしてもその後に200に上書きしたレスポンスが返却されます。

余談ですが、動作確認の反省ポイントはHTTPメソッド: HEADのAPIを使っていたことです。GETで確認していればレスポンスがindex.htmlに上書きされることの早期発見に繋がったはずです。
カスタムエラーレスポンスの意図
ではなぜこんな設定をしていたのか。
一言で言うと/以外のリクエストでindex.htmlを返却するためです。
これはフロントエンドがSPAであることが関係しています。SPAは名の通り1つのhtml(今回はindex.html)を起点に動作します。画面遷移はJavaScriptで実装されたrouterが担います。routerがURLを管理し、動的importが評価されるたびにブラウザがアセットのリクエストを飛ばします。

SPAの場合はこの起点となるindex.htmlありきです。
当たり前ですがindex.htmlがユーザーに届けられなければ、アプリケーションのエントリーポイントが無いので後続のjsやcssを読み込めません。
CloudFrontでSPAをホスティングしている場合、ルートページ以外への直接アクセスでindex.htmlが存在しない状態が発生し得ます。
例えばhttps://mntsq.com/hogeというURLへrouterを介さずにブラウザのURLバーへ直接入力してリクエスト、新規タブで画面遷移、またはリロード等です。
その場合は、GET: https://mntsq.com/hogeリクエストがCloudFrontへ飛びます。
しかしS3は/hogeというオブジェクトが無いのでエラーを返します。非認証なら403、認証後なら404です。結果としてブラウザにindex.htmlが届けられなくなってしまいます。

これを回避するためにカスタムエラーレスポンスを設定していました。

しかし、カスタムエラーレスポンスの対処ではマルチオリジン構成の場合に課題があります。
カスタムエラーレスポンスの課題
カスタムエラーレスポンスはS3以外のオリジンへのレスポンスも上書きしてしまうことです。
カスタムエラーレスポンスはCloudFrontのディストリビューションに対して設定されます。
CloudFrontにおいてディストリビューションとオリジンの以下の関係性です。
S3だけでなくAPIのレスポンスが403や404の場合にも上書きされてしまいます。そうなるとクライアント側でAPIのエラーハンドリングもできません。

解決策
CloudFront Functionsでリクエストを上書きします。
CloudFront Functionsはカスタムエラーレスポンスと違いディストリビューション事ではなくビヘイビア毎に紐つけることができます。
つまり/*へのリクエストだけを上書きし、/api/*へのリクエストに対しては何もしないという制御ができます。
処理の分割としては以下のようになります。

CloudFront FunctionsではCloudFrontのリクエストやレスポンスにJavaScriptで軽量な処理を挟むことができます。
シーケンス図内の⑨ 必要に応じてindex.htmlを要求の箇所は、拡張子の有無で識別できました。
そのため、拡張子の無いリクエストはindex.htmlのリクエストに上書きするというCloudFront functionを以下のように実装しました。
function handler(event) { const request = event.request const uri = request.uri const pathTail = uri.substring(uri.lastIndexOf("/") + 1) const hasExtension = pathTail.includes(".") if (!hasExtension) { request.uri = "/index.html" } return request }}
CloudFront Functionsで注意すべき点はJavaScriptのランタイム環境です。
JavaScriptランタイム1.0と2.0の2種類ありますがどちらもECMAScript 5.1に準拠しています。
大規模でレイテンシーの影響を受けやすいCDNカスタマイズのための軽量な関数を記述できるとあります。(link) この軽量さを実現するためにランタイム環境として使える機能も厳選されているようなので注意が必要です。(link)
上記のCloufFront FunctionsはJavaScriptランタイム2.0で動作しています。
まとめ
カスタムエラーレスポンスからCloudFront Functionsに切り替えることでマルチオリジンのSPAをホスティングし、APIのエラーも適切にハンドリングできるようになりました。
元々カスタムエラーレスポンスで403,404を200に上書きするという設定に違和感がありましたが、直感の通りでした。CloudFront FunctionsはAWSコンソール上にテスト機能があり非常に助かりました。
MNTSQの仕事にご興味を持たれた方は、ぜひ採用情報のページをご覧ください。