一次 Cloudflare Pages 上 Astro 博客文章 404 的排障实录
记录一次 Astro 博客部署到 Cloudflare Pages 后文章详情页、RSS 与 Sitemap 异常 404 的完整排障过程,以及最终如何定位到发布目录与缓存掩盖问题。
一次 Cloudflare Pages 上 Astro 博客文章 404 的排障实录#
前几天我把博客升级到新的 Astro 与 Cloudflare 适配方案之后,本地构建一切正常,首页和博客列表看起来也能访问,于是我理所当然地以为部署已经成功了。
但实际上,线上已经悄悄坏掉了。
最先暴露问题的是一篇旧文章:
https://blog.0xd00.com/blog/git-proxy-tutorial.htmltext访问直接 404。继续排查后我发现,不只是旧的 .html 永久链接有问题,连文章详情页、rss.xml、sitemap.xml 这类依赖完整内容产物的路径也都出了异常。
这篇文章记录一下整个排障过程。它并不是一个“改个配置就好”的简单问题,中间还被 Cloudflare 缓存狠狠误导了一次。
问题现象#
当时线上表现很诡异:
- 首页
/可以打开 - 博客列表
/blog也可以打开 - 文章详情页
/blog/git-proxy-tutorial却是404 rss.xml和sitemap.xml同样404- 带
.html的旧文章链接也全部失效
而本地构建结果却非常正常:
pnpm buildbash构建通过,dist/client 下也能看到完整产物:
dist/client/blog/git-proxy-tutorial.html
dist/client/rss.xml
dist/client/sitemap.xmltext这就形成了第一个矛盾:
- 本地产物存在
- 本地构建通过
- 线上文章页依旧 404
如果只看表面,很容易怀疑是 Astro 路由、适配器兼容,或者文章集合过滤逻辑出了问题。
第一轮误判:我以为只是文章路由格式不兼容#
项目里当时使用的是:
build: {
format: 'file'
}ts这意味着文章会被生成成下面这种形式:
/blog/git-proxy-tutorial.htmltext而不是:
/blog/git-proxy-tutorial/index.htmltext考虑到 Cloudflare 对目录式路由通常更稳,我第一反应是:
- 把
format: 'file'改成format: 'directory' - 再为旧的
.html链接额外生成兼容文件
于是第一版修复思路是:
- 新路径统一走无扩展名目录页
- 旧路径保留
.html兼容
这个方向本身没错,但它并没有解释一个更大的问题:
为什么连 rss.xml 和 sitemap.xml 也一起没了?
真正的干扰项:Cloudflare 缓存制造了“部分正常”的假象#
真正让我意识到问题不在页面代码,而在部署层,是下面这组验证。
如果直接请求页面,看起来是正常的:
curl -I https://blog.0xd00.com/
curl -I https://blog.0xd00.com/blogbash能得到 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/nullbash结果是:
/->404/blog->404/blog/git-proxy-tutorial->404/rss.xml->404
到这里,问题性质就完全变了。
这说明此前看到的首页和列表页 200,并不是源站真的正常,而只是 Cloudflare 边缘缓存里还残留着旧版本内容。
换句话说:
- 线上“看起来还能打开”的那些页面,其实是缓存命中
- 当前正在被发布的新版本源站,实际上根本没有完整站点内容
这一步非常关键。如果没有做 no-cache 验证,很容易在错误的方向上继续修半天。
根因定位:Cloudflare 实际发布的目录错了#
继续核对构建结构后,答案就很清楚了。
Astro 在当前 Cloudflare 适配配置下,构建产物并不是直接扔在 dist/ 根目录,而是拆成了:
dist/client
dist/servertext其中真正的静态站点内容都在:
dist/clienttext包括:
- 首页 HTML
- 博客文章 HTML
- RSS
- Sitemap
- 静态资源
但线上现象表明,Cloudflare Pages 仍然在按旧配置发布 dist,而不是 dist/client。
这就解释了为什么:
- 本地构建是成功的
dist/client内有完整内容- 线上源站却是空的
因为它根本没发布对目录。
为什么不是直接去改 Cloudflare 控制台?#
理论上最直接的办法,是去 Cloudflare Pages 后台把构建输出目录改成:
dist/clienttext但这个方案有两个现实问题:
- 我当时是在本地和 Git 仓库里排障,没有直接接管 Pages 控制台配置
- 这类“平台外配置”如果不落进仓库,下次再迁移、再重建项目时还会踩一遍
所以我最终采用了一个更稳、更仓库内闭环的修法:
既然线上还在错误地发布 dist,那我就在构建结束后,把 dist/client 的内容同步镜像到 dist 根目录。
这样即使 Pages 继续发布 dist,它拿到的也会是完整站点。
最终修复方案#
最终落地的修复分成两层。
1. 新路由改为目录式输出#
为了让 /blog/post 这种路径在 Cloudflare 上表现更稳定,我把构建格式改成了目录式:
build: {
format: 'directory'
}ts这样文章产物会变成:
dist/client/blog/git-proxy-tutorial/index.htmltext而不是单文件:
dist/client/blog/git-proxy-tutorial.htmltext2. 为旧 .html 链接生成兼容别名#
旧文章链接已经被搜索引擎、RSS 阅读器和外部分享收录过,不能直接丢掉。
所以我又加了一层构建后处理逻辑,把目录式文章页面再复制出一个 .html 版本。例如:
dist/client/blog/git-proxy-tutorial/index.html
-> dist/client/blog/git-proxy-tutorial.htmltext这样历史链接就不会继续 404。
3. 把 dist/client 镜像到 dist#
最后,也是这次真正把线上救回来的关键步骤:
在 astro:build:done 钩子里,把 dist/client 下的内容同步到 dist 根目录。
核心逻辑大致如下:
const clientDir = fileURLToPath(dir)
const distDir = dirname(clientDir)
const entries = await readdir(clientDir, { withFileTypes: true })
await Promise.all(
entries.map(async (entry) => {
const source = join(clientDir, entry.name)
const destination = join(distDir, entry.name)
if (entry.isDirectory()) {
await cp(source, destination, { recursive: true, force: true })
} else {
await copyFile(source, destination)
}
})
)ts这样构建结束后,dist 根目录里就会直接出现:
dist/index.html
dist/rss.xml
dist/sitemap.xml
dist/blog/git-proxy-tutorial/index.html
dist/blog/git-proxy-tutorial.htmltext也就是说:
- 如果 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/nullbash2. 给 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
也就是说,这次问题最终被完整修复:
- 首页恢复
- 文章详情页恢复
- RSS 与 Sitemap 恢复
- 旧
.html链接恢复兼容
一些总结#
这次故障给我的几个直接教训是:
- 本地构建成功不等于线上部署成功。尤其在接入平台适配器后,必须搞清楚最终发布目录到底是谁。
- Cloudflare 缓存会制造错觉。看到
200不代表源站真的正常,关键是要用no-cache重新验证。 - 优先让修复进入仓库,而不是停留在控制台。控制台改配置解决的是当下,仓库内的构建兼容解决的是以后。
- 兼容旧链接值得认真处理。博客不是后台系统,历史 URL 很可能已经被收录、被引用、被分享,不能想当然地直接废弃。
如果你也在用 Astro + Cloudflare Pages,并且在升级后遇到了“本地正常、线上文章页 404”的诡异情况,可以优先检查两件事:
- 你的真实站点产物是不是在
dist/client - 你线上发布的到底是
dist,还是dist/client
很多时候,答案就藏在这里。*** End Patch EOF