Cloudflare で purge しても age が伸び続けるのは壊れていない
エッジキャッシュを入れたあと、記事を更新してもページが変わりませんでした。cf-cache-status は HIT のまま、age は伸び続けます。
+0s HIT age: 212
+91s HIT age: 302 ← 保持時間 300 秒を超えた
+453s HIT age: 665 ← 11 分経過。内容も古いまま保持時間を過ぎているのに HIT が返り、age が単調増加する。ここから私は「キャッシュの失効が壊れている」と結論しました。
この結論は間違いでした。 キャッシュは正常で、更新も正しく反映されていました。読み方を間違えていただけです。
purge はページを作り直さない
理解の要はこれです。
キャッシュを消す処理(purge)は、キャッシュを消すだけです。ページを新しく作るのは、次に誰かがアクセスしたときです。
書けば当たり前なのですが、デバッグ中はこれを忘れます。「消えたのに古いものが返ってくる」と見えたら、まず疑うべきは「本当に誰かがアクセスしているか」です。
そして私の確認方法がまずかった。
curl -sI https://example.com/some-article-I は HEAD リクエストです。キャッシュがあればキャッシュから返るので、サーバー側のプログラムには届きません。 届かないので再描画も起きません。そして age だけが進みます。
私は「更新されない」ことを確認するたびに、更新されない状況を自分で維持していました。
ログと突き合わせて分かったこと
サーバー側の実行ログを見たら一発でした。
■ 過去90分の Worker 実行: 265 件
■ うち該当ページ: 2 件90 分間で、そのページにプログラムまで到達したリクエストは 2 回だけでした。誰も見に来ていなかったのです。私の HEAD リクエストは全部キャッシュから返っていました。
そして実際にサイトの管理画面から記事を公開して、ブラウザで開いた場合の記録がこれです。
13:13:11Z 公開ボタン
13:13:15Z ページの再描画 ← 4 秒後
13:13:39Z 公開ボタン
13:13:42Z ページの再描画 ← 3 秒後3〜10 秒で反映されていました。 私が「94 秒かかった」と測ったケースも、公開から 94 秒後に私自身が curl でアクセスした、というだけでした。遅延の正体は、誰かが見に来るまでの待ち時間です。
閲覧者から見れば、アクセスした瞬間に必ず最新が返ります。遅延はゼロです。
age は何を表しているか
age は「このキャッシュが作られてから何秒経ったか」です。「更新されるべきなのにされていない時間」ではありません。
保持時間を過ぎても HIT が返ることは、それ自体はおかしくありません。stale-while-revalidate を設定していれば、Cloudflare は期限切れのものをそのまま返しつつ、裏で更新します。このとき cf-cache-status は UPDATING になります。
実際、別のページでは正常な遷移が観測できました。
11:46:49 HIT age 287
11:47:19 UPDATING age 317 ← 期限切れを検知、裏で再検証
11:47:50 HIT age 29 ← 差し替え完了、age がリセット私が見ていたページとの違いは、そのページには他からアクセスがあったことだけです。
何回間違えたか
この件で私が出して、あとから実測で覆された結論を並べます。
- 「EmDash がキャッシュ失効を呼んでいない」 → 呼んでいました。ソースを読んだら 11 箇所ありました
- 「`stale-while-revalidate` が壊れている」 → 正常でした。他ページで期待どおりの遷移を確認
- 「消せなくなったキャッシュがある」 → 消えていました。再描画のきっかけが無かっただけ
- 「その状態からは回復しない」 → 回復しました
4 回連続で外しています。共通しているのは、レスポンスヘッダだけを見て、サーバー側のログと突き合わせなかったことです。
とくに 1 は罪深くて、「ログにエラーも失効の記録も出ていないから、呼ばれていないのだろう」と推論しました。しかし実装を読むと、失効処理は結果を捨てていて成功も失敗もログに出ない構造でした。「ログに無い」を根拠にできない場面だったのに、根拠にしてしまった。
キャッシュのデバッグで守ること
同じ失敗を避けるための、実務的な結論です。
1. `curl -I` で「更新されない」を確認しない
HEAD はキャッシュから返るので、確認しているつもりで何も起こしていません。ブラウザで開くか、少なくとも GET で本文まで取ってください。
2. `age` が伸びることを異常だと決めつけない
アクセスが無ければ伸び続けるのが正常です。異常かどうかは、その間にサーバー側で実行が起きたかで判断します。
3. レスポンスヘッダとサーバーログを必ず突き合わせる
ヘッダだけでは「キャッシュから返った」ことしか分かりません。「なぜ再描画されなかったのか」はサーバー側にしか記録がありません。 Cloudflare Workers なら Workers Logs で、パスごとの実行回数と時刻が見られます。
4. 「ログに無い」を根拠にする前に、そもそも記録される実装かを確認する
今回の失効処理のように、結果を捨てていて成功も失敗も残らないコードは珍しくありません。
5. 待ち時間を十分に取る
私は 35 秒で打ち切って「効かない」と結論した場面もありました。キャッシュの伝播や再検証は、秒単位で完結するとは限りません。
上流の報告も同じ勘違いをしていた
調べる過程で、使っている Astro 製の CMS「EmDash」 の GitHub に同じ症状の報告が上がっているのを見つけました。バージョン構成まで完全に一致していました。
その報告には「EmDash はキャッシュ失効をどこでも呼んでいない。パッケージ内にそのような呼び出しは見当たらない」と書かれていました。私がした誤診とまったく同じです。実際には 11 箇所で呼ばれています。
同じ落とし穴に、別々の人間が独立にはまっている。それだけ読み違えやすい構造だということだと思います。
なお、その報告の症状(保持時間いっぱい古いまま)が私のケースと同じ原因なのかは分かりません。私の場合はアクセスが無かったことで説明が付きましたが、相手の環境は確認できていません。
関連
無料プランの CPU 上限で 503 になった話は低負荷なのに 503 が返るに、そもそも HTML がキャッシュされていなかった話はCloudflare は HTML をキャッシュしないに書いています。