CDN が何もキャッシュしない理由

agentready.md の静的ファイルは、どれも同じ行を返していました。

cf-cache-status: BYPASS

フォント、スタイルシート、スクリプト、画像。MISS ではありません。MISS はエッジがまだそのファイルを見ていないという意味で、いま取得したコピーはちゃんと保持されます。BYPASS は、エッジがレスポンスを読んだうえで保存してはいけないと判断したという意味で、明日も同じ判断になります。訪問者は全員が全バイトをオリジンから引いていて、放っておけば永久にそのままでした。

原因ではなかった四つの設定

  • Development Mode: オフ。三時間エッジのキャッシュを丸ごと止める設定で、消し忘れが多いので最初に見る。
  • Caching Level: Standard。
  • Page Rules: Cache Level を Bypass にしているものはなし。
  • Browser Cache TTL: まともな値。

管理画面がキャッシュについて言っていることは、すべて「これを保存しろ」でした。エッジは何ひとつ保存していませんでした。

答えはレスポンスの中にあった

探しに行った一行だけでなく、ヘッダーのブロックを丸ごと読めばすぐ見えます。

curl -sI https://agentready.md/fonts/ibm-plex-sans-latin.woff2
HTTP/2 200
content-type: font/woff2
cache-control: public, max-age=31536000, immutable
set-cookie: _csrf=8f3c…; Path=/; SameSite=Strict
cf-cache-status: BYPASS

三行目は一年を要求しています。四行目が、誰もそれを認めない理由です。

Set-Cookie を積んだレスポンスは、共有キャッシュには保存できません。 クッキーは一人の訪問者のものです。そのレスポンスを保存して次の人に渡せば、他人のクッキーを渡すことになります。どの CDN も拒否します。Cloudflare はその拒否に BYPASS という名前を付けているだけで、名前も出さずに黙って保存しない CDN もあります。

クッキーは自分たちが付けていました。サイトのどこでフォームを描画してもトークンが用意されているように、ミドルウェアがリクエストごとに CSRF トークンを生成して添付していたのです。その「リクエストごと」には woff2 フォントへのリクエストも入ります。フォームを描画せず、トークンを読まず、コードも実行しないファイルです。

修正

CSRF は弱めません。使えないものにトークンを配るのをやめるだけです。

const ASSET_PREFIXES = ['/css/', '/js/', '/fonts/', '/images/'];
const COOKIELESS_FILES = new Set([
  '/favicon.ico', '/badge.js', '/robots.txt', '/sitemap.xml', '/llms.txt',
]);

function servesWithoutCookie(path) {
  return COOKIELESS_FILES.has(path)
    || ASSET_PREFIXES.some((prefix) => path.startsWith(prefix))
    || path.endsWith('.md');
}

判定は拡張子ではなくパスで行います。いちばんキャッシュしてほしいレスポンスの一部は、ディスクから読むのではなくテンプレートから生成されるからです。各ページの Markdown 版や SVG のバッジがそれで、こちらもフォームは描画しません。

そのクッキーに心当たりがないなら、セッション層を見てください。ミドルウェアを通ったリクエストすべてでセッションを開き、中に何も入っていなくてもクッキーを送るフレームワークは珍しくありません。

どの構成でも使える調べ方

トップページではなくアセットを見ること。トップページは正当な理由でキャッシュ不可なことが多く、そこから読み込まれる三十個のファイルについては何も教えてくれません。

for u in / /css/app.css /js/app.js /fonts/ibm-plex-sans-latin.woff2; do
  echo "== $u"
  curl -sI "https://example.com$u" \
    | grep -iE 'cache-control|set-cookie|cf-cache-status|x-cache|^age'
done

同じ URL を二回叩きます。二回目はヒットになるはずです。Cloudflare なら cf-cache-status: HIT、CloudFront なら x-cache: Hit from cloudfront、Varnish や nginx なら増えていく age: ヘッダー。二回目もミスなら、そのオブジェクトは保持されていません。理由はいま表示したヘッダーの中にあります。

ほかの容疑者

Cache-Control: private はブラウザだけで、共有キャッシュには絶対に入りません。no-store はブラウザにも入りません。no-cache は「キャッシュするな」ではなく「再利用のたびに再検証しろ」で、ファイルごとに往復が一回発生することに変わりはありません。

Vary: * はそれだけでレスポンスをキャッシュ不可にします。Vary: Cookie もほぼ同罪で、キャッシュキーに訪問者ごとに違う値が入るため、何も共有されなくなります。Vary: User-Agent は一つのファイルを何千もの変種に割ります。ここに置いていいのは Accept-Encoding です。

クエリ文字列。多くのキャッシュは URL 全体をキーにするので、?utm_source=?fbclid= が付いた変種はそれぞれ別のオブジェクトとしてオリジンから取得されます。キャンペーン用のパラメータはエッジで落とすか正規化しておきます。

そしてフレームワークの既定値。バイパスではありませんが、コストはそれに近い。静的ファイルすべてに max-age=0 が付いていると、ページ遷移のたびにアセットの数だけ再検証リクエストが飛びます。

筋の通ったキャッシュ方針

基準はファイルの種類ではなく、その URL が何を約束しているかです。

内容のハッシュを含む URL(app.css?v=9f2ac41bapp.9f2ac41b.css)は内容そのものの住所で、ファイルが変われば URL が変わります。これらには public, max-age=31536000, immutable を付けてよく、パージする対象は永久に生まれません。同じファイルでもトークンなしで要求されたら一時間です。トークンこそが約束であって、誰もしていない約束を根拠にキャッシュはしません。ロゴや OGP 画像のように同じ URL のまま差し替えるものは一週間。

そして例外です。手元でいちばんキャッシュ向きに見えるファイルなので、ここでよく間違えます。他人のページに埋め込まれ、こちらでバージョンを付けられない URL で配るものです。badge.js を一時間にしているのはそのためだけです。HTML も URL もこちらの管理外のサイトで動くので、一年の immutable は一年間手の届かないバグを意味します。

@fastify/static でファイルを配っている場合

半日を溶かした二点。既定で Cache-Control: public, max-age=0 を送ります。そして setHeaders で自分でヘッダーを付けるなら cacheControl: false も一緒に渡してください。渡さないと、ライブラリが既定の maxAge から自前のヘッダーを組み立てて、何も言わずに上書きします。残るのは、意図して付けたように見えるヘッダーです。

app.register(fastifyStatic, {
  root: join(__dirname, 'public'),
  cacheControl: false,           // これがないと setHeaders が上書きされる
  setHeaders: setStaticCacheHeaders,
});

キャッシュ設定を触る前に、レスポンスを一つ最後まで読むこと。キャッシュを壊しているヘッダーは、たいてい名前に cache が入っていません。

サイトを調べる · AI 対応チェックリスト · ボットは許可されているか