Fastly で Vary を最大限に活用
Fastly ブログで最も人気のある記事の1つは、同僚 Rogier Mulhuijzen 通称「Doc」の「Best practices for using the Vary header」です。この記事が2014年に公開されて以来、Fastly のお客様は (私の以前の勤務先である Financial Times や日経も含めて)、A/B テストから国際化まであらゆることに Vary を使用し始め、その方法はますますクリエイティブになっています。しかしそれと同時に、Vary の使い方を誤ったり、Vary の機能を誤解している人がまだ大勢います。
この記事は、拡張した手引きを提供することを目的としており、Fastly などの中間キャッシュで Vary の価値を引き出すための少し変わった方法も説明しています。
Vary の仕組み : 要約
一般的なケースは Vary: Accept-Language
です。これを使用すると、同じ Web ページの別言語バージョンをサーバーが作成して、ブラウザのローカル言語設定に基づいて提供できます。次のリクエストを受け取ったと想像してみてください。
GET /path/to/page HTTP/1.1
Host: example.com
このリクエストが Fastly に届くと、VCL で vcl_hash 関数を使用して、そのオブジェクトのキャッシュキーを判別します。デフォルトでは、URL ホストとフルパスの組み合わせです (存在する場合はクエリ文字列が含まれます)。
cache-key = req.host + req.url
= "example.com/path/to/path"
Fastly は、ここで、このキーと一致するオブジェクトをキャッシュ内で見つけようとします。このオブジェクトはキャッシュ内にないため、これは失敗になります。そのため、Fastly はリクエストをオリジンサーバーへ送ります。アプリケーションは、このリクエストに Accept-Language
ヘッダーが含まれないことを確認して、サイトのデフォルト言語を使用します。可能であれば正しい言語を提供したいのは当然です。そのため、将来のユーザーが特定の言語をリクエストした場合に、Fastly がこのレスポンスをキャッシュヒットとして提供することは望ましくありません。これを回避するために、リクエストに何も指定されていない場合でも、レスポンスヘッダーに Vary: Accept-Language
を含めます。
Fastly 側では、オリジンのレスポンスを受け取って Vary ヘッダーに気付き、最初のリクエストの Accept-Language
の値をレスポンスの vary-key にコピーします。リクエストに Accept-Language がなかったため、このキャッシュオブジェクトの vary-key は空文字列になります。
Cache-key | "example.com/path/to/something" |
Vary-key | "" |
Response object |
|
ここで、Fastly は同じ URL に対して2つ目のリクエストを受け取ります。
GET /path/to/page HTTP/1.1
Host: example.com
Accept-Language: ja-jp,en;q=0.8
vcl_hash 関数は前と同じキャッシュキーを生成します。__ヒットしました。__でも、待ってください。キャッシュで vary-key も見つかったため、Vary ヘッダーの値を見て、リクエスト vary-key を構築するために使用します。このケースでは次のとおりです。
Cache hit’s |
|
Request’s |
|
Cache hit’s | "" |
キャッシュ内のレスポンスは、受信リクエストに対して生成されるキャッシュキーと一致しますが、vary-key とは一致しません (“” != “ja-jp,en;q=0.8”
のため)。したがって、これは実際にはミスであり、このリクエストをオリジンサーバーに送信します。
ここで、オリジンサーバーが Accept-Language
ヘッダーを見て、レスポンスを日本語で生成し、そのレスポンスを Fastly に提供します。繰り返しになりますが、必ず Vary: Accept-Language
ヘッダーを含めてください。このレスポンスが Fastly に届くと、Fastly は Vary ヘッダーを見て、新しいキャッシュオブジェクトを格納します。
Cache-key | "example.com/path/to/something" |
Vary-key | "ja-jp,en;q=0.8" |
Response object |
|
これで、2つのキャッシュオブジェクトが存在するようになりました。どちらも同じキャッシュキーと一致しますが、vary-key が異なっています (キャッシュされた両方のレスポンスに同じ Vary 値がありますが、それらをトリガーした受信リクエストの該当するヘッダーで値が違います)
別の新しい Accept-Language
(たとえばスペイン語の “es-es”) を含む3つ目のリクエストが届くと、いつものように vcl_hash を使用してキャッシュキーを計算し、それが__2つの__既存のキャッシュオブジェクトと一致することを見出します。次に、それらの Vary ヘッダーが異なるリクエストヘッダーを示しているケースに備えて、各キャッシュヒットについて個別に新しいリクエストの vary-key を計算します。
Object 1 | Object 2 | |
---|---|---|
| “Accept-Language” | “Accept-Language” |
Computed vary-key for active request | “es-es” | “es-es” |
Value of the cache object’s vary-key | "" | “ja-jp,en;q=0.8” |
Match? | No | No |
別の新しい Accept-Language
(たとえばスペイン語の “es-es”) を含む3つ目のリクエストが届くと、いつものように vcl_hash を使用してキャッシュキーを計算し、それが__2つの__既存のキャッシュオブジェクトと一致することを見出します。次に、それらの Vary ヘッダーが異なるリクエストヘッダーを示しているケースに備えて、各キャッシュヒットについて個別に新しいリクエストの vary-key を計算します。
キーは各キャッシュオブジェクトごとに再計算する必要があり、vcl_hash 関数を使用するよりもかなり効率が下がります。このため、各キャッシュオブジェクトのバリエーションを200までに制限しています。実際には、同じ URL についてキャッシュされるすべてのオブジェクトは、ほとんどのケースでは同じ Vary ヘッダーになります (上記のように)。やがて、ヘッダーが異なる場合に発生する問題が引き起こされます。
では、Vary の基本を理解したところで、いくつかの経験則を以下に示します。
オリジンサーバーのレスポンスがリクエストヘッダーの値 (存在の有無を含む) に依存する場合は、ヘッダー名を Vary レスポンスヘッダーに含めます。
一般的に多くの値を持つ可能性がある項目 (
User-Agent
など) に基づいて処理を変更しないでください。作成されるバリエーションが多くなりすぎて、キャッシュヒット率が非常に低くなり、すぐに200個のバリエーション制限に到達するためです。同じ URL のバーリエーションに対して同じ値の Vary を使用します。
Accept-Language
は Vary を使用するのに適した例ですが、それにもかかわらず言語選択のプロキシとして位置情報を使うデベロッパーがとても多いのにはフラストレーションが溜まります。私が旅行で日本に来たからといって、突然、日本語を話せるようになるわけではありません (そうなれば本当にいいのですが)。私が話す言葉は私のブラウザによって示されます。私がいる場所ではなく、そのデータを使用してください。このヘッダーはすべてのブラウザによって送信されます。使用しない理由はありません。
Accept-Language の地理的クラスタリング
しかしながら、地理的位置と好まれる言語の間に少なくとも強い相関関係があるのは明らかです。Fastly 配信拠点 (POP) のいくつかで確認された最も一般的な Accept-Language
値を見るとそれがわかります。
Washington DC | Frankfurt | Tokyo | |
1 | en-us | en-US,en;q=0.8 | ja-jp |
2 | en-US,en;q=0.8 | it-IT,it;q=0.8,en-US;q=0.6,en;q=0.4 | ja-JP,en-US;q=0.8 |
3 | en-US | en-us | ja-JP |
4 | en-US,en;q=0.5 | it-it | ja-JP,ja;q=0.8,en-US;q=0.6,en;q=0.4 |
5 | en | tr-tr | ja,en-US;q=0.8,en;q=0.6 |
6 | pt-BR,pt;q=0.8,en-US;q=0.6,en;q=0.4 | ru | ja |
7 | en_US | tr-TR,tr;q=0.8,en-US;q=0.6,en;q=0.4 | ko-KR,ko;q=0.8,en-US;q=0.6,en;q=0.4 |
8 | es-ES,es;q=0.8 | pl-PL,pl;q=0.8,en-US;q=0.6,en;q=0.4 | ko-KR |
9 | en,* | ru-RU,ru;q=0.8,en-US;q=0.6,en;q=0.4 | en-us |
10 | en-US;q=1 | de-de | ko-KR,en-US;q=0.8 |
ヨーロッパ諸国間の密接な相互関係のおかげで、フランクフルトはヨーロッパの広い地域にサービスを提供しており、ヨーロッパ大陸全体のユーザー向けの最適な POP かもしれません。この結果、ここでも英語が1位ではあるものの、それ以外はさまざまなヨーロッパ言語が占めています。対照的に、米国の POP は英語、スペイン語、ポルトガル語の偏ったリクエストを受け取り、日本の POP は予想どおり日本語を求められています。
language_lookup
を使用して Vary の精度を下げる
前のリストは、大文字と小文字の区別が統一されていなかったり、さまざまな可能性の言語と優先順位がランダムに組み合わされていたりゴチャゴチャしています。東京におけるリクエスト数6位までの言語はすべて日本語ですが、表記が少しずつ異なっています。多くの場合、処理の変更に使用するヘッダーには、ガベージ値を含め潜在値のロングテールが付くことがあり、キャッシュのパフォーマンスを損ないます。
上記の例に、“ja-jp,en;q=0.8” という vary-key がありますが、これにはメイン言語だけでなく、バリアント (日本で話されている日本語)、第2選択肢 (このケースでは英語、すべてのバリアント)、優先順位 (0.8) が含まれています。これは、このリクエストを行ったユーザーに特有のもので、他の日本語ユーザーは少しずつ異なる設定を使用する可能性があります。これに加え、オリジンサーバーが実際に日本語をサポートしていない可能性があります。
Fastly は、この問題を解決するために VCL 拡張機能を提供しています。VCL (vcl_recv
内) で accept.language_lookup
関数を使用すると、ユーザーがリクエストした Accept-Language
を正規化された言語コード (オリジンサーバーがサポートしていることがわかっている) に変換できます。
set req.http.Accept-Language =
accept.language_lookup("en:de:fr:pt:es:zh-CN", "en",
req.http.Accept-Language);
インバウンドリクエストで Accept-Language: ja-jp,en;q=0.8
と指定されている場合、上記のコードによってこのヘッダーが Accept-Language: en
に変換されます。ユーザーは英語で問題ないと指定しており、バックエンドサーバーでは日本語がサポートされないためです。
これは素晴らしいことです。リクエストが再定義されて、オリジンが英語レスポンスで応答でき、それを単純に vary-key “en” としてキャッシュできるためです。
Cache-key | "example.com/path/to/something" |
Vary-key | "en" |
Response object |
|
受信リクエストに Accept-Language
ヘッダーがない場合でも、この正規化プロセスによって Accept-Language: en
ヘッダーが追加されます。つまり、サポートしている言語ごとに1つのコンテンツのコピーのみをキャッシュすることになります。
プライベートヘッダーを使用して精度をさらに下げる
VCL を使用すると、Fastly のお客様はリクエストに別のヘッダーを追加してから、オリジンサーバーに転送することができます。オリジンサーバーは、ブラウザから送信されていない場合でもそれらのヘッダーに対して Vary を使用できるのでしょうか?はい、できます!しかし、重要な検討事項がいくつかあります。
場合によっては、VCL でリクエストからデータの重要な部分を導出できます。ただし、既存のヘッダーを上書きする代わりに、新しいヘッダーを作成します。たとえば、Fastly ホームページの表示は、利用者が匿名か、無料ユーザーとしてログインしているか、有料ユーザーとしてログインしているかによって異なります。Fastly が受け取るすべてのリクエストには、ユーザーを特定する Cookie が含まれます (または、匿名ユーザーの場合は含まれません) が、それらの値はユーザーごとに固有です。次に例を示します。
Cookie: Auth=e3J0eXAiOxJKV1QiLTJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJybmlra2VpX3dlYiIsImlzcyI6ImFwaWd3Lm44cy5qcCIsImRzX3Jhbmsi;
オリジンサーバーが Cookie に基づいて処理を変更するとしたら、ユーザーごとに別のバリアントを格納する必要があり、これはキャッシュのパフォーマンスにとって非常に悪い影響を及ぼします。代わりに、VCL で Cookie ヘッダーを読み取り、暗号化し、ユーザーに関する有用なデータを導出して、いくつかの新しいヘッダーを作成します。この記事の目的ではないため、この変換がどのように機能するかについては説明しませんが、次のようなヘッダーを追加することになると想像してください。
Cookie: Auth=e3J0eXAiOxJKV1QiLTJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJybmlra2VpX3dlYiIsImlzcyI6ImFwaWd3Lm44cy5qcCIsImRzX3Jhbmsi;
Fastly-UserID: 12345
Fastly-UserRole: free-user
Fastly-UserGroups: 53, 723, 111
オリジンサーバーが生成するページがユーザーの役割のみに関して変化する場合、レスポンスではその1つのヘッダーのみを選択できます。
Vary: Fastly-UserRole
Cache-Control: max-age=31536000
ただし、ブラウザが最初に送信したヘッダーを少しだけクリーンアップする Accept-Language
の変換とは異なり、この Vary ルールはプライベートヘッダーに対して処理を行い、Web ブラウザに対しては意味がありません。これが問題になることを回避するため、Fastly の vcl_deliver
ステージでレスポンスを変換してから、それをブラウザに送信して戻す必要があります。
Vary を変更してプライベートデータ (このケースでは Cookie
) から導出したリクエストヘッダーを参照するか、ダウンストリームキャッシングを完全に無効にして Vary ヘッダーを削除するかいずれかが可能です。Cookie に基づいた処理の変更は、どうしてもキャッシングの死刑宣告になる可能性が高いため (Google Analytics を使うと誰もがリクエストごとに Cookie を変更する)、単純にしておく方がよさそうです。
set resp.http.Cache-Control = "private, no-store";
これを行わないと、ユーザーがサインアウトしても、ログインユーザー向けのページが引き続き表示されます。リクエストにFastly-UserRole
ヘッダーがなく、ブラウザはどの役割についても以前のレスポンスを一致させるためです。
同一 URL に可変の Vary 値を使用して精度を下げる
原則として同じ URL のバリエーションに対しては一貫した Vary 値を返すべきであると前に説明しました。これはほとんどのケースで通用しますが、複数のヘッダーに対して処理を変え、それらのヘッダーが予測できる方法で相互作用するケースでは無駄でもあります。たとえば、一連の A/B テストが行われるホームページがありますが、それらのテストはユーザーがサインインしている場合にのみ適用されるとします。VCL で、受信リクエストに興味深いリクエストヘッダーを追加できます。
Fastly-UserRole: anon-user
Fastly-ABTestFlags: A
このリクエストがオリジンサーバーに転送されると、オリジンサーバーは匿名ユーザー向けのホームページを生成し、A/B テストのフラグを無視します。匿名ユーザーに対しては A/B テストを実行しないためです。しかしながら、この URL をリクエストしたユーザーがログインしている可能性があり、その場合はテストフラグに基づいて処理を変更する必要があるため、ホームページのすべてのバリエーションでテストフラグに基づいて処理を変更します。
Vary: Fastly-UserRole, Fastly-ABTestFlags
この場合、キャッシュには以下のバリアントのセットが Fastly キャッシュノードによって格納されることになります。
この Vary 値について A/B テストバケットのさまざまなバリエーションを正しく格納できましたが、すべての匿名ユーザーのエクスペリエンスは同じであるにもかかわらず、各テストバケットに匿名ユーザー向けホームページのコピーも格納されていることに注意してください。
これを修正する方法はいくつかあります。複数のヘッダーを1つに統合して組み合わせの数を減らすこともできますが、リクエストの際に調査されたヘッダーに基づいてオリジンの Vary レスポンスを変更する方法がすっきりした解決方法であると考えます。
REQUEST | RESPONSE |
REQUEST | RESPONSE |
これを行うために、NodeJS アプリでミドルウェアを作成して、req.isLoggedIn()
のようなメソッドを公開する方法が気に入っています。このメソッドを呼び出して “Fastly-UserRole” ヘッダーを読み取り、レスポンスの処理を変更するためのヘッダーのリストに追加し、必要な場合に処理を変更することを忘れないようにします。これで、キャッシュの精度が適切になりました。
普通、Vary について少しでも知っているほとんどの人は、こんなことはしないようにと言うはずです。vary-key を決定論的な方法で評価し、すべてのバリエーションが同じヘッダーセットを使用すると見なす最適化を実装しないことは、キャッシュの性能に依存します。私はすべてのキャッシングプロキシをテストしたわけではないので、注意してください (ただし、この手法が Fastly では完全に動作することは確認しています)。
Vary の代替手段
Vary は同じ URL のキャッシュオブジェクトを区別するメカニズムであり、同じ処理をする方法は他にもあります。最もわかりやすいのは、Fastly の背後で個別の URL を使用する方法か、URL 以外を含むようにキャッシュキーの計算方法を変える方法です。これらの方法と Vary を使用する方法の主な違いは、パイプ記号で囲まれたそれぞれがバリエーションをどのように分類するかです。
Browser | Fastly | Origin | |
Vary | Sees Vary | Sees Vary | Sets Vary |
Modify URL in VCL | Sees Vary | Sees separate URLs; Sets Vary | Sees separate URLs |
Custom hash | Sees Vary | Sees same URLs as different cache objects; Sets Vary | Sees same URL |
URL の変更
同じ URL を読み込む2人のユーザーに異なるコンテンツを返すという基本の Vary 要件がまだある場合、あらゆるところで URL を変更してもうまくいきませんが、Fastly および Fastly のアップストリーム (オリジンサーバー) でリクエストを個別のものとして扱えます。たとえば、vcl_recv
で次のようにできます。
if (req.url ~ "?") {
set req.url = req.url "&lang=" req.http.Accept-Language;
} else {
set req.url = req.url "?lang=" req.http.Accept-Language;
}
これを vcl_recv
で行うと、リクエストの Accept-Language
のバリエーションごとに、キャッシュ内で異なる URL が探され、オリジンサーバーに提供されます。これらのバリエーションが同一 URL を占める唯一の場所は、現在ブラウザ内です。
私はこの手法を polyfill.io のために User-Agent 正規化の VCL を開発するときに使用しました。うまくいきましたが、いくつかデメリットがありました。
Fastly からブラウザへのレスポンスでは引き続き Vary を使用する必要があります。これはオリジンサーバーではおそらく追加されないため (オリジンのレスポンスには適用されないため)、カスタム VCL を使用して
vcl_deliver
で追加する必要があります。URL の操作がすべてのリクエストに適用されます。回避するには、たとえばパターンと一致する URL のみに制限するなど、さらに複雑な VCL を作成する必要があります。そうすると VCL とバックエンドに分離ロジックが配置され、しばしば、すっきりしないアーキテクチャになります。
Fastly にパージリクエストを送信した場合、URL の1つだけがパージされます。それぞれに個別のキャッシュキーがあり Fastly ではバリアントと見なされないためです。通常、同じキャッシュキーの可変レスポンスは、その URL に対する1つのパージですべて消去されます。
キャッシュキーの変更
vcl_hash で、キャッシュキーのアルゴリズムを変更することもできます。次に例を示します。
sub vcl_hash {
set req.hash += req.url;
set req.hash += req.http.host;
set req.hash += req.http.Accept-Language;
#FASTLY hash
return(hash);
}
これは URL の変換と似ています。URL は標準ハッシュアルゴリズムの構成要素の1つであるため、URL の変換は本質的にはハッシュの変換でもあります。違いは、URL 変換とは異なり、ハッシュアルゴリズムの変更ではオリジンサーバーに提供される URL が変更されないことです。
オリジンサーバーがおかしな状況になるため、これは気に入りません。オリジンサーバーが Accept-Language
ヘッダーを使用して各言語のレスポンスを生成し、Fastly がそれらを個別にキャッシュしますが、オリジンは Vary ヘッダーによるバリエーションを公開する必要がありません。もしそうすると、Fastly がキャッシュキーと vary-key の両方に言語を含めるためです。これによるパフォーマンスの大幅な低下はありませんが、いずれ問題が発生して、後で困ることになるような気がします。
Accept-Encoding の正規化とエッジ GZip
Accept-Encoding
に基づく処理の変更は、これまでのところ最も一般的な Vary の使用方法です。このため、Fastly は Accept-Encoding
値を自動的に正規化します。値は1つのトークンに変換されます。現時点では、”gzip”
または空文字列 (圧縮がサポートされない場合) です (Brotli 圧縮について疑問がある場合は、このコミュニティの投稿を参照してください)。
gzip 圧縮を実行することもでき (ユーザーが有効化を選択した場合)、オリジンで Accept-Encoding
を無視して、エッジだけで処理できるようになります。
リクエストメタデータに対して Vary を使用する前にリクエストメタデータを正規化するメリットについては既に説明しています。これは、エッジそのもので実行できる変換として理解できる値に正規化することで、この原則を1段階先に進めます。このためオリジンサーバーはまったくバリエーションを作成する必要がありません。
最後に
やれやれ、なんとか最後までたどり着きましたね。今回学んだことをおさらいしておきましょう!
Vary を使用すると、同じコンテンツの異なるバリエーションを同じ URL に対してキャッシュでき、何らかのリクエストヘッダーの値に基づいて選択できます。
これがよく使用されるのは、
Accept-Encoding
(gzip / no-gzip) です。おそらく、Accept-Language
についてももっと使用すべきです。現在、ブラウザの他のリクエストヘッダーに基づく処理の変更はあまり役に立ちません。しかし、CDN でそれらを変換すると、プライベートヘッダー内に追加の役立つ値が生成されます。
Vary をどのレスポンスに含めるか、またなぜ含めるかについては注意する必要があります。大部分では、何をしているかをよく理解していない限り、同じ URL に対して常に同じ Vary 値を使用してください。
では、みなさんこれで Vary がうまく使えますね!