package-lock.json里看不见的坑:跨平台原生依赖为什么会消失
一、案例
突然某天某次服务器构建挂了:
#vite build
#14 40.36
#14 40.57 /source/node_modules/rollup/dist/native.js:121
#14 40.57 throw new Error(
#14 40.57 ^
#14 40.57
#14 40.57 Error: Cannot find module @rollup/rollup-linux-x64-gnu. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory.
#14 40.57 at requireWithFriendlyError (/source/node_modules/rollup/dist/native.js:121:9)
#14 40.57 at Object.<anonymous> (/source/node_modules/rollup/dist/native.js:130:76)
#14 40.57 ... 2 lines matching cause stack trace ...
#14 40.57 at Module.load (node:internal/modules/cjs/loader:1275:32)
#14 40.57 at Module._load (node:internal/modules/cjs/loader:1096:12)
#14 40.57 at cjsLoader (node:internal/modules/esm/translators:298:15)
#14 40.57 at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:240:7)
#14 40.57 at ModuleJob.run (node:internal/modules/esm/module_job:263:25)
#14 40.57 at async ModuleLoader.import (node:internal/modules/esm/loader:540:24) {
#14 40.57 [cause]: Error: /lib64/libm.so.6: version `GLIBC_2.29' not found (required by /source/node_modules/@rollup/rollup-linux-x64-gnu/rollup.linux-x64-gnu.node)
#14 40.57 at Module._extensions..node (node:internal/modules/cjs/loader:1651:18)
#14 40.57 at Module.load (node:internal/modules/cjs/loader:1275:32)
#14 40.57 at Module._load (node:internal/modules/cjs/loader:1096:12)
#14 40.57 at Module.require (node:internal/modules/cjs/loader:1298:19)
#14 40.57 at require (node:internal/modules/helpers:182:18)
#14 40.57 at requireWithFriendlyError (/source/node_modules/rollup/dist/native.js:103:10)
拧巴的地方:本地 Mac 没问题、之前服务器也一直没问题、全新 Docker 容器、npm install 阶段 added 1627 packages 全绿——直到 build 才炸。
二、背景:平台原生绑定 + optionalDependencies
现代前端工具链用 C++/Rust 加速,”主包 + 一堆平台原生包”是普遍套路:rollup、esbuild、swc、@next/swc、sharp、lightningcss、turbo、biome、prisma…
它们的共同做法都是:主包在 optionalDependencies 里列出 20+ 个平台包,每个平台包用 os / cpu 字段限定自己。npm 装的时候按当前平台只挑一个装上,其它合理跳过——这是设计得没毛病的。
问题不在设计本身,在 package-lock.json 怎么被生成。
三、真正的坑(npm/cli#4828)
当 node_modules 已存在时,如果 npm 要重新生成或更新 package-lock.json,它会以”当前 node_modules 实际装了什么”为准写 lock。其它平台的 optional 条目会在这一步被丢掉。
具体链条:
- Mac 上
npm install,node_modules/@rollup/里只装了darwin-arm64(正常) - 某种操作触发 lock 重新生成(下一节展开)
- npm 读现有
node_modules,把 lock 重写成”只含 darwin-arm64” - 这份看起来正常的 lock 被提交
- Linux CI
npm ci严格照 lock 装,@rollup/rollup-linux-x64-gnu根本不在 lock 里,跳过 vite build时 rollup 找不到原生绑定,报错
关键点:install 阶段全程 exit 0,added N packages 不会提示缺了原生包。
前提:这个 bug 只在 node_modules 已存在的前提下触发。真正的”没 lock + 没 node_modules + 全新容器”从零装,是能把所有平台的 optional 条目都写进新 lock 的。事故是通过被污染的 lock 传染到 CI 的,不是 CI 环境自己长出来的。
四、什么操作会触发 lock 重新生成
node_modules 存在 + 以下任一 = lock 可能被”清洗成单平台”:
npm install <新包>/npm update/npm uninstall—— 最常见。你只是想加个依赖,npm 顺手重算了整份 lock。lock 文件损坏 —— 最典型是提交了带 Git 合并冲突标记的 lock:
grep -c '<<<<<<< ' package-lock.json # 51npm 读不了坏 lock,静默降级到”重新生成”,此时如果
node_modules还在,就命中同一个坑。这也解释了本次案例的”突然某天挂”——某次 merge 后有人本地 install 了一下,把 lock 洗成 Mac-only 就提交了。npm install --no-package-lock—— 只保证当前命令不读写 lock,不会删磁盘上已有的坏 lock,也不删node_modules。下次任何一次正常 install 依然会踩。- npm 大版本升级 ——
lockfileVersion迁移会触发一次重算。
五、几个常见误区
误区 1:--no-package-lock / --force 能绕开
不行。它们只影响当前这条命令,不删磁盘上已经坏了的 lock、不删已经存在的 node_modules、更管不到 workspaces 子目录下的 nested lockfile(比如 dr-new/package-lock.json)。下一次 install 或 CI 依然可能从这些残留状态里读到错的东西。
误区 2:错误信息说”删 lock 和 node_modules 重装就好”
对,但两个都要删干净,monorepo 里 workspace 子目录的也要一起删。只删一半等于没修——留下的那一半会污染下次操作。
误区 3:CI 用 npm install 更”稳”
反了。npm install 会主动帮你”修正” lock,静默行为多、不受欢迎。CI 应该用 npm ci:lock 里有什么装什么,不改 lock,lock 非法立即报错。可预测才是 CI 想要的。
六、修复
一次性修好
# 所有 lock 和 node_modules 都删干净(monorepo 别漏了子目录)
rm -rf node_modules */node_modules
rm -f package-lock.json */package-lock.json
# 从零装
npm install
# 验证 lock 里包含目标平台
grep -c 'rollup-linux-x64-gnu' package-lock.json # ≥ 1
grep -c 'rollup-win32-x64-msvc' package-lock.json # ≥ 1
git add -A && git commit -m "fix: regenerate lock with all platform binaries"
CI 换 npm ci
RUN npm ci
兜底:显式补装
如果短期没法动 lock:
RUN npm ci || npm install
RUN npm install @rollup/rollup-linux-x64-gnu --no-save --force
指名安装不走 optional 逻辑,一定装上。缺点是每个用原生绑定的工具都得单独补。
防复发
.gitattributes 里给 lock 加合并策略,避免手改冲突把它弄坏:
package-lock.json merge=ours
(合并完再本地重新 npm install 校准,然后提交。)
pre-commit hook 拦一层:
if grep -qE '^(<<<<<<< |=======|>>>>>>> )' package-lock.json 2>/dev/null; then
echo "package-lock.json 里有未解决的冲突标记,拒绝提交"
exit 1
fi
七、教训
- 别在
node_modules存在时让 npm 重新生成 lock。要重装,两个一起删。 - CI 用
npm ci,不要用npm install。 - 看到
Cannot find module @xxx/xxx-linux-*这类报错,先怀疑 lock 里缺平台包。rollup / esbuild / swc / next-swc / sharp / lightningcss 都是同款失败模式。 - 别把带冲突标记的 lock 提交上去。它不是普通的坏文件,会诱导 npm 走”以
node_modules现状重生成”的路径,把所有平台包洗掉。 - monorepo 的 workspace 子目录 lockfile 也要一起管。删的时候别漏。
八、修复版本
Issue #4828 从 2022 年提出后经历过一次不彻底的修复(PR #5282,随 npm 8.x 发布),但在有 node_modules 时重新生成 lock 的场景下依然复现,因此长期没关闭。真正解决它的是 PR #8184 “arborist: omit failed optional dependencies from installed deps”,随 @npmcli/arborist@9.0.2 一起被 bundle 进 npm 11.3.0(2025-04-08) 发布。
也就是说:
- npm < 11.3.0:踩雷。老 CI/构建镜像(Node 20 / npm 10.x、Node 22 早期 / npm 10.9.x)都在射程内,需要靠上面的方案自保。
- npm ≥ 11.3.0:机制上已修好。但已经污染的 lock 不会被 npm 自动救回——旧 lock 里缺 Linux 平台包就是缺,
npm ci照样装不出来。所以要么升 npm 后重新生成一次 lock 提交,要么继续跟着上面的修复步骤走。
要用上修复,Node 24 及以上默认自带 npm 11;如果卡在 Node 22,可以 npm i -g npm@latest 显式升级。
参考
- npm/cli#4828 - Missing platform-specific optional dependencies
- PR #8184 - fix(arborist): omit failed optional dependencies from installed deps(真正的修复,落在
@npmcli/arborist@9.0.2→ npm 11.3.0)
更新
以上解法并没有解决问题,还是报错。
三、真正的根因:GLIBC 版本不匹配
关键在错误里的 [cause]: 那行(完整日志里被折叠、很容易看漏):
[cause]: Error: /lib64/libm.so.6: version `GLIBC_2.29' not found
(required by /source/node_modules/@rollup/rollup-linux-x64-gnu/rollup.linux-x64-gnu.node)
at Module._extensions..node (node:internal/modules/cjs/loader:1651:18)
三个证据可以证明这不是 optional 包漏装:
@rollup/rollup-linux-x64-gnu/rollup.linux-x64-gnu.node这个文件路径在报错里被打印出来了,说明包已经装上了、文件存在- 报错来自
Module._extensions..node—— Node 加载原生.node文件时的分支,能走到这里就已经过了”找不到模块”这一关 GLIBC_2.29 not found是动态链接器的报错,说明二进制运行时期望 GLIBC 2.29 的符号,但当前系统的 libm 太老没有
再让 CI 打一行 ldd --version:
ldd (GNU libc) 2.28
闭环:构建镜像 glibc 2.28,rollup 二进制要 GLIBC 2.29+,直接加载失败。
五、二进制符号对照:证据链闭环
从 npm registry 把 rollup 各版本官方发布的 Linux 二进制 tarball 拉下来,strings 抽 .node 文件里的 GLIBC_x.y 符号引用,看每个版本的最高要求:
| rollup 版本 | 发布时间 | 二进制要求的最高 GLIBC |
|---|---|---|
| 4.52.4 ~ 4.62.2(20+ 个版本) | 2025-10 ~ 2026-06 | GLIBC_2.14 |
| 4.62.3 | 2026-07-26 | GLIBC_2.34(同时引入 2.29、2.32) |
复现脚本(可自行验证):
mkdir /tmp/rollup-glibc && cd /tmp/rollup-glibc
for v in 4.52.4 4.55.1 4.58.0 4.60.0 4.62.0 4.62.1 4.62.2 4.62.3; do
curl -sL "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-${v}.tgz" -o r.tgz
rm -rf x && mkdir x && tar -xf r.tgz -C x
max=$(strings x/package/rollup.linux-x64-gnu.node | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -V | tail -1)
echo "${v} max-required=${max}"
done
结论:整整 9 个月、跨越 20 多个补丁版本,rollup Linux 二进制的 GLIBC 基线一直稳定在 2.14(老到 CentOS 6 都能跑),而在 4.62.2 → 4.62.3 一个补丁版本号里,编译基线直接跳到了 2.34。
六、为什么会突变,rollup 官方为什么不说
rollup CHANGELOG 里 4.62.3 只写了一条 bug fix(Sanitize illegal characters preserved modules input base #6439),关于编译环境一字未提。
真实原因:rollup 官方 GitHub Actions 里编译 Linux 二进制的 runner 从旧 ubuntu-* 镜像切换到了新版本。GitHub 早前已宣布逐步淘汰 ubuntu-20.04 runner(glibc 2.31),迁移到 ubuntu-22.04 / ubuntu-24.04(glibc 2.35 / 2.39)。这类”CI 基础镜像刷新”是 npm 原生包维护里的常见操作,通常不会写进 changelog —— 对包作者来说这只是”构建脚本没变,跟着 GitHub Actions 走”。
对下游用户的杀伤力:
- 发版节奏不透明:一个补丁版本号(
.z递增)就能让二进制兼容性发生断层,语义化版本号完全掩盖了这类变化。 - 报错文案强误导:
Cannot find module会被误读成”依赖没装齐”,而真实原因(GLIBC)藏在[cause]里。 - “之前也偶尔挂过”是巧合,不是同一个 bug:本项目在事故前的 9 个月里也偶发过同款报错主文案,但那几次 rollup 版本的 GLIBC 基线从未变过(都是 2.14)。那属于另一类问题(npm optional 包漏装 / CI 缓存命中错),跟本次 4.62.3 的 GLIBC 跃迁无关。同一句
Cannot find module掩盖了至少两种截然不同的根因,也是这类事故一直难查的关键。
七、修复
短期:pin 版本
在根 package.json 加 overrides:
{
"overrides": {
"rollup": "4.62.2"
}
}
