HTTP キャッシュと CDN — Cache-Control の設計
Cache-Control は指定できる語が多く、組み合わせも自由なので、なんとなく書いてしまいがちなヘッダーです。しかし選び方には型があります。**そのリソースは誰と共有できるか、どれくらい古くてよいか、期限が切れたらどうするか。**この 3 つを順に決めれば、書くべき値は自然に定まります。
この章で学ぶこと
- 私有キャッシュと共有キャッシュの違いと、それを分けるディレクティブ
no-cacheとno-store、max-ageとs-maxageの違い- 期限切れ後の振る舞いを決める
stale-while-revalidateとmust-revalidate - リソースの性質からキャッシュ設計を導く手順
レンダリングパターン で触れた CDN 配信の話を前提にします。
誰がキャッシュを持つのか
リクエストがオリジンサーバーへ届くまでに、キャッシュを持ちうる場所が複数あります。
| 種類 | 誰が持つか | 特徴 |
|---|---|---|
| 私有キャッシュ | ブラウザ | そのユーザー 1 人のためのもの。他人には見えない |
| 共有キャッシュ | CDN、リバースプロキシ | 複数のユーザーで共有される |
この区別が設計の出発点です。**ユーザーごとに内容が違うレスポンスを共有キャッシュに載せると、他人のデータが配られます。**これはキャッシュ設計で最も重大な事故です。
そして目的も違います。私有キャッシュはその人の再訪を速くするもので、共有キャッシュはオリジンへのリクエスト数そのものを減らすものです。オリジンの負荷を下げたいなら、効くのは共有キャッシュのほうです。
オリジンサーバーの内側にも、データベースへのクエリを減らすためのキャッシュがあります。この図でいうと オリジンサーバー と データベース の間です。層が違うので効き方も違い、そちらは スケールの方向 が扱います。
新鮮 (fresh) と陳腐 (stale)
キャッシュされたレスポンスには「いつまで新鮮とみなすか」の期限があります。
- fresh — 期限内。オリジンに聞かずにそのまま返してよい
- stale — 期限切れ。原則としてオリジンに確認してから使う
期限切れになっても、キャッシュは即座に捨てられるわけではありません。検証 (revalidation) という仕組みで「まだ変わっていないか」をオリジンに問い合わせられます。
# キャッシュ側の問い合わせ
GET /api/recipes/42 HTTP/1.1
If-None-Match: "abc123"
# 変わっていなければ本文なしで返る
HTTP/1.1 304 Not Modified
ETag や Last-Modified を使ったこのやり取りは、本文を転送しないので軽く済みます。それでもオリジンへの往復は発生するので、ゼロにはなりません。
ディレクティブを役割で分類する
Cache-Control の値は、役割ごとに 4 つのグループに分けると整理できます。
1. 誰に保存させるか
| ディレクティブ | 意味 |
|---|---|
public | 共有キャッシュに保存してよい |
private | 私有キャッシュにだけ保存してよい。CDN は保存しない |
no-store | どのキャッシュも一切保存してはいけない |
2. どれだけ新鮮とみなすか
| ディレクティブ | 意味 |
|---|---|
max-age=N | 生成から N 秒間は新鮮。すべてのキャッシュに効く |
s-maxage=N | 共有キャッシュでの新鮮期間。私有キャッシュは無視する。共有キャッシュでは max-age より優先される |
max-age が測っているのは「レスポンスを受け取ってから」ではなく「オリジンで生成されてから」の経過時間です。CDN で 50 秒眠っていたレスポンスが max-age=60 で届いたら、ブラウザにとっての残りは 10 秒です。**表しているのは期間であって時刻ではありません。**この違いは 日時とタイムゾーン が扱います。
3. 期限が切れたらどうするか
| ディレクティブ | 意味 |
|---|---|
must-revalidate | 期限切れ後は必ず検証する。オリジンに繋がらなければ 504 を返す |
stale-while-revalidate=N | 期限切れ後 N 秒間は古いものを返しながら裏で検証する |
stale-while-revalidate は待ち時間を消す仕組みです。期限が切れた瞬間のリクエストにも即座に古い内容を返し、更新は裏で進めます。次のリクエストからは新しい内容になります。レンダリングパターンで見た ISR と同じ考え方で、一度だけ古い内容を踏むかわりに、誰も待たせないという取引です。
4. 検証そのものを省く
| ディレクティブ | 意味 |
|---|---|
no-cache | 保存してよいが、再利用のたびに必ず検証する |
immutable | 新鮮なあいだは内容が変わらないと約束する。検証を省略できる |
誤解されやすい 2 組
no-cache と no-store
名前から受ける印象が実態とずれています。
| 保存 | 再利用 | |
|---|---|---|
no-cache | する | 毎回検証してから |
no-store | しない | できない |
**no-cache は「キャッシュするな」という意味ではありません。**保存は許可したうえで、使う前に必ずオリジンへ確認させる指示です。変わっていなければ 304 が返り、本文の転送は起きません。「速さは捨てたくないが、古いものは絶対に見せたくない」という要件に合います。
no-store は本当に何も残しません。個人情報や決済情報のように、端末や中継機にデータが残ること自体が問題になる場合に使います。
max-age と s-maxage
役割分担が明快です。
Cache-Control: public, s-maxage=60, max-age=0
これは「CDN では 60 秒間そのまま配ってよい。ブラウザは毎回 CDN に聞け」という指示です。オリジンへのリクエストは 60 秒に 1 回まで減り、それでいて各ユーザーは最大 60 秒遅れの内容を見ます。
逆に max-age=60, s-maxage=0 にすると、CDN は毎回オリジンへ確認しに行き、ブラウザだけが 60 秒キャッシュします。**オリジンの負荷はほとんど下がりません。**共有キャッシュを効かせたいなら s-maxage を使います。
リソースの性質から設計する
3 つの問いに順に答えれば、書くべき値が決まります。
代表的な 3 類型に落とすと次のようになります。
| 類型 | 例 | 設計 | 理由 |
|---|---|---|---|
| 内容ハッシュ付きの静的ファイル | app-a3f8c2.js | public, max-age=31536000, immutable | 内容が変われば URL が変わる。同じ URL は永久に同じ内容なので、検証すら不要 |
| 共有できて鮮度要件のある API | 商品情報、記事本文 | public, s-maxage=<秒>, stale-while-revalidate=<秒>, max-age=0 | CDN で受け止めてオリジンを守りつつ、指定秒で追随する |
| ユーザー固有のデータ | マイページ、お気に入り | private, no-cache | 共有キャッシュに載せてはいけない。かつ常に最新を見せたい。何がユーザー固有かを決めるのは多くの場合クッキー |
なぜハッシュ付きファイルは 1 年なのか
31536000 秒は 365 日です。ビルドのたびにファイル名のハッシュが変わる仕組み (キャッシュバスティング) を使っていれば、**同じ URL の中身は二度と変わりません。**だから期限を最大まで伸ばして問題ありません。
ここに immutable を足すと、ユーザーが再読み込みしたときの検証まで省けます。これを付けずに max-age=14400 (4 時間) のような短い値にすると、せっかくハッシュを付けた意味が薄れます。4 時間ごとに不要な検証が走り、デプロイ頻度と無関係にキャッシュが切れます。
なぜ max-age=0 を添えるのか
public, s-maxage=60, max-age=0 の max-age=0 は、ブラウザに独自のキャッシュを持たせないための指定です。
理由は待ち時間の長さではありません。max-age は生成からの経過時間で測るので、CDN で眠っていたぶんはブラウザ側の残り時間から引かれます (RFC 9111 §4.2 の response_is_fresh = (freshness_lifetime > current_age))。ブラウザにも 60 秒を許したところで、遅れが 120 秒になることはありません。
問題は取り消せなくなることです。CDN のキャッシュは自分でパージできますが、いったんユーザーの端末へ配ったコピーは回収できません。誤った内容を配ってしまったとき、CDN を直しても、その 60 秒のあいだ手が届かない相手が残ります。max-age=0 にしておけば、ユーザーが見るのは常に CDN の現在の状態です。CDN からの応答は速いので、体感は落ちません。
よくある誤解
「no-cache はキャッシュを禁止する」 — 禁止しません。保存は許可したうえで、再利用のたびに検証させます。禁止するのは no-store です。
「s-maxage を書けばブラウザにも効く」 — 効きません。私有キャッシュは s-maxage を無視します。ブラウザ側を制御するのは max-age です。
「private を付けても CDN でキャッシュできる」 — できません。private は共有キャッシュへの保存を禁じます。CDN で受けたいのに private を書くのは矛盾です。
「ハッシュ付きファイルにも短い max-age を付けるべき」 — 不要です。URL が内容を特定するので、期限を切る理由がありません。immutable を足して検証も省くのが定石です。
「stale-while-revalidate があれば古い内容は返らない」 — 逆で、あえて古い内容を返す仕組みです。返しながら裏で更新します。許容できる古さの上限を秒数で指定します。
確認問題
問 1. 次の 3 件の障害報告が届きました。それぞれどこに原因がありますか。
報告 1「ログアウトしたのに、別のユーザーのマイページが表示された」
報告 2「CSS を直してデプロイしたのに、古い見た目のままの人がいる」
報告 3「記事を修正して CDN のパージもしたのに、一部の人には 1 時間経っても古いままだ」
答え:
| 報告 | どこに原因があるか |
|---|---|
| 1 | ユーザー固有のレスポンスを共有キャッシュに載せてしまっている |
| 2 | 中身が変わるのに URL が変わらないまま、長い期限を付けている |
| 3 | ブラウザ側に期限を持たせている |
報告 1 は事故の重さが違います。**他人のデータが配られるので、キャッシュの設定ミスがそのまま情報漏洩になります。**共有してよいかどうかは、鮮度の話より先に決めます。
報告 2 の原因は Cache-Control だけにあるのではありません。同じ URL のまま中身を差し替えたことが根にあります。期限を短くして回避もできますが、ビルドでファイル名に内容ハッシュを付ければ、URL が中身を特定するようになり問題自体が消えます。
報告 3 が示すのは、CDN のパージがブラウザのキャッシュには届かないという事実です。いったん期限を付けて配ったレスポンスは回収できません。だから鮮度は共有キャッシュ側で制御し、ブラウザには持たせません。
どう直すかは本文の「リソースの性質から設計する」の表に戻って判断します。
問 2. 内容ハッシュ付きの JS バンドルに public, no-cache を指定しました。何が起きますか。
答え: 表示は正しいままですが、再訪のたびに検証の往復が発生します。
no-cache は保存を許したうえで、使う前に必ずオリジンへ確認させる指示です。表示が壊れることはありません。
ただし「本文の転送は起きない」と言い切れるのは、レスポンスに ETag か Last-Modified が付いているときです。この検証子があって初めて 304 Not Modified が返せます。付いていなければ確認のたびに 200 で全文が返るので、キャッシュしていないのとほとんど変わりません。
問題は往復そのものです。ハッシュ付きのファイル名は、内容が変われば URL も変わることを保証しています。つまりこの URL の中身は永久に変わらないので、確認する意味がありません。public, max-age=31536000, immutable にすれば、この往復ごと消せます。
no-cache が要るのは「同じ URL で中身が変わりうる」ときです。URL が内容を特定できているなら、検証は不要です。
問 3. public, max-age=600 とだけ指定しました。CDN とブラウザはそれぞれ何秒キャッシュしますか。
答え: どちらも「生成から 600 秒」までです。合わせて 1200 秒にはなりません。
max-age はすべてのキャッシュに効きます。s-maxage を書かなかったので、共有キャッシュもこの値に従います。
ここで効くのが、max-age は生成からの経過時間で測るという性質です。キャッシュは応答を返すとき Age を付ける決まりになっており、ブラウザはそれを足し込んで残り時間を計算します。CDN で 599 秒眠っていたレスポンスを受け取ったブラウザにとって、残りは 1 秒です。期限が積み上がることはありません。
- 出典: RFC 9111 §4 —「検証せずに応答するとき、キャッシュは
Ageを生成しなければならない」 - 出典: RFC 9111 §4.2 —
response_is_fresh = (freshness_lifetime > current_age)
代償は待ち時間ではなく、取り消せなくなることです。ブラウザに配ったコピーはパージできないので、誤った内容を配ってしまうと最大 600 秒間は手が届きません。
鮮度を制御したいなら、効かせる先を分けます。
Cache-Control: public, s-maxage=600, max-age=0
CDN で 600 秒受け止めてオリジンを守り、ブラウザには持たせません。ユーザーが見るのは常に CDN の最新状態です。
まとめ
- キャッシュには私有 (ブラウザ) と共有 (CDN・プロキシ) があり、
private/publicで載せてよい先を決めます - オリジンの負荷を下げるのは共有キャッシュです。ブラウザのキャッシュは再訪を速くするだけです
no-cacheは「保存はするが毎回検証」、no-storeは「一切保存しない」。名前と意味がずれているので注意しますmax-ageは全キャッシュ、s-maxageは共有キャッシュだけに効きますstale-while-revalidateは古い内容を返しながら裏で更新して、待ち時間を消します- 内容ハッシュ付きのファイルは
max-age=31536000, immutableが定石です。短い期限を付ける理由がありません - 設計は「共有できるか → 変わるか → 残ってよいか」の 3 問で決まります
- Laravel API 開発ガイド — キャッシュ — アプリケーション内部のキャッシュ設計 (本章は HTTP 層の話)
- MDN — Cache-Control
- RFC 9111 — HTTP Caching
- RFC 5861 — stale-while-revalidate