3.4 常见构建失败与排查

第三篇最难写的不是哪一节配置,而是这一节。上线前两周,构建失败的红色邮件像闹钟一样准时:周一是依赖装不上,周三是 Node 版本不对,周五内存溢出。每修一次都要花半小时翻日志定位。这节把我们踩过的六类构建失败整理成一张排查表,外加两个真实案例,遇到同样问题至少能少走半小时弯路。后续第四篇的云函数和第五篇的 CI 流水线,都会复用这里沉淀的排查套路。

3.4.1 排查方法论:固定三步

无论报多离谱的错,我们的排查顺序固定三步:

第一步,看日志最后 50 行。构建日志动辄上万行,错误堆栈一定在末尾,从头读只会被 npm 的进度条淹没。

第二步,本地复现。CI 环境跑的是 npm ci 加 npm run build,本地用完全相同的命令跑一遍。能复现的 bug 都好修,复现不了的才是真麻烦。复现环境要尽量对齐 CI:同样的 Node 版本、同样干净的目录(删掉 node_modules 重装)、同样的环境变量。我们为此写了一个 local-verify.sh,一键清环境重建并跑构建,比敲一串命令省事。

第三步,二分定位。注释掉一半页面、回退最近一次提交,直到错误消失,边界就是问题点。二分听着笨,但在复现不了的玄学故障面前,它比任何技巧都可靠。

常见的六类失败,症状、原因、解法一张表:

症状 直接原因 解法
npm 报 404 找不到包 包名拼错或版本不存在 核对 package.json 版本号
npm ci 报 lockfile 不一致 lockfile 与 package.json 不同步 本地 npm install 后一起提交
heap out of memory 构建吃内存超容器上限 加 --max-old-space-size
突然找不到某个组件 文件名大小写不统一 统一小写中划线命名
构建成功但图片 404 引用路径与产物不一致 用相对路径并校验产物存在
构建越来越慢直到超时 依赖与缓存日积月累 CI 定期清理缓存重建

第三行内存那条,是我们加了一个大型 JSON 数据页之后出现的。本地永远不触发——开发机有 16G 内存随便用,CI 容器只给 2G。构建命令从 npm run build 改成带环境变量的形式:

NODE_OPTIONS=--max-old-space-size=4096 npm run build

改完当周构建时间从 90 秒涨到 120 秒,但再没崩过。这个参数后来写进了 2.2 构建脚本 的 npm scripts 里,线上本地共用一套。

配套的还有一套快速定位命令,打印出来贴在工位上的:

现象 先跑什么
依赖类报错 npm ls 看树,再 npm ci 验锁
模块找不到 node -v 与 .nvmrc 对比版本
编译错误 tsc --noEmit 只跑类型检查
打包后白屏 本地 npx serve dist 预览产物
CI 与本地不一致 删除 node_modules 与锁文件全量重建

这张表本身就是从一次次事故里抠出来的,每一条背后都是一次上线延误。

3.4.2 案例一:npm ci 的幽灵失败

某天 CI 突然挂在 npm ci,报 lockfile does not match package.json。但查 git log,package.json 三天没人动过。最后定位:同事周五 npm install 装了个新包,package.json 改了,但编辑器保存时的自动格式化把它又改回了旧格式,lockfile 是新的、manifest 是旧的,npm ci 一比对就崩。

修复本身只是重新 npm install 并提交,真正学到的是流程:依赖变更必须单独一个 commit,review 时一眼能看清 lockfile 与 manifest 同时变化。这条规矩后来写进了 2.1 仓库搭建 的约定里——所有与依赖有关的改动,提交描述必须贴上 npm ls 的关键输出。另外我们还给 CI 加了一个自动校验:npm install 与 lockfile 的哈希不一致就拒绝通过,把人工 review 会漏的环节交给机器。依赖问题是构建失败的头号杀手,这个检查上线后,这类故障再没出现过。

3.4.3 案例二:sharp 安装失败

2.4 节的图片压缩用 sharp,它在 CI 上报了一个奇怪的错:预编译二进制下载失败。本地完全正常,CI 连续挂了三次。

sharp 安装时会下载预编译的 libvops 二进制包,CI 的网络策略拦了这个下载域名。两种常规解法:配置 npm 走内部镜像,或者把预编译文件提交进仓库自己托管。我们选了更简单的第三种——给安装过程加上可见日志,并把压缩步骤做成失败可降级而不是整单崩:

npm install --foreground-scripts

加 --foreground-scripts 只是为了让 sharp 的安装日志可见,出问题时第一眼看到是不是二进制下载失败,而不是一个毫无上下文的 exit code 1。图片压缩脚本本身加了 try-catch,压缩失败时记录日志、继续构建,大不了这次发布的图是原图——这是 2.4 图片处理 留的兜底设计。顺带一提,这类平台内置二进制的依赖在切换 CI 环境时也要复核:我们从 x86 容器换到 ARM 构建机时,sharp 又报了一次架构不匹配,预编译文件得分架构下载,脚本里加了 uname -m 判断才消停。

3.4.4 案例三:Node 版本对不上

开头说过周三那次是 Node 版本不对,展开讲讲。同事本地用 Node 20,CI 镜像还停在 Node 16,一段用了 fetch 的代码本地跑得欢,CI 直接报 undefined is not a function。报错信息里没有半个字提到版本,围着他转了二十分钟才想起 node -v 这回事。

根因是版本没锁。修法两步:仓库根目录加 .nvmrc 写死 20,CI 配置读这个文件动态选镜像,本地 nvm use 自动切。再升级一步,package.json 里写 engines 字段:

"engines": { "node": ">=20.0.0" }

配合 npm 的 engine-strict,版本不够直接拒装,想将错就错都难。这类问题教会我们一件事:构建环境的每一项版本都是代码,都得进仓库跟着 git 走,任何依赖个人电脑状态的环境最终都会在 CI 上翻车。

3.4.5 上线前预检清单

坑修完后,排查经验固化成 scripts/precheck.ts,部署前必跑,把能在本地发现的问题拦在本地:

  • 构建:npm run build 通过,dist 非空且包含 index.html;
  • 资源:dist 内图片文件数与 src/assets 一致;
  • 协议:产物内无 http:// 绝对引用,即 3.3 SSL 证书 的混合内容检查;
  • 体积:单页首屏 JS 超过 250KB 告警;
  • 依赖:npm ci --dry-run 与 lockfile 校验一致。

预检脚本配合 5.1 CI/CD 自动部署,push 到 main 时自动执行,任何一项失败就终止发布。本地构建与线上构建的差异,靠这份清单基本消掉。预检清单不是一次定稿的,每出一次新故障就加一条检查项,运行半年后基本稳定——清单本身也在跟着项目长大。

3.4.5 日志与事后复盘

构建平台的日志默认只保留一段时间,出事不存证,事后复盘就是空谈。我们做了两件事。一是在构建脚本里把关键步骤耗时打成摘要:依赖安装多少秒、编译多少秒、压缩多少秒、上传多少秒,每次部署打到群里。哪一步突然变慢,看摘要就知道,不用等用户投诉。二是失败日志单独归档到我们自己的 COS 桶,命名带日期和 commit 哈希,三个月前的失败现场也随时能调出来。存储桶的开通与上传脚本见 3.1 存储桶开通,归档目录直接复用了那套 sync 流程。

复盘的规矩是:每级严重故障(影响了上线时间的)48 小时内写一条记录进 docs/,格式固定为现象、根因、修复、预防四段。事故不可怕,可怕的是同一个事故发生第二次——四段式记录就是奔着预防那一段去的。

小结

构建故障看上去五花八门,根因就那么几类:依赖、环境、资源、内存。记住固定三步:看日志尾部、本地复现、二分定位;日常把 npm ci、预检脚本、命名规范养成习惯,故障率会肉眼可见地下降。第三篇到这里收尾——桶开通了、域名解析了、证书挂上了、构建排查清单有了,下一章进入 4.1 CDN 接入,讲缓存策略与刷新,把这站真正跑快。

延伸阅读