HTTPキャッシュ入門:ETag・Cache-Control・304を実測
php -S で ETag と Cache-Control を実装し、304 で減る通信量と時間を実測。毎回変わる ETag、1秒以内の更新で古い 304 が返る罠、no-cache の誤解、CDN の注意点まで。
シリーズ:HTTP 講座 4/6(中級)。 curl -v のやり取りが読めて、ヘッダーも付けられるようになった人が、次に「ブラウザや CDN にどれだけ保存させるか」を決められるようになるための回です。読み終えると Cache-Control・ETag・Last-Modified を正しく設定し、条件付きリクエストに 304 Not Modified で答え、キャッシュを無意味にしたり危険なほど古くしたりする3つのバグを見分けられます。
この記事は、「リリースしたのにお客様の画面が直らない。キャッシュを消してくださいと案内するしかないのか」という問い合わせを受けた人のためのものです。逆に「アプリが起動のたびに同じ 180 KB のカタログを落としていて通信量が多い」という指摘も、原因は同じ数個のレスポンスヘッダーにあります。以下はすべて Apple Silicon Mac(M1 Pro)で curl 8.7.1、PHP 8.5.6(php -S)、Python 3.9.6(http.server)を 127.0.0.1 上で実際に動かした結果と、example.com と当サイトへの数回の読み取り専用リクエストの結果です。
キャッシュが確認する2つのこと
キャッシュとは、あとで使うために保存した応答のコピーのことです。ブラウザのディスクキャッシュ、CDN のエッジ、リバースプロキシ、アプリの HTTP クライアントが持つものもすべてキャッシュです。保存した応答を使う前に、キャッシュは2つのことを確認します。
- まだ新鮮か。 サーバーは「問い合わせなしで何秒使ってよいか」を主に
Cache-Control: max-age=<秒>で伝えます。その秒数のあいだ、キャッシュは自分で答え、サーバーにはリクエストすら届きません。 - 古くなっていたら、中身は変わったか。 古くなったコピーはすぐ捨てられません。キャッシュは同じ GET に
If-None-Match: <保存した ETag>かIf-Modified-Since: <保存した Last-Modified>を付けた条件付きリクエストを送ります。変わっていなければサーバーは本文なしの304 Not Modifiedを返し、キャッシュは手元のコピーを使い続けます。
2つ目の確認に使う値をバリデーター(検証子)と呼びます。ETag(エンティティタグ)は "41455fbfcdb6b7a0" のように二重引用符で囲んだ文字列で、内容が変われば変わります。Last-Modified は日時で、精度は1秒です。この「1秒」が後で出てくるバグの原因になります。
素の静的サーバーは何を返すか
まず手元にあるサーバーで確かめます。1,500件の商品を並べた 179,500 バイトの JSON(random.seed(42) で生成)を Python の組み込みサーバーで配信しました。
cd ~/demo/cache/site
python3 -m http.server 18150 --bind 127.0.0.1
curl -sI http://127.0.0.1:18150/products.json
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.9.6
Date: Thu, 12 Nov 2026 14:28:41 GMT
Content-type: application/json
Content-Length: 179500
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
Last-Modified はありますが、ETag も Cache-Control もありません。それでも If-Modified-Since には答えるので、再検証は成立します。
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H "If-Modified-Since: Thu, 12 Nov 2026 14:28:40 GMT" http://127.0.0.1:18150/products.json
304 0
$ curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H 'If-None-Match: "abc"' http://127.0.0.1:18150/products.json
200 179500
PHP の組み込みサーバーは静的ファイルに対してさらに少なく、Content-Type と Content-Length しか返しませんでした。バリデーターがないので、再検証は一切できません。
Cache-Control がない場合、キャッシュは推測してよいことになっています。RFC 9111 ではこれをヒューリスティックな新鮮さと呼び、目安として「Last-Modified からの経過時間の 10%」を挙げています。10日前に更新されたファイルなら、約1日は問い合わせなしで使われる可能性があります。「キャッシュヘッダーは何も付けていない」は「何もキャッシュされない」という意味ではないのです(ブラウザの挙動は仕様からの説明で、ここでは実行していません。curl 自体はキャッシュを持ちません)。
php -S のルーターで ETag と If-None-Match を実装する
次にサーバー側を自分で書きます。site フォルダーの隣に router.php として保存し、cd site && php -S 127.0.0.1:18151 ../router.php で起動します。
<?php
// php -S 127.0.0.1:18151 router.php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/site/products.json';
if ($path === '/good.json') {
$body = file_get_contents($file);
$etag = '"' . substr(hash('sha256', $body), 0, 16) . '"';
$lastMod = gmdate('D, d M Y H:i:s', filemtime($file)) . ' GMT';
header('Content-Type: application/json');
header('Cache-Control: no-cache'); // 保存可・毎回再検証
header("ETag: $etag");
header("Last-Modified: $lastMod");
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($inm !== '' && in_array($etag, array_map('trim', explode(',', $inm)), true)) {
http_response_code(304); // 本文なし
exit;
}
echo $body;
exit;
}
if ($path === '/bad.json') { // よくある失敗:毎回変わる ETag
$body = file_get_contents($file);
header('Content-Type: application/json');
header('ETag: "' . md5($body . microtime()) . '"');
$inm = $_SERVER['HTTP_IF_NONE_MATCH'] ?? '';
if ($inm === md5($body)) { http_response_code(304); exit; }
echo $body;
exit;
}
return false; // それ以外は php -S にそのまま配信させる
ETag は本文のハッシュなので、内容が変わったときだけ変わり、同じファイルを持つサーバーなら何台あっても同じ値になります。最初のリクエストは全体が返ります。
HTTP/1.1 200 OK
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:53 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
受け取った ETag を引用符ごと送り返すと、ヘッダーだけが返ります。
$ curl -si -H 'If-None-Match: "41455fbfcdb6b7a0"' http://127.0.0.1:18151/good.json
HTTP/1.1 304 Not Modified
Host: 127.0.0.1:18151
Date: Thu, 12 Nov 2026 14:28:54 GMT
Connection: close
X-Powered-By: PHP/8.5.6
Content-Type: application/json
Cache-Control: no-cache
ETag: "41455fbfcdb6b7a0"
Last-Modified: Thu, 12 Nov 2026 14:28:40 GMT
引用符を外して If-None-Match: 41455fbfcdb6b7a0 と送ると 200 179500 でした。引用符も値の一部です。
ここでの no-cache は「保存するな」ではありません。「保存してよいが、使う前に毎回確認せよ」です。ETag と組み合わせると、いつ変わるかわからないデータには最も安全な設定になります。再利用のたびにかかるのは小さな往復1回で、全体のダウンロードではありません。
Cache-Control の選び方:判断表
| 内容 | ヘッダー | 結果 |
|---|---|---|
| パスワード画面、ワンタイムトークン | Cache-Control: no-store |
どのキャッシュにも保存されない |
| いつ変わるかわからない API データ | Cache-Control: no-cache + ETag |
保存され、使うたびに再検証。変化なしなら 304 |
| ブログの HTML | Cache-Control: public, max-age=600 + ETag |
10分は再利用、その後は再検証 |
| ユーザーごとの応答(マイページ、カート) | Cache-Control: private, no-cache |
ブラウザは保存可、CDN など共有キャッシュは不可 |
ファイル名にハッシュ入りの CSS/JS(app.3f9c1e.css) |
Cache-Control: public, max-age=31536000, immutable |
1年再利用。リリースでは新しい名前を使う |
名前が変わらずリリースで中身が変わる(app.css) |
短い max-age か no-cache + ETag |
ここで長い max-age にすると「古い CSS のまま」問題になる |
| CDN とブラウザで期間を変えたい | s-maxage=<秒> を追加 |
共有キャッシュは s-maxage、ブラウザは max-age を使う |
実測:304 で何が減るか
good.json を、1リクエストごとに curl を1回起動して計測しました。全体取得50回、正しい If-None-Match 付きの再検証50回を1セットとして3回。さらに curl の --limit-rate 1M で遅い回線を模して10回ずつ2セット行いました。
| ケース | ステータス | 1回の本文バイト数 | 中央値 |
|---|---|---|---|
| ループバック・全体取得(3×50回) | 200 | 179,500 | 1.57 / 1.43 / 1.47 ms |
| ループバック・再検証(3×50回) | 304 | 0 | 1.38 / 1.40 / 1.38 ms |
| 1 MB/s 制限・全体取得(2×10回) | 200 | 179,500 | 173.29 / 173.01 ms |
| 1 MB/s 制限・再検証(2×10回) | 304 | 0 | 1.43 / 1.46 ms |
ヘッダーは 200 が 252 バイト、304 が 262 バイトで、304 だからヘッダーが小さくなるわけではありません。全体取得50回で本文 8,975,000 バイト、再検証50回では 0 バイトでした。
この表から言えることは2つです。速い回線では 304 でもほとんど時間は縮みません。往復と curl の起動時間が支配的だからで、効くのは通信量(ユーザーのギガ)です。もう1つは、サーバー側の仕事は減っていないことです。このルーターは 304 を返すときもファイルを読んでハッシュを計算しています。本文の生成が重い(DB クエリなど)なら、バージョン番号やリリース時に保存したハッシュのような安い値から ETag を作りましょう。
よくある失敗:実際に起きること と 対策
1. 応答のたびに変わる ETag。 bad.json はハッシュに microtime() を混ぜています。受け取った ETag を次のリクエストに入れる操作を10回続けました。
bad.json x10: 200 200 200 200 200 200 200 200 200 200
再検証は毎回全体ダウンロードになり、キャッシュ用のヘッダーはただのオーバーヘッドです。ロードバランサーの後ろにある複数台のサーバーが、inode 番号や更新時刻から ETag を作っている場合も同じことが起きます。対策: ETag は内容(ハッシュ)か、自分で管理するバージョン番号から作ります。
2. Last-Modified の精度は1秒。 ファイルを書き、Last-Modified を読み、同じ1秒のうちに別の内容で書き直してから Python のサーバーに再検証しました。
Last-Modified seen: Thu, 12 Nov 2026 14:29:52 GMT
-> 304
actual file: {"version": 2}
ファイルは変わっているのに「変更なし」と答え、クライアントは version 1 を使い続けます。書き込んですぐ書き直すビルドスクリプトでそのまま起きる現象です。対策: ETag を送ります。両方あるときは If-None-Match が If-Modified-Since より優先されます(RFC 9110)。
3. ETag を単純な文字列として比べる。 good.json は完全一致で比べているので、W/"41455fbfcdb6b7a0" を送るクライアントには 200 が返ります。W/ は弱い ETag の印で、「意味は同じだがバイト単位で同一とは限らない」という意味です。圧縮後によく付きます。If-None-Match は弱い比較を使うので、正しいサーバーは W/ を無視して比べます。後の練習問題1で直します。
4. no-cache を「キャッシュしない」と読む。 ログイン画面に no-cache を付けて安心しているチームがありますが、応答は保存されます。秘密情報には no-store です。
実際のヘッダー:example.com と当サイト
執筆日に読み取り専用で確認しました。example.com は CDN 経由で配信されています。
HTTP/2 200
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Thu, 05 Nov 2026 21:17:41 GMT
age: 8757
cf-cache-status: HIT
Cache-Control も ETag もありません。age: 8757 は、エッジがコピーを約2.4時間保持していることを示します。If-Modified-Since 付きで再検証すると、本文 0 バイトの 304 でした。Last-Modified しかないのでヒューリスティックが適用され、ページは 580,303 秒前のものなので、その 10% の約16時間は再利用されうる計算です。
当サイト(事業者固有のヘッダーは省略):
HTTP/2 200
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
last-modified: Thu, 12 Nov 2026 04:13:31 GMT
etag: W/"22d0-6af53ceb-1d219362a7cb89c4;gz"
cache-control: public, max-age=600
面白いのは、Accept-Encoding なしだと ETag が弱い W/"…;gz" で、curl --compressed だと gzip で返り、強い "…;gz" になったことです。弱い値をそのまま If-None-Match に入れて送っても、本文 0 バイトの 304 が返りました。弱い比較が仕様どおり動いています。
CDN の落とし穴
CDN は共有キャッシュなので、ここまでの話がすべて当てはまり、さらに罠が3つあります。
- 意図しないものまでキャッシュする。 リダイレクトや API 応答がエッジに保存され、オリジンを直してもコピーが切れるまで反映されません。301 で実際に起きた件は ホスティングの CDN が API 応答と 301 をキャッシュしていた に書きました。
Varyがキャッシュキーを決める。Accept-EncodingやOriginで応答が変わるならVaryで宣言しないと、別のクライアントの版が配られます。- パージ(削除)は全拠点に即時ではない。 静的ファイルはバージョン付きのファイル名にすれば、パージ自体が要らなくなります。
正直な限界・バージョンの注記
- 実行したもの: curl 8.7.1、PHP 8.5.6 組み込みサーバー、Python 3.9.6 の
http.server。すべて 127.0.0.1、Apple M1 Pro。ループバックの時間には curl の起動1回分が含まれます。--limit-rateは帯域を模すだけで、遅延やパケットロスは再現しません。 - 実行していないもの: ブラウザのキャッシュ、Service Worker、CDN の
stale-while-revalidate処理。ブラウザの挙動は仕様からの説明です。 - 仕様: HTTP キャッシュは RFC 9111(2022年6月)で、RFC 7234 を置き換えました。ETag・
Last-Modified・条件付きリクエストは RFC 9110。immutableは RFC 8246、stale-while-revalidate/stale-if-errorは RFC 5861 です。10% のヒューリスティックは RFC 9111 の目安で、すべてのキャッシュが従う規則ではありません。 - 当サイトと example.com の観察は1日分のスナップショットです。設定が変われば値も変わります。
練習問題
1. good.json の If-None-Match を弱い比較に直してください。W/"x" は "x" に一致し、"old", "x" のようなリストも一致し、* も一致すること。
2. 10日前に更新され、Last-Modified だけ付いて Cache-Control がないファイルがあります。RFC 9111 の目安(10%)なら、キャッシュは問い合わせなしでどれくらい使えますか。推測をやめさせるヘッダーは何ですか。
解答
1. in_array の判定を次の関数に置き換えます。
function etag_matches(string $etag, string $header): bool {
if (trim($header) === '*') return true;
$strip = fn(string $t): string => preg_replace('#^W/#', '', trim($t));
foreach (explode(',', $header) as $t) {
if ($strip($t) === $strip($etag)) return true; // 弱い比較
}
return false;
}
5種類のヘッダー値で実行した結果です。
"41455fbfcdb6b7a0" => 304
W/"41455fbfcdb6b7a0" => 304
"old", "41455fbfcdb6b7a0" => 304
41455fbfcdb6b7a0 => 200
* => 304
引用符なしの値は 200 のままで、これで正しい動作です。有効なエンティティタグではないからです。
2. 約1日(10日の10%)です。max-age=600 や no-cache のように Cache-Control を明示すれば、キャッシュは推測しなくなります。
次へ
よくある質問
Cache-Control: no-cache を付ければキャッシュされませんか?
されます。no-cache は「保存してよいが、使う前に毎回サーバーに確認すること」という意味です。何も保存させたくないなら no-store を使います。実験では no-cache と ETag を付けた応答に対して、再検証のたびに本文 0 バイトの 304 Not Modified が返りました。
304 Not Modified でどれくらい節約できますか?
節約できるのは本文で、往復そのものは残ります。179,500 バイトの JSON を50回取得すると本文は 8,975,000 バイト、50回の再検証では 0 バイトでした。ループバックでは時間差はほぼなし(中央値 1.4〜1.6 ms 対 1.4 ms)ですが、curl を 1 MB/s に制限すると全体取得 173 ms、304 は 1.4 ms でした。
サーバーがいつまでも 304 を返さないのはなぜですか?
よくある原因は、応答のたびに変わる ETag です。microtime() を混ぜた ETag で10回続けて再検証すると、10回とも 200 でした。ほかに、If-None-Match の値を引用符なしで比べている、W/ 付きの弱い ETag を受け付けていない、という原因もあります。