2022年8月31日
モダンWebにおけるキャッシングのための新HTTP標準
本記事は、原著者の許諾のもとに翻訳・掲載しております。
一般ユーザー向けの大規模なWebサイトや、モダンWeb上で動作するWebアプリケーションを運営する場合、CDNなどのキャッシングサービスによって静的コンテンツをキャッシュすることが極めて重要です。
しかしこうしたサービスは、非常に複雑で分かりにくいものです。
幸い、IETF(Internet Engineering Task Force)のHTTPワーキンググループがこの状況を改善すべく、HTTPの新標準策定に取り組んでいます。 最近、同ワーキンググループでは、キャッシングのデバッグとキャッシュ設定の管理を容易にすることを目的とした、HTTPヘッダに関する2つの新標準案の発表に向けて活発な動きがありました。
このことが何を意味し、どのように機能するのか、そしてWeb制作に携わる開発者全てがなぜ注目すべきなのかについて見ていきます。
新標準
この記事で取り上げる標準案は以下の2つです。
これらはHTTP標準を改訂し、CDNを活用した現在のWebに対応したものにすることを目的としており、Fastly、Akamai、Cloudflareといった人気の高いCDNが用いている既存の手法を取り入れた仕様を提供します。 ここに挙げた企業はいずれも自ら標準の策定に携わっています。
どちらの標準案も比較的新しい仕様です。Cache-Statusは2021年に複数回にわたるレビューが行われており、現在は(8月以降)最終レビューと正式なRFCとしての発表を待っているところです。 一方、Targeted Cache-Controlヘッダは現在標準案として採用されていますが、最後のフィードバックを募っているところです。 どちらもIETFの支持を得ており、既に多くの議論が行われています。 今後大きな変更が行われる可能性は低いものの、どちらもまだ新しいため、現時点ではまだ幅広いサポートは期待できないでしょう。
キャッシングが重要な理由
ユーザ向けの注目度の高いWebアプリケーションを運営する場合、キャッシュとCDNはエンドユーザに優れたパフォーマンスを妥当なコストで提供するうえで不可欠です。 キャッシュとCDNはWebサーバの手前でリバースプロキシの役割を果たし、以下を保証します。
- コンテンツのキャッシング。全ての訪問者から直接バックエンドサーバに対して静的コンテンツのリクエストが行われないようにし、リクエストの頻度を減らすため。
- トラフィックの急増に耐えうるコンテンツデリバリ。静的キャッシュはアプリケーションサーバよりも規模の拡大がはるかに容易であるため。
- コンテンツリクエストのバッチ処理。1000件のキャッシュミスが同時に発生しても、バックエンドサーバへのリクエストが1件で済むようにするため。
- コンテンツの物理的分散。ユーザの所在地にかかわらず、レスポンスが迅速に届くようにするため。
注目度の高いWebサイトを運営するなら、モダンWebにコンテンツをホスティングするうえでこれら全てが必須となります。 ネットが広く普及している今、トラフィックの急増とレイテンシの問題はネット利用者の増加に伴い解決が困難になる一方です。
Troy Huntが、自身が運営する人気サイト「Pwned Passwords」について執筆した、キャッシングの仕組みを詳しく説明した記事が参考になります。 記事では以下の具体例が紹介されています。
- 毎週彼のドメインから477.6GBのサブリソースが配信されている
- そのうち476.7GBはキャッシュから配信されている(キャッシュヒット率99.8%)
- 同サイトのAPIには毎週3240万件のクエリが送信されている
- そのうち3230万件はキャッシュから配信されている(キャッシュ率99.6%)
- 残りのAPIエンドポイントは、Azureのサーバレス関数が対処する
このサイトのホスティング(1日数百万件ものパスワードチェック)に要する費用は、1日3セント程度です。 これだけのトラフィックを全て自前のサーバで処理した場合、膨大なコストがかかります。 合理的なキャッシングの仕組みを構築すれば、迅速かつ効果的に、安く処理できます。これは大きな問題です。
解決すべき問題は何か
これは結構なことなのですが、キャッシング設定の構築とデバッグは容易ではありません。
主な問題は、一定の規模になるとほとんどの場合、任意のリクエストパスに多数のキャッシングレイヤーが関与することです。 ほとんどの構成では、独自のキャッシング機能が組み込まれた何らかのロードバランサ、APIゲートウェイ、リバースプロキシがバックエンドサーバの手前に配置され、さらにその前には広く分散された低レイテンシ拠点からエンドユーザにこのコンテンツを提供するグローバルCDNがあります。 さらに、バックエンドサーバ自体も内部の結果をキャッシュする場合があり、企業やプロバイダが独自のキャッシングプロキシを運用していることもあり、多くのクライアント(特にWebブラウザ)が独自のキャッシング機能を備えています(こうしたクライアントにも、Service Workerなどのキャッシングレイヤーがさらに備わっている場合もあり、一層複雑にしています)。
各レイヤーには異なるキャッシング構成が求められます。 例えば、ブラウザはユーザ固有のデータをキャッシュできる場合がありますが、CDNがこうしたデータをキャッシュできてはいけません。 また、新しいコンテンツができるだけ早くエンドユーザに見えるようにするため全てのレイヤーのキャッシュに適用されなくてはいけません。
これらのレイヤーと、各レイヤー独自の構成がどう作用し合うのかを予測するのも複雑であり、さまざまな形で好ましくない結果につながる可能性があります。
- コンテンツが一切キャッシュされず、トラフィックがバックエンドサーバに過剰な負荷をかける。
- コンテンツはキャッシュされるが、分散CDNではない下位のレイヤーに限定される。
- 古いレスポンスが想定よりも長くキャッシュに保持され、コンテンツのアップデートを困難にする。
- キャッシュから間違ったレスポンスが配信され、ドイツのユーザにフランス語のコンテンツが提供されたり、ひどいケースでは未認証のユーザにログインコンテンツが提供されたりする。
- リクエストがCDNを全く通らず、バックエンドまたはリバースプロキシから直接配信される。
- WebサイトまたはAPIのキャッシングが一貫して行われず、古いデータと新しいデータが混在した全く役に立たないデータが読み出される。
これはかなりひどい状態です。
多くのキャッシュ設定が、リクエストやレスポンスのメタデータ自体(Cache-Controlヘッダなど)の中に存在することが、事態を一層悪くしています。 これは正確な設定を実現するうえでは非常に効果的ですが、設定自体がこれらのレイヤーを通過して、途中でキャッシュされる可能性があることも意味します。
知らず知らずのうちに誤って「これを永久にキャッシュする」というレスポンスをキャッシュした場合、極めて面倒なことになってしまいます。 全てのレイヤーのキャッシュを強制的に無効化して問題を解決するのは、想像するより難しいことです。
ピッタリのXKCDは常に存在する(訳注: XKCD とはアメリカの風刺系web漫画サイト)
Cache-Statusはどう役立つか?
明確な問題の1つは、キャッシングシステム内のトレーサビリティです。 レスポンスはどこから来たのか?なぜそのレスポンスが送信されたのか?
そのレスポンスはキャッシュから返されたのか、それともサーバから送信されたのか? キャッシュから返された場合、どのキャッシュか? それはあとどれくらい有効か? キャッシュから返されたのではない場合、それはなぜか? その新しいレスポンスは後で使用するために保存されたものか?
Cache-Statusレスポンスヘッダは、これら全ての情報がレスポンス自体に含まれるようにし、リクエストを見た全てのCDNおよびその他のキャッシュを1つの一貫した形式で提供するための構造を提供します。 ヘッダは以下のようなものになります。
Cache-Status: OriginCache; hit; ttl=1100, "CDN Company Here"; fwd=uri-miss;
Cache-Statusヘッダの形式
ヘッダの形式は以下になります。
Cache-Status: CacheName; param; param=value; param..., CacheName2; param; param...
キャッシュのリストであり、それぞれゼロ以上のステータスパラメータを持ちます。 キャッシュはレスポンス順に並びます。 最初のキャッシュがオリジンサーバに最も近く、最後のキャッシュがクライアントに最も近いものです。
レスポンスがこのヘッダとともにキャッシュされると、その後のレスポンスでもそれが保持されます。 しかし、今回のレスポンスがどこに保存され、前回のレスポンスがどこから来たのかを右側のパラメータ値から判断することも可能です。
パラメータ付きのキャッシュはカンマで区切られ、パラメータ自体はセミコロンで区切られます(現在は標準化されている構造化ヘッダRFCのsf-listとsf-item構文)。 キャッシュ名は、スペースなど本来無効な文字が含まれる場合は引用符で囲まれることがあります。
Cache-Statusヘッダパラメータ
各キャッシュの挙動を説明するため、いくつかのパラメータと値を定義します。
hit
- リクエストが上流に送られることなく、レスポンスはこのキャッシュから返されますfwd=<reason>
- これが設定されている場合、リクエストが次の上流レイヤーに送信されています。これには次のような理由が付いてきます。fwd=bypass
- キャッシュはこのリクエストを処理しないよう構成されているfwd=method
- 使用したHTTPメソッドにより、リクエストを転送する必要があるfwd=uri-miss
- リクエストURIと一致するキャッシュデータはなかったfwd=vary-miss
- URIと一致するキャッシュデータはあったが、Varyヘッダに含まれるヘッダが一致しなかったfwd=miss
- 一致するキャッシュデータはなかった(理由が不明な場合など、他の理由による)fwd=stale
- 一致するキャッシュデータはあったが、古い(stale)データであるfwd=partial
- 一致するキャッシュデータはあったが、レスポンスの一部に対してのみ(前回のリクエストがRangeヘッダを使用したなど)fwd=request
- リクエストがキャッシュされていないデータを要求した(Cache-Controlヘッダで)
fwd-status=<status>
- fwdが設定されている場合、次のホップから受け取ったレスポンスステータスですstored
- fwdが設定されている場合、このキャッシュが受信したレスポンスを後で使用するために保管したかどうかを示しますcollapsed
- fwdが設定されている場合、リクエストが他のリクエストと一緒に畳まれているかどうかを示します(同等のリクエストが既に処理中のため、複製されていない)ttl=<ttl>
- このキャッシュがこのレスポンスをあとどれくらい(秒数)「fresh」(鮮度が高い)と見なすかkey
- このキャッシュのレスポンスのキー(実装固有)detail
- 追加の実装固有情報のための自由形式フィールド
これらを使用することで、以下のようなレスポンスヘッダを解釈できます。
Cache-Status: ExampleCache; hit; ttl=30; key=/abc
これは、ExampleCacheがリクエストを受信し、キャッシュの中(key / abcの下)にレスポンスがあったのでそれを返し、そこから30秒間はそのキャッシュを返し続けることを意味します。
以下のようなもっと複雑な例も検討してみましょう。
Cache-Status:
Nginx; hit,
Cloudflare; fwd=stale; fwd-status=304; collapsed; ttl=300,
BrowserCache; fwd=vary-miss; fwd-status=200; stored
(可読性を上げるために改行しています)
これは、ブラウザがリクエストを送信したが、Varyヘッダに含まれるヘッダが一致しなかったため、同じURIで保持するキャッシュレスポンスを使用しなかったことを意味します。
リクエストは次にCloudflareが受信し、Cloudflareは一致するレスポンスをキャッシュしていましたが(Nginx; hit
というレスポンス。Nginxのキャッシュから読み出されたレスポンスであることを意味する)、そのレスポンスは古くなっています。
これに対処するため、CloudflareはNginxにレスポンスを再検証するようリクエストを送信し、Nginxは304(変更なし)レスポンスをCloudflareに返して既存のキャッシュレスポンスがまだ有効であることを伝えています。 送信されたリクエストは折り畳まれており、同じコンテンツについて同時に複数のリクエストがCloudflareに届いたが、上流に送信されたリクエストは1つだけであることを意味します。 Cloudflareは次の5分間、再検証されたデータを提供し続けます。
有益な情報がたくさん含まれています。 注意深く読み解くことで、このヘッダだけでもレスポンスのコンテンツがどこから来たのか、リクエストパス全体に沿って現在どのようにキャッシュされているのかが正確に分かります。
(キャッシング構成のデバッグに不慣れな人には、上記は手ごわそうに聞こえるかもしれませんが、これらの情報が1カ所にまとめられていることで、同じ情報を一から導き出すより何倍も負担が軽減されます。)
Cache-Statusの実践
これは全く新しい概念ではありませんが、全てのキャッシュを1カ所にまとめることで、一貫したデータの単一の供給源を提供することに本当のメリットがあります。
現在、各キャッシュプロバイダがさまざまな(それぞれ微妙に一致しない)ヘッダを使用しており、NginxのX-Cache-Status、CloudflareのCF-Cache-Status、FastlyのX-Served-ByやX-Cacheなどがあります。それぞれ、ここに含めることができる情報の一部を提供しており、今後は徐々にCache-Statusに置き換わっていくことが望まれます。 現在、ほとんどの主要コンポーネントとプロバイダはデフォルトでCache-Statusを搭載していませんが、Fastly、Akamai、Facebookなど多数の企業が標準化プロセスに関わっているため、多くのWebサービスやツールに採用される日はそう遠くないと思います。 実際、Squidの組み込みサポートやCaddyのキャッシングハンドラー、Fastlyのドロップインレシピなど、既に進展が見られています。 2021年8月にRFCの発表があったばかりなのでまだかなり新しいですが、今後も引き続きサポートが広がっていくことを期待しています。 CDNまたはキャッシングコンポーネントの開発者は、ユーザのデバッグを支援するためにCache-Statusを採用することをお勧めします(これらのサービスを使っているのであれば、Cache-Statusの導入を求めることをお勧めします)。
Targeted Cache-Controlのメリット
既存のCache-Controlヘッダは、キャッシングがこれほど複雑化する前(1999年)に設計されたものです。 当時はIE 4.5がリリースされたばかりで、RIMが最初のBlackberryの発売準備を進めており、インタラクティブWebページの第一波を表す「Web 2.0」という言葉が生まれた時代でした。 テラバイト単位のデータをキャッシュするためにマルチレイヤーCDNアーキテクチャを構成することは大きなテーマではなかったのです。
時代は変わりました。
1999年に定義されたCache-Controlヘッダはリクエスト/レスポンスヘッダであり、リクエスト(どのようなキャッシュレスポンスを受け入れるか)とレスポンス(このレスポンスを今後どのようにキャッシュするか)に関するさまざまなキャッシングパラメータを定義できます。
ここではリクエストの構成については特に注目していませんが、レスポンスキャッシュの構成は非常に重要です。 レスポンスのCache-Controlは現在、以下のようにレスポンスの処理方法をキャッシュに伝えるディレクティブのリストによって定義されます。
Cache-Control: max-age=600, stale-while-revalidate=300, private
これは、「このコンテンツを10分間キャッシュし、次に、これを再検証する間、古いコンテンツを最大5分間配信する。ただし、これはプライベート(シングルユーザのブラウザなど)キャッシュでのみ行う」という意味です。
ここで設定されたルールは、同じリクエストを処理する全てのキャッシュが同様に従わなくてはならず、ツールとしてはやや粒度が粗いと言えます。
制御ルールの対象をエンドユーザのキャッシュに制限することは可能であり(private
を使用)、過去には、共有されたキャッシュ(CDNなど)にしか適用されないいくつかの重複したディレクティブ(s-maxage
やproxy-revalidate
など)を追加していましたが、それ以上の精度や柔軟性を望むことはできません。
これはつまり、以下のことは行えないことを意味します。
- ブラウザとCDNで、stale-while-revalidateに異なる時間を設定する
- レスポンスに対し、内部のキャッシングロードバランサにおける全てのリクエストで再検証が必要だが、CDNでは不要とのフラグを付ける
- CDNでのキャッシングは有効にしつつ、外部の共有キャッシュ(エンタープライズプロキシなど)にはコンテンツをキャッシュしないよう伝える
これでは、多くの高度なユースケースに対応できません。 ほとんどのキャッシングコンポーネントでは、これに対処するためにコンポーネント内のルールを定義するための構成オプションが用意されていますが、これはこれで柔軟性に欠け、レスポンスに応じて異なるルールを構成するのが難しくなります。
Targeted Cache-Controlは、全てのキャッシュではなく特定のキャッシュのみを対象としたCache-Controlのディレクティブを設定するための新しいヘッダを定義することで、この問題の解決を図るものです。
Targeted Cache-Controlの仕組み
Targeted Cache-Controlを使用するには、サーバが以下のような形式のレスポンスヘッダを設定する必要があります。
<Target>-Cache-Control: param, param=value, param...
ヘッダのプレフィックスには、これを適用する特定の対象が入ります。 構文は、Structured Fieldsの標準形式を使用するため、厳密にはCache-Controlが使用する構文とは若干異なりますが、実質的にはほとんど同じです。
ここで使用する対象には、固有のサービスやコンポーネントの名前、あるいはキャッシュクラスなどが入ります。
仕様で定義する対象は1つだけ(CDN-Cache-Control
。全ての分散CDNキャッシュに適用し、他のキャッシュには適用しない)ですが、後から他のクラスを定義することもできます。
将来的には、Client-Cache-Control
がHTTPクライアントにおけるキャッシングのみを対象にルールを設定し、ISP-
がインターネットサービスプロバイダ、Organization-
が企業組織のキャッシュを対象とする、といったことも可能です。
これらのヘッダを使用するには、それをサポートする各キャッシュがマッチする対象のリストを優先順に定義(固定またはユーザ定義)します。他により具体的なマッチがなければ、最初にマッチした<target>-Cache-Control
ヘッダまたは通常のCache-Control
ヘッダ(あれば)を使用します。
全体として、既存のキャッシングの仕組みに既になじみがある場合、かなりシンプルで使いやすいと感じるでしょう。Targetedヘッダは特定の対象とマッチし、キャッシングのルールは対象に応じて自由に設定でき、ベストマッチが優先されます。以下の例をご覧ください。
Client-Cache-Control: must-revalidate
CDN-Cache-Control: max-age=600, stale-after-revalidate=300
Squid-Cache-Control: max-age=60
Cache-Control: no-store
これは以下のような意味になります。
- エンドクライアント(少なくとも、筆者が考案した
Client-Cache-Control
ヘッダを認識するもの)は、このコンテンツをキャッシュできるが、使用する前に毎回再検証しなくてはならない - 全てのCDNがコンテンツを10分間キャッシュでき、次に、これを再検証する間、古いレスポンスをさらに5分間使用できる
- Squid(キャッシング機能を備えたリバースプロキシ)は、コンテンツを60秒間だけキャッシュできる(
stale-while-revalidate
指示はないため、古い間は暗黙的に使用できない) - Targeted Cache-Control指示を理解できないものは、このコンテンツをキャッシュしてはならない
Targeted Cache-Controlの実践
Cache-Statusよりも新しく、標準化プロセスの早い段階にあるため、まだ変更が加わる可能性があります。フィードバックがある場合、ここからGitHubにアクセスして仕様書を入手し、同じレポジトリ内で問題を報告(またはワーキンググループのメーリングリストにメッセージを送信)し、意見を述べることができます。 とは言え、仕様書自体はFastly、Akamai、Cloudflareの代表者が執筆しているため、業界のサポートは既に十分得られており、作業はもうかなり進んでいるため、大きな変更が加えられる可能性は低いと思われます。
現状、CloudflareとAkamaiが既にサポートしているため、これらのキャッシュを使用する場合、今からCDN-Cache-Control
、Akamai-Cache-Control
、Cloudflare-CDN-Cache-Control
を使用した正確な構成を開始できます。今後他の多くのツールやサービスについても同様のサポートが行われる可能性が高いため、引き続き注目してください。
今後の展望
2021年、キャッシングは依然として複雑ですが、Cache-StatusとTargeted Cache-Controlが急速に成熟しており、これらによって構成とデバッグはかなり容易になると見られます。キャッシングに携わっている方は注目する価値があります。
IETFが最近取り組んでいるHTTP標準は2つだけですが、Webの発展に寄与したい方や今後登場する予定の標準について知りたい方は、Rate-Limitingヘッダ、Proxy-Status、HTTPメッセージダイジェスト、HTTPクライアントヒントなど他にも多数あるのでチェックしてみてください。 HTTPは発展途上の標準であり、今後も続々登場します。 これらのいずれかに興味のある方は、ワーキンググループのメーリングリストに参加し、新たな開発情報をチェックしたり意見を共有したりすることをお勧めします。
HTTPリクエスト、キャッシング、エラーのテストやデバッグをご希望の方は、HTTP ToolkitでHTTPの傍受、調査、模擬をお試しください。
10か月前(※訳注:2022年8月の翻訳記事公開時点)にTim Perryが公開
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa