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つのことを確認します。

  1. まだ新鮮か。 サーバーは「問い合わせなしで何秒使ってよいか」を主に Cache-Control: max-age=<秒> で伝えます。その秒数のあいだ、キャッシュは自分で答え、サーバーにはリクエストすら届きません。
  2. 古くなっていたら、中身は変わったか。 古くなったコピーはすぐ捨てられません。キャッシュは同じ GET に If-None-Match: <保存した ETag> か If-Modified-Since: <保存した Last-Modified> を付けた条件付きリクエストを送ります。変わっていなければサーバーは本文なしの 304 Not Modified を返し、キャッシュは手元のコピーを使い続けます。

2つ目の確認に使う値をバリデーター(検証子)と呼びます。ETag(エンティティタグ)は "41455fbfcdb6b7a0" のように二重引用符で囲んだ文字列で、内容が変われば変わります。Last-Modified は日時で、精度は1秒です。この「1秒」が後で出てくるバグの原因になります。

フロー図。リクエストがキャッシュに届き、保存済みのコピーが max-age の範囲内で新鮮かを確認する。新鮮ならキャッシュが答え、サーバーには何も届かない。古ければ保存した ETag を If-None-Match に入れて条件付きリクエストを送る。ETag が一致すればサーバーは本文なしの 304 Not Modified を返し、キャッシュは手元のコピーを使う。一致しなければ新しい本文と新しい ETag 付きの 200 を返す。
キャッシュの判断の流れ。新鮮なら問い合わせずに再利用し、古ければ再検証して 304(本文なし)か 200(新しい本文)を受け取る。

素の静的サーバーは何を返すか

まず手元にあるサーバーで確かめます。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 を使う
判断図。最初の問い:パスワード画面やワンタイムトークンのような秘密か。はいなら no-store。いいえなら、特定ユーザー専用か。はいなら private, no-cache と ETag。いいえなら、URL を変えずに内容が変わるか。ファイル名にハッシュが入っていて変わらないなら public, max-age=31536000, immutable。同じ URL のまま変わるなら短い max-age か no-cache と ETag。
Cache-Control の選び方。まず保存してよいか、次に URL を変えずに内容が変わるかで決める。

実測: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 を作りましょう。

リクエスト時間の中央値の棒グラフ。ループバックでは 179,500 バイトの JSON の 200 全体ダウンロードが 1.57 ms、304 の再検証が 1.38 ms。curl を 1 MB/s に制限すると全体ダウンロードは 173.29 ms、304 は 1.43 ms だった。
1リクエストあたりの中央値(1回目。ループバックは50回、1 MB/s 制限は10回)。Apple M1 Pro、curl 8.7.1、php -S で実測。

よくある失敗:実際に起きること と 対策

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 を受け付けていない、という原因もあります。

  • #HTTP
  • #中級
  • #キャッシュ
  • #ETag
  • #Cache-Control
  • #304
  • #curl
  • #PHP

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑