Cloudflare が HTTP で最も醜い部分 Vary をサポート
Vary はどのリクエストフィールドがレスポンスに影響しうるかをキャッシュに伝えるが、どの違いが重要かまでは伝えない。Cloudflare は Cache Rules で Vary をサポートした。
日本語
コピー

レスポンスヘッダーの Vary は、「HTTP でいまだに改善できていない最悪の部分」と呼ばれてきた。同じ記事はこれを「ひどい、継ぎ接ぎの仕組み」であり、中間層間の「相互運用性が驚くほど悪い」とも評している。こうした評価を目にすれば、事情を知るエンジニアは両手を上げてゆっくり後ずさりするのが普通だ。Vary の推奨と言える代物ではない。だが醜いからといって役に立たないわけではない。1 つの URL に対して正しいレスポンスが複数あり得る。たとえばサーバーがブラウザごとに異なる画像形式を返すことがある。キャッシュが Vary を無視すれば、あるリクエストに間違ったバイト列を返しかねない。しかし元のヘッダー値をすべて別物として扱えば、似たようなリクエストが数千、数万のほとんど再利用できないキャッシュエントリに膨れ上がる。Vary はどのリクエストフィールドがレスポンスに影響し得るかをキャッシュに伝えるが、どの差異が本当に重要かは伝えない。現在、すべてのプランで Cache Rules が Vary をサポートしている。どのリクエストヘッダーがレスポンスに影響し得るかを宣言するのは依然としてオリジン側の役割だが、個々のヘッダーを Cloudflare がどう扱うかは自分で決められる。既知のネゴシエーション系リクエストヘッダーを正規化してもいいし、微妙な違いが本当に重要なときは具体的な値をそのまま通してもいいし、変動が予測しにくすぎるならキャッシュを丸ごとバイパスしてもいい。オリジンが起こり得る変動を宣言し、キャッシュにとってどの程度の変動が意味を持つかを決めるのは自分だ。
Vary の仕組み
Vary は標準の HTTP レスポンスヘッダーで、Cloudflare のような中間キャッシュに対し、どのリクエストフィールドがオリジンの返すレスポンスに影響し得るかを伝える。サイトは Vary を使い、同じ URL で言語、画像形式、圧縮方式、地域ごとのコンテンツを出し分ける。ある URL がどちらも有効な 2 つの表現を返すとしよう。ブラウザがページをリクエストする:
GET /catalog HTTP/1.1
Host: example.com
Accept: text/html
オリジンは HTML を返し、Accept がレスポンスに影響し得るフィールドだと示す:
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=3600
Vary: Accept
API クライアントは別の設定で同じ URL をリクエストできる:
GET /catalog HTTP/1.1
Host: example.com
Accept: application/json
今度は正しいレスポンスが JSON だ。Vary: Accept ヘッダーは、URL だけでは複数のレスポンスから 1 つを選べないこと、リクエストの Accept の値も考慮しなければならないことをキャッシュに伝える。
Vary がなければ、先にキャッシュへ入ったレスポンスが両方のクライアントに配られる。HTML が先に来れば、API クライアントはマークアップを受け取り、JSON パーサーが即座にエラーを吐く。JSON が先に来れば、ページを期待するブラウザに API レスポンスが届く。
Vary は、キャッシュが間違ったレスポンスをリクエスト元に返すのを防ぐ。だがそこから難しい問いが生じる。2 つのリクエストが異なるヘッダーフィールド値を持つとき、それらは本当に別々のレスポンスを必要とするのか?
正しいキャッシュが役に立たなくなるとき
Vary はどのリクエストフィールドがレスポンスに影響し得るかをキャッシュに伝えられるが、そのレスポンスが何を表すかは伝えられない。例を挙げよう。あるオリジンが英語、フランス語、ドイツ語の 3 言語しか配信しないとする。あるクライアントはこう送るかもしれない:
Accept-Language: en-US, fr;q=0.8
別のクライアントはこうリクエストするかもしれない:
Accept-Language: fr;q=0.8, en-GB
どちらのリクエストも英語を好んでいる。オリジンのレスポンスが両者を同じ英語のレスポンスに対応させることは十分あり得る。だがキャッシュは元の値を比較するので、両者が等価だと安全に断定できない。順序が違い、言語タグも違う(そしてオリジンはそうした違いを区別しない)。その結果、レスポンスボディがバイト単位で完全に同一でも、キャッシュは 2 つのバリアントとして保存しかねない。
これがまさに Vary の核心的な問題だ。アプリケーションはしばしば、膨大なリクエスト値からごく少数の表現を生成する。何千もの言語設定が最終的にサポート対象の 3 言語に落ち着くことをオリジンは知っているが、キャッシュは通常知らない。
レスポンスが複数のフィールドで変化すると、問題は何倍にも膨らむ。1 フィールドが 10 通りの値を取れば 10 バリアント、3 フィールドがそれぞれ 10 通りなら 1,000 通りの組み合わせになり得る。実際のヘッダーフィールドの基数はそれをはるかに超え得る。User-Agent は値の数が膨大で、cookie は訪問者ごとに違い、設定系ヘッダーフィールドは順序、書式(空白もタブも区別される!)、品質値の点でばらつく。
結果としてこうなる。キャッシュは完全に正しく動作していながら、ほとんど常にコールド状態(エントリが一度も再利用されない)になり得る。同じレスポンスが各エントリに散らばり、それらのエントリはトラフィックが少なすぎて温かいままキャッシュに残れない。容量を食い、互いに追い出し合い、キャッシュヒット率を下げ、より多くのリクエストをオリジンへ押し戻す。追い出しはコールドなエントリを消せても、レスポンスが同じだからといって統合はできない。
ある分析は約 5 万の人気サイトにわたる 1 億 2 千万件超のレスポンスを調べ、3,000 近いサイトが 4 つ以上のフィールドで差異を生んでいると判明した。中には 10、23、さらには 47 のフィールドで差異を生むサイトもあった。適切な場面で Vary を使えるツールを顧客に提供しつつ、使いすぎてキャッシュが形骸化しないようにしたい。
高基数の差異の中には意図的なものもある。CDN やリバースプロキシが地理情報などの値を注入し、コンテンツを予測可能な形で分割することがある。取り得る値が制御されていて、各コンポーネントがその意味を同じように理解していれば、これは機能する。そうした制約がなければ、キャッシュは二度と再利用されないかもしれないバリアントの山へと砕け散る。
これがまさに、Vary をサポートする際に解くべき設計上の難題だ。正しいレスポンスを返すのに十分な変動は残しつつ、リクエスト間の偶発的な差異がキャッシュ効率を破壊するのを許さない。
Cache Rules で Vary を制御する
Cloudflare の顧客には、Vary のようなネゴシエーションを扱う方法が以前からいくつかあった。キャッシュをバイパスしてオリジンに任せる。カスタムキャッシュキーやその他のルールでオリジンのネゴシエーションロジックを再現する。Worker を使う。あるいは Vary for images のような機能を使う。
これらのオプションは今でも有用だが、キャッシュを諦めるか、アプリケーションのロジックを重複させるか、追加のコードを書くか、対象範囲の狭いユースケースしかカバーできないかのいずれかだった。Vary in Cache Rules はサポートを2つの判断に分割し、こうした既存機能の隙間をちょうど埋めてくれるかもしれない。
- オリジンは
Varyで、どのリクエストヘッダーがレスポンスに影響しうるかを示す。 - Cache Rule は、各ヘッダーの値の扱いを Cloudflare がどう決めるかを定義する。
Cache Rule は、すべてのレスポンスに vary を付けるよう強制するわけではない。オリジンが Vary を返さなければ、Cloudflare は通常どおりレスポンスをキャッシュする。ただし、リクエストをオリジンへ転送する前に、ルールが Accept と Accept-Language を書き換えることはある。
オリジンが Vary を返す場合、Cloudflare はそこに列挙された各ヘッダーに対して設定済みの操作を適用する。個別に設定していないヘッダーには、ルールのデフォルト操作が使われる。操作は3つある。
| 操作 | Cloudflare の動作 | 適した用途 |
|---|---|---|
normalize | キャッシュのバリアントを選択する前にヘッダーを正規化し、等価なリクエストが同じキャッシュ済みレスポンスを共有できるようにする。Accept、Accept-Language、Accept-Encoding にはそれぞれのヘッダー規則を適用する。それ以外のヘッダーでは、省略可能な空白を除去し、重複するヘッダー行を元の順序で結合し、大文字小文字と内部の空白はそのまま保持する。 | ネゴシエーション系ヘッダーの出発点として推奨。多数のリクエスト値が少数のレスポンスに対応する場合に向く。 |
passthrough | ヘッダーの生のバイト列でキャッシュを照合し、大文字小文字、空白、順序、重複する値をそのまま扱う。ヘッダーが複数行にまたがる場合、Cloudflare はそれらを順にカンマで結合してからキャッシュ照合を行う。パススルーは送信されるヘッダー行を変更しない。Respect Strong ETags が無効なときは、Cloudflare が Accept-Encoding を書き換えることがある。 | 取りうる値が限られ、その具体的な値がレスポンスを変えるヘッダー。 |
bypass | オリジンが Vary にそのヘッダーを列挙している場合、レスポンスを保存しない。既存のキャッシュエントリは削除されないので、消したい場合は手動でパージする。 | パーソナライズ、高いカーディナリティ、予測不能なヘッダー。たとえば Cookie や User-Agent。 |
デフォルトでは normalize を推奨する。値がパーソナライズされていたり上限がなかったりする単一のヘッダーには bypass を使う。具体的な値がレスポンスを変える場合は passthrough を使う。
たとえば passthrough は、オリジンが等価とみなす値であっても、大文字小文字、空白、順序、重複の違いをそのまま保持する。Vary: X-View と passthrough を組み合わせると、次の3つの値は3つの異なるキャッシュキーを生成する。
X-View: compact,full
X-View: Compact,full
X-View: compact, full
十分な偶発的差異があれば、再利用できたはずのレスポンスも、キャッシュの中では一度きりの変種へと変わっていく。
どの操作が設定されていても、Vary: * は常にキャッシュを迂回する。つまり、リクエストのあらゆる側面——HTTP メッセージの外側にある情報(クライアント IP アドレスなど)まで含めて——が、オリジンがどのレスポンスを選ぶかに影響しうる。したがって Cloudflare は、オリジンに問い合わせることなく、そのレスポンスを後続のリクエストに使うことはできない。
レスポンスがキャッシュを通過する仕組み
上記の /catalog リクエストの一つを追いながら、Cloudflare の内部を見ていく。
最初のリクエストの時点では、Cloudflare はそのリソースの Vary データを持っていないため、キャッシュ検索はミスになる。Cloudflare がオリジンに問い合わせる前に、一致した Cache Rule が設定済みのフィールドを正規化できる。
これは、最終的なレスポンスに Vary が含まれるかどうかを Cloudflare が把握するより前に起こりうる。Cache Rule は許容される正規化を定義し、そのフィールドがキャッシュ変種の一部になるかどうかは、その後のレスポンスが決める。
この順序は重要だ。Cloudflare が複数の元の値を同じ正規化キャッシュキーにまとめた一方で、オリジンには元の値がそのまま届く場合、オリジンは異なるレスポンスを返すことがあり、キャッシュは後からそれらを互換性があるとみなしてしまう。正規化した値を転送すれば、オリジンの選択とキャッシュを一致させられる。
オリジンが返すもの:
Vary: Accept, Accept-Language
Cloudflare はこれらのヘッダー名を記録し、レスポンスを 1 つのキャッシュバリアントとして保存する。ヘッダー値は Cache Rule による処理を経て、同じリソースの他のバリアントとこのバリアントを区別するために使われる。
/catalog に対する別のリクエストが到着すると、Cloudflare はそのリソースのベースキャッシュキーから始める。通常は URL に、設定済みの他のキーフィールドを加えたものだ。続いて保存済みの Vary フィールドを読み取り、新しいリクエスト内のこれらのヘッダーに Cache Rule を適用して、一致するキャッシュバリアントを特定する。
正規化後にこうなるとする:
Accept: text/html
Accept-Language: en,fr
Cloudflare はこれらの値を使って一致するキャッシュバリアントを直接検索し、リクエストを保存済みのバリアントと一つずつ突き合わせたりはしない。
一致するバリアントが存在し、かつ有効期限が切れていなければ、そのリクエストはキャッシュヒットとなる。そうでなければ Cloudflare はリクエストをオリジンに送り、返ってきたレスポンスを別のバリアントとして保存することがある。
オリジンのレスポンスが最後のピースを埋める。Vary に挙げられた各ヘッダーについて、Cloudflare はそのヘッダーに設定されたアクションを採用する。ヘッダーが個別に挙げられていなければ、ルールのデフォルトアクションを採用する。
Varyが含まれていなければ、Cloudflare は通常どおりキャッシュする。- 挙げられたヘッダーがすべて normalize または passthrough に解決されるなら、Cloudflare はそのレスポンスをキャッシュバリアントとして保存できる。
- 挙げられたフィールドのいずれかが bypass を使っていれば、Cloudflare はそのレスポンスを保存しない。
- レスポンスに
Vary: *が含まれていれば、Cloudflare は保存しない。
ここでオリジンに大きな責任が生じる。リクエストフィールドの違いによって変わりうるキャッシュ可能なレスポンスは、エラー応答やフォールバック応答も含めて、対応する Vary ヘッダーを一貫して返さなければならない。一つでも返し損ねたレスポンスがあれば、Cloudflare はそれをキャッシュしてしまい、分離に必要なバリアントの区別を欠くことになる。
図のキャッシュキーは概念的なものだ。後者のリクエストは、キャッシュ内のレスポンスがまだ期限切れになっていないことを前提としている。
あるキャッシュリソースに対するパージは、その Vary バリアントすべてに及ぶ。カスタムキャッシュキーに関する既存の要件は引き続き適用される。
Vary の設定を変更しても、既存の内容は自動的にパージされない。新しいポリシーは異なるキャッシュキーを生む可能性がある。リクエストはミスとなり、新しいキーの下で再び埋められる一方、古いエントリは期限切れになるかパージされるまで残り続ける。
正規化が等価なリクエストをまとめる
英語とフランス語のリクエストの話を覚えているだろうか?
Accept-Language: en-US, fr;q=0.8
Accept-Language: fr;q=0.8, en-GB
どちらのリクエストも英語を優先しているが、passthrough はそれらを別々のバリアントとして扱う。Cache Rule が en、fr、de を許可していれば、normalize は両方を en、fr に還元し、同じキャッシュレスポンスを共有する。
そのために Cloudflare は Accept、Accept-Language、Accept-Encoding の値を小文字に変換し、品質値で並べ替える。品質値の高い順で、同じ場合はアルファベット順だ。したがって、クライアントが指定した順序はキャッシュキーに影響しない。並べ替えの後、Cloudflare は品質値がゼロでないエントリからパラメータを削除する。言語タグを短縮する場合や、設定されたフォーマットと言語でフィルタリングする場合にも q=0("not acceptable")が失われることがある。たとえば en-US;q=0 が en になる。オリジンがこれらの除外項目を見る必要があるなら、Accept または Accept-Language に passthrough を使う。
指定したメディアタイプや言語だけを Accept と Accept-Language に残すようルールを設定することもできる。地域付きの言語タグ(en-US など)は、完全なタグを設定しない限り基本言語の en に還元される。こうして normalization を、オリジンが実際に提供するフォーマットと言語に揃えられる。
オリジンが選ぶ内容とキャッシュのマッチングを一致させるため、Cloudflare は正規化した Accept と Accept-Language の値をオリジンに転送する。Respect Strong ETags を有効にすると、正規化した Accept-Encoding の値も転送する。それ以外のヘッダーはキャッシュのマッチングのためだけに正規化される。
Cache Rules で Vary を設定する
Cloudflare ダッシュボードで Caching > Cache Rules に進み、レスポンスをキャッシュ可能にするルールを新規作成または編集して、Vary 設定を追加する。まずデフォルトの動作を設定し、そのうえでオリジンが指定すると想定されるヘッダーを追加する。
同じ設定は Rulesets API の http_request_cache_settings フェーズでも行える。デフォルト設定は、オリジンが Vary で指定しているが個別に設定していないヘッダーに対して、フォールバックの動作を選ぶ。
次の例は Accept と Accept-Language を、設定済みのフォーマットと言語の集合に正規化する。デフォルトの normalize 操作は Vary で指定された他のヘッダーにも同様に適用される:
{
"rules": [
{
"ref": "vary_negotiated_content",
"description": "Cache bounded negotiated representations",
"expression": "(http.host eq \"example.com\" and http.request.uri.path eq \"/catalog\")",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"vary": {
"default": {
"action": "normalize"
},
"headers": {
"accept": {
"action": "normalize",
"media_types": ["text/html", "application/json"]
},
"accept-language": {
"action": "normalize",
"languages": ["en", "fr", "de"]
}
}
}
}
}
]
}
これは http_request_cache_settings フェーズのエントリポイントに送る PUT の完全なリクエストボディだ。PUT はそのエントリポイントのすべてのルールを置き換える。すでに Cache Rules がある場合は、それらを rules 配列に書くか、対応する単一ルールの作成・更新操作を使う。
オリジンがメディアタイプと言語の組み合わせごとに1つの表現を返すなら、内容の組み合わせは6通りになる。だからといってキャッシュのキーが最大6個とは限らない。優先順位、欠けているリクエストヘッダー、正規化後に空になる値によって、キーはもっと増えうる。対応する範囲は小さく抑え、ルールの境界を明確に定義する。公開後は、同じ URL に異なるヘッダー値を付けてテストし、それらが同じキャッシュバリアントに正規化されることを確認する。同じクライアントからテストリクエストを送り、返るフォーマットと言語が期待どおりか確かめ、CF-Cache-Status を確認する。キャッシュが満たされた後はヒットするはずで、miss が続いたり想定外の bypass が出たりするなら原因を調べる。
制限、より多くの例、Terraform での設定方法については Vary のドキュメントを参照。
なぜカスタムキャッシュキーを使わないのか?
ここで自然に浮かぶ疑問は「Accept と Accept-Language をカスタムキャッシュキーに加えればいいのでは?」というものだ。
これらのフィールドが常にリソースの同一性の一部なら、それで問題ない。だがカスタムキャッシュキーは、オリジンがそれらを使っているかどうかに関わらず、ルールが対象とするすべてのレスポンスに設定した次元を追加する。
Vary はレスポンスによって決まるが、同じベースキーを持つキャッシュ可能なレスポンスには、一貫した Vary フィールドの集合が必要だ。
あるリクエスト属性が常にリソースを定義するなら、カスタムキャッシュキーを使う。オリジンがキャッシュ可能なレスポンス全体で同じリクエストフィールドの集合を宣言するなら、Vary を使う。同じヘッダーを両方に入れるのは、その重複が意図的でテスト済みでない限り避ける。
今すぐ Cache Rules で Vary を使う
Vary は明白な問題の解決に役立つ。1つの URL に対して正しいレスポンスが複数ありうる、という問題だ。だがキャッシュにはより難しい問いを投げかける——どのリクエストの違いが本当に重要か? オリジンは自分が返せるレスポンスを把握している。キャッシュは、各レスポンスをどのリクエストが再利用できるかを知る必要がある。
Vary Cache Rules はこの2つの視点をつなぐ。オリジンはどのリクエストフィールドがレスポンスに影響しうるかを示す。あなたはそれらの値を正規化するか、透伝で正確な違いを保つか、レスポンスをキャッシュしないかを選べる。
Vary は決して使い物にならないほど醜くはなかった。だが対応するフォーマットと言語を手作業で設定するのは、すべてのアプリに合うとは限らない。私たちは期限切れの Availability Hints ドラフトの考え方でこの作業を減らせるか検討している——オリジンが提供する表現を直接記述できるようにする、というものだ。
Cache RulesのVaryは Free、Pro、Business、Enterprise の各プランで利用でき、Cloudflare dashboard、Rulesets API、Terraform から使える。