ページキャッシュを自作しはじめたとき、コードの大半は「速く返す部分」になると思っていました。
実際に書き上がってみると、逆でした。速く返す部分は、ファイルを読んで exit するだけで、数行です。長かったのは、返してはいけない場合を判定する部分でした。行数でいうと、こちらが本体です。
そして事故が起きるのも、こちらです。キャッシュのバグは、たいてい「他人のページを他人に見せる」形で出ます。ログイン中の管理画面が、ログアウト状態の訪問者に配られる。カートの中身が別の人に見える。速さの調整をミスしても遅くなるだけですが、除外の判定をミスすると、情報が漏れます。
自作したキャッシュプラグインをWordPress.orgで公開しているので、その除外条件を全部、判定順に並べておきます。移植するときのリファレンスとして使えるように、それぞれ理由も書きました。
実物はここで読めます。
判定は、2段階に分かれる
先に全体の形を。判定するタイミングは2つあります。
第1段階は、WordPressが読み込まれる前です。リクエストの内容だけで判断できるものを、ここで弾きます。キャッシュを返す前でもあるので、ここが速さに直結します。
第2段階は、レンダリングが終わったあとです。出力バッファのコールバックで、できあがったHTMLとレスポンスヘッダを見て、保存してよいかを判断します。ページを作らないと分からないことは、ここでしか判定できません。
同じ判定を両方に置く必要はありませんが、どちらに置けるかは考える価値があります。 第1段階で弾けるものを第2段階に回すと、無駄にページを生成することになります。逆に、第2段階でしか分からないものを第1段階に置こうとすると、判定が推測になります。
第1段階:WordPressロード前の除外
上から順に、実装している判定です。順序にも意味があるので、その順で書きます。
1. リクエストメソッド
GET と HEAD 以外は、全部除外します。POST はもちろん、PUT や DELETE も。
$method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
if ($method !== 'GET' && $method !== 'HEAD') {
return; // キャッシュに関与しない
}
理由は、副作用のあるリクエストにキャッシュを返しても意味がなく、保存する対象でもないからです。フォーム送信の結果をキャッシュしたら、次の人が他人の送信結果を見ます。
いちばん最初に置いているのは、判定が最も安いからです。
2. Authorization ヘッダ
Authorization ヘッダが付いていたら除外します。Basic、Bearer、Digest、どれでも。
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
return;
}
認証を通したリクエストは、その人向けのレスポンスである可能性が高い。ヘッダの中身を見て判断するより、あったら諦めるほうが安全でした。
3. Host の許可リスト(fail-close)
リクエストの Host が、設定した許可リストに含まれていなければ除外します。
$host = isset($_SERVER['HTTP_HOST']) ? strtolower($_SERVER['HTTP_HOST']) : '';
if (!in_array($host, $allowed_hosts, true)) {
return; // 許可リストに無ければキャッシュしない
}
ここは fail-close にしています。許可リストが未設定だったり、判定に失敗した場合も、キャッシュしません。
Host はキャッシュキーのディレクトリ部分に使うので、任意の値を通すと、攻撃者が指定した Host でディレクトリが作られます。キャッシュ汚染の入口になるので、知っているホストだけを通します。
4. WordPress のシステムURL
管理画面やログイン、cron、XML-RPC は除外します。
// 境界のアンカーを付ける(部分一致にしない)
$pattern = '#(^|/)(wp-admin(/|$)|wp-login\.php|wp-cron\.php|xmlrpc\.php)#';
if (preg_match($pattern, $request_uri)) {
return;
}
ここで、単純な部分一致にしないことが要ります。strpos($uri, 'wp-admin') !== false のように書くと、/blog/wp-admin-guide/ のような普通の記事URLまで巻き込みます。
除外条件は、緩いと情報漏洩になりますが、雑だと正常なページがキャッシュされません。どちらの失敗も起きるので、境界のアンカーを入れています。
5. EC のパス
WooCommerce を使っているサイトでは、次のパスを除外します。
cart / checkout / my-account / wc-api / wc-ajax / add-to-cart
$ec_patterns = ['/cart', '/checkout', '/my-account', 'wc-api', 'wc-ajax', 'add-to-cart'];
どれも、ユーザーごとに内容が変わる場所です。カートの中身をキャッシュして配ってしまうのは、キャッシュプラグインの典型的な事故なので、パスの段階で落とします。
自分のプラグインは EC 専用ではありませんが、この判定は入れています。使う人がECを入れているかどうかは、こちらから分からないので。
6. バイパス cookie
次の cookie があるリクエストは除外します。
-
wordpress_logged_in_(ログイン中キャッシュを無効にしている場合) woocommerce_cart_hashwp_woocommerce_session_woocommerce_items_in_cartcomment_author_wp-postpass_
$bypass_prefixes = [
'woocommerce_cart_hash',
'wp_woocommerce_session_',
'woocommerce_items_in_cart',
'comment_author_',
'wp-postpass_',
];
if (!$cache_logged_in) {
$bypass_prefixes[] = 'wordpress_logged_in_';
}
foreach (array_keys($_COOKIE) as $name) {
foreach ($bypass_prefixes as $prefix) {
if (strpos($name, $prefix) === 0) {
return;
}
}
}
前方一致で見ています。 これらの cookie はハッシュ付きの名前になるので、完全一致では拾えません。
comment_author_ を入れているのは、コメント投稿後の「あなたの名前」が入った表示をキャッシュしないためです。wp-postpass_ は、パスワード保護記事を通過した状態を示すので、その状態でのHTMLを保存すると、パスワードを知らない人に中身が配られます。
7. HTML ではない拡張子
.xml .json .css .js .map .txt などは除外します。
if (preg_match('#\.(xml|json|css|js|map|txt|xsl|ico|png|jpe?g|gif|svg|webp)$#i', $path)) {
return;
}
ページキャッシュはHTMLを対象にしているので、それ以外は扱いません。フィードやサイトマップをHTMLと同じ扱いで保存すると、Content-Type がずれます。
8. クエリ文字列(許可リスト方式)
許可していないクエリパラメータが付いていたら除外します。そして、許可したものだけをキャッシュキーに含めます。
// 許可リスト。既定で無視するもの(キーに含めず、同一キーに集約)
$ignored = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'fbclid', 'gclid', 'ref'];
// キーに含めるもの(ページングなど)
$allowed = ['page', 'paged', 'p'];
拒否リストではなく許可リストにしたのには理由があります。拒否リストだと、知らないパラメータが来たときにキャッシュが増え続けます。 ?xyz=1 ?xyz=2 と適当なパラメータを付けて叩かれると、その数だけファイルが作られる。ディスクを埋められます。
そして utm_* のような計測パラメータは、無視して同じキーに集約します。これをやらないと、SNSからの流入で ?utm_source=... が付くたびに別のキャッシュになって、ヒット率が落ちます。
9. 設定による除外(正規表現、fail-close)
利用者が設定できる除外を、4種類持っています。URI、cookie、User-Agent、referrer。それぞれ正規表現です。
// 正規表現のコンパイルに失敗したら、キャッシュしない側に倒す
$compiled = @preg_match($user_pattern, $subject);
if ($compiled === false) {
return; // パターンが壊れている → 安全側
}
if ($compiled === 1) {
return; // マッチ → 除外
}
ここも fail-close です。利用者が書いた正規表現が壊れていたとき、判定できないので、キャッシュしません。
エラーを無視して「マッチしなかった」として扱うと、除外したいページがキャッシュされます。設定を書いた人は除外したつもりなので、いちばんまずい形の失敗になります。
10. モバイル判定
モバイルのキャッシュを無効にしている設定なら、モバイルからのリクエストを除外します。有効かつ「モバイルを分離」する設定なら、キーに -mobile を付けて別のキャッシュにします。
判定はUAの正規表現です。ここは完璧にはならないので、分離するかどうかを設定で選べるようにしました。テーマがレスポンシブなら分離は不要ですし、UAで出し分けているなら必須です。
11. スキーム不一致
http と https のスキームが、サイト設定と食い違っている場合は除外します。
WordPress内部でリダイレクトが起きるケースを保護するためです。リダイレクト前の状態をキャッシュしてしまうと、リダイレクトループのような形で固定されることがあります。
第2段階:レンダリング後の除外
ページが作られたあと、出力バッファのコールバックで判定します。
1. HTTP ステータス
200 以外は保存しません。404 だけ、設定が有効なときに 404-index として別に保存します。
$status = http_response_code();
if ($status !== 200) {
if (!($status === 404 && $cache_404)) {
return $html; // 保存せずに返す
}
}
404 をキャッシュできるようにしているのは、存在しないURLへの大量アクセスで毎回WordPressを起動させられるのを防ぐためです。ただ、既定では無効にしています。404の内容が動的なサイトもあるので。
2. DONOTCACHEPAGE 定数
if (defined('DONOTCACHEPAGE') && DONOTCACHEPAGE) {
return $html;
}
他のプラグインやテーマが「このページはキャッシュするな」と表明するための、事実上の共通規約です。自分のプラグインの都合ではなく、エコシステムの作法として見ています。
3. Set-Cookie が付いている
レスポンスヘッダに Set-Cookie があれば保存しません。
foreach (headers_list() as $header) {
if (stripos($header, 'Set-Cookie:') === 0) {
return $html;
}
}
これは、いちばん怖い判定だと思っています。 Set-Cookie が付いているレスポンスは、その人に固有の状態を作っている途中です。それをキャッシュして配ると、他人のセッションを渡すことになりかねません。
自分はこの判定を、実装の後から足しました。入れる前にログイン直後のページが配られていたら、と考えると、いまでもぞっとします。
4. Cache-Control
private と no-store があれば保存しません。no-cache は許可します。
// no-cache は「使う前に再検証しろ」であって「保存するな」ではない
if (preg_match('/(private|no-store)/i', $cache_control)) {
return $html;
}
ここは間違えやすいところです。no-cache を「保存禁止」として扱うと、キャッシュできるページをかなり取りこぼします。保存を禁止しているのは no-store、共有キャッシュを禁止しているのが private です。
5. Vary
Vary が Accept-Encoding 以外を含んでいたら保存しません。
// Accept-Encoding だけは gzip/plain の2系統で持っているので許容
if ($vary !== '' && strtolower(trim($vary)) !== 'accept-encoding') {
return $html;
}
Vary は「このヘッダによって内容が変わる」という宣言です。URLベースのキーで保存する仕組みでは、ヘッダ違いを区別できません。Vary: Accept-Language が付いたページをキャッシュすると、日本語の人に英語のページを返します。
対応できない Vary は、保存しない。 これが安全側でした。
6. HTML の完全性
</html> が含まれていなければ保存しません。
if (stripos($html, '</html>') === false) {
return $html;
}
途中で切れた出力、リダイレクト、fatal error で止まったページを保存しないためです。不完全なHTMLをキャッシュすると、その壊れたページが延々と配られます。エラーにならないので、誰も気づきません。
7. 検索結果
is_search() なら保存しません。
検索クエリは無限に作れるので、キャッシュすると際限なくファイルが増えます。しかも同じクエリが再訪される確率は低い。ヒットしないキャッシュを溜めるだけになります。
8. パスワード保護記事
post_password_required() に相当する状態なら保存しません。第1段階で wp-postpass_ cookie も見ていますが、こちらは記事側の属性として確認します。
同じ危険に対して、2箇所で判定しています。 冗長ですが、片方が抜けたときに漏れる内容が重いので、両方置いています。
9. 記事単位の無効化フラグ
投稿メタで、その記事だけキャッシュを止められるようにしています。
// 投稿メタ _prime_cache_disabled が立っていたら保存しない
if (get_post_meta($post_id, '_prime_cache_disabled', true)) {
return $html;
}
動的な要素を含む記事を、利用者が個別に外せるようにするためです。設定の正規表現でURIを指定してもらうより、編集画面のチェックボックスのほうが間違いが少なかったので。
10. レスポンスが短すぎる
255バイト未満なら保存しません。
正常なHTMLがその長さになることはないので、何かが失敗した結果だと判断できます。空の出力や、エラーメッセージだけのレスポンスを保存しないための、最後の網です。
保存の実装:原子的に書く
判定を通ったら、ファイルに書きます。ここも一箇所だけ注意点があります。
function my_atomic_write(string $path, string $content): bool {
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return false;
}
// 同一ディレクトリに一時ファイルを作る(rename が同一FS内になるように)
$tmp = $path . '.' . getmypid() . '.tmp';
if (file_put_contents($tmp, $content, LOCK_EX) === false) {
return false;
}
// rename は同一FS内なら原子的
if (!rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
直接 file_put_contents($path, ...) にすると、書き込み途中のファイルを他のリクエストが読みます。読んだ側は、途中で切れたHTMLを受け取る。しかもエラーにならないので、気づきません。
一時ファイルを同じディレクトリに作るのも理由があります。/tmp に作ってから rename すると、ファイルシステムをまたぐ場合があり、その rename は原子的になりません。
gzip版も同時に作っておくと、対応するクライアントに .html.gz をそのまま返せます。
キーの設計で、やらなかったこと
除外条件と並んで、キーの正規化にも判断があります。2つだけ書いておきます。
rawurldecode をしません。 デコードすると %2F と / が同じものになり、別々のURLが同じキーに衝突します。「正規化したほうがきれいだ」と思ってやると、異なるものを同じにしてしまう。キャッシュキーでは、これが事故になります。
ハッシュを切り詰めすぎません。 cookie やクエリの組み合わせを md5 で畳んでいますが、16桁(64bit)残しています。短くすると、攻撃者が同じハッシュになる値を安価に作れて、他人が見るバリアントに内容を流し込めます。ハッシュは短くしたくなる場所ですが、切り詰めた分だけ攻撃コストが下がります。
確認手順
実装したら、この順で確かめます。実際に自分がやっている順です。
1. ログアウト状態で、素のページ。 シークレットウィンドウで開いて、2回目が速くなるか。キャッシュファイルが実際に作られているかも、ディレクトリを見て確認します。
2. ログイン状態で、キャッシュされないこと。 ログインしたまま同じページを開いて、キャッシュファイルが増えないか。ここが漏れていると、いちばんまずい形の事故になります。
3. ログイン→ログアウトの直後。 Set-Cookie が付くタイミングで保存されていないか。ログイン直後とログアウト直後の両方を見ます。
4. ?utm_source=test を付けて開く。 キャッシュが増えないこと(同じキーに集約されること)。
5. 適当なパラメータ ?zzz=1 を付けて開く。 許可リストにないので、キャッシュされないこと。ここでキャッシュが増えるなら、許可リストが機能していません。
6. POST を投げる。 フォームを送信して、その結果がキャッシュされないこと。
7. 404 と検索。 存在しないURLと ?s=test で、意図した挙動になっているか。
8. パスワード保護記事。 パスワードを入れた状態のHTMLが保存されていないこと。
9. キャッシュを消して、もう一度全部。 クリア機能が本当に消しているか。消し残しがあると、古い内容が配られ続けます。
10. 設定の正規表現に、壊れたパターンを入れる。 [ だけを入れてみて、キャッシュが止まること(fail-close の確認)。ここを確かめる人は少ないと思いますが、ここが通ってしまう実装だと、除外設定が静かに無効になります。
実装の順番
全部をいっぺんに作る必要はありません。効く順に並べるとこうです。
1. メソッド・ログイン cookie・管理画面の除外。 この3つがないと、公開してはいけない状態です。ここだけは最初に入れます。
2. 原子的な書き込み。 あとから直すのは面倒なので、最初から rename で書きます。
3. 第2段階の Set-Cookie と </html>。 レンダリング後の判定で、いちばん事故を防ぐ2つです。
4. クエリの許可リスト。 ディスクを埋められる問題への対処。公開する前に入れておきたい。
5. fail-close の徹底。 判定の失敗を、全部「保存しない」に倒す。実装のバグが情報漏洩にならない状態を作ります。
6. gzip、304、モバイル分離、404 キャッシュ。 ここからは速さと効率の話なので、後回しでいい。
逆に、最初から作り込まなくていいのは、キャッシュの自動削除の細かい制御でした。投稿更新時に全消しでも、最初は動きます。細かく差分だけ消す仕組みは、運用してから必要な形が見えてきます。
早見でまとめ
- ページキャッシュの実装量は、返す処理より「返さない判定」が本体
- 判定は2段階。WordPressロード前(リクエストで分かる分)と、レンダリング後(HTMLとヘッダで分かる分)
- ロード前:メソッド / Authorization / Host許可リスト / システムURL / ECパス / バイパスcookie(前方一致)/ 非HTML拡張子 / クエリ許可リスト / 設定の正規表現 / モバイル / スキーム不一致
- レンダリング後:ステータス / DONOTCACHEPAGE / Set-Cookie / Cache-Control(
no-cacheは許可、禁止はprivateとno-store)/ Vary /</html>の有無 / 検索 / パスワード保護 / 記事単位フラグ / 255バイト未満 - 管理画面の判定は境界アンカー付きで。部分一致は普通の記事URLを巻き込む
- 判定に失敗したら保存しない(fail-close)。正規表現のコンパイルエラーも含めて
- 書き込みは同一ディレクトリの一時ファイル +
rename。直接書き込みは途中を読まれる - キーは
rawurldecodeしない。ハッシュを切り詰めすぎない - 対応できない
Varyは、キャッシュしないと決める
除外のリストは、書いているうちに伸び続けました。いまも、まだ抜けているものがある気がしています。キャッシュの実装で怖いのは遅くなることではなく、誰かのページを誰かに見せることなので、そこは伸ばし続けるつもりです。役に立ったらストックして、キャッシュを実装するとき手元に置いてください。
実物はここで読めます。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。