CloudFront で Next.js の RSC がキャッシュ事故を起こす話

古川

エンジニア

古川

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-typetext/htmltext/html(RSC ペイロードでない)
body67,111 bytes67,111 bytes(バイト単位で完全一致)
x-cacheHit from cloudfrontHit from cloudfront
age41914191(同一 = 同じキャッシュ実体)
varyAccept-EncodingAccept-Encoding

RSC: 1 を付けて「差分データちょうだい」と要求しているのに、返ってきたのは HTML。しかも 1) と 2) がバイト単位で完全一致age も同じ 4191 秒でした。つまり CloudFront が RSC ヘッダを見ずに 1 つの棚を使い回している動かぬ証拠です。

決め手は vary: Accept-Encoding だけだったこと。RSC などのヘッダが vary に入っていない、つまりキャッシュキーが RSC を区別していません。

ついでにクエリ文字列も確認しました。適当なキャッシュバスター ?cb=xxxx を付けても x-cache: Hitage も変わらず)だったので、クエリ文字列もキャッシュキーに入っていないことが分かりました。キャッシュキーが実質パスだけ、という状態です。

よく出てくる対策はカスタム Cache Policy 前提だった

この現象、検索すると対策はだいたい「CloudFront のキャッシュキーに _rsc を含める」に集約されます。具体的にはカスタム Cache Policy を作り、キャッシュキーに含めるクエリ文字列を _rsc に、必要なら RSC 系ヘッダも追加するというものです。

参考にした記事や Issue もこの方針でした。

いざ 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-typetext/htmltext/x-component
body 先頭<!DOCTYPE html>1:"$Sreact.fragment"(RSC ペイロード)
サイズ67,111 bytes33,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 で確認するところまでやると安心です。

記事を書いた人

古川

エンジニア

古川