コンテンツネゴシエーションとVaryによるキャッシュ分割
同じURLで言語や形式を出し分けても、キャッシュ汚染を防げる仕組み。Accept系ヘッダによる表現選択、Varyがキャッシュキーを分割する原理、Client Hintsへの移行と落とし穴まで原理から理解できる。
- 1.コンテンツネゴシエーションは、同一URLに対しクライアントのAccept系ヘッダとサーバーの判断で複数の表現(言語・形式・符号化)から1つを選んで返す仕組み。
- 2.Varyは「どのリクエストヘッダが応答の選択に影響したか」をキャッシュへ伝え、その値を実効キャッシュキーへ織り込ませることで、誤った表現の配信を防ぐ。
- 3.Vary対象が多いほどキャッシュは細分化されヒット率が落ちるため、User-Agentでの分岐は避け、Client Hints+Accept-CHへ移行して必要な軸だけを宣言的に絞るのが定石。
コンテンツネゴシエーションとは何か
**コンテンツネゴシエーション(content negotiation)**は、1つのURLが指す1つのリソースに対し、**複数の表現(representation)**を用意しておき、クライアントとサーバーの合意で最適な1つを選んで返す仕組みです。表現が分かれる軸は主に3つあります。言語(日本語版・英語版)、メディアタイプ(text/html と application/json)、コンテンツ符号化(Brotli圧縮版・無圧縮版)です。同じ https://example.com/article でも、受け手の事情に応じて中身を出し分けられます。
ここで核心になるのが、URLは同じまま中身が変わるという点です。URLが違えばキャッシュも別エントリになるので問題は起きません。やっかいなのは「同一URLで複数の答えがある」状態で、これをキャッシュへ正しく教えないと、英語版を要求した利用者へ日本語版が返る、といった**表現の取り違え(キャッシュ汚染)**が発生します。これを防ぐのが後述のVaryです。
ネゴシエーションには2方式あります。サーバーが選ぶサーバー駆動型(Accept系ヘッダを見てサーバーが決定)と、サーバーが選択肢の一覧(300 Multiple Choices)を返してクライアントが選ぶエージェント駆動型です。実運用のほぼ全てはサーバー駆動型なので、本記事もこれを中心に扱います。
Accept系ヘッダによる表現選択
クライアントは、自分が受理できる表現の好みをリクエストヘッダで申告します。代表的なのが4つのAccept系です。
GET /article HTTP/1.1
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8
Accept-Language: ja, en-US;q=0.8, en;q=0.5
Accept-Encoding: br, gzip
Accept-Charset: utf-8
末尾の;q=は**品質値(q値、0〜1)**で、各候補への相対的な好みを表します。省略時はq=1です。サーバーは「自分が生成できる表現の集合」と「クライアントが受理可能な集合」を突き合わせ、q値の高いものを優先して1つを選びます。Accept-Language: ja, en-US;q=0.8なら、日本語版があれば日本語、無ければ米国英語、という優先順になります。
選択アルゴリズムは概ね次の擬似コードです。
candidates = サーバーが提供できる表現の一覧
for each ヘッダ軸 in [メディアタイプ, 言語, 符号化]:
クライアントの受理集合とのマッチングでスコア付け(q値を反映)
最終スコアが最大の表現を選択
選択に使った軸を Vary に列挙して応答
重要なのは、サーバーが選択に使った軸を、後で必ずVaryに申告する責任を負う点です。Accept-Languageを見て言語を切り替えたなら、応答にVary: Accept-Languageを付けなければなりません。これを怠ると、キャッシュは言語差を区別できなくなります。
q値はあくまで相対的な好みです。Accept-Language: en;q=0.9, ja;q=0.1であっても、サーバーが英語版を持たず日本語版しか無ければ日本語が返ります。クライアントは「出せるなら英語が望ましい」と伝えているだけで、存在しない表現を強制はできません。q値を「言語の強制スイッチ」と誤解しないことが要点です。
Varyがキャッシュキーを分割する仕組み
キャッシュ(ブラウザ、CDN、リバースプロキシ)は通常、リクエストのメソッドとURLを主キーとして応答を格納します。しかしコンテンツネゴシエーションが絡むと、同じURLでも返すべき中身がリクエストヘッダによって変わります。そこでVaryの出番です。
Vary応答ヘッダは「この応答の選択に影響したリクエストヘッダ名」を列挙します。キャッシュはこれを読み、実効キャッシュキーを次のように拡張します。
キャッシュキー = (メソッド, URL) ← Vary なし
キャッシュキー = (メソッド, URL, Vary に挙げた各リクエストヘッダの値) ← Vary あり
たとえば応答にVary: Accept-Languageがあれば、キャッシュは「URL+Accept-Languageの値」の組み合わせごとに別エントリを保持します。Accept-Language: jaで来た要求には日本語版エントリを、Accept-Language: enには英語版エントリを返し、両者が衝突しません。
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Language: ja
Vary: Accept-Language, Accept-Encoding
この応答に対し、キャッシュは「URL + 要求のAccept-Language + 要求のAccept-Encoding」をキーにします。言語が2種・符号化が2種なら、最大で 2 × 2 = 4 通りのエントリに分割されます。ここにVaryの本質的なトレードオフがあります。Vary対象の軸が増えるほど、キャッシュは細かく分割され、同じ表現を再利用できる確率(ヒット率)が下がります。
Vary: User-Agentを付けると、User-Agent文字列が少しでも違えば別エントリになります。実世界のUser-Agentはバージョン・OS・端末の組み合わせで事実上無限のバリエーションがあり、キャッシュキーが爆発してヒット率がほぼゼロに落ちます。端末別に出し分けたい場合でもVary: User-Agentは避け、後述のClient Hintsで必要な軸だけを宣言するのが正解です。
Varyの落とし穴と「*」
Varyにはもう1つ特別な値があります。Vary: *です。これは「この応答は何らかの未宣言の要因で変わり得るため、いかなるリクエストにも再利用してはならない」という意味で、実質的にキャッシュを無効化します。CDNやプロキシはVary: *を見たら、その応答を共有キャッシュとして保存・再利用しません。意図せず付与すると配信が全てオリジンに戻り、性能を大きく損ないます。
もう1つの典型的な事故が、符号化のVary漏れです。Content-Encodingで圧縮を有効にしながらVary: Accept-Encodingを付け忘れると、CDNは圧縮版と無圧縮版を同一キーで扱い、Brotli非対応のクライアントへBrotli応答を返してしまいます。圧縮の交渉とVaryの関係はBrotli/gzipによるコンテンツ符号化と転送圧縮の原理で詳述しています。
| Vary の値 | 意味 | キャッシュへの影響 |
|---|---|---|
| 指定なし | URL とメソッドだけでキャッシュ | ヒット率は最大。ただし表現の取り違えリスク |
| Accept-Encoding | 圧縮方式ごとに分割 | 圧縮版/無圧縮版を正しく区別(実質必須) |
| Accept-Language | 言語ごとに分割 | 言語数ぶんエントリ増。Cookieでの切替なら別途検討 |
| User-Agent | UA文字列ごとに分割 | バリエーション爆発でヒット率が壊滅(非推奨) |
| * | 未宣言要因で変動 | 事実上キャッシュ不可(共有キャッシュは保存しない) |
なお、キャッシュ側のVaryの扱いは「正規化(normalization)」の有無で結果が変わります。素朴な実装はAccept-Encodingの値を文字列としてそのまま比較するため、gzip, brとbr, gzipを別物と見なしエントリが無駄に増えます。CDNの多くはAccept-Encodingをbr/gzip/identityの少数の正規形へ畳んでからキーにし、分割数を抑えています。自前のキャッシュ層を実装するなら、この正規化を入れるかどうかでヒット率が大きく変わります。
Client Hintsへの移行と注意点
User-Agentを端末判定や画像出し分けに使う慣習は、上記のとおりキャッシュと相性が最悪です。これを置き換えるのがClient Hints(クライアントヒント)です。サーバーが「欲しい情報の種類」を宣言し、ブラウザがそれに応じて必要な軸だけを専用ヘッダで送る、という協調モデルになっています。
仕組みは2段階です。まずサーバーが応答でAccept-CHを送り、受け取りたいヒントを宣言します。
HTTP/1.1 200 OK
Accept-CH: Sec-CH-DPR, Sec-CH-Viewport-Width, Sec-CH-Width
Vary: Sec-CH-DPR, Sec-CH-Viewport-Width
次回以降、ブラウザは宣言されたヒントだけをリクエストに付けます(例: Sec-CH-DPR: 2 でデバイスピクセル比、Sec-CH-Viewport-Width: 1280 でビューポート幅)。サーバーはこれを見て、たとえば高解像度端末には2倍密度の画像を返す、といった出し分けができます。出し分けに使ったヒントは、やはりVaryに列挙してキャッシュへ伝えます。
この方式の利点は、キャッシュ分割の軸を粗い離散値に保てることです。User-Agentの無限のバリエーションと違い、DPRは実質1・2・3程度、ビューポート幅もサーバー側でブレークポイント単位に丸めれば数通りに収まります。必要な軸だけをVaryに入れられるので、ヒット率を保ったまま端末適応ができます。
ブラウザがヒントを送るのは、サーバーがAccept-CHで宣言した後の2回目以降です。初回リクエストにはヒントが付きません。そのため初回は控えめなデフォルト(標準解像度の画像など)で応答し、Accept-CHを返して2回目以降に最適化する設計が必要です。初回からヒント前提のロジックを書くと、ヒント欠落時に破綻します。
Accept-CHに加えてCritical-CHを返すと、ブラウザは「そのヒントが無いと正しく応答できない」と理解し、ヒントを付けてその場でリクエストを自動的に再送します。初回の最適化漏れを1往復で回収でき、重要なヒント(DPRなど)を確実に効かせたい場合に有効です。ただし再送が増えるので、本当に必須の軸だけをCritical-CHに入れます。
「同一URLで中身が変わるのにVaryが無い」構成は誤りです。逆に「全応答にVary: User-Agent」も誤りで、キャッシュ無効化に等しい。正解は『選択に実際に使った軸だけをVaryに列挙し、UA依存はClient Hintsへ移す』。Vary: *はキャッシュ不可の宣言である点も頻出ポイントです。
まとめ
コンテンツネゴシエーションは、同一URLで言語・形式・符号化などの表現を出し分ける仕組みで、サーバーはAccept系ヘッダとq値を突き合わせて1つを選びます。その選択をキャッシュへ正しく伝える要がVaryで、Varyに挙げた軸は実効キャッシュキーへ織り込まれ、表現の取り違えを防ぎます。代償としてVary対象が増えるほどキャッシュは分割されヒット率が落ちるため、User-Agentでの分岐は避け、Client HintsとAccept-CHで必要な軸だけを離散値で宣言するのが現代の定石です。キャッシュキー分割の全体像はブラウザキャッシュの階層と判定アルゴリズムを、鮮度と再検証の基礎はHTTP キャッシュを、ヘッダ自体を圧縮して送る仕組みはHTTP/2の多重化とHPACKヘッダ圧縮の原理を併読すると、配信最適化の判断軸がつながります。
Web/フロントエンド Article
コンテンツネゴシエーションとVaryによるキャッシュ分割を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
HTTP
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
Varyは「どのリクエストヘッダが応答の選択に影響したか」をキャッシュへ伝え、その値を実効キャッシュキーへ織り込ませることで、誤った表現の配信を防ぐ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「HTTP / キャッシュ」に近いか確認する。
- 強みである「コンテンツネゴシエーションは、同一URLに対しクライアントのAccept系ヘッダとサーバーの判断で複数の表現(言語・形式・符号化)から1つを選んで返す仕組み。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。