問題と適用される場面
あるWebアプリケーションには4種類のGETレスポンスがあります:
/index.htmlはURLが固定されており、デプロイごとに変更され、現在の静的アセットを参照します。/assets/app.8f3c.jsはファイル名にコンテンツハッシュを含んでいるため、コンテンツが変更されるとURLも変更されます。/api/catalogは公開されている商品カタログです。サーバーはAccept-Languageに基づいて表現を選択し、プロダクトとしては約60秒間のステイル(古さ)を許容します。/api/cartは認証済みユーザー向けにパーソナライズされており、CDNや他のユーザーによって再利用されてはなりません。
あるリリース後、一部のユーザーが古いHTMLを保持し続け、デプロイですでに削除された古いJavaScriptファイルをリクエストしてしまいます。カタログが時折誤った言語で表示されることもあります。また、チームは no-cache を「保存しない」という意味として扱っているため、提案された修正案は意図した動作を記述できていません。プライベートなブラウザキャッシュ、共有CDNキャッシュ、鮮度、再検証、キャッシュキーがどのように相互作用するかを説明してください。その上で、これら4つのレスポンスすべてについてレスポンスヘッダー、デプロイ順序、検証方法を設計してください。
60秒という値は面接用の前提条件であり、情報源からの本番測定値ではありません。基本的なスコープはHTTPキャッシュを対象とします。Service Worker Cache、アプリケーションメモリキャッシュ、フレームワークのデータキャッシュはフォローアップで扱います。これは、ブラウザとHTTPプラットフォームの動作を論理的に考察する能力を重視するフロントエンド、フルスタック、またはWebパフォーマンスの質問であり、カテゴリは frontend です。
面接官が評価しているポイント
第1のシグナルは、候補者が「レスポンスを保存してよいか」「誰が再利用できるか」「どのくらいの期間新鮮であるか」「ステイルになった後はどうなるか」という4つの問いからポリシーを導き出しているかどうかです。Cache-Control ディレクティブを丸暗記していると、保存と直接再利用を混同しがちです。優れた回答は、ヘッダーを記述する前にデータの境界を明確に定義します。
第2のシグナルは、no-cache と no-store の正確な区別です。no-cache は保存を許可しますが、再利用のたびにオリジンでの検証を要求します。no-store はキャッシュに対してレスポンスを保存しないよう指示します。すべての動的レスポンスに no-store を適用すると、条件付きリクエストの利点や一部のブラウザ機能が失われます。これは、要件としてHTTPキャッシュへの保存が実際に禁止されている場合にのみ適用すべきです。
第3のシグナルは、正しい鮮度と検証のモデルです。新鮮な(freshな)レスポンスは、通常アップストリームサーバーに問い合わせることなく再利用できます。ステイル(stale)になった場合、または検証が必要な場合、クライアントは If-None-Match または If-Modified-Since を送信できます。表現に変更がなければサーバーは 304 Not Modified を返し、キャッシュはメタデータを更新して保存済みのボディを再利用します。
最後に、面接官はデプロイとキャッシュキーに関する論理的な思考を見ています。完璧な静的アセットヘッダーがあっても、古いHTMLが削除されたファイルを指しているリリースプロセスを修復することはできません。また、Vary: Accept-Language を省略すると、共有キャッシュが同じURLに対する英語のリクエストに中国語の表現を返してしまう可能性があります。優れた回答は、ヘッダー、アセットの寿命、表現のバリアント、再現性のあるテストを網羅します。
回答前の確認事項
- 4つのレスポンスすべてがCDNを経由しますか?
s-maxageは共有キャッシュを制御し、プライベートブラウザの鮮度を設定しません。CDNがなければ、パブリックAPIのポリシーをよりシンプルにできます。 - カタログの言語はURLまたはリクエストヘッダーのどちらから取得されますか?
/api/catalog?locale=zh-CNはすでにキャッシュキーの一部として個別のURIを使用しています。表現がAccept-Languageに依存する単一URLの場合、適切なVaryフィールドが必要です。 - ブラウザはカートのレスポンスを保存し、後で検証してもよいですか? 単一ユーザーのプライベートキャッシュが保存できる場合は
private, no-cacheを使用します。セキュリティまたはプロダクトポリシーでHTTPキャッシュへの保存が一切禁止されている場合はno-storeを使用します。 - 古いハッシュ化アセットはどのくらいの期間利用可能な状態を維持すべきですか? 即座に削除すると、古いタブ、履歴ナビゲーション、段階的デプロイ、ロールバックバージョンが壊れます。保持期間は、古いエントリーポイントがアクセス可能な期間にロールバックウィンドウを加えた期間をカバーする必要があります。
- カタログデータはどの程度までステイルになることが許容されますか? 「最大60秒」という要件では、通常の鮮度と、再検証時やエラー時の一時的なステイル利用を区別する必要があります。古いコンテンツが一切許容されない場合、
stale-while-revalidateやstale-if-errorで再利用を延長することはできません。 - Service Workerはインストールされていますか? Service Workerはリクエストをインターセプトし、通常のHTTPキャッシュが関与する前にCache Storageのエントリを返すことができます。別レイヤーとして検証する必要があります。
30秒の回答フレームワーク
「各レスポンスについて、まず保存可能か、誰が再利用できるか、いつまで新鮮か、その後どのように検証されるかを判断します。コンテンツハッシュ付きのJavaScriptアセットには、同じURLのバイト列が不変であることを前提に1年間の max-age と immutable を付与します。HTMLには no-cache とETagを付与し、ブラウザによる保存を許可しつつ通常利用時の検証を必須にします。パブリックカタログはCDNに s-maxage=60 を許可し、ETagを追加し、同一URLで複数言語を配信する場合は Vary: Accept-Language を使用します。カートは最低でも private とし、ブラウザへの保存が許容される場合は private, no-cache を、保存自体が禁止されている場合のみ no-store を使用します。HTMLより先に新しいアセットをデプロイし、古いハッシュファイルは残します。そして、キャッシュなしの200、フレッシュヒット、304再検証、言語バリアント、古いHTMLの再生をテストします。」
ステップ別の詳細解説
ステップ 1: 保存、再利用、鮮度、検証を個別にモデル化する
HTTPキャッシュは単一のスイッチではありません。リクエストパスには少なくとも4つの決定が必要です:
- 保存(Storage):
no-storeは保存を禁止します。それ以外のレスポンスは、メソッド、ステータス、認証、明示的なキャッシュルールに従います。 - 再利用スコープ(Reuse scope): ブラウザは通常1人のユーザーにサービスを提供するプライベートキャッシュです。CDNやプロキシは共有キャッシュです。
privateは共有キャッシュによる再利用を防ぎ、publicはそれを明示的に許可できます。 - 鮮度(Freshness):
max-age=Nは通常、プライベートおよび共有キャッシュがレスポンスを直接再利用できる期間を定義します。共有キャッシュでは、s-maxage=Nがmax-ageを上書きします。現在のレスポンスの経過時間はDateやAgeなどのメタデータも反映します。 - ステイル後の動作(Behavior after staleness): キャッシュはバリデータを使用して条件付きリクエストを送信できます。変更のない表現は304を生成し、保存されたボディが再利用されます。変更された表現は新しいコンテンツとともに200を生成します。
したがって、「キャッシュヒット」には2つの異なるコストが存在します。フレッシュヒットはアップストリームへの確認を必要としません。再検証ヒットは完全なレスポンスボディの転送を回避できるものの、ネットワークラウンドトリップと検証処理が発生します。面接の回答ではどちらを意味しているかを明記すべきです。
ステップ 2: コンテンツハッシュ付きの静的アセットに長い鮮度期間を設定する
変更されたコンテンツに新しいURLが付与されることがビルドで保証されている場合、静的アセットは以下を返すことができます:
Cache-Control: public, max-age=31536000, immutable31536000 秒は1年です。immutable は、リソースが新鮮である間は変更されないことを示し、特定のリロードパスでの不要な検証を回避できます。重要なのは1年という特定の数値ではなく、次の不変条件のペアです:
- 特定のハッシュ化URLにおけるバイト列は決して変わらない。
- 新しいバージョンは、新しいHTMLから参照される新しいURLを使用する。
同じ長期URLでファイルを上書きすると、クライアントに古いコンテンツが残ります。また、古いハッシュ化ファイルを削除すると、それらをリクエストする可能性がある古いタブ、ブラウザ履歴、段階的なエッジノード、ロールバックリリースが壊れます。堅牢なリリース手順では、まず新しいアセットをアップロードしてアクセス可能であることを検証し、その後にHTMLを公開し、古いエントリーポイントやロールバックバージョンからアクセスされなくなるまで古いハッシュ化アセットを保持します。
ステップ 3: 通常の再利用ごとに固定HTML URLを再検証する
エントリーHTMLは表現ごとにURLを自然に変更することはできないため、以下を返すことができます:
Cache-Control: no-cache
ETag: "index-v184"ブラウザはドキュメントを保存できますが、通常の再利用には検証が必要です。その後のリクエストには以下が含まれます:
If-None-Match: "index-v184"表現に変更がない場合、サーバーは304を返します。リリース後に新しいHTMLと新しいETagがある場合は200を返します。ETagは表現のバージョンを識別します。コンテンツハッシュである必要はありませんが、表現が変化したときには必ず変化しなければなりません。レスポンスには Last-Modified が含まれる場合もあります。リクエストに If-None-Match と If-Modified-Since の両方が含まれている場合、HTTPの仕様上、より精度の高いETag条件が優先されます。
no-cache は「保存しない」という意味ではありません。304再検証を失うことを受け入れた上で、HTMLをHTTPキャッシュに一切入れないことがプロダクトとして要求される場合にのみ no-store を使用してください。ブラウザのBack/Forward Cache(bfcache)も通常のHTTP再検証パスを通らずにページスナップショットを復元できるため、「戻るボタンで古いページが表示された」という事象は Cache-Control だけで診断することはできません。
ステップ 4: パブリックAPIに対してブラウザとCDNの動作を独立して制御する
カタログが通常60秒の遅延を許容すると仮定します。ブラウザはリクエストごとに確認を行うべきですが、CDNは最大60秒間レスポンスを直接再利用できます:
Cache-Control: public, max-age=0, s-maxage=60
ETag: "catalog-v927"
Vary: Accept-Language各ディレクティブには個別の役割があります:
max-age=0はブラウザによる保存を許可しますが、即座にステイルにするため、以降の利用は検証パスに入ります。s-maxage=60は共有キャッシュが60秒間直接再利用することを許可します。ETagはステイル後にブラウザまたはCDNが条件付きリクエストを行えるようにします。Vary: Accept-Languageは保存されたレスポンスを選択する際、言語リクエストヘッダーを含めるようキャッシュに指示します。
再検証のレイテンシを下げる引き換えに、プロダクトとしてさらに30秒のステイルデータを許容する場合は、 stale-while-revalidate=30 を追加します。この標準ディレクティブはCDNだけでなく、それをサポートするすべてのキャッシュに影響します。 CDNの動作のみを変更したい場合は、選択したプロバイダ固有の明示的なCDN設定を使用してください。 コンテンツが60秒より古くなってはならないというビジネス保証がある場合は、このステイルウィンドウを追加してはなりません。これは 鮮度を犠牲にして低レイテンシを得るための明示的なトレードオフであり、デフォルトの最適化ではありません。
ロケールをURLに含める(例: /api/catalog?locale=zh-CN)方が予測しやすい場合が多くあります。ロギング、ウォーミング、無効化、キャッシュキーが明示的になり、単一URLで表現を区別するためにレスポンスが Accept-Language を必要としなくなります。安易に Vary: User-Agent を追加することは避けてください。取り得る値の数が膨大になり、共有キャッシュの再利用が機能しなくなる可能性があります。
ステップ 5: パーソナライズされたカートを保護する
カートが共有キャッシュから他のユーザーに配信されてはなりません。ブラウザが現在のユーザーのレスポンスを保存してもよいが、再利用前に確認が必要な場合は、以下を返します:
Cache-Control: private, no-cache
ETag: "cart-v19"private は保存をプライベートキャッシュに制限します。no-cache は再利用前の検証を要求します。Vary: Cookie でユーザーを分離しようとしないでください。Cookie値のカーディナリティは極めて高く、キャッシュキーとプライバシーの両方のリスクを生み出します。パーソナライズされたレスポンスは、共有再利用を明示的に防止する必要があります。
セキュリティ要件が「レスポンスをいかなるHTTPキャッシュにも書き込んではならない」である場合は、以下を使用します:
Cache-Control: no-storeprivate, no-cache, max-age=0 を追加する必要はありません。重複した、またはより制限の厳しいディレクティブを追加してもポリシーが安全になるわけではなく、その意図の監査が難しくなるだけです。また、今後のレスポンスに no-store を追加しても、以前に保存された新鮮なコピーが消去されるわけではありません。インシデント対応には、CDNパージ、バージョニングされたURL、古いエントリの期限切れ待ち、または対象を絞ったブラウザクリアメカニズムが必要になる場合があります。
ステップ 6: キャッシュポリシーをデプロイプロトコルに結び付ける
削除されたJavaScriptのエラーは、ヘッダーだけで修復することはできません。デプロイプロトコルは以下を行う必要があります:
- すべての新しいハッシュ化アセットをアップロードする。
- 本番エッジロケーションから各アセットがアクセス可能であり、正しいMIMEタイプ、圧縮、ヘッダーを持っていることを検証する。
- 新しいURLを参照するHTMLを公開する。
- 古いタブ、段階的リリース、ロールバックのウィンドウ期間中、古いハッシュ化アセットを保持する。
- ロールバック時は、古いアセットがまだ存在している間に古いHTMLを復元する。
- アクセス可能なエントリーポイントから参照されなくなった後にのみアセットを削除する。
複数のノードがHTMLを生成する場合、それらのETagも表現と一致している必要があります。同一のコンテンツに対して異なるETagが生成されると、不要な200レスポンスが発生します。異なるコンテンツに対して同じ強ETagを再利用すると、誤った304が生成される可能性があります。圧縮、言語、その他の表現の違いにも、適切なキャッシュキーや Vary の処理が必要です。
ステップ 7: 1回のリロードではなく、一連のリクエストを検証する
ブラウザのDevToolsまたは curl を使用してリクエストマトリックスを構築します:
curl -I "$ORIGIN/index.html"
curl -I -H 'If-None-Match: "index-v184"' "$ORIGIN/index.html"
curl -I -H 'Accept-Language: zh-CN' "$ORIGIN/api/catalog"
curl -I -H 'Accept-Language: en-US' "$ORIGIN/api/catalog"コマンドを実行する前に、ORIGIN を対象サイトのオリジンに設定してください。以下のすべてを検証します:
- 初回リクエストが200、意図したヘッダー、バリデータを返すこと。
- 静的アセットが新鮮な間はアップストリームに問い合わせず、URLが変更されると新しいファイルを取得すること。
- 変更のないHTMLの条件付きリクエストが304を返し、デプロイ後は新しいETagとともに200を返すこと。
- カタログのCDNヒット時に
Ageが増加し、共有鮮度の期限切れ後に検証が発生すること。 - 2つの言語リクエストが異なるボディを受け取り、レスポンスに
Varyが含まれること。 - カートに共有キャッシュの再利用を許可する設定がないこと。
- 古いHTMLを再生しても、参照されているハッシュ化アセットが200で取得できること。
- DevToolsの「Disable cache」はネットワーク診断には便利ですが、実際のキャッシュパスのテストの代わりにはならないこと。
通常のリロード、強制リロード、新しいタブ、進む/戻るナビゲーション、Service Workerがあるパスを個別にテストしてください。強制リロードはリクエストのキャッシュディレクティブを変更するため、そのパスのみをテストしても通常のユーザーの動作は再現されません。
質の高い模範解答
「私はヘッダーを列挙することから始めません。各レスポンスについて、『保存可能か』『誰が再利用できるか』『いつまで新鮮か』『その後どのように検証されるか』という4つの問いに答えます。
JavaScriptファイルはコンテンツハッシュ化されているため、そのURLはバイト列とともに変更されます。私は public, max-age=31536000, immutable を返し、『同じURLのコンテンツは絶対に上書きしない』ことをリリースの不変条件とします。エントリーHTMLはURLが固定されているため、ETag付きの no-cache を使用します。保存は可能ですが、通常の再利用時には検証されます。変更がないコンテンツには304が返され、変更されたコンテンツには新しいHTMLと新しいETagが返されます。no-cache は保存を許可します。保存を禁止するディレクティブは no-store です。
パブリックカタログは約60秒の遅延を許容します。ブラウザには max-age=0、CDNには s-maxage=60 を使用します。再検証レイテンシを隠すためにさらに30秒のステイルデータを許容する場合は stale-while-revalidate=30 を追加し、そうでなければ省略します。同じ /api/catalog のURLが Accept-Language によって異なる表現を返す場合、Vary: Accept-Language が必要です。ロケールをURLに含めるようにすれば、さらに考慮しやすくなります。
カートは明示的にユーザー専用(プライベート)です。ブラウザの保存と検証が許容される場合は private, no-cache を返します。ポリシーで保存が禁止されている場合は no-store のみを返します。パーソナライズされたレスポンスを共有キャッシュに許可したり、カーディナリティの高いCookieを共有キャッシュキーとして使用したりはしません。
古いHTMLが削除されたバンドルをリクエストする問題は、デプロイプロトコルのバグも浮き彫りにしています。新しいアセットを先にアップロードし、次にHTMLを公開し、古いエントリーおよびロールバックウィンドウ期間中は古いハッシュアセットを保持します。私の検証では、単なる1回の強制リロードだけでなく、初回の200、フレッシュヒット、ETagベースの304、デプロイ後の200、2つの言語表現、カートの共有キャッシュ境界、そして古いHTMLの再生を網羅してテストします。」
よくある間違い
no-cacheを保存禁止と解釈する → 保存を許可し、再利用前の検証を要求します → 保存を禁止するにはno-storeを使用するか、304の恩恵を受けるためにno-cacheとバリデータを維持してください。- すべてのリソースに1年の鮮度期間を設定する → URL固定のHTMLが古いリリースを指し続ける可能性があります → コンテンツアドレス指定されたアセットに長期の鮮度を適用し、エントリードキュメントは検証してください。
- リリース直後に古いハッシュアセットを削除する → 古いタブ、履歴、ロールバックリリースがそれらを参照する可能性があります → エントリーポイントの前にアセットを公開し、アクセス可能な期間中はそれらを保持してください。
- 鮮度ポリシーなしでETagを設定する → キャッシュに明確な直接再利用期間がなく、不要な検証が発生する可能性があります → 鮮度と検証をセットで定義してください。
- 304を完全にコストゼロと呼ぶ → レスポンスボディは節約できますが、ラウンドトリップと検証の負荷は依然としてかかります → レイテンシとデータの許容度が見合う場合は、適切な鮮度期間を選択してください。
Varyなしで単一URLから複数言語を配信する → 共有キャッシュが誤った表現を再利用する可能性があります → 言語をURLに含めるか、キャッシュキーにVary: Accept-Languageを追加してください。- カートの分離に
Vary: Cookieを使用する → 高カーディナリティのキーはヒット率を低下させ、プライバシーリスクを高めます →privateまたはno-storeで共有再利用を防いでください。 - 新しく追加された
no-storeディレクティブが古いエントリを消去すると想定する → 新しいヘッダーは、直接再利用され続けている新鮮なレスポンスには届きません → パージするか、バージョニングするか、古いエントリの期限切れを待ってください。 - DevToolsの強制リロードでのみテストする → 強制リロードはリクエストのキャッシュディレクティブを変更します → 通常のナビゲーション、フレッシュヒット、再検証、履歴復元、Service Workerパスをテストしてください。
フォローアップの質問
フォローアップ 1: CDNまたはオリジンに障害が発生した際、カタログはステイルデータを配信できますか?
ビジネス上の許容度を2つのウィンドウに分割します:通常動作時の最大60秒と、オリジンの5xxレスポンス時の個別の許容値です。エラー時に古いデータが許容される場合は、期間を限定した stale-if-error の採用を検討してください。価格や在庫を古くしてはならない場合は、代わりにエラーを表示します。stale-while-revalidate はバックグラウンド再検証のレイテンシを隠し、stale-if-error は障害時のステイル再利用を許可します。これらは異なる問題を解決します。
フォローアップ 2: サーバーは強ETagと弱ETagのどちらをいつ使用すべきですか?
強ETagは2つの表現がバイト単位で同等であることを意味し、正確な範囲リクエスト(Range requests)やバイトの完全な同一性が重要な場合に適しています。弱ETagは W/ で始まり、バイト単位の違いがあってもセマンティックな同等性を表します。これはサーバーレンダリング出力における無関係なフォーマット変更などに適しています。キャッシュ検証にはどちらも使用できますが、If-Match による同時実行制御や範囲リクエストでは強比較規則の再確認が必要です。
フォローアップ 3: なぜDevToolsは304を表示せずに「from memory cache」や「from disk cache」と表示するのですか?
新鮮なレスポンスは条件付きリクエストなしで再利用できるため、304は発生しません。メモリやディスクはブラウザによる保存場所の選択を表しており、個別のHTTPセマンティクスではありません。1つのDevToolsラベルからポリシー全体を推測するのではなく、Cache-Control、Age、リクエストが送信されたかどうか、レスポンスの現在の経過時間を調査してください。
フォローアップ 4: Service Workerを追加した後、レスポンスヘッダーを変更しても効果がなかったのはなぜですか?
Service Workerがネットワークリクエストの発生前に古いCache Storageレスポンスを返す可能性があり、その場合通常のHTTPキャッシュは関与しません。fetchハンドラ、Cache Storageのバージョン、activate時のクリーンアップ、クライアントをclaimするタイミングを調査してください。キャッシュ名やリソースマニフェストをバージョニングし、古いService Workerによって制御されているページがどのようにアップグレードされるかを検証してください。
フォローアップ 5: ハッシュ化アセットを参照するパーソナライズされたHTMLはどのようにキャッシュすべきですか?
HTMLは Cache-Control: private, no-cache を使用して共有再利用を防ぎ、プライベートキャッシュでの再利用前に検証を行うことができます。すべてのユーザーで同一であるハッシュ化静的アセットは、引き続きパブリックな長期キャッシュを使用できます。認証されたページとその静的リソースに単一の共通ポリシーを適用する必要はありません。各表現がパーソナライズされているかどうかに基づいて境界を設定してください。
フォローアップ 6: デプロイにおいて古い静的アセットを安全に削除するにはどうすればよいですか?
現在のHTML、ロールバック可能なHTML、ルートマニフェスト、リリースレコードから参照セットを作成し、古いページの最大アクセス可能ウィンドウを加算します。ハッシュ化アセットは、公開可能なエントリーポイントから参照されなくなり、ロールバックウィンドウが終了し、アクセスログに有効なリクエストが表示されなくなった後にのみ遅延削除の対象となります。1つの誤った判断でロールバックが破壊されないよう、クリーンアップジョブは停止可能にし、直近の複数のリリースを保持する必要があります。