CDN での HTTP フレッシュネスのテスト
CDN はすべて HTTP キャッシングを使用してパフォーマンスを最適化しています。ただし、さまざまな CDN でこれを行う方法がわずかに異なる場合があり、お客様にとって状況が複雑になります。このブログ記事では、CDN の相互運用性について説明し、今後に備えて CDN の違いを識別するために役立つ共通のテストスイートを紹介します。
フレッシュネスをテストする理由
RFC7234 によって、「フレッシュネス」は HTTP キャッシングの中心的な概念の1つと示されています。フレッシュなレスポンスを使用できる場合には、オリジンサーバーにアクセスする必要がなく効率が向上します。これは、実質的には、パブリッシャーとすべてのダウンストリーム HTTP キャッシュの間での、「この期間であればパブリッシャーに確認せずにパブリッシャーのコンテンツを提供できる」という約束です。
長年のうちに、CDN とリバースプロキシは HTTP のフレッシュネスに関するコントロールの一部 (リクエストの Cache-Control
など) を無視するように進化し、Surrogate-Control
などいくつか新しいコントロールを追加しました。多くの場合、これは現時点で賢明なやり方です。CDN とリバースプロキシはオリジンサーバーと特別な関係があり、それを利用してサービスを最適化しているためです (Poul-Henning Kamp の言葉では「Varnish」の追加)。
残念ながら、各 CDN がこれを行う方法が異なり、互換性がないこともあるという話を耳にします。これはお客様にとってよいことではありません。CDN とリバースプロキシが予想外に HTTP キャッシングのルールに違反すると、好ましくない問題が発生して驚かされる可能性があります。
たとえば、パーソナライズされたコンテンツがキャッシュされないようにするために、Cache-Control: private
を利用しているサードパーティ製サーバーフレームワークを使用していると想像してください。これに従わない CDN を間に入れると、悲惨な結果がもたらされる可能性があります。あるユーザーのみを対象にしたコンテンツが別のユーザーに表示されます。フレームワークでは HTTP キャッシングが正常に機能していると (正しく) 想定しているためです。
同時に、HTTP は5年前に比べて、はるかに想像力豊かできめ細かい方法で使用されています。これは特に HTTP ベースの API で顕著です。その結果、プロトコルの関連機能すべてを信頼して使用できるように、CDN とリバースプロキシは仕様に合った予測可能な方法で動作することが期待されています。
__CDN が HTTP キャッシングの詳細レベルをどのように処理するかという共通プロファイル__が存在し、「通常」動作との違いに関する詳しいドキュメントもあることが理想です。これで、CDN の切り替えに伴う摩擦やリスクが軽減されるはずです。また、サードパーティ製ソフトウェアは、増え続ける CDN と統合しようとするのではなく、1つを対象にすることができます。
ただし、そのようなプロファイルの定義は簡単ではありません。たとえ、仕様や他の CDN との互換性がないとしても、現在の CDN の個々の動作が異なっているのには正当な理由があるためです。また、すべての CDN (Fastly を含む) は、既存のお客様を驚かせるような動作方法の変更を望んでいません。
私たちにできるのは、CDN (またはリバースプロキシ) がどのように動作すべきかという考えをまとめて、問題がある箇所を特定できるように、データを収集することです。つまり、__CDN やリバースプロキシのためのテストスイート__を用意して、彼らやそのお客様が動作の様子を理解できるようにする必要があるということです。これは、いずれ動作をどこに収束するのが合理的かという議論を進める助けとなるはずです。
私はしばらく前に Web ブラウザでの HTTP キャッシングのための一連のテストを作成し、これらが Web プラットフォームテスト (W3C のブラウザ向け共通テストスイート) に含まれています。これがパブリック CDN テストスイートのベースとして適切であることがわかりました。
現在、このテストでは、仕様どおりの普通の HTTP と厳密に一致しているかどうかが確認されます。前述したように、CDN が仕様から離れたのには正当な理由がある場合もあり、「正しい」動作が何かということについて共通の合意はまだありません。したがって、適合テストとして扱われるべきではありません。つまり、全問合格しても賞品はないのです。
しかしながら、このテストは優れたツールであり、CDN がどのように動作しているかを把握するだけでなく、コンテンツがどのように処理されているか、またパフォーマンスを向上させるためにコンテンツをどのように調整できるかについて洞察を得ることができます。
Fastly の結果
では、現在の Fastly の状況はどうでしょうか。数字でいえば、○69、✗36です。実際のところかなりいい結果です。これは、以下の詳細を読んで、厳密な HTTP 準拠と離れた理由を見るとわかります。
どうしてすべてパス (○) ではないのでしょうか。多くの CDN と同様に、Fastly もオリジンサーバーとの「特別な」関係があります。Fastly は可能であれば常にキャッシングを促しています。また、キャッシングメタデータを上書きする外部情報 (VCL などで) が得られることがよくあります。HTTP キャッシングが設計されたのは、CDN が考案されるよりも前であり、汎用的なフォワードプロキシキャッシュでは意味をなすことが、CDN の場合には意味をなさないことがあります。
また、Fastly は Varnish に基づいているという歴史があります。既存のお客様は Varnish および/または Fastly サービスの動作に依存しているため、場合によっては動作の変更によってお客様の迷惑になることがあります。
Fastly における大きな違いは、半ば公然の秘密兵器 VCL です。必要であれば、VCL を使用することで Fastly をほぼすべてでパス (○) させることができます (105のうち103)。HTTP とさらに準拠した動作が必要な場合は、以下にリンクしたスニペットをアカウントに追加してアクティブ化するだけです。5秒後には有効になります。
そうすることがサイトにとって適切な場合もあれば適切でない場合もあります。続きを読むと理由がわかります。
明示的なフレッシュネス : Expires および Cache-Control: max-age
ほとんどの人は Expires
と Cache-Control: max-age
をよく知っています。これらは HTTP レスポンスでフレッシュネス有効期間を宣言するための基本的な方法です。Expires、レスポンス Cache-Control: max-age
(および 's-maxage
)、それぞれの相対的な優先度、経過時間の計算などについて、Fastly は関連するテストほぼすべてにパスしています。これらのヘッダーを無視するのは、Surrogate-Control
(以下参照) を設定するか、VCL で明示的に上書きする場合のみです。
唯一の例外は、レスポンスが Expires のみを使用してフレッシュネス有効期間を設定したときに、クロック同期の重大な問題があるアップストリームキャッシュが存在している場合です (たとえば、Fastly とオリジンの間にリバースプロキシがあるとき)。この問題の詳細については、以下の「Date、Age および Expires」を参照してください。
ヒューリスティックフレッシュネス
レスポンスで明示的なフレッシュネスが割り当てられなくても、特定の状況では HTTP キャッシュが独自のヒューリスティックフレッシュネス有効期間を計算できます。多くのコンテンツではフレッシュネスが明示的に設定されないためです。ただし、問題が発生しないようにするため、ヒューリスティックフレッシュネスが許可されるのは、特定のステータスコードの場合のみ、かつ明示的な情報が__まったく__ない場合のみです。
Fastly では、VCL やコントロールパネルで ttl パラメーターによって、使用するヒューリスティックをコントロールすることができます。また、テストによって、__許可されていないすべてのレスポンスステータスコードでのヒューリスティックフレッシュネスの使用を適切に回避している__ことが示されます。
しかし、多くのブラウザや他の中間キャッシュと同様に、Fastly はヒューリスティックフレッシュネスについて非常に保守的です。テストによって、__HTTP で許可されるすべてのステータスコードで使用しているわけではない__ことがわかります。これには、204 (No Content)
、405 (Method Not Allowed)
、414 (URI Too Long)
、501 (Not Implemented)
、および Cache-Control: public
を含むすべてのレスポンスが含まれます。明示的なフレッシュネス情報がない場合、これらのレスポンスはキャッシュに格納されません。
それらのレスポンスもキャッシュしたい場合には、次の VCL を vcl_fetch
で使用してください。
# This sets a heuristic TTL of 1 hour if Last-Modified is present.
if (
( ! beresp.http.Cache-Control:max-age
&& ! beresp.http.Cache-Control:s-maxage
&& ! beresp.http.Expires
&& time.is_after(now, std.time(beresp.http.Last-Modified, now))
) && (
http_status_matches(beresp.status, "200,203,204,404,405,410,414,501")
|| beresp.http.Cache-Control:public
)
)
{
set beresp.ttl = 3600s;
set beresp.cacheable = true;
}
private、no-cache および no-store
他の Cache-Control
レスポンスディレクティブを使用して、さまざまな方法でキャッシュを制限できます。
private
ディレクティブでは Fastly のような「共有」キャッシュへのキャッシングが禁止されます。また、Fastly が Cache-Control: private
のレスポンスのキャッシングを適切に回避していることがテストでわかります (ドキュメントを参照)。
no-cache
レスポンスディレクティブと no-store
レスポンスディレクティブはよく混同されます。no-cache では、キャッシングで格納することは許可されますが、オリジンサーバーに確認 (If-None-Match
検証の使用など) せずに再利用することができません。no-store
ではキャッシング自体が禁止されます。事実上、レスポンスはキャッシングシステムを迂回する必要があります。
Varnish に基づいているため Fastly は 'no-cache' と 'no-store' の両方をデフォルトで無視し、レスポンスを格納して、オリジンサーバーに確認せずに提供します。このため、__該当するテストに失敗__します。
ただし、これらを vcl_fetch
でサポートすることはできます。
if (beresp.http.Cache-Control:no-store) { return (pass); }
if (beresp.http.Cache-Control:no-cache) {
set beresp.ttl = -1s;
set beresp.grace = 0s;
return (deliver);
}
警告 : ここでの no-cache
サポートは仕様に正しく従っていますが、これらのリクエストを実質的にシリアライズしているため、効率が大幅に下がることを認識してください。本番環境にデプロイする際には注意が必要です。no-cache
の厳密なセマンティクスが必要ない場合には no-store
の使用をお勧めします。
Surrogate-Control
Surrogate-Control ヘッダーは、かなり曖昧なエッジアーキテクチャ仕様に隠されています。これを使用すると、コンテンツプロバイダーがフレッシュネス情報を CDN に明示的に伝え、レスポンスの明示的なフレッシュネス情報コントロールをオーバーライドすることができます。
Surrogate-Control は、Fastly を含め多くの CDN で長年にわたって採用されてきました (ドキュメントを参照)。しかし、仕様の簡潔さ (エディターである私のせい) と、このヘッダーが Cache-Control に似ていることから、機能、シンタックスおよびセマンティクスのさまざまな解釈が多数生まれました。
このため、テストの目的は、max-age
および no-store
ディレクティブから始めて、Surrogate-Control
のために合理的で相互運用可能な基盤を確立することです。想定されるのは、キャッシュが Expires
または Cache-Control
の前に Surrogate-Control
に従い、キャッシュが超過しないように Age
ヘッダーのような要素も考慮することです。
テストからは、__Fastly が Surrogate-Control: max-age
を適切に処理しているが、まだ Surrogate-Control: no-store
に従っていない__ことがわかります。
上記の Cache-Control: no-store と同様に、これも vcl_fetch
に追加できます。
if (beresp.http.Surrogate-Control:no-store) { return (pass); }
リクエストの Cache-Control
多くの CDN と同様に Fastly はリクエストの Cache-Control ディレクティブを無視します。このため、リクエストの Cache-Control
に関するテストにはほとんど失敗します。
CDN がこのようにするには正当な理由があります。クライアントによるキャッシュのコントロールを拡張することは、Web サイト運営者のコントロールが縮小することを意味するからです。パフォーマンスと可用性を1つの CDN に依存している場合、これは攻撃ベクトルになる可能性があるため、ほとんどの顧客はリクエストの Cache-Control
をデフォルトで無効にします。
ただし、サイトがリクエストの Cache-Control
に従う必要がある場合 (たとえば、HTTP API を提供しており、クライアントが認証されている場合)、VCL では簡単にこれを行うことができます。
vcl_recv
では :
# Cache-Control is removed from requests when we try to cache
# so we make a copy of the fields we need forwarded
if (req.http.Cache-Control) {
set req.http.Forward-Cache-Control = req.http.Cache-Control;
}
# handle no-store
if (req.http.Cache-Control:no-store) {
# For the edge node
set req.http.Forward-Cache-Control:no-store = "1";
return(pass);
}
if (req.http.Forward-Cache-Control:no-store) {
# For the shield node
unset req.http.Forward-Cache-Control;
return(pass);
}
if (req.http.Forward-Cache-Control:max-stale) {
declare local var.maxstale RTIME;
set var.maxstale = std.atoi(req.http.Forward-Cache-Control:max-stale);
set req.max_stale_while_revalidate = var.maxstale;
} else {
set req.max_stale_while_revalidate = 0s;
}
vcl_hit
では :
declare local var.ttl INTEGER;
if (req.http.Forward-Cache-Control:no-cache) {
set obj.ttl = 0s;
restart;
}
if (req.http.Forward-Cache-Control:max-age) {
declare local var.maxage INTEGER;
set var.maxage = std.atoi(req.http.Forward-Cache-Control:max-age);
set var.ttl = var.maxage;
set var.ttl -= obj.ttl;
if (var.ttl < 1) {
return (pass);
}
}
if (req.http.Forward-Cache-Control:min-fresh) {
declare local var.minfresh INTEGER;
set var.minfresh = std.atoi(req.http.Forward-Cache-Control:min-fresh);
set var.ttl = obj.ttl;
set var.ttl -= var.minfresh;
if (var.ttl < 1) {
return (pass);
}
}
vcl_miss
では :
if (req.http.Forward-Cache-Control:only-if-cached) {
error 504 "Gateway Error";
}
if (req.http.Forward-Cache-Control) {
set bereq.http.Cache-Control = req.http.Forward-Cache-Control;
}
unset bereq.http.Forward-Cache-Control;
vcl_fetch
では :
# Save responses for request Cache-Control: max-stale
set beresp.stale_while_revalidate = 1h;
リクエストの Cache-Control: max-stale
を有効にするために、vcl_fetch
では beresp.stale_while_revalidate
が設定されていることに注意してください。これは、stale-while-revalidate および max-fresh の処理で最大値として動作するため、それに応じて値を調整します。stale-while-revalidate を使用する場合には、vcl_recv
で req.max_stale_while_revalidate
を希望の値に変更してください。
ステータスコードおよびキャッシング
HTTP では、レスポンスに明示的なフレッシュネス情報 (または Cache-Control: public
) がある限り、ほぼすべてのステータスコード (不明なものでさえ) をキャッシュできます。多くの HTTP キャッシュと同様に、Fastly はさらに保守的で、認識しているステータスコードしかキャッシングしません。ただし、これは (推測どおり) vcl_fetch
で変更できます。
if (
beresp.http.Cache-Control:max-age
|| beresp.http.Cache-Control:s-maxage
|| beresp.http.Expires )
{
set beresp.cacheable = true;
}
500 (Internal Server Error)
と 503 (Service Unavailable)
については、ここで特に説明する必要があります。これらはサーバーで問題が発生していることを示すため、Fastly はデフォルトで、できるだけ長い間オリジンに対してリクエストを再試行します。クライアントに対してこれらを記述する場合は、vcl_fetch
で次のようにします。
if (beresp.status == 500 || beresp.status == 503) {
set req.http.Fastly-Cachetype = "ERROR";
return (deliver);
}
Date、Age および Expires
Fastly はフレッシュネスが Cache-Control
に基づいたレスポンスを正しく処理していますが、フレッシュネスが Expires
のみに基づいたレスポンスについてはいくつかの問題が見つかりました。
Age
レスポンスヘッダーは、レスポンスがアップストリームキャッシュで費やした時間をキャッシュが把握するために役立ちます。これは、Expires
に基づく場合でさえ、レスポンスのフレッシュネス有効期間の計算に使用されます。
Expires のフレッシュネスを計算する HTTP のアルゴリズムは、実際は以下のようになります。
freshness_lifetime = Date - Expires - Age
Date
と Expires
の両方はオリジンサーバーに由来するため、キャッシュのクロックがオリジンサーバーと適切に同期しているかどうかは関係ありません。ただし、Fastly はこの計算を行いません。代わりに、Expires
ヘッダーとローカルクロックを比較して、レスポンスがあとどれだけの期間フレッシュかを判別します。Age
は無視します。
コードのコメントから判断すると、このアプローチは Varnish から継承されたようです。オリジンサーバーのクロックが Fastly のクロックと適切に同期している場合、この方法はうまくいきます。ただし、ずれが大きい場合には、レスポンスのキャッシュが意図したよりも長くなったり短くなったりすることになります。
これはテストによって識別できたバグです。現在、既存のお客様の VCL に影響せずにこの問題を修正する方法を調べているところです。今のところ、オリジンと Fastly の間でキャッシュを使用し、Expires
を使用している場合は、NTP を使用してオリジンのクロックが適切に同期していることを確認してください。
これらのヘッダーは、ダウンストリームキャッシュ (たとえばブラウザのキャッシュ) を正しく扱うためにも重要です。テストによって、Fastly が Age
ヘッダーをクライアントに送信していることが示されました。ただし、フレッシュネスが Expires
に基づいているとき、レスポンスが Fastly のキャッシュに格納されていた期間のみに基づいて行われます。レスポンスがアップストリームでキャッシングされていても、Fastly から送られる Age
には反映されていません。Fastly よりもダウンストリームのキャッシュがレスポンスを格納していた期間が長すぎる可能性があります。
これは、テストスイートを使用して見つけることができたもう1つのバグです。影響するのは、フレッシュネスのために Expires
のみを使用しているレスポンスだけです。現在、既存のお客様が影響を受けないようにしながら、このバグを修正する方法を探しているところです。それまでの間、Fastly とオリジンの間にキャッシュ (Varnish または他のリバースプロキシなど) があるとき、そのキャッシュ内と Fastly およびダウンストリームのキャッシュでの 'Expires' を使用するオブジェクトのフレッシュネス有効期間が指定の期間を超えないようにするには、以下を vcl_fetch
に追加します。
if (beresp.http.Age) {
set beresp.http.Before-Fastly-Age = beresp.http.Age;
}
vcl_deliver
では :
if (resp.http.Before-Fastly-Age) {
declare local var.age INTEGER;
set var.age = std.atoi(resp.http.Age);
set var.age += std.atoi(resp.http.Before-Fastly-Age);
set resp.http.Age = var.age;
unset resp.http.Before-Fastly-Age;
}
同様に、Date
ヘッダーを使用して、Expires
ヘッダーに正しく従うようにできます。Fastly はすべてのレスポンスで Date
を現在時刻に更新しますが、これは Age
に対して潜在的に悪い相互作用があります。ダウンストリーム HTTP キャッシュでは、前述の Expires
フレッシュネスのアルゴリズムが使用されるためです。
ただし、Fastly は Date
を更新してから Age
も送信します。つまり、ダウンストリームキャッシュ (ブラウザのキャッシュなど) によって、レスポンスのフレッシュネス期間が Expires
によって許可されるよりも短いと見なすことになります。
繰り返しますが、これはテストスイートを使用して見つけることができるバグです。影響するのは、フレッシュネスのために Expires
のみを使用しているレスポンスだけです。Fastly は、現在のお客様に影響せずに修正する方法を調べているところです。ただし、フレッシュネスのために Expires
のみを使用するレスポンスが、ダウンストリームキャッシュでレスポンスの有効期間全体を示すことが重要な場合は、vcl_fetch
でこれを対処できます。
if (beresp.http.Date) {
set beresp.http.Before-Fastly-Date = beresp.http.Date;
}
vcl_deliver
では :
if (resp.http.Before-Fastly-Date) {
set resp.http.Date = resp.http.Before-Fastly-Date;
unset resp.http.Before-Fastly-Date;
}
今後の展望
長期的には、すべての CDN とリバースプロキシがこれらのテストすべて (およびその他のテスト) にパスするのを見るほど嬉しいことはありません (フレッシュネスはまだ始まったばかりです)。そこにたどり着くには、いくつものテストの変更、CDN およびリバースプロキシの調整、多くの議論が必要になります。また、かなりの時間もかかるでしょう。
それで構いません。オープンテストを実施すると、議論が広がるだけでなく実装の決定にも影響することになります。また、お客様にとって Fastly がどのように HTTP を扱っているかに関する透明性が高まります。
CDN の相互運用性が向上することは、すべての人に利益をもたらします。Cache-Control
ヘッダーの処理方法に基づいて製品を区別する CDN はありません (私が知る限りでは)。しかし、そのような違いは機能と効率性の両方でお客様のデメリットになる場合があります。このような細かい部分の処理方法の一貫性が向上するほど、多くのお客様およびサードパーティのツールとフレームワークが、1回限りで設定するプラットフォームではなく、構築の基盤として使用できる堅固なプラットフォームとして CDN を見なすようになります。
ご協力いただける場合は、このテストスイートを使用して、(バグまたは新しいテストについて) 問題を報告し、プルリクエストを行ってください。CDN をお使いの場合は、相互運用性が重要であることを CDN プロバイダーに伝えてください。また、CDN の立場の方であれば、どのテストに合格すべきか、また最高のものにするために他に変更すべき箇所は何かについてぜひ話し合いたいと思います。前述したように、場合によって HTTP 仕様に従わないことには正当な理由がありますが、CDN によるプロトコル処理の気まぐれな変更は誰の役にも立ちません。
VCL に関してご協力してくれた、Rogier Mulhuijzen と Andrew Betts に感謝します。