next/font/google 会在构建时从 fonts.gstatic.com 下载 woff2 文件。当 Google 轮换了某个文件的哈希之后,我的部署开始因为 404 而失败,而我在本地根本复现不出来——因为同一个请求在早几秒的时候已经在我机器上成功过了。修复方式:把字体文件放进仓库,改用 next/font/local,让这些字节成为你自己掌握的输入。
症状
一次部署连续失败了两次。应用里什么都没改——同一个 commit,同一份 lockfile,同一个 Node。
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'最后那一行出现了 21 次。404 来自 fonts.gstatic.com。
我当时的猜测
我以为是自己把部署容器搞坏了。依赖解析错了、某个 Docker 层有问题、或者有代理吞掉了请求。因为同样的命令在我机器上——next build,Next.js 16.2.10——构建完全正常。两次。全绿。
这就是陷阱。“本地能构建”感觉像是容器坏了的证据。它什么都证明不了。
为什么这个判断是错的
next/font/google 不是运行时的 CDN 链接,它是一个构建时的下载器。Next 自己的文档就随包发布在 node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13,写得明明白白:
你也可以方便地使用所有 Google Fonts。CSS 和字体文件会在构建时下载,并与你其余的静态资源一起自托管。浏览器不会向 Google 发出任何请求。
浏览器不发请求。发请求的是你的构建。而每一个请求,都是一次让构建因为和你代码毫无关系的原因而失败的机会。
真正的机制是这样的,来自随包发布在 node_modules/next/dist/compiled/@next/font/dist/google/ 的 loader:
get-google-fonts-url.js根据你的配置项拼出一个css2?family=...URL。fetch-css-from-google-fonts.js通过fetch-resource.jsGET 这段 CSS,后者硬编码了一个 Chrome 104 的 user agent,好让 Google 返回 woff2 而不是 ttf。find-font-files-in-css.js从中抽取src: url(...)条目——带完整哈希、带版本号的fonts.gstatic.com路径。fetch-font-file.js把每一个都下载下来,构建再把它们输出到.next/static/media。
第 3 步就是密闭性死掉的地方。那些 URL 不是你能控制的稳定输入,它们就是你构建运行的那一刻 Google 放在 CSS 响应里的东西。
而 fetch-resource.js 对非 200 只有一种处理策略:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}它被包在 retry(fn, 3) 里,也就是 async-retry,所以是首次尝试再加三次:对着一个必然 404 的 URL 发四次请求,404 四次。
轮换窗口
失败的那个请求要的是 .../inter/v20/UcCB3Fwr...woff2。而当我自己去请求同一个字体家族的 css2 端点时,Google 返回的是 .../inter/v20/UcCO3Fwr...woff2。
UcCB 对 UcCO。差一个字符。哈希在半路被轮换了:我手上的那份 CSS 指向的是一个已经不存在的文件路径。
那它为什么在我机器上构建成功了?
我的第一个假设是缓存过期:我的 .next 是轮换之前留下的,所以本地构建是在从磁盘上重放一个好的响应,而容器打的是线上的端点。显而易见,干净利落,而且——在这个项目里——是错的。
这个 loader 的缓存是 loader.js 顶部创建的两个内存里的 Map(cssCache、fontCache)。它们存在的意义只是避免客户端和服务端两个编译器把同一个 URL 抓两遍,进程一结束它们就没了。Turbopack 的磁盘缓存倒是能跨进程存活,但 experimental.turbopackFileSystemCacheForBuild 在 Next 16 里需要显式开启,默认是关的(node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204),而这个项目没设置它。.next/cache 在这里实际装的东西是:.tsbuildinfo、两个很小的 info 文件,以及正好一条 fetch-cache 记录——191,476 字节,content-type: application/octet-stream,wasm 的魔数,还有一个值为 data:application/octet-stream;base64,… 的 url 字段。一个 data URI。不是字体,甚至根本不是一次网络调用。我磁盘上压根没有可以重放的 Google 响应缓存。
所以两台机器其实都真的去问了 Google。它们问的是不同的那一秒,各自最多问四次,而宿主机这边的尝试刚好撞上了一个正常的响应。
我确实把 .next 改名后重新构建过——宿主机也复现了失败,当时感觉像是铁证。并不是:那个坏窗口还开着,同样一次干净的构建在一小时后就通过了。反正还是用改名代替删除吧,又不费什么事。但一次复现并不能把机制卖给你;在你怪罪某个缓存之前,先确认它真的存在。
更难缠的部分:同一个故障,两种严重程度
在宿主机上,404 打印出来了,next build 依然以 0 退出。在容器里,同样的 404 变成了 Turbopack 硬邦邦的模块解析错误,构建直接挂掉。
退出码为 0 的那种情况是 retry.js 在说话,而不是某种被容忍的失败。每次重试之前都会先把错误打出来:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}也就是说,一份满是字体 404 的日志,可能意味着“第 3 次尝试恢复了”,也可能意味着“第 4 次尝试挂了”,而两种情况下的日志行长得一模一样。看退出码,别看那些吓人的文字。
在生产构建里,这个 loader 不会做的事情就是悄悄降级——它的 catch 会按环境分支,只有 dev 才拿得到一个 fallback 字体:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}这个分支才是本地开发真正的陷阱。在 next dev 里,同一个失效的 URL 会给你一个带 override metrics 的 local(...) 系统字体,所以那个能干掉你部署的故障,在你机器上只表现为排版稍微有点不对劲。
Next.js 16 把 Turbopack 变成了 next build 的默认打包器(node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116),而在 Turbopack 这条路径上,抛出的错误会变成无法解析的 @vercel/turbopack-next/internal/font/google/font 模块——我的日志里有 21 个。
诊断步骤
这套流程你拿去用,它让我不用再靠猜:
- 用
curl -I请求错误里那个确切的 woff2 URL。404 → 是文件没了,不是你的网络。 - 用现代 Chrome 的 user agent 去
curl同一个字体家族的css2?family=...端点。返回 200,而且src: url(...)里的哈希不一样 → 确认是轮换,不是服务中断。 mv .next .next-bad然后在本地重新构建。这里失败就是一次实时复现——但在你把账算到缓存头上之前,先确认当时真的有缓存:Turbopack 的构建缓存需要显式开启,所以过期的.next通常是无辜的。- 同样这次干净构建,也在宿主机上跑一遍,跑两次,中间隔开一段时间。这一步是大家最容易跳过的,也正是它能区分“文件没了”和“文件消失了十分钟”。
一小时后,一次干净的构建通过了,零个 404。这就是这个 bug 的形状:它是一个窗口,不是一种状态——这也正是为什么,不管在哪台机器上,一次绿色构建都不算答案。
修复方式,以及它真实的代价
把 woff2 文件放进仓库,改用 next/font/local。这样字体的字节就是你自己掌握的输入,进了 git,而你的构建为了排版发出的网络调用是零。
代价是实打实的,我也不打算装作没有:
- 更新归你负责。 Google 重新发布某个字体家族时,不再有免费的升级。这正是重点所在——但现在这是你的活儿了。
- 你必须做子集化——而且你现在发出去的东西大概率比你以为的多。
subsets: ["latin"]并不会过滤下载的内容。它根本就没进到css2URL 里;loader.js是把它当作subsetsToPreload传给findFontFilesInCss的,所以它只决定哪些文件拿到 preload 提示。Google 响应里的每一个@font-face都会被下载并输出。Launch 在全部 8 个字体家族上都声明了subsets: ["latin"],照样输出了 35 个 woff2 文件,其中只有 9 个在文件名里带着.p这个 preload 标记。所以把字体放进仓库,其实是一个真正去裁剪字符集的机会(fonttools 里的pyftsubset)——但如果你只是把 woff2 文件直接从.next/static/media里拷出来,那你在体积上什么都没改变。 - 仓库体积。 这部分对我来说扩展性最差。在我卖的五套模板里,loader 一共声明了 51 个 Google 字体家族:
folio-template/src/app/layout.tsx12 个,lens-template/src/app/fonts.ts6 个,levain-template/src/app/fonts.ts8 个,launch-template/src/lib/fonts.ts8 个,booking-template/src/lib/fonts.ts17 个。一旦把字重、字形和 subsets 组合起来,这些数字会急剧膨胀:launch 模板的一次干净构建会往.next/static/media输出 35 个 woff2 文件,booking 则输出 92 个(ls .next/static/media | grep -c woff2)。
所以诚实的版本是:把你真正会渲染的字体放进仓库,其余的删掉。一个提供 17 种字体家族的主题切换器,就是 17 个构建时的 HTTP 依赖,其中任何一个都可能在某个周二用 404 干掉你的部署。
这次失败的构建是 Launch 的——在线 demo 在 https://launch.violettadev.com。
下次怎么抓到它
- 本地全绿不是信号。 当同一个 commit 在你机器上通过、在 CI 上失败时,默认的解释应该是你在两个不同的时刻采样了网络——而不是某台机器坏了。在你去找原因之前,先把失败的那次构建重跑一遍。
- 即使退出码是 0,也去构建日志里 grep 一下字体的 404。 它们意味着这次是重试赢了。同样的请求,同样的脆弱,离一次红色部署只差一次尝试。
- 把每一次构建时的 fetch 都当成依赖。 字体、wasm 二进制块、远程 schema、构建时拉取的 CMS 内容。把你的构建会发出哪些网络调用写下来。大多数人回答不出关于自己项目的这个问题,在我的构建用 404 的音量告诉我之前,我也答不出来。