登录坏了两天,却没有任何日志记下
本文译自 2026-09-24 的英文原文。
2026-09-14,创始人想登录,看到的是「Google 登录未能启动」。邮件链接也不行,提示的是同一句话。我们几分钟内就在浏览器里复现了。在那之前没有人报告过,我们这边也没有任何东西发出警报,可它从两天前的一次部署起就已经坏了。
浏览器里跑的是什么
登录页会加载一个小小的 JavaScript chunk,它负责创建两种登录方式都要用的 Supabase 客户端。线上的这个 chunk 长 429 字节。还原压缩之后,它的全部内容是:
function t(){throw Error("PUBLIC_SUPABASE_URL / PUBLIC_SUPABASE_ANON_KEY are not set …")}
没有项目 URL,没有密钥,只有一个抛错的函数。构建时,import.meta.env.PUBLIC_SUPABASE_URL 和它的同伴都是空的,所以客户端工厂开头的那个检查永远为真,压缩器也就理所当然地把整个模块折叠成了一次抛错。浏览器端每一个需要这个客户端的调用,无论是 Google OAuth 还是邮件一次性链接,都在同一个第一步失败,这就是为什么两种毫不相干的登录方式会报出同一条错误。
为什么变量是空的
从 2026-09-10 起,网站一直从干净检出部署:用 git worktree 为要发布的那个 commit 新建一份检出,目的正是不让仓库之外的任何东西混进构建。这条规则是对的。但它也意味着这份检出里没有 apps/web/.env.local,而那两个变量原本就放在以前负责部署的那台机器上的这个文件里。部署命令是 astro build && wrangler deploy,在干净的目录树里,astro build 找不到任何变量。
有两件事把它掩盖了。我们的 CI 有意在零环境变量下构建网站,因为任何预渲染的路由都不允许在构建时碰数据库,所以 CI 是绿的:它构建出的正是同一个坏掉的包,只是从不部署。另外,Worker 的运行时配置里确实有一个 PUBLIC_SUPABASE_URL,看上去似乎能管到这里;但构建时烘焙进静态包的值,没法在运行时补上。客户端工厂旁边的注释早就写明了这一点。
9 月 12 日和 13 日的两次部署就是这样发出去的。两次都报告成功,因为它们确实成功了。
为什么没有任何日志记下
故障完全发生在访客的浏览器里,在任何请求离开浏览器之前。服务器从没看到过一次登录尝试,所以服务器日志无话可说。网站当时还没有浏览器端的错误上报。页面本身返回 200,所以任何可用性检查都不会注意到。这是一个功能的彻底中断,配着一套无可挑剔的指标。
修复,分两部分
9 月 14 日的热修复,重新构建了当时线上的同一个 commit,仍然从干净检出构建,只是把这两个变量导出到了构建所在的 shell 里。部署之前,我们确认构建出的 chunk 包含项目的 Supabase URL,也不包含那个「are not set」的抛错。新的 chunk 是 208,403 字节。登录恢复了。
长期的修复放进了部署脚本。现在 astro build 和 wrangler deploy 之间有一道关卡:它打开构建出的 Supabase chunk,只有其中包含项目 URL、且不含上面那种只有一次抛错的形状,才允许继续。变量为空的构建根本到不了 wrangler deploy。这两个变量本身是公开的 URL 和公开的匿名密钥,都不是机密,所以把它们挡在构建之外从来就不是什么保密措施,只是那个文件放在哪里造成的偶然。
我们还把这条规则写在了描述部署的地方:要部署的网站构建,必须注入这两个变量,而这道关卡就是证明。
我们没有做的事
我们没有放松干净检出的规则。检出仍然只从 commit 构建,没有别的东西;这两个值从执行部署的 shell 环境里传进来,关卡检查的是结果,而不是信任过程。
我们也没有加一个按计划用真实账号登录的合成登录检查。这道关卡在成本最低的时刻、也就是部署之前,抓住的正是我们实际遇到的这一类故障:一个连客户端都构造不出来的包。如果登录因为包里看不出来的原因坏掉,仍然需要有人去试一试才知道。
事实从哪里来
429 字节的 chunk 及其内容,是 2026-09-14 热修复之前从线上网站抓取的;208,403 字节的替换版本是热修复之后抓取的。受影响的两次部署,在我们的部署记录里以 Cloudflare 版本标识和它们的构建 commit 标明。这道关卡是仓库里 scripts 层级的代码,2026-09-14 以来的每一次网站部署都会执行它。