Astro + Cloudflare で公開した記事が 404 のままになる原因
記事を公開したのに、記事の URL を開くと「ページが見つかりません」のままでした。トップページと記事一覧には新しい記事が載っていて、そこからクリックしても 404 です。管理画面では公開済みになっています。
Astro のサーバーレンダリングを Cloudflare Workers で動かし、Astro 製の CMS「EmDash」で記事を管理している構成です。2026 年 8 月 28 日に本番で再現したので、その記録です。
再現手順
公開前の記事の URL を、先に 2 回開いておきます。
/hogehoge-cache-test-20260828
1 回目: 404 cf-cache-status: MISS
2 回目: 404 cf-cache-status: HIT age: 32 回目が HIT になっています。404 のレスポンスがエッジにキャッシュされました。
次に管理画面で同じスラッグの記事を作って公開し、もう一度開きます。
/hogehoge-cache-test-20260828 → 404 cf-cache-status: HIT age: 5713
/hogehoge-cache-test-20260828?x=1 → 200素の URL は 404 のまま、age は伸び続けています。クエリを付けると別のキャッシュキーになるので 200 が返り、記事は正しく公開されていると分かります。残っているのは、公開前にキャッシュされた 404 だけでした。
404 も routeRules の対象になっていた
このサイトはエッジキャッシュを 30 日にしています。指定は astro.config.mjs の routeRules です。
const CACHE = { maxAge: 2592000, swr: 86400 };
routeRules: {
"/": CACHE,
"/[slug]": CACHE,
"/posts/[...path]": CACHE,
},routeRules はリクエストのパスにパターンを照合します。/hogehoge-cache-test-20260828 は /[slug] に一致するので、そのリクエストはキャッシュ対象です。レスポンスが 200 か 404 かは見ていません。
私は「記事ページが 30 日キャッシュされる」設定のつもりでしたが、実際には「そのパスに対するレスポンスが何であれ 30 日キャッシュされる」設定でした。存在しない URL への 404 も、同じルールで 30 日保持されます。
最初は「ルート直下のスラッグを持つサイトだけの問題」と思っていましたが、/posts/[...path] のように多階層のルールを持つサイトでも /posts/zz-probe は 2 回目に HIT になりました。routeRules に載っている経路なら、階層に関係なく同じことが起きます。
公開が失効させるタグと、404 に付いているタグが交わらない
記事を公開すると、EmDash は該当するキャッシュを失効させます。emdash/dist/astro/routes/api/content/[collection]/[id]/publish.mjs の該当行です。
if (cache?.enabled) await cache.invalidate({ tags: [collection, resolvedId] });タグは ["posts", "01ABC…"]、つまりコレクション名と記事の ID です。公開済みの記事ページには、描画時に Astro.cache.set(cacheHint) でこのタグが付いているので、更新すれば消えます。
404 のレスポンスにはこのタグが付いていません。記事が存在しないので、記事の ID もありません。@astrojs/cloudflare の cache provider が付けるタグは、パスから作られる 1 つだけです。
// @astrojs/cloudflare/dist/cache/provider.js
const tags = [...options.tags ?? []];
const { pathname } = new URL(request.url);
tags.push(pathTag(pathname));
headers.set("Cache-Tag", tags.join(","));["posts", "01ABC…"] と ["astro-path:/…"] は交わりません。公開しても 404 のキャッシュだけが消え残ります。
ローカルの wrangler dev でヘッダを確かめると、404 に付くタグはさらに単純でした。
cache-tag: astro-path:/404存在しないスラッグの記事ページは Astro.rewrite("/404") で 404 ページを描画しているので、タグはリクエストのパスではなく書き換え先の /404 で作られます。どの URL の 404 も、同じ 1 つのタグを共有していました。
キャッシュの設定は、リクエストごとの 1 つのオブジェクトに積まれる
直し方を決めるために、Astro の実装を読みました。core/cache/handler.js と core/cache/runtime/cache.js です。
- リクエストごとに
AstroCacheオブジェクトが 1 つ作られる - 開始時に
routeRulesをリクエストのパスに照合し、一致したらcache.set(そのオプション)を呼ぶ。routeRulesの正体はこの初期値 - ミドルウェア、ページ、
rewrite先の 404.astro が呼ぶAstro.cache.set()は、すべて同じオブジェクトへの追記。maxAgeは後勝ちで上書き、タグは積み上げ、set(false)で無効化 - ミドルウェアとページの処理がすべて終わった後に、溜まった状態から
Cloudflare-CDN-Cache-ControlとCache-Tagを生成してレスポンスに書く
つまり routeRules が既定値で、ページ側の set() がそれを上書きする関係です。「404 なら短くする」は routeRules には書けません。パスでしか照合できず、ステータスを見ないからです。書けるのは 404.astro の側です。
この順序から、やってはいけないことも 1 つ分かりました。ミドルウェアで Cloudflare-CDN-Cache-Control ヘッダを直接書き換えても、後から Astro が headers.set で上書きするので効きません。最初はそれで直そうとしていました。
404.astro で 5 分に上書きした
---
Astro.response.status = 404;
if (Astro.cache?.enabled) Astro.cache.set({ maxAge: 300, swr: 0 });
---rewrite("/404") 経由でも同じリクエスト内なので、この 1 行が /[slug] ルールの 30 日を 5 分に置き換えます。
swr: 0 は必要でした。maxAge だけを上書きすると、routeRules の stale-while-revalidate=86400 がそのまま残ります。ローカルで確かめた結果です。
maxAge だけ: cloudflare-cdn-cache-control: public, max-age=300, stale-while-revalidate=86400
swr: 0 も: cloudflare-cdn-cache-control: public, max-age=300, stale-while-revalidate=0
200 ページ: cloudflare-cdn-cache-control: public, max-age=2592000, stale-while-revalidate=86400(変化なし)stale-while-revalidate が残っていると、5 分を過ぎた後の最初のアクセスには古い 404 を返しながら裏で取り直す動きになります。公開直後に自分で確認しに行く場面では、その 1 回が「まだ直っていない」に見えます。
本番にデプロイして、未使用の URL で確かめました。
1 回目: 404 cf-cache-status: MISS
2 回目: 404 cf-cache-status: HIT age: 0
5 分半後: 404 cf-cache-status: EXPIRED30 日残っていた 404 が、5 分で取り直されるようになりました。
purge の仕組みを作らなかった理由
最初に考えたのは「公開時に astro-path: のタグも失効させる」ことでした。ミドルウェアで content API への書き込みを捕まえて、レスポンスの slug から公開 URL のパスを組み立て、タグを消す。実装して動くところまで作りましたが、取り下げました。
- slug から URL を組み立てる規則をコード側に持つ必要がある。EmDash の
url_patternは実際のルーティングと食い違っているサイトがあった - 予約公開は Cron の
scheduled()から実行されるので、ミドルウェアでは捕まえられない - 守っているもの(404 を 30 日キャッシュし続けること)の価値が小さい。スキャナは毎回違う URL を叩くので 404 のキャッシュはほとんど当たらず、D1 の読み取り量にも効いていなかった
「入れるのは安いが、持ち続けるのは高い」仕組みでした。404 は 5 分で切れる、という性質を 1 つ足すだけなら、公開・予約公開・復元・スラッグ変更のどの経路で起きても最長 5 分で直り、保守するものがありません。
この構成で気をつけること
routeRulesはリクエストのパスで照合し、レスポンスのステータスを見ない。記事ページのルールは、そのパスに返る 404 にもそのまま効く- EmDash が公開時に失効させるタグはコレクション名と記事 ID。404 にはそのタグが付かない
- 404 のタグは
astro-path:/404で全 URL 共通。将来まとめて消したくなったらcache.invalidate({ path: "/404" })の 1 回で済む Astro.cache.set()はrouteRulesの値を上書きできる。rewrite先のページからでも効くmaxAgeを上書きするときはswrも一緒に書く。routeRulesの値が残る- ミドルウェアで
Cloudflare-CDN-Cache-Controlを直接書いても、Astro が後から上書きする
公開直後に自分で URL を確認しに行く操作は、この問題を最も踏みやすい操作でした。「まだ出ていないな」と見に行ったその 1 回が、30 日残る 404 を作っていました。