Cloudflare で HTML がキャッシュされないのは既定の仕様
「Cloudflare を通しているからキャッシュは効いている」と思い込んでいました。実際に測ったら、HTML はキャッシュされていませんでした。
しかも「キャッシュミスしている」のではありません。キャッシュの判定対象にすらなっていないという状態でした。
まず実測
自分のサイトで、CSS と HTML のレスポンスヘッダを比べます。
$ curl -sI https://example.com/_astro/Base.BaEWF_vM.css
cf-cache-status: HIT
cache-control: public, max-age=31536000, immutable
$ curl -sI https://example.com/posts/some-article
(cache-control も cf-cache-status も無い)CSS には cf-cache-status: HIT が付いて 1 年キャッシュされています。一方 HTML には `cf-cache-status` ヘッダ自体が付きません。
MISS ですらないのがポイントです。MISS なら「キャッシュを探したが無かった」という意味ですが、ヘッダが無いのは「そもそもキャッシュの仕組みに入っていない」ことを表します。
なぜそうなるのか
Cloudflare の既定のキャッシュは拡張子ベースです。.css .js .jpg .png などは既定でキャッシュしますが、HTML は既定ではキャッシュしません。
理由は、Cloudflare から見て HTML の中身が分からないからです。ブログの記事も、ログイン後のマイページも、返ってくるのは同じ text/html です。中身を知らないまま勝手にキャッシュすると、他人のマイページが別の人に配られるような事故が起きます。だから安全側に倒して既定では素通しする設計になっています。
つまりこれは不具合ではなく、こちらから「このページは安全にキャッシュしてよい」と宣言していないというだけの話です。
8 サイト全部が同じ状態でした
自分の運用している 8 サイトを全部確認したら、例外なく `Cache-Control` が無く、`cf-cache-status` も付いていませんでした。
サーバーサイドレンダリングで Astro 製の CMS「EmDash」 からコンテンツを引いて配信する構成なので、すべてのリクエストが Worker を起動し、毎回データベースに問い合わせています。 1 リクエストあたり 10 件以上のクエリが飛んでいました。
クローラーがサイトを一巡すると、記事数ぶんフル実行されます。これが原因で無料プランの CPU 上限に当たって 503 を返していた話は、低負荷なのに 503 が返るに書きました。
効かせるには 2 つ必要
Cache-Control を付けるだけでは足りません。
1. レスポンスにキャッシュ指示を付ける
2. Cloudflare にそれを「HTML でもキャッシュする」と解釈させる
2 が要るのは、前述のとおり既定の判定が拡張子ベースだからです。通常の Cache-Control を付けても、Cloudflare は HTML をキャッシュ対象と見なしません。
ここで使えるのが `Cloudflare-CDN-Cache-Control` です。Cloudflare 独自のヘッダで、ドキュメントでは最も優先度が高いと説明されています。これを使うと、既定の拡張子ベースの判定を迂回できます。ダッシュボードで Cache Rules を作る必要はありません。
Astro の場合は設定だけで済みました
私のサイトは Astro のサーバーサイドレンダリング構成です。調べたら、必要な部品はすべて揃っていて、繋がっていないだけでした。
Astro 7 には Route Caching という仕組みがあり、各ページで次のように呼びます。
---
const { entries, cacheHint } = await getCollection("posts");
Astro.cache.set(cacheHint);
---私のサイトではこの行が既に全ページに書かれていました。 ところがこの API は、プロバイダが設定されていないと何もしません。 コード中の if (Astro.cache?.enabled) というガードがその判定です。
そして Cloudflare 用のアダプタには、そのプロバイダが標準で入っていました。
// astro.config.mjs
import { cacheCloudflare } from "@astrojs/cloudflare/cache";
export default defineConfig({
cache: { provider: cacheCloudflare() },
routeRules: {
"/posts/[...path]": { maxAge: 300, swr: 86400 },
},
});これだけです。プロバイダの実装を読むと、やっていることは 3 つでした。
Cloudflare-CDN-Cache-Controlを付けて、HTML をキャッシュ対象にするCache-Tagを付けて、あとで消せるように目印を残す- コンテンツが更新されたら、そのタグを指定してキャッシュを消す
配線は済んでいて、電源が入っていなかったという状態でした。
注意した点
キャッシュ対象を広く取りすぎない
私のサイトは記事がルート直下(/記事名)に来る構成でした。ここで /[...path] のような広いパターンを書くと、管理画面まで巻き込みます。 ルートを個別に列挙しました。
なお /[slug] のような 1 階層のパターンは、/search のような固定ページにもマッチします。これは実際に踏みました。
フォームページが安全か確認する
キャッシュすると全員に同じ HTML が配られるので、リクエストごとに変わる値が埋まっていると壊れます。問い合わせフォームのページを 2 回取得して差分を取り、CSRF トークンのような値が無いこと、Set-Cookie が出ていないことを確認しました。
差分はありましたが、中身は Cloudflare がエッジで注入するボット対策用のもので、こちらが生成した HTML は同一でした。
保持時間の意味
maxAge: 300 は「5 分間は古い内容が出るかもしれない」という意味ではありません。コンテンツを更新した瞬間にキャッシュは消えます(前述の Cache-Tag の仕組み)。この 5 分は、記事を編集していないのに何かの拍子で表示が変わりうる場合の、最大のズレ幅です。
効果
導入前後で、リクエストの内訳がこう変わりました。
- キャッシュ対象外(
none): 96.1% → 10.4% - ヒット(
hit): 3.4% → 29.2%
そして CPU 上限超過(exceededCpu)は、導入前 6 時間で 3 件だったものが、導入後はゼロになりました。
miss と expired がまだ多いのは、キャッシュを温めている最中だからです。時間が経てばヒット率はさらに上がります。
見えないヘッダに注意
最後にひとつ、ハマりどころを。
`Cloudflare-CDN-Cache-Control` は、クライアントには届きません。 ドキュメントに「Cloudflare に消費され、クライアントに返るレスポンスからは取り除かれる」と明記されています。
つまり curl -I で見ても表示されません。ヘッダが見えないことを「設定できていない証拠」と読むと誤診します。 確認するなら cf-cache-status を見てください。
キャッシュのデバッグでは他にも読み違えやすい点があり、私は age の解釈で丸一日を溶かしました。その話はキャッシュの age が伸び続けるのは、壊れているからではないに書いています。