ブログ一覧

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: 3

2 回目が 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.mjsrouteRules です。

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.jscore/cache/runtime/cache.js です。

  • リクエストごとに AstroCache オブジェクトが 1 つ作られる
  • 開始時に routeRules をリクエストのパスに照合し、一致したら cache.set(そのオプション) を呼ぶ。routeRules の正体はこの初期値
  • ミドルウェア、ページ、rewrite 先の 404.astro が呼ぶ Astro.cache.set() は、すべて同じオブジェクトへの追記。maxAge は後勝ちで上書き、タグは積み上げ、set(false) で無効化
  • ミドルウェアとページの処理がすべて終わった後に、溜まった状態から Cloudflare-CDN-Cache-ControlCache-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 だけを上書きすると、routeRulesstale-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: EXPIRED

30 日残っていた 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 を作っていました。

関連

この記事をシェア