Notes

不会报错的错误

那天本来只想做两件小事:去掉一个用不上的定时任务,把照片挪到对象存储上。

结果这一天里抓到 6 个真实缺陷,其中 4 个是既有代码里潜伏已久的,2 个是我自己在这一天里新引入的。它们有一个共同点让我印象很深:没有一个会正常报错。

这篇是完整的复盘。我尽量把「当时怎么想的、错在哪、后来怎么发现的」都写清楚,因为比起结论,这些过程更值得留下来。

一、两个目标,和一次自我修正

第一件事很简单:本机的 launchd 定时备份任务,机器不常开,它保证不了备份新鲜度,决定去掉。

第二件事是把 24MB 的发布图从自己的服务器挪到阿里云 OSS。动机不是性能问题,而是想让图片流量从 ECS 上分出去,顺便学习一下对象存储的接入方式。

做的过程中我提出了一个「更优雅」的方案:不要把 OSS 域名写进各个生成器,而是让生成器照旧产出相对路径,最后加一步后处理统一改写。 理由是规则集中在一处,切换存储位置只改一个配置。

这个想法听起来对,实际是错的,而且错得很隐蔽 —— 它把整个部署闸门卡死了。下面第五节会详细说。

我需要一个对照来说明这一天的性质:我提出的方案里,有一个被自己推翻了;我做出的判断里,有两次是错的。 把它们写出来比只列成功更有价值。

二、先说 4 个既有的缺陷

这 4 个都在仓库里躺了不止一天,没有一个是你用页面时会立刻看到症状的。

1. bash 3.2 的多字节解析 bug

现象是一行报错:

tools/backup-install.sh: line 30: LOG�: unbound variable

变量名里冒出一个替换字符。但奇怪的地方在于:这个文件是完全合法的 UTF-8 —— 用 python3 和 od 逐字节检查过,$LOG 就在最该在的位置上,编码没有任何问题。那为什么 bash 说它「未定义」?

用缩减法定位:把那一行单独拎出来跑,仍然失败;把前面的中文去掉,仍然失败;把行首空格去掉,仍然失败。最后缩到极简,才看出规律 ——

bash 3.2.57(macOS 自带的那版)在 UTF-8 locale 下,当一个非 ASCII 字符紧邻一个裸 $变量 时,会错误地计算字节边界,把 $ 吃掉。 于是 $LOG 被解析成一个不存在的变量 LOG�。

关键在于这个描述里的「字节边界」:它不是「中文加变量就出错」。tools/backup.sh 里有 5 行同样形态的写法,全部正常。触发与否取决于具体的字节位置。

所以「按模式批量替换」这种做法在这里是无效的 —— 唯一可靠的办法是逐行真跑一遍。我写了个小验证器,把 8 个脚本里所有「含非 ASCII 且含裸变量引用」的行(312 行)单独在真实 locale 下执行,最后确认只有 4 行会触发:

文件后果
analytics-install.sh:53该脚本整体经 bash -s 送到服务器执行,此行会中断服务器端的安装
healthcheck.sh:45站点非 200 时,健康检查自己崩掉,报错信息丢失
healthcheck.sh:58证书读不出时同上
backup-install.sh:30--remove / --run 的提示崩溃、退出码 1(动作本身已完成)

修法是加花括号 $LOG → ${LOG}。这里最值得记的一点是:「C locale 下通过、UTF-8 下失败」这个对照实验,把死胡同变成了一分钟的结论。 在那之前我一直以为是文件编码或工具链的问题。

2. 部署成功,退出码却是 1

deploy.sh 每次跑完都 exit 1,但输出里明明是「✅ 部署完成」,线上也确实更新了。

根因在一段看起来无害的清理函数:

cleanup() {
  rm -f "$TARBALL"
  [[ -n "${STAGE:-}" ]] && rm -rf "$STAGE"
  [[ -n "${ASKPASS:-}" ]] && rm -f "$ASKPASS"     # ← 元凶
}
trap cleanup EXIT

没设 SSH_PASS 时 ASKPASS 为空,最后那条 [[ … ]] && … 的退出码是 1。它是函数的最后一条命令,于是函数的退出码就是 1,而 EXIT trap 又用这个值覆盖了脚本本身的退出码。

这类问题不影响功能,但让自动化无法判断成败 —— 一个「永远报告失败」的部署脚本,等于没有部署状态。加一行显式 return 0 即可。

3. 对象存储签名:给每个子资源都加了 ?

自己实现 OSS 签名时,签名的资源串应该长这样:

/bucket/?list-type=2&max-keys=3

我写成了:

/bucket/?list-type=2&?max-keys=3

每个子资源前都加了 ?。服务端算出来的签名必然不匹配。定位的方法是看服务端回显的 StringToSign —— 它告诉我服务端实际用的是什么,和我的拼法一比就出来了。

4. ETag 大小写导致增量同步全部重传

上传工具靠「远端 ETag == 本地文件 MD5」判断是否需要传输。第一次跑得很好,第二次却把 175 个文件全部重传 —— 明明一个字节都没改。

原因:ETag 是十六进制大写,Node 算出来的 MD5 是小写。 字符串比对自然全部不等。

修掉之后第二次运行变成了「175 个全部跳过」。这个 bug 本身很浅,但它说明一件事:增量逻辑必须真的验证第二次运行。 只跑一次的话,它会一直表现得完全正常。

三、我自己引入的缺陷

5. 指纹可能与对象失配

图片迁到 OSS 后,我发现部署到线上的图片 URL 上带着 ?v=xxxxxxxx 缓存指纹。这看起来很合理,但来源不对:

tools/version-assets.mjs 是在 staging 目录里「按路径查本地文件」算哈希的。图片迁到 OSS 之后,staging 里那份 images/ 已经没有任何页面引用了 —— 它给出的指纹,取自一个与线上对象无关的文件。

只要两者内容不同步,浏览器就会拿着一个错误的 ?v= 一直用旧图,而页面看起来完全正常。

修法是把指纹提前到生成 URL 的那一刻,按同一个本地文件算 sha256 前 8 位写进 URL。这样 URL 与内容必然对得上,不再依赖「某个目录里恰好还有一份副本」。

修的过程中我自己又踩了两个坑,都值得一提:

  • 正则末尾没有把「文件路径」匹配进来,导致回调拿不到文件名 —— 指纹静默地一个都没加上,没有任何报错。
  • 定位路径时我用了 '/images/' 去找,而相对路径的匹配串是 'images/'(没有前导斜杠),lastIndexOf 返回 -1,于是整段路径被丢掉、只剩下域名 —— 页面全坏,但构建过程一声不吭。

6. 前缀后处理把部署闸门永久卡死

这是这一天最重要的一个,因为它推翻的是我的设计,不是我的代码。

一开始我做成了「生成器照旧产出相对路径 → 最后加一步后处理统一改写」。这个设计的致命处在于它与部署闸门 update.mjs --check 的语义直接冲突:

  • 每个生成器在 --check 时会重新生成一遍期望值(相对路径),和磁盘上的产物比对;
  • 而磁盘上的产物已经被后处理步骤改成了绝对 URL;
  • 于是每一张照片、每一个页面都被判定为「与源不一致」。

deploy.sh 部署前会跑这个闸门,结果是永久拒绝部署。而我第一次遇到它时的第一反应是「闸门误报了」—— 其实它没有误报,它忠实地反映了一个事实:「源」和「产物」之间隔着一个校验看不到的步骤。

正确的做法是让前缀在生成 URL 的那一刻就带上。这样生成器的期望值本身就含前缀,比对两边口径一致,闸门恢复可用。

付出的代价是:前缀逻辑要出现在每个产 URL 的地方,而不是集中一处。我接受了这个代价 —— 一条能真正工作的闸门,比一处漂亮的集中配置更重要。

顺带一提,base=local(默认)时整个前缀步骤是彻底的空操作,产物与「没有 OSS 这件事」逐字节相同。切换存储位置仍然只改一个配置字段,只是改完必须重新构建 —— 这本来就是应该的。

四、从这 6 个里能提炼出什么

原则 1:先怀疑「位置」,再怀疑「逻辑」。 第 6 个缺陷里,代码本身完全正确,错的是它跑的时机。同一个函数,放在生成前是对的,放在生成后就是错的。

原则 2:不报错的错误最贵。 第 5 个缺陷(指纹错、页面正常)和第 4 个(增量失效、结果正确)都需要你专门去验证那个机制本身才会暴露。功能测试不会发现它们。

原则 3:闸门如果不运行,就等于没有闸门。 我写的一个工具里有个「直接执行才动手」的守卫,写法是拿 import.meta.url 和 process.argv[1] 比。但前者是绝对路径、后者是相对路径,永远不相等 —— 工具一次都没有真正执行过,而它退出码是 0、输出也正常。我是靠一个临时调试开关才发现的。

原则 4:测试必须能证伪。 同一天里我遇到过两次「假绿」:一次是测试脚本自己写错,让所有用例都「通过」;一次是工具压根没跑。一个从不失败的测试,提供的信息量是零。 现在我会给每个验证都配一个已知会失败的对照组。

原则 5:验证要深入到真实用户看到的那一层。 我一度满足于「产物里的图片 URL 都能取到」。但那只是证明我自己写的东西自洽。真正有说服力的做法是:从线上返回的 HTML 和 JS 里抽出 URL,逐个请求,再用无头浏览器实际渲染一遍,确认图片真的画出来了 —— 这是唯一能同时覆盖 CSP、TLS 和资源存在的检查。

原则 6:每个改动都要能一步退回。 图片迁移保留了 local / oss 一个开关;前缀方案切换后产物仍可逐字节还原;DNS 和 CSP 的改动都先备份了原文件。回滚能力决定了你敢不敢往前试。

五、这一天真正用到的方法

按有效性排序:

  1. 对照实验。 「同一输入换一个变量,结果是否不同」—— LC_ALL=C 与 zh_CN.UTF-8 的对比,直接判定了一个模糊报错的性质。
  2. 最小复现。 把问题缩到 4 行,才知道它是 bash 而不是文件编码。缩小范围的时间,永远比猜测省。
  3. 先量后改。 扫描全部 312 行候选、逐行实测,而不是按模式批量替换 —— 因为这类 bug 依字节位置触发,模式匹配会误判。
  4. 端到端验证。 从线上产物抽 URL、用真实浏览器渲染。
  5. 留退路。 备份、开关、可还原。

而最难的一步,其实是承认自己刚才的结论是错的。这一天里我有两次断言被自己推翻:一次是「阈值写错了」(其实是默认值),一次是「测试全绿」(其实是测试没跑)。两次都是靠再做一次更严格的验证发现的 —— 没有一次是靠重新读一遍代码发现的。

六、尾声:关于「不知道」

这天结束时,我回头看那两个最初的目标,发现真正花时间的不是「把照片放到 OSS 上」——那部分只占很小一段。时间几乎全花在发现并拆除那些不会出声的陷阱上。

这也让我对「把经验写下来」这件事更认真了一点。这些陷阱的共性不是难,而是它们在你不去看的时候,表现得和正常一模一样。所以它们的价值不在于「知道了就能写对」,而在于「知道了才会想到去看」。

至于那些我至今仍然不知道的:它们多半也在某个我认为「已经验证过了」的地方安静地待着。这份清单大概永远不会是完整的 —— 但每多一条,下一次能看的地方就多一处。

Get in Touch

联系

这篇写完之后又踩了新的坑,或者你有别的看法 —— 欢迎写信来聊。