キャッシュを制する者はWebを制す:`Cache-Control`の深淵と現場の作法
ネットワークエンジニアやWeb APIの設計者であれば、一度はこんな修羅場をくぐり抜けたことがあるはずです。
「本番リリースしたのに、クライアントの画面に古いデータが表示され続ける」
「APIのレスポンスを絶対にキャッシュさせたくないのに、なぜかプロキシサーバーが前回のデータを親切に返してしまう」
「認証トークンが含まれる機密データが、あろうことかCDNのキャッシュに乗ってしまった」
Webの高速化においてキャッシュは最強の武器ですが、その挙動を司るHTTPヘッダー、特に `Cache-Control` の解釈を少しでも誤ると、システムの信頼性を揺るがす致命的なトラブルに直結します。
今日は、RFC(HTTP Semantics)の仕様書が語る冷徹な定義と、私たちインフラ・開発エンジニアが現場の最前線で血を流しながら学んだ「キャッシュのリアルな挙動」について、徹底的に紐解いていきましょう。ブラウザ、CDN、そして途中に立ち塞がる無数のリバースプロキシを味方につけるための作戦会議を始めます。
—
1. キャッシュ制御の主役:`Cache-Control` とは何か
HTTP/1.0の時代、キャッシュの制御は `Expires`(日時指定)や `Pragma: no-cache` といった、いささか場当たり的なヘッダーに依存していました。しかし、タイムゾーンのズレや複雑化するネットワークトポロジに対応しきれず、HTTP/1.1(RFC 2068、のちにRFC 7234を経てRFC 9111へ継承)で登場したのが、この `Cache-Control` です。
`Cache-Control` の美しさは、「ディレクティブ(指示子)」と呼ばれるキーワードをカンマ区切りで並べるだけで、ブラウザからオリジンサーバーまでの全経路(中間キャッシュサーバーやCDN)に対するキャッシュ方針を宣言的にコントロールできる点にあります。
まずは、現場で最も頻繁に遭遇し、かつ誤解されやすい主要なディレクティブたちの本当の姿を確認していきましょう。
—
2. 主要ディレクティブの解剖:仕様と現場での誤解
`max-age=`
- RFCの定義: レスポンスが生成されてから、キャッシュが「新鮮(Fresh)」とみなされる最大秒数。
- 現場の視点: 最も基本にして最も強力なディレクティブです。例えば `max-age=3600` であれば、ブラウザは1時間の間、サーバーに問い合わせることなく手元のキャッシュを返します。ネットワークのラウンドトリップ(RTT)をゼロにする魔法ですが、「1時間は古いデータが見えても構わない」というビジネス上の許容が前提となります。
`no-cache`
- 名前の罠度: ★★★★★(エンジニアを最も混乱させるワーストワード)
- RFCの定義: キャッシュの保存を禁止しているわけではない。キャッシュは保存するが、使用する前に必ずオリジンサーバーに問い合わせ(バリデーション)を行い、データが更新されていないか確認(条件付きリクエスト)しなければならない。
- 現場の視点: 名前から「一切キャッシュするな」と誤認されがちですが、実際は「毎回サーバーにお伺いを立てろ(Stale-while-revalidateの前段階)」という意味です。鮮度を保ちつつ、通信量を減らしたい(304 Not Modifiedを活用したい)場合に真価を発揮します。
`no-store`
- RFCの定義: キャッシュの保存を完全に禁止する。メモリ上にも、ディスク(ストレージ)上にも、一切レスポンスデータを残してはならない。
- 現場の視点: 個人情報、クレジットカード情報、セッションIDを含むAPIレスポンスなど、セキュリティやコンプライアンス上、絶対に漏洩・残留させてはならないデータには、必ずこれを付与します。「ノーコンパイル、ノーセーブ」の鉄の掟です。
`must-revalidate`
- RFCの定義: キャッシュが「古く(Stale)なった」場合、オリジンサーバーに接続して検証するまでは、いかなる理由であっても古いキャッシュデータをクライアントに返してはならない。
- 現場の視点: 通常、ネットワークが切断されたオフライン状態などの異常時には、古いキャッシュ(Staleキャッシュ)をフォールバックとして返すことが許容される場合があります。しかし、金融システムなどで「古いデータを見せるくらいならエラーを返せ」という厳密性が求められるシーンでは、この `must-revalidate` が必須となります。
—
3. 通信フロー:ブラウザと中間キャッシュの動き
言葉だけではイメージしづらいので、`Cache-Control: max-age=60, must-revalidate` が指定されたリソースに対する、ブラウザとCDN(中間キャッシュ)の挙動をシーケンスとして整理します。
[Client / Browser] [CDN / Reverse Proxy] [Origin Server (API)]
| | |
|— 1. GET /api/data ———>| |
| |— 2. GET /api/data ——->|
| |<-- 3. 200 OK (Cache: max-age=60)
|<-- 4. 200 OK (Store in cache)-| |
| |
| (経過時間: 30秒 - 新鮮な状態) |
|--- 5. GET /api/data --------->| |
|<-- 6. 200 OK (Cache Hit!) ----| ※サーバーに到達せずCDNから即座に返却
| |
| (経過時間: 90秒 - 期限切れ=Stale状態) |
|--- 7. GET /api/data --------->| |
| |— 8. GET (If-None-Match) ->|
| |<-- 9. 304 Not Modified -----|
|<-- 10. 200 OK (Refresh cache)-| |
ここで重要なのは、「クライアントとオリジンサーバーの間にいるCDNやプロキシも、このヘッダーの指示に従って挙動を変える」という点です。CDNの設定(Cache Rulesなど)で独自にキャッシュ期間を上書きしていない限り、HTTPヘッダーの意図はインターネット全体に伝播します。
—
4. 実務で役立つ実装・設定例
ここからは、実際のWeb開発やインフラ構築の現場ですぐにコピペして使える設定例とコードスニペットを紹介します。
パターンA: 厳格な機密データ用 API(一切キャッシュさせない)
ユーザーのプロフィール情報や決済情報を返すAPIのレスポンスヘッダー。
Python (FastAPI) の例:
from fastapi import FastAPI, Response
app = FastAPI()
@app.get(“/api/v1/user/profile”)
async def get_user_profile(response: Response):
# 機密情報のため、ブラウザにもCDNにも絶対にキャッシュさせない
response.headers[“Cache-Control”] = “no-store, no-cache, must-revalidate, proxy-revalidate”
response.headers[“Pragma”] = “no-cache” # HTTP/1.0互換のため
response.headers[“Expires”] = “0”
return {“user_id”: 1048, “email”: “engineer@example.com”}
パターンB: 頻繁に更新される公開リソース(常に最新を検証)
「データはある程度保持したいが、更新があったら即座に反映させたい」というニュースフィードなどのAPI。
Node.js (Express) の例:
app.get(‘/api/v1/posts’, (req, res) => {
// キャッシュは許可するが、利用時は必ずETag等でサーバーにバリデーションを行う
res.setHeader(‘Cache-Control’, ‘private, no-cache’);
res.json({ posts: […] });
});
パターンC: 静的アセット配信の最適化(Nginx側での強制付与)
アプリケーションサーバー側でヘッダーを付与し忘れた場合や、Nginxで一括して強烈なキャッシュをかけたい場合のサーバー設定。
Nginx 設定ファイル (`nginx.conf`):
location ~ \.(jpg|jpeg|png|gif|ico|css|js)$ {
# 画像やJS/CSSなどの静的ファイルは1年間キャッシュ。イミュータブル(不変)を宣言
expires 1y;
add_header Cache-Control “public, max-age=31536000, immutable”;
# セキュリティヘッダーの付与も忘れずに
add_header X-Content-Type-Options nosniff;
}
パターンD: フロントエンドからのフェッチ制御(Fetch API)
ブラウザ側からあえてキャッシュをバイパスして最新データを取得したい場合のJavaScriptコード。
JavaScript (Fetch API):
async function fetchLatestData() {
try {
const response = await fetch(‘/api/v1/status’, {
// ブラウザのキャッシュを強制的にバイパスし、サーバーへリクエストを飛ばす
cache: ‘no-store’,
headers: {
‘Accept’: ‘application/json’
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(‘最新のステータス:’, data);
} catch (error) {
console.error(‘通信エラー:’, error);
}
}
—
5. シニアが教えるトラブルシューティングとデバッグの極意
最後に、現場でキャッシュトラブルに直面した際、私たちがどのように原因を特定しているか、その「現場の勘所」を伝授します。
1. まずはブラウザの「Disable cache」を過信しない
開発者ツール(DevTools)の「Disable cache」にチェックを入れていても、中間CDNやブラウザのハードキャッシュの挙動が完全に再現できないケースがあります。確実を期すなら、シークレットウィンドウ(プライベートブラウジング)を使用するか、後述の `curl` コマンドで直接ヘッダーを叩いてレスポンスを目視するのが鉄則です。
2. `curl` でレスポンスヘッダーを丸裸にする
curl -I -v https://api.example.com/v1/data
このコマンドを叩き、出力された `Cache-Control`、`Age`(CDNにキャッシュされてからの経過秒数)、`X-Cache`(CloudflareやCloudFrontなどのCDN固有のヒット判定ヘッダー)を確認してください。`Age: 1840` などと表示されていれば、「あ、今CDNのキャッシュがヒットしているな」と一瞬で特定できます。
3. 「Vero(ベロ)」ではなく「Vary」ヘッダーの罠に気づく
「同じURLなのに、ユーザーAとユーザーBでキャッシュが混ざってとんでもない情報が表示された!」という事故の多くは、`Vary` ヘッダーの指定漏れに起因します。レスポンスが `Cookie` や `Accept-Language` によって変化する場合、`Vary: Cookie` のように宣言しておかないと、CDNが異なるユーザーに対しても最初のキャッシュをばら撒いてしまいます。キャッシュを設計する際は、常に `Vary` とのセットで考える癖をつけましょう。
—
まとめ
`Cache-Control` は、単なるパフォーマンスチューニングのための設定値ではありません。それは、「クライアント、中間サーバー、オリジンサーバーの間で結ばれる、データの鮮度とセキュリティに関する厳格な契約」です。
仕様書の文字面を追うだけでなく、「今、このパケットはどこを通っていて、どこでキャッシュされ、どこで破棄されるべきか」というパケットの旅路を頭の中で描けるようになったとき、あなたも真のネットワーク/インフラ・スペシャリストの領域に到達しています。
今日の知見が、あなたの次のアーキテクチャ設計や、深夜の障害対応を鮮やかに救う手立てとなることを願っています。
コメント