HTTP キャッシュ

概要

HTTP キャッシュは、リクエストに関連付けられたレスポンスを格納し、格納されたレスポンスを後続のリクエストのために再利用します。

再利用にはいくつかの利点があります。まず、リクエストを元サーバーに届ける必要がないため、クライアントとキャッシュの距離が近いほど、レスポンスが速くなります。ほとんどの例は、ブラウザー自身がブラウザーのリクエストのキャッシュを格納するものです。

また、レスポンスが再利用可能な場合、元のサーバーはリクエストを処理する必要がありません。つまり、リクエストを解釈してルーティングしたり、クッキーに基づいてセッションを復元したり、結果をDBに問い合わせたり、テンプレートエンジンをレンダリングしたりする必要がありません。これによりサーバーの負荷が軽減されます。

キャッシュを適切に管理運営することは、システムを健全にするためにとても重要です。

キャッシュの種類

HTTP Caching 仕様書では、キャッシュには大きく分けて 2 つの種類があります。プライベートキャッシュ共有キャッシュです。

プライベートキャッシュ

プライベートキャッシュとは、特定のクライアント(通常はブラウザーキャッシュ)に結びついたキャッシュのことです。格納されるレスポンスは他のクライアントと共有されないので、プライベートキャッシュはそのユーザー用にパーソナライズされたレスポンスを格納することができます。

一方、プライベートキャッシュ以外のキャッシュにパーソナライズされたコンテンツが格納されると、他のユーザーがそのコンテンツを取得できる可能性があり、意図しない情報漏洩が発生する可能性があります。

レスポンスにパーソナライズされたコンテンツを含み、プライベートキャッシュにのみ格納したい場合は private ディレクティブを指定しなければなりません。

http
Cache-Control: private

パーソナライズされたコンテンツは通常クッキーによって制御されますが、クッキーの存在が常にプライベートであることを示すとは限らないため、クッキーだけでレスポンスがプライベートになるわけではありません。

レスポンスに Authorization ヘッダーがある場合、プライベートキャッシュには格納されない(public を指定しない限り、共有キャッシュに格納される)ことに注意してください。

共有キャッシュ

共有キャッシュはクライアントとサーバーの間に格納され、ユーザー間で共有できるレスポンスを格納するために使用されます。共有キャッシュはさらに、プロキシーキャッシュマネージドキャッシュ に分類されます。

プロキシーキャッシュ

アクセス制御する機能に加えて、一部のプロキシーはネットワークからのトラフィックを削減するためにキャッシュを実装しています。これは通常サービス開発者によって管理されるものではないので、適切な HTTP ヘッダーなどによって 制御しなければなりません。しかし、過去には時代遅れのプロキシーキャッシュ実装、すなわち HTTP キャッシュ標準を正しく理解していない実装などが、開発者に問題を発生させることが多くありました。

以下のようなキッチンシンクヘッダーを用いて、no-store のような現在の HTTP Caching 仕様書のディレクティブを理解しない、「古くて更新されていないプロキシーキャッシュ」の実装を回避しようします。

http
Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

しかし、近年 HTTPS が一般的になり、クライアント/サーバ間の通信が暗号化されるようになったため、 経路上のプロキシーキャッシュはレスポンスをトンネリングすることしかできず、 キャッシュとして振る舞うことができない場合が多くなっています。そのような場合は、レスポンスを見ることすらできないので、時代遅れのプロキシーキャッシュ実装を心配する必要はありません。

他にも、 TLS ブリッジプロキシーが、組織が管理する認証局 (CA) の資格情報を PC にインストールすることで、中間者 (person-in-the-middle) 的にすべての通信を復号し、アクセス制御などを行う場合、レスポンスの内容を見たり、キャッシュしたりすることが使用可能です。ただし、近年は CT (certificate transparency) が普及し、ブラウザーによっては SCT (signed certificate timestamp) で発行された証明書しか許可しないものもあるため、このメソッドでは企業ポリシーを適用する必要があります。このような制御された環境では、プロキシーキャッシュが「古くて更新されない」ことを心配する必要はありません。

マネージドキャッシュ

マネージドキャッシュは、サービス開発者が元サーバの負荷を分散し、コンテンツを効率的に開発するために明示的に展開するものです。例えば、リバースプロキシー、CDN、キャッシュ API と組み合わせたサービスワーカーなどがあります。

マネージドキャッシュの特性は、展開する製品によって異なります。ほとんどの場合、Cache-Control ヘッダーと自分自身で設定ファイルやダッシュボードを通してキャッシュの動作を制御することができます。

例えば、HTTP キャッシュ仕様書では、基本的にキャッシュを明示的に削除する方法は定義されていません。しかし、マネージドキャッシュでは、ダッシュボード操作や API 呼び出し、再起動などによって、格納されたレスポンスをいつでも削除することができます。これにより、より積極的なキャッシュ戦略が可能になります。

標準 HTTP キャッシュ仕様書のプロトコルを無視して、明示的な操作を行うことも使用可能です。例えば、次の例はプライベートキャッシュやプロキシキャッシュをオプトアウトし、自分自身で管理キャッシュにのみキャッシュする戦略を使用するように指定することができます。

http
Cache-Control: no-store

例えば、Varnish Cache は VCL (Varnish Configuration Language、DSL の一種)ロジックを使用してキャッシュストレージを処理しますが、サービスワーカーとキャッシュ API との組み合わせにより、JavaScript でそのロジックを作成することができます。

つまり、マネージドキャッシュが意図的に no-store ディレクティブを無視したとしても、それを「標準に準拠していない」とみなす必要はないということです。キッチンシンクヘッダーの使用を避け、使用している管理キャッシュ機構のドキュメントを注意深く読み、使用する機構で指定された方法で適切にキャッシュを制御していることを確実に保持することです。

なお、CDNによっては、そのCDNに対してのみ有効なヘッダーを自分自身で提供しているものもあります(Surrogate-Control など)。現在、これらを標準化するために CDN-Cache-Control ヘッダーを定義する作業が進められています。

キャッシュの種類

ヒューリスティックキャッシュ

HTTP は可能な限りキャッシュするように設計されているので、Cache-Control が指定されていなくても、ある条件が満たされればレスポンスは格納されて再利用されます。これはヒューリスティックキャッシュと呼ばれます。

例えば、次のようなレスポンスを見てみましょう。このレスポンスの最終更新は 1 年前です。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2021 22:22:22 GMT

<!doctype html>
…

1 年間更新されなかったコンテンツは、その後しばらく更新されないことがヒューリスティックに知られています。そのため、クライアントはこのレスポンスを(max-age がないにもかかわらず)格納し、しばらく再利用します。どのくらいの期間再利用するかは実装次第ですが、仕様では格納した後の時間の約 10 %(この場合は 0.1 年)を推奨しています。

ヒューリスティックキャッシュは Cache-Control 対応が広く採用される以前の回避策であり、基本的にすべてのレスポンスは Cache-Control ヘッダーを明示的に指定する必要があります。

age に基づく新鮮さと古さ

格納される HTTP レスポンスには、新鮮 (fresh) と古い (stale) の 2 つの状態があります。新鮮な状態は通常、レスポンスがまだ有効で再利用できることを示し、古い状態はキャッシュされたレスポンスがすでに期限切れであることを意味しています。

レスポンスがいつ新鮮で、いつ古くなったかを判断する基準は age です。 HTTP では、 age はレスポンスが生成されてからの経過時間です。これは他にもキャッシュ機構における TTL に似ています。

次のレスポンス例を見てみましょう(604800 秒は 1 週間)。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Cache-Control: max-age=604800

<!doctype html>
…

この例のレスポンスが格納されたキャッシュは、レスポンスが生成されてからの経過時刻を計算し、その結果をレスポンスの age として使用します。

例えば、レスポンスの max-age の意味は以下の通りです。

  • レスポンスの age が 1 週間未満である場合、そのレスポンスは新鮮です。
  • レスポンスの age が 1 週間を超える場合、そのレスポンスは古いです。

格納されたレスポンスが新鮮である限り、クライアントのリクエストを履行するために使用されます。

レスポンスが共有キャッシュに格納されるとき、クライアントにレスポンスの age を通知する必要があります。例えば、共有キャッシュがレスポンスを 1 日間格納された場合は、次の例のようになります。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Cache-Control: max-age=604800
Age: 86400

<!doctype html>
…

そのレスポンスを受け取ったクライアントは、レスポンスの max-ageAge の差である残り 518400 秒の間、そのレスポンスが新鮮であることを受け取ります。

有効期限または max-age

HTTP/1.0 では、鮮度は Expires ヘッダーを使用して指定 していました。

Expires ヘッダーは、経過時間を指定するのではなく、明示的な時刻を使用してキャッシュの有効期間を指定します。

http
Expires: Tue, 28 Feb 2022 22:22:22 GMT

しかし、この時刻形式は解釈が難しく、多くの実装バグが見つかり、システムクロックを意図的にずらすことで問題を誘発する可能性がありました。そのため、HTTP/1.1 では Cache-Control に経過時間を指定する max-age が採用されました。

ExpiresCache-Control: max-age の両方が利用できる場合は、 max-age を優先するように定義されています。そのため、HTTP/1.1 が広く使用されるようになった現在では Expires を提供する必要はありません。

Vary

レスポンスを判別する方法は、基本的に URL に基づいています。

keyed with url

しかし、同じ URL を持っていてもレスポンスのコンテンツが常に同じとは限りません。特にコンテンツネゴシエーションが行われた場合、サーバからのレスポンスは AcceptAccept-LanguageAccept-Encoding リクエストヘッダーの値によって変わる可能性があります。

例えば、Accept-Language: en ヘッダーで返されてキャッシュされた英語のコンテンツに対して、Accept-Language: ja リクエストヘッダーがあるリクエストに対してキャッシュされたレスポンスを再利用することは望ましくありません。この場合、"Accept-Language" を Vary ヘッダーの値に追加することで、レスポンスが言語に基づいて別個にキャッシュされるようにすることができます。

http
Vary: Accept-Language

これにより、キャッシュはレスポンス URL と Accept-Language リクエストヘッダーの合成に基づいて生成されます。

URL と言語がキーになる例

また、(例えばレスポンシブデザインのために)ユーザーエージェントに基づいてコンテンツの最適化を提供している場合、 Vary ヘッダーの値に "User-Agent" を含めることができます。しかし、一般的に User-Agent リクエストヘッダには非常に多くのバリエーションがあり、キャッシュが再利用される可能性を大幅に縮小します。そのため、可能であれば User-Agent リクエストヘッダーに基づくのではなく、機能検出に基づいて動作を変化させる方法を検討してください。

キャッシュされたパーソナライズされたコンテンツが他に再利用されるのを防ぐために、クッキーを使用するアプリケーションでは、Varyにクッキーを指定する代わりに Cache-Control: private を指定する必要があります。

検証

古くなったレスポンスはすぐには破棄されません。HTTPには、元のサーバーに問い合わせることで、古いレスポンスを新しいものに変換する仕組みがあります。これは検証、または再認証と呼ばれます。

検証は If-Modified-Since または If-None-Match リクエストヘッダーを含む条件付きリクエストを使用することで行われます。

If-Modified-Since

以下のレスポンスは 22:22:22 に生成され、 max-age は 1 時間なので、 23:22:22 まで新鮮であることがわかります。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600

<!doctype html>
…

23:22:22 になると、レスポンスは古くなり、キャッシュを再利用できなくなります。そのため、下記のリクエストはクライアントが If-Modified-Since リクエストヘッダーを持つリクエストを送信し、指定した時刻以降に変更があったかどうかをサーバーに問い合わせることを示しています。

http
GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-Modified-Since: Tue, 22 Feb 2022 22:00:00 GMT

コンテンツが指定した時刻から変更されていない場合、サーバーは 304 Not Modified というレスポンスを返します。

このレスポンスは「変更されていない」ことを示すだけなので、レスポンス本体はありません。ステータスコードだけですので、転送サイズは非常に小さくなります。

http
HTTP/1.1 304 Not Modified
Content-Type: text/html
Date: Tue, 22 Feb 2022 23:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
Cache-Control: max-age=3600

そのレスポンスを受信すると、クライアントは格納される古いレスポンスを新しいものに戻し、残りの 1 時間の間に再利用することができます。

サーバーはオペレーティングシステムのファイルシステムから更新時刻を取得することができ、静的ファイルを提供する場合には相対的に容易です。しかし、いくつかの問題があります。例えば、時刻形式が複雑で解釈しにくい、分散サーバーではファイル更新時刻の同期が取りにくいなどです。

このような問題を解決するために、 ETag レスポンスヘッダーが標準化されました。

ETag/If-None-Match

ETag レスポンスヘッダーの値は、サーバーが生成する任意の値です。サーバーがどのように値を生成しなければならないかという制限はないので、サーバーはどのような意味に基づいて値を設定しても自由です。例えば、本体のコンテンツのハッシュやバージョン番号などです。

例えば、 ETag ヘッダーにハッシュ値を用い、 index.html リソースのハッシュ値が 33a64df5 の場合、レスポンスは以下のようになります。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
ETag: "33a64df5"
Cache-Control: max-age=3600

<!doctype html>
…

そのレスポンスが古かった場合、クライアントはキャッシュされたレスポンスの ETag レスポンスヘッダーの値を受け取り、それを If-None-Match リクエストヘッダーに入れ、リソースが変更されたかどうかをサーバーに問い合わせます。

http
GET /index.html HTTP/1.1
Host: example.com
Accept: text/html
If-None-Match: "33a64df5"

リクエストされたリソースの ETag ヘッダーの値がリクエストの If-None-Match の値と同じ場合、サーバーは 304 Not Modified を返します。

しかし、リクエストされたリソースがこれで異なる ETag 値を持つようになったとサーバが判断した場合、サーバは代わりに 200 OK とリソースの最新バージョンで応答します。

メモ: RFC9110 では、できれば 200 レスポンスに対して ETagLast-Modified の両方を送信することを推奨しています。 キャッシュの再検証の際に、 If-Modified-SinceIf-None-Match の両方が存在する場合は、 If-None-Match を優先します。 キャッシュだけを考えているのであれば、 Last-Modified は不要だと思うかもしれません。 しかし、 Last-Modified はキャッシュに有益なだけではなく、コンテンツ管理 (CMS) システムが最終更新時刻を表示したり、クローラーがクロール頻度を調整したり、他にも様々な目的で使用する標準 HTTP ヘッダーです。 そのため、 HTTP エコシステム全体を考慮すると、 ETagLast-Modified の両方を提供した方が良いでしょう。

強制的な再検証

レスポンスを再利用せず、常に最新のコンテンツをサーバーから取得したい場合は、 no-cache ディレクティブを使用して強制的に検証することができます。

レスポンスに Cache-Control: no-cacheLast-ModifiedETag とともに追加することで、下記で示すように、リクエストされたリソースが更新されていればクライアントは 200 OK レスポンスを受け取り、更新されていなければ 304 Not Modified レスポンスを受け取ります。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Date: Tue, 22 Feb 2022 22:22:22 GMT
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT
ETag: deadbeef
Cache-Control: no-cache

<!doctype html>
…

max-age=0must-revalidate の組み合わせは no-cache と同じ意味を持つとよく言われています。

http
Cache-Control: max-age=0, must-revalidate

max-age=0 はレスポンスが即座に古くなることを意味しており、 must-revalidate は古くなったレスポンスを再検証せずに再利用してはいけないことを意味しています。つまり、この組み合わせの意味は no-cache と同じです。

しかし、この max-age=0 を使用することは、HTTP/1.1 より前の実装の多くが no-cache ディレクティブを処理することができなかったことの名残です。そのため、その制限に対処するために、回避策として max-age=0 を使用していました。

しかし、これで HTTP/1.1 準拠のサーバが広く普及したため、 max-age=0must-revalidate の組み合わせを使用する理由はなくなりました。

キャッシュしない

no-cache ディレクティブはレスポンスを格納しないようにするのではなく、再検証せずにレスポンスを再利用しないようにします。

レスポンスをキャッシュに格納したくない場合は、 no-store を使用してください。

http
Cache-Control: no-store

しかし、一般的に、「キャッシュしない」という要件は、実際には以下のような設定になります。

  • プライバシー保護のため、特定のクライアント以外にレスポンスを格納されたくない場合。
  • 常に最新の情報を提供したい場合。
  • 時代遅れの実装であり、何が起こるか分からない場合。

このような状況では、 no-store は常に適切なディレクティブとは限りません。

次の節では、その状況をより詳しく見ていきます。

他の人と共有しないようにする

パーソナライズされたコンテンツを含むレスポンスが、予期せずキャッシュの他のユーザーに見えてしまうのは問題です。

このような場合、private ディレクティブを使用することで、パーソナライズされたレスポンスは特定のクライアントにのみ格納され、他のキャッシュユーザーに漏れることはなくなります。

http
Cache-Control: private

このような場合、 no-store を指定したとしても、 private も指定しなければなりません。

常に最新のコンテンツを提供する

no-store ディレクティブはレスポンスが格納されることを防ぎますが、同じ URL に対して既に格納されているレスポンスを削除することはありません。

言い換えれば、特定の URL に対して古いレスポンスが既に格納されている場合、 no-store を返しても古いレスポンスが再利用されることはありません。

しかし、no-cache ディレクティブは格納されたレスポンスを再利用するために、 クライアントに検証リクエストを送らせます。

http
Cache-Control: no-cache

サーバーが条件付きリクエストに対応していない場合は、クライアントに毎回サーバーにアクセスするように強制し、常に 200 OK で最新のレスポンスを取得することができます。

古い実装への対応

no-store を無視する古い実装を回避するために、以下のようなキッチンシンクのようなヘッダーが使用されているかもしれません。

http
Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate

推奨 されている方法は、このような古い実装に対処するために代替手段として no-cache を使用することです。また、no-cache が最初から指定されていても、サーバーは常にリクエストを受け取るので問題ありません。

もし気になるのが共有キャッシュであれば、 private も追加することで、意図しないキャッシュを防ぐことができます。

http
Cache-Control: no-cache, private

no-store で失われるもの

no-store を追加することは、キャッシュをオプトアウトするために正しい方法だと思うかもしれません。

しかし、no-store を自由に許可することは推奨されません。ブラウザーのバック/フォワードキャッシュを含めるために、 HTTP とブラウザーが持つ多くの利点を失ってしまうからです。

したがって、ウェブプラットフォームの全機能の利点を享受するには、 no-cacheprivate と組み合わせて使用することを推奨します。

再読み込みと強制再読み込み

検証はレスポンスだけでなく、リクエストに対しても行うことができます。

再読み込み (reload) と強制再読み込み (force reload) は、ブラウザー側から実行される検証の一般的な例です。

再読み込み

ウィンドウの破損からの回復やリソースの最新バージョンへの更新のために、ブラウザーはユーザーに再読み込み機能を提供しています。

ブラウザーの再読み込み中に送信される HTTP リクエストを簡略化すると、以下のようになります。

http
GET / HTTP/1.1
Host: example.com
Cache-Control: max-age=0
If-None-Match: "deadbeef"
If-Modified-Since: Tue, 22 Feb 2022 20:20:20 GMT

(Chrome、Edge、Firefox からのリクエストは上記のものにとてもよく似ています。 Safari からのリクエストは多少異なります。)

リクエストの max-age=0 ディレクティブは、「age が 0 以下のレスポンスの再利用」を指定します。つまり、事実上、中間で格納されたレスポンスは再利用されません。

その結果、リクエストは If-None-MatchIf-Modified-Since によって検証されます。

この動作はフェッチ標準でも定義されており、JavaScript でキャッシュモードを no-cache に設定して fetch() を呼び出すことで再現することができます(この場合 reload は正しいモードではないことに注意してください)。

js
// メモ: "reload" は通常の再読み込みには正しいモードではありません。 "no-cache" です。
fetch("/", { cache: "no-cache" });

強制再読み込み

ブラウザーがリロード時に max-age=0 を使用するのは、 HTTP/1.1 より前の古い実装の多くが no-cache を理解していなかったためです。しかし、 no-cache はこの用途ではこれで問題なく、強制再読み込みはキャッシュされたレスポンスをバイパスするための追加の方法です。

ブラウザーが 強制再読み込みしているときの HTTP リクエストは以下のようになります。

http
GET / HTTP/1.1
Host: example.com
Pragma: no-cache
Cache-Control: no-cache

(Chrome、Edge、Firefox からのリクエストは上記のものにとてもよく似ています。 Safari からのリクエストは多少異なります。)

これは no-cache による条件付きリクエストではないので、元のサーバーから 200 OK を取得することは確実です。

この動作はフェッチ標準でも定義されており、JavaScript でキャッシュモードを reload にして fetch() を呼び出すことで再現できます(force-reload ではないことに注意してください)。

js
// メモ: "reload" ("no-cache" ではない)は「強制再読み込み」で正しいモードです。
fetch("/", { cache: "reload" });

再検証の回避

変更されることのないコンテンツには、キャッシュバスティング、つまり、リクエスト URL にバージョン番号やハッシュ値などを含めることにより、長い max-age を指定してください。

しかし、ユーザーが再読み込みすると、たとえサーバーがそのコンテンツが不変であることを知っていたとしても、再検証リクエストが送られます。

これを防ぐために、immutable ディレクティブを使用して、コンテンツは変更されないので再検証は不要であることを明示することができます。

http
Cache-Control: max-age=31536000, immutable

これにより、再読み込み時の不要な再検証を防ぐことができます。

このディレクティブを実装する代わりに、 Chrome は実装を変更し、サブリソースの再読み込み中に再検証を行わないようにしました。

格納されたレスポンスの削除

長い max-age で格納されているレスポンスを削除する方法は基本的にありません。

例えば、以下のように https://example.com/ からのレスポンスが格納されていたとします。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: max-age=31536000

<!doctype html>
…

サーバー上で有効期限が切れたレスポンスを上書きしたいと思うかもしれませんが、レスポンスが格納されると、キャッシュによりそれ以上のリクエストがサーバーに到達しないため、サーバー側でできることはまだありません。

詳細仕様で言及されているメソッドのひとつに、 POST のような安全でないメソッドで同じ URL のリクエストを送るというものがありますが、通常多くのクライアントにとって意図的に行うことは困難です。

Clear-Site-Data:cache ヘッダーと値の仕様もありますが、すべてのブラウザーが対応しているわけではありません。また、使用されている場合でも、ブラウザーのキャッシュにのみ影響し、中間キャッシュには影響を及ぼしません。

したがって、格納されるレスポンスはユーザーが手動で再読み込み、強制再読み込み、履歴クリアの操作を行わない限り、その max-age 期間は残るものとみなされます。

キャッシュはサーバーへのアクセスを削減し、サーバーがその URL の制御を失うことを意味しています。サーバーが URL の制御を失いたくない場合、例えばリソースが頻繁に更新されるような場合、 no-cache を追加して、サーバが常にリクエストを受け取り、意図するレスポンスを送信できるようにする必要があります。

リクエストの折りたたみ

共有キャッシュは主に元のサーバーの前にあり、元のサーバーへのトラフィックを削減することを意図しています。

したがって、複数の同じリクエストが同時に共有キャッシュに到着した場合、 中間キャッシュはそれ自身に代わって単一のリクエストをオリジンに転送し、 その結果をすべてのクライアントに再利用することがあります。これはリクエストの折りたたみと呼ばれます。

リクエストの折りたたみはリクエストが同時に到着したときに起こるので、レスポンスに max-age=0no-cache が指定されたとしても、再利用されます。

レスポンスが具体的なユーザーにパーソナライズされていて、折りたたみ時に共有されたくない場合は private ディレクティブを追加してください。

リクエストの折りたたみ

良くあるキャッシュパターン

Cache-Control の仕様には多くのディレクティブがあり、すべてを理解するのは難しいかもしれません。しかし、ほとんどのウェブサイトは一握りのパターンの組み合わせでカバーすることができます。

この章では、キャッシュを設計する際の一般的なパターンを記述します。

既定の設定

上記のように、キャッシュの既定値(つまり Cache-Control のないレスポンス)は単純に「キャッシュしない」ではなく、いわゆる「ヒューリスティックキャッシュ」に従った暗黙的なキャッシュです。

ヒューリスティックキャッシュを避けるためには、すべてのレスポンスに既定値の Cache-Control ヘッダーを明示的に付与することが推奨されます。

既定値では常に最新バージョンのリソースを転送するように保持するために、既定値の Cache-Controlno-cache を含めるのが一般的です。

http
Cache-Control: no-cache

また、サービスワーカースクリプトが Cookie や他のログインメソッドを実装しており、コンテンツがユーザーごとにパーソナライズされている場合は、他のユーザーと共有されないように private も指定されなければなりません。

http
Cache-Control: no-cache, private

キャッシュの破棄

キャッシュと最もうまく動作するリソースは、コンテンツが変更されることのない静的で不変なファイルです。そして、変更されるリソースについては、コンテンツが変更されるたびに URL を変更し、 URL 単位を詳しくキャッシュできるようにするのが一般的なベストプラクティスです。

例えば、次のような HTML を考えてみましょう。

html
<script src="bundle.js"></script>
<link rel="stylesheet" href="build.css" />
<body>
  hello
</body>

現代のウェブ開発では、開発の進捗に応じて JavaScript や CSS のリソースが頻繁に更新されます。また、クライアントが使用する JavaScript や CSS リソースのバージョンがずれていると、表示が崩れてしまいます。

そのため、上記の HTML では bundle.jsbuild.cssmax-age でキャッシュすることが難しくなっています。

従って、 JavaScript と CSS をバージョン番号やハッシュ値に基づいて変更する部分を含めた URL で提供することができます。その方法のいくつかを下記に示します。

# ファイル名にバージョンを入れる
bundle.v123.js

# クエリーにバージョンを入れる
bundle.js?v=123

# ファイル名にハッシュを入れる
bundle.YsAIAAAA-QG4G6kCMAMBAAAAAAAoK.js

# クエリーにハッシュを入れる
bundle.js?v=YsAIAAAA-QG4G6kCMAMBAAAAAAAoK

キャッシュは URL に基づいてリソースを判別するので、リソースが更新されたときに URL が変更されると、キャッシュは再利用されません。

html
<script src="bundle.v123.js"></script>
<link rel="stylesheet" href="build.v123.css" />
<body>
  hello
</body>

その設計であれば、 JavaScript も CSS リソースも長時間キャッシュすることができます。では、 max-age はどれくらいの長さに設定するべきなのでしょうか? QPACK 仕様がその答えを提供しています。

QPACK は HTTP ヘッダーフィールドを圧縮するための標準であり、一般的に使用するフィールド値の表が定義されています。

一般的に使用するキャッシュヘッダーの値を下記に示します。

36 cache-control max-age=0
37 cache-control max-age=604800
38 cache-control max-age=2592000
39 cache-control no-cache
40 cache-control no-store
41 cache-control public, max-age=31536000

これらの番号付きオプションのいずれかを選択すると、 HTTP3 で転送するときに値を 1 バイトに圧縮することができます。

番号 37, 38, 41 は 1 週間、 1 ヶ月、 1 年の期間です。

キャッシュは新しい項目が保存されると古い項目を除去するので、 max-age が 1 週間に設定されていたとしても、 1 週間後に格納されるレスポンスが存在する確率はそれほど高くありません。したがって、実際にはどちらを選んでもあまり違いはありません。

41 番が最も長い max-age (1 年)を持っていますが、 public が付いていることに注意してください。

public 値は Authorization ヘッダーを表示している場合でも、レスポンスを保存できるようにする効果があります。

メモ: public ディレクティブは Authorization ヘッダーが設定されたときにレスポンスを格納する必要がある場合にのみ使用してください。 max-age が指定されている限り、レスポンスは共有キャッシュに格納されるからです。

そのため、レスポンスが基本的な認証でパーソナライズされている場合、 public があると問題が発生する可能性があります。もしそれが心配なら、 2 つ目の値である 38 (1 ヶ月)を選ぶことができます。

http
# bundle.v123.js へのレスポンス

# 認証によりパーソナライズされることがない場合
Cache-Control: public, max-age=31536000

# 確信が持てない場合
Cache-Control: max-age=2592000

検証

再読み込み時にリソースを再送信する必要がないように、 Last-Modified ヘッダーと ETag ヘッダーを設定することを忘れないでください。ビルド済みの静的ファイルに対してこれらのヘッダーを生成するのは簡単です。

ここでの ETag 値はファイルのハッシュです。

http
# bundle.v123.js へのレスポンス
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: YsAIAAAA-QG4G6kCMAMBAAAAAAAoK

さらに、 immutable を追加することで、再読み込み時の検証を防ぐことができます。

組み合わせた結果を下記に示します。

http
# bundle.v123.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 1024
Cache-Control: public, max-age=31536000, immutable
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: YsAIAAAA-QG4G6kCMAMBAAAAAAAoK

キャッシュ破棄は、コンテンツが変更されたときに URL を変更することで、長期間にわたってレスポンスをキャッシュ可能にするテクニックです。このテクニックは、画像などのすべてのサブリソースに適用できます。

メモ: immutable と QPACK を使用することを評価する場合: もし immutable が QPACK が提供する定義済みの値を変更することを懸念しているのであれば、 Cache-Control 値を 2 行に分割することで、immutable の部分を別個の文字にエンコードすることができると考えることができます。ただし、これは特定の QPACK 実装が用いるエンコーディングアルゴリズムに依存します。

http
Cache-Control: public, max-age=31536000
Cache-Control: immutable

主要リソース

メインリソースはサブリソースと異なり、URLの装飾ができないため、キャッシュ破棄ができません。

次の HTML そのものが格納されている場合、サーバー側でコンテンツが更新されても最新版を表示することはできません。

html
<script src="bundle.v123.js"></script>
<link rel="stylesheet" href="build.v123.css" />
<body>
  hello
</body>

その場合、 HTML を格納するためではなく、常に最新の状態にしたいだけなので、 no-store ではなく、 no-cache が適切でしょう。

さらに、 Last-ModifiedETag を追加することで、クライアントは条件付きのリクエストを送ることができるようになり、HTML に更新がない場合は 304 Not Modified を返すことができるようになります。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: no-cache
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: AAPuIbAOdvAGEETbgAAAAAAABAAE

この設定はパーソナライズされていない HTML には適切ですが、例えばログイン後など、クッキーを使用してパーソナライズされるレスポンスには private を指定することを忘れないでください。

http
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024
Cache-Control: no-cache, private
Last-Modified: Tue, 22 Feb 2022 20:20:20 GMT
ETag: AAPuIbAOdvAGEETbgAAAAAAABAAE
Set-Cookie: __Host-SID=AHNtAyt3fvJrUL5g5tnGwER; Secure; Path=/; HttpOnly

同じことは、favicon.icomanifest.json.well-known、キャッシュ破棄を使用して URL を変更できない API エンドポイントにも使用することができます。

ほとんどのウェブコンテンツは、前述した 2 つのパターンの組み合わせに応じた対応が可能です。

マネージドキャッシュの詳細

前節で説明したメソッドでは、キャッシュ破棄を使用することでサブリソースを長時間キャッシュすることができますが、メインリソース(通常は HTML 文書)はキャッシュすることができません。

メインリソースのキャッシュが難しいのは、HTTP キャッシュ仕様の標準ディレクティブを使用するだけでは、サーバーでコンテンツが更新されたときにキャッシュの内容を積極的に削除する方法がないからです。

しかし、CDN やサービスワーカーなどのマネージドキャッシュを展開することで使用可能です。

例えば、API やダッシュボードの処理によってキャッシュの破棄が可能な CDN では、メインのリソースを格納し、サーバーで更新が発生した場合にのみ関連するキャッシュを明示的に破棄することで、より積極的なキャッシュ戦略が可能になります。

サービスワーカーは、サーバで更新が発生したときにキャッシュ API のコンテンツを削除できれば、同じことができます。

より詳しい情報は、 CDN のドキュメント化や サービスワーカーのドキュメント を参照してください。

関連情報