0xd00 随笔小记

Back

一次 Cloudflare Pages 上 Astro 博客文章 404 的排障实录#

前几天我把博客升级到新的 Astro 与 Cloudflare 适配方案之后,本地构建一切正常,首页和博客列表看起来也能访问,于是我理所当然地以为部署已经成功了。

但实际上,线上已经悄悄坏掉了。

最先暴露问题的是一篇旧文章:

https://blog.0xd00.com/blog/git-proxy-tutorial.html
text

访问直接 404。继续排查后我发现,不只是旧的 .html 永久链接有问题,连文章详情页、rss.xmlsitemap.xml 这类依赖完整内容产物的路径也都出了异常。

这篇文章记录一下整个排障过程。它并不是一个“改个配置就好”的简单问题,中间还被 Cloudflare 缓存狠狠误导了一次。

问题现象#

当时线上表现很诡异:

  1. 首页 / 可以打开
  2. 博客列表 /blog 也可以打开
  3. 文章详情页 /blog/git-proxy-tutorial 却是 404
  4. rss.xmlsitemap.xml 同样 404
  5. .html 的旧文章链接也全部失效

而本地构建结果却非常正常:

pnpm build
bash

构建通过,dist/client 下也能看到完整产物:

dist/client/blog/git-proxy-tutorial.html
dist/client/rss.xml
dist/client/sitemap.xml
text

这就形成了第一个矛盾:

  • 本地产物存在
  • 本地构建通过
  • 线上文章页依旧 404

如果只看表面,很容易怀疑是 Astro 路由、适配器兼容,或者文章集合过滤逻辑出了问题。

第一轮误判:我以为只是文章路由格式不兼容#

项目里当时使用的是:

build: {
  format: 'file'
}
ts

这意味着文章会被生成成下面这种形式:

/blog/git-proxy-tutorial.html
text

而不是:

/blog/git-proxy-tutorial/index.html
text

考虑到 Cloudflare 对目录式路由通常更稳,我第一反应是:

  • format: 'file' 改成 format: 'directory'
  • 再为旧的 .html 链接额外生成兼容文件

于是第一版修复思路是:

  1. 新路径统一走无扩展名目录页
  2. 旧路径保留 .html 兼容

这个方向本身没错,但它并没有解释一个更大的问题:

为什么连 rss.xmlsitemap.xml 也一起没了?

真正的干扰项:Cloudflare 缓存制造了“部分正常”的假象#

真正让我意识到问题不在页面代码,而在部署层,是下面这组验证。

如果直接请求页面,看起来是正常的:

curl -I https://blog.0xd00.com/
curl -I https://blog.0xd00.com/blog
bash

能得到 200,这会让人误以为部署至少大体没问题。

但只要带上绕过缓存的请求头,或者追加随机参数重新打源站,请求结果立刻完全变了:

curl -s -D - -H 'Cache-Control: no-cache' 'https://blog.0xd00.com/?_ts=xxx' -o /dev/null
curl -s -D - -H 'Cache-Control: no-cache' 'https://blog.0xd00.com/blog?_ts=xxx' -o /dev/null
curl -s -D - -H 'Cache-Control: no-cache' 'https://blog.0xd00.com/blog/git-proxy-tutorial?_ts=xxx' -o /dev/null
curl -s -D - -H 'Cache-Control: no-cache' 'https://blog.0xd00.com/rss.xml?_ts=xxx' -o /dev/null
bash

结果是:

  • / -> 404
  • /blog -> 404
  • /blog/git-proxy-tutorial -> 404
  • /rss.xml -> 404

到这里,问题性质就完全变了。

这说明此前看到的首页和列表页 200,并不是源站真的正常,而只是 Cloudflare 边缘缓存里还残留着旧版本内容

换句话说:

  • 线上“看起来还能打开”的那些页面,其实是缓存命中
  • 当前正在被发布的新版本源站,实际上根本没有完整站点内容

这一步非常关键。如果没有做 no-cache 验证,很容易在错误的方向上继续修半天。

根因定位:Cloudflare 实际发布的目录错了#

继续核对构建结构后,答案就很清楚了。

Astro 在当前 Cloudflare 适配配置下,构建产物并不是直接扔在 dist/ 根目录,而是拆成了:

dist/client
dist/server
text

其中真正的静态站点内容都在:

dist/client
text

包括:

  • 首页 HTML
  • 博客文章 HTML
  • RSS
  • Sitemap
  • 静态资源

但线上现象表明,Cloudflare Pages 仍然在按旧配置发布 dist,而不是 dist/client

这就解释了为什么:

  1. 本地构建是成功的
  2. dist/client 内有完整内容
  3. 线上源站却是空的

因为它根本没发布对目录。

为什么不是直接去改 Cloudflare 控制台?#

理论上最直接的办法,是去 Cloudflare Pages 后台把构建输出目录改成:

dist/client
text

但这个方案有两个现实问题:

  1. 我当时是在本地和 Git 仓库里排障,没有直接接管 Pages 控制台配置
  2. 这类“平台外配置”如果不落进仓库,下次再迁移、再重建项目时还会踩一遍

所以我最终采用了一个更稳、更仓库内闭环的修法:

既然线上还在错误地发布 dist,那我就在构建结束后,把 dist/client 的内容同步镜像到 dist 根目录。

这样即使 Pages 继续发布 dist,它拿到的也会是完整站点。

最终修复方案#

最终落地的修复分成两层。

1. 新路由改为目录式输出#

为了让 /blog/post 这种路径在 Cloudflare 上表现更稳定,我把构建格式改成了目录式:

build: {
  format: 'directory'
}
ts

这样文章产物会变成:

dist/client/blog/git-proxy-tutorial/index.html
text

而不是单文件:

dist/client/blog/git-proxy-tutorial.html
text

2. 为旧 .html 链接生成兼容别名#

旧文章链接已经被搜索引擎、RSS 阅读器和外部分享收录过,不能直接丢掉。

所以我又加了一层构建后处理逻辑,把目录式文章页面再复制出一个 .html 版本。例如:

dist/client/blog/git-proxy-tutorial/index.html
-> dist/client/blog/git-proxy-tutorial.html
text

这样历史链接就不会继续 404。

3. 把 dist/client 镜像到 dist#

最后,也是这次真正把线上救回来的关键步骤:

astro:build:done 钩子里,把 dist/client 下的内容同步到 dist 根目录。

核心逻辑大致如下:

这样构建结束后,dist 根目录里就会直接出现:

dist/index.html
dist/rss.xml
dist/sitemap.xml
dist/blog/git-proxy-tutorial/index.html
dist/blog/git-proxy-tutorial.html
text

也就是说:

  • 如果 Cloudflare 正确发布 dist/client,站点正常
  • 如果 Cloudflare 仍然错误发布 dist,站点也正常

从而把部署侧配置的不确定性“吸收”进了仓库构建流程。

关键验证方式#

这次排障里最有价值的一条经验,不是某个 Astro 配置项,而是验证方法本身。

很多时候,Cloudflare 会让你误以为线上是“部分正常”的,因为边缘节点缓存会继续返回旧页面。

真正靠谱的验证方式有两个:

1. 带 no-cache 请求头#

curl -s -D - -H 'Cache-Control: no-cache' 'https://blog.0xd00.com/blog/git-proxy-tutorial?_ts=1' -o /dev/null
bash

2. 给 URL 追加随机参数#

curl -I 'https://blog.0xd00.com/rss.xml?_ts=123456'
bash

只有这样,才能真正知道:

  • 你看到的是缓存
  • 还是当前新部署的源站结果

最终结果#

在新提交部署完成后,我再次用 no-cache 验证了源站状态:

  • / 返回 200
  • /blog 返回 200
  • /blog/git-proxy-tutorial 返回 200
  • /blog/git-proxy-tutorial.html 返回 308,随后跳转到新路径
  • /rss.xml 返回 200

也就是说,这次问题最终被完整修复:

  1. 首页恢复
  2. 文章详情页恢复
  3. RSS 与 Sitemap 恢复
  4. .html 链接恢复兼容

一些总结#

这次故障给我的几个直接教训是:

  1. 本地构建成功不等于线上部署成功。尤其在接入平台适配器后,必须搞清楚最终发布目录到底是谁。
  2. Cloudflare 缓存会制造错觉。看到 200 不代表源站真的正常,关键是要用 no-cache 重新验证。
  3. 优先让修复进入仓库,而不是停留在控制台。控制台改配置解决的是当下,仓库内的构建兼容解决的是以后。
  4. 兼容旧链接值得认真处理。博客不是后台系统,历史 URL 很可能已经被收录、被引用、被分享,不能想当然地直接废弃。

如果你也在用 Astro + Cloudflare Pages,并且在升级后遇到了“本地正常、线上文章页 404”的诡异情况,可以优先检查两件事:

  1. 你的真实站点产物是不是在 dist/client
  2. 你线上发布的到底是 dist,还是 dist/client

很多时候,答案就藏在这里。*** End Patch EOF

一次 Cloudflare Pages 上 Astro 博客文章 404 的排障实录
https://blog.0xd00.com/blog/cloudflare-pages-astro-blog-404-debug
Author 0xd00
Published at 2026年4月16日
Comment seems to stuck. Try to refresh?✨