CloudFront で Next.js の RSC がキャッシュ事故を起こす話
「新規ページが人によって見れない」
社内でこんな報告が上がってきました。
新しく作ったページがなぜか見れない
最初は 404 かと思ったのですが、よく聞くと 404 ではなく「表示が壊れて見れない」状態でした。しかも厄介なのが、自分の環境では普通に見れてしまうこと。ブラウザを変えても再現しません。見れる人と見れない人がいる、というやつです。
見れないと言われたのは Next.js(App Router)で新規追加したページでした。「人によって・タイミングによって挙動が変わる」というのは、だいたいキャッシュが絡んでいます。今回もそうで、犯人は前段に立てている CloudFront のキャッシュキー設定でした。
この記事は、その原因調査と、CloudFront の Free プラン(カスタム Cache Policy が使えない)のまま直した記録です。ネットで見つかる対策の多くはカスタム Cache Policy 前提で、Free プランだとそのままでは真似できなかったので、そこを中心に書きます。
原因:Next.js は同じ URL で 2 種類のレスポンスを返す
まず前提から。Next.js の App Router は、同じ URL でもリクエストの種類によって返す中身が 2 種類あります。
- フルロード(アドレスバー直打ち・リロード・外部サイトから流入)→ 完成品の HTML
- サイト内のリンク遷移(
<Link>クリックやプリフェッチ)→ RSC ペイロード(React Server Components の差分データ。text/x-componentという HTML とは別形式)
サーバはリクエストに付く RSC: 1 ヘッダを見て、HTML か RSC ペイロードかを出し分けています。加えてクライアント遷移のときは URL に ?_rsc=<hash> というクエリも付きます。
普通の静的サイトなら「1 つの URL = 1 つの中身」なので、CloudFront がパス単位でキャッシュしても問題ありません。ところが Next.js は「1 つの URL = 2 つの中身」。ここでキャッシュキーの設計を誤ると事故ります。
CloudFront のキャッシュキー(=どのレスポンスを再利用するかを決めるラベル)が パスだけで作られていると、この 2 種類を区別できません。棚が 1 つしかないイメージです。
- 誰かがリンク経由で先にアクセス → 棚に「RSC ペイロード」が入る
- 次に別の人が URL 直打ちで来る → 棚の「RSC ペイロード」が返る
- ブラウザは HTML を期待しているので表示できない = 「見れない」
どちらが先に棚へ入るかはアクセス順とエッジ拠点次第です。新規ページはキャッシュが空っぽなので「最初の 1 アクセスが何を入れるか」で決まりやすく、だから新規ページで発生し、人によって見れたり見れなかったりします。
curl で現状を確認する
推測だけだと確信が持てないので、curl で同じ URL を 2 通りの叩き方をして比べました。ヘッダとレスポンス本文の先頭を見ます。
URL="https://<distribution>.cloudfront.net/<新規ページのパス>"
# 1) フルロード相当(RSC ヘッダなし)→ HTML が返るべき
curl -sS -D - -o /tmp/plain.body -H 'Accept: text/html' "$URL"
# 2) クライアント遷移相当(RSC ヘッダあり)→ RSC ペイロードが返るべき
curl -sS -D - -o /tmp/rsc.body \
-H 'Accept: text/x-component' -H 'RSC: 1' -H 'Next-Router-Prefetch: 1' "$URL"
結果がこれです。
| 1) フルロード | 2) RSC ヘッダあり | |
|---|---|---|
| content-type | text/html | text/html(RSC ペイロードでない) |
| body | 67,111 bytes | 67,111 bytes(バイト単位で完全一致) |
| x-cache | Hit from cloudfront | Hit from cloudfront |
| age | 4191 | 4191(同一 = 同じキャッシュ実体) |
| vary | Accept-Encoding | Accept-Encoding |
RSC: 1 を付けて「差分データちょうだい」と要求しているのに、返ってきたのは HTML。しかも 1) と 2) がバイト単位で完全一致、age も同じ 4191 秒でした。つまり CloudFront が RSC ヘッダを見ずに 1 つの棚を使い回している動かぬ証拠です。
決め手は vary: Accept-Encoding だけだったこと。RSC などのヘッダが vary に入っていない、つまりキャッシュキーが RSC を区別していません。
ついでにクエリ文字列も確認しました。適当なキャッシュバスター ?cb=xxxx を付けても x-cache: Hit(age も変わらず)だったので、クエリ文字列もキャッシュキーに入っていないことが分かりました。キャッシュキーが実質パスだけ、という状態です。
よく出てくる対策はカスタム Cache Policy 前提だった
この現象、検索すると対策はだいたい「CloudFront のキャッシュキーに _rsc を含める」に集約されます。具体的にはカスタム Cache Policy を作り、キャッシュキーに含めるクエリ文字列を _rsc に、必要なら RSC 系ヘッダも追加するというものです。
参考にした記事や Issue もこの方針でした。
- Next.jsでRSC Payloadがブラウザに表示される問題と対処法 - Qiita
- CloudFront CachePolicyにNext.js RSCヘッダーが含まれておらずキャッシュ汚染が発生する · Issue #100 · aws-samples/serverless-full-stack-webapp-starter-kit
いざ AWS コンソールでカスタム Cache Policy を作ろうとして、詰まりました。
ハマりどころ:Free プランだとカスタム Cache Policy が作れない
CloudFront の Cache Policy 選択画面で、カスタム欄が 「Available with the Business plan」 とグレーアウトされていて選べません。このディストリビューションは Free プランで、カスタム Cache Policy は上位プランの機能でした。
つまり、ネットで見つかる「カスタムで _rsc をキーに入れる」がそのままでは使えない。ここが今回いちばん引っかかった点です。
選べるのはマネージドのプリセットが 2 つだけでした。
UseOriginCacheControlHeaders— オリジンの Cache-Control を尊重。クエリ文字列はキャッシュキーに含めないUseOriginCacheControlHeaders-QueryStrings— 同上だが、クエリ文字列の値に応じて別の中身をキャッシュする
現状チェックが付いていたのは前者で、これはさっき確認した「クエリを無視する」挙動と一致します。原因そのものです。
解決:マネージドの -QueryStrings で十分だった
ここで思い出したのが、RSC のクライアント遷移には必ず ?_rsc=… が付き、フルロードには付かないという点です。
- フルロード:
/path(クエリなし) - RSC 遷移:
/path?_rsc=abc…(クエリあり)
つまりクエリ文字列を見るだけで 2 種類は区別できます。ヘッダをキーに足さなくても、クエリをキーに足せば棚が分かれる。それをやってくれるのが後者の UseOriginCacheControlHeaders-QueryStrings です。
Cache Policy を UseOriginCacheControlHeaders-QueryStrings に切り替えて保存し、汚染済みの在庫を捨てるために /* の Invalidation を実行しました。設定を直しても古い実体が残っていると返り続けるので、ここは必須です(特に今回は cache-control: s-maxage=31536000、1 年でエッジに居座る設定でした)。
Invalidation 完了後、同じ curl をもう一度回しました。
| 1) フルロード | 2) RSC ヘッダ + ?_rsc | |
|---|---|---|
| content-type | text/html | text/x-component |
| body 先頭 | <!DOCTYPE html> | 1:"$Sreact.fragment"(RSC ペイロード) |
| サイズ | 67,111 bytes | 33,704 bytes |
2 種類が別々の棚に分かれ、RSC リクエストにはちゃんと text/x-component の差分データが返るようになりました。2 回目以降は同じキーで x-cache: Hit になり、キャッシュもきちんと効いています。
もう一つ確認できたのは、RSC リクエストが正しく差分データを返せたということは、オリジン(Next.js)に RSC ヘッダが届いていたということです。ここが届いていないと、クエリで棚は分かれても中身がまた HTML になってしまう。事前には設定画面だけで判断できなかったので、切り替え → Invalidation → 再確認、の順で白黒つけました。結果、-QueryStrings だけで完全に直り、Business プランへのアップグレードもカスタム Cache Policy も不要でした。
トレードオフ:全クエリがキーに乗る
-QueryStrings は _rsc だけでなくすべてのクエリ文字列をキャッシュキーに含めます。そのため ?utm_source=... のような広告・計測用のクエリが付いた URL は、中身が同じ HTML でも値ごとに別の棚になり、キャッシュのヒット率が少し下がります。
とはいえ表示は正しいままで実害は小さいので、今回はこのままにしました。もしヒット率を最適化したくなったら、その時はカスタム Cache Policy を使い、キーに含めるクエリを _rsc だけに絞るのが本筋です。それには少しプラン側の話が関わるので、次で触れます。
なぜ参考記事はカスタムできて、今回はできなかったのか
そもそも、参考記事は当たり前のようにカスタム Cache Policy を使っているのに、なぜ今回のディストリビューションは使えなかったのか。調べてみると、この差は記事の新旧でも払っている金額の大小でもなく、ディストリビューションが「フラットレートのプラン制」に乗っているか「従量課金(pay-as-you-go)」かの違いでした。
CloudFront には今、月額固定でまとめて課金するプラン制(Free / Pro / Business / Premium)と、サービスごとに使った分だけ払う従量課金の 2 系統があります。公式のプラン別機能表を見ると、カスタム Cache Policy(custom caching rules)は Business 以上でしか使えず、Free と Pro には含まれていません。今回の staging はプラン制の Free に乗っていたので、ここで弾かれていました。
一方、従量課金のディストリビューションはカスタム Cache Policy も Origin Request Policy も追加費用なしで自由に使えます。参考記事が CDK や CloudFormation で組んでいるのはこの従量課金側なので、そもそもプランの壁が存在しません。「向こうはできてこちらはできない」のは、置かれている土俵が違っただけでした。
どうしてもカスタムを使いたい場合、有料の Business プラン(執筆時点で月 200 ドル前後)へ上げるほかに、そのディストリビューションを従量課金へ切り替える手もあります。従量課金ならカスタム Policy は追加費用なしで使え、CloudFront の永続無料枠(月 1TB 転送・1000 万リクエスト)があるので低トラフィックの staging ならほぼ無料で収まります。ただしプランがまとめて見てくれていた WAF や DNS などは個別課金に戻るので、そこは天秤にかける必要があります。
まとめ
「新規ページが人によって見れない」の正体は、Next.js の HTML と RSC ペイロードを CloudFront が区別できずに片方だけキャッシュしていたこと、でした。
Free プランでカスタム Cache Policy が使えなくても、RSC 遷移が必ず ?_rsc を伴う性質を利用して、マネージドの UseOriginCacheControlHeaders-QueryStrings に切り替えるだけで直せます。設定を変えたら /* の Invalidation を忘れずに、そして「オリジンに RSC ヘッダが届いているか」を curl で確認するところまでやると安心です。
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。