package-lock.json里看不见的坑:跨平台原生依赖为什么会消失
本文由 小茗同学 发表于 2026-07-27 浏览(301)
最后修改 2026-07-28 标签:npm lock package

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 条目会在这一步被丢掉。

具体链条:

  1. Mac 上 npm installnode_modules/@rollup/ 里只装了 darwin-arm64(正常)
  2. 某种操作触发 lock 重新生成(下一节展开)
  3. npm 读现有 node_modules,把 lock 重写成”只含 darwin-arm64”
  4. 这份看起来正常的 lock 被提交
  5. Linux CI npm ci 严格照 lock 装,@rollup/rollup-linux-x64-gnu 根本不在 lock 里,跳过
  6. 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
    # 51
    

    npm 读不了坏 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

七、教训

  1. 别在 node_modules 存在时让 npm 重新生成 lock。要重装,两个一起删。
  2. CI 用 npm ci,不要用 npm install
  3. 看到 Cannot find module @xxx/xxx-linux-* 这类报错,先怀疑 lock 里缺平台包。rollup / esbuild / swc / next-swc / sharp / lightningcss 都是同款失败模式。
  4. 别把带冲突标记的 lock 提交上去。它不是普通的坏文件,会诱导 npm 走”以 node_modules 现状重生成”的路径,把所有平台包洗掉。
  5. 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 显式升级。

参考

更新

以上解法并没有解决问题,还是报错。

三、真正的根因: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 包漏装:

  1. @rollup/rollup-linux-x64-gnu/rollup.linux-x64-gnu.node 这个文件路径在报错里被打印出来了,说明包已经装上了、文件存在
  2. 报错来自 Module._extensions..node —— Node 加载原生 .node 文件时的分支,能走到这里就已经过了”找不到模块”这一关
  3. 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 走”。

对下游用户的杀伤力

  1. 发版节奏不透明:一个补丁版本号(.z 递增)就能让二进制兼容性发生断层,语义化版本号完全掩盖了这类变化。
  2. 报错文案强误导Cannot find module 会被误读成”依赖没装齐”,而真实原因(GLIBC)藏在 [cause] 里。
  3. “之前也偶尔挂过”是巧合,不是同一个 bug:本项目在事故前的 9 个月里也偶发过同款报错主文案,但那几次 rollup 版本的 GLIBC 基线从未变过(都是 2.14)。那属于另一类问题(npm optional 包漏装 / CI 缓存命中错),跟本次 4.62.3 的 GLIBC 跃迁无关同一句 Cannot find module 掩盖了至少两种截然不同的根因,也是这类事故一直难查的关键。

七、修复

短期:pin 版本

package.jsonoverrides

{
  "overrides": {
	"rollup": "4.62.2"
  }
}