作者视角:10 年分布式系统工程师,专注安全加固与风险防范。 栈:Node.js 20.11 / npm 10.5.0、pnpm 8.15、Docker 24.0.7(BuildKit)、GitHub Actions、一个 3 节点 K8s 1.28 自建集群。 结论先行:「CI 全绿、本地/生产炸」里有一类高频根因不在代码,而在"安装命令"本身——
npm install会重新解析 semver 范围,npm ci只认 lockfile。两者混用,等于让每次构建的依赖树变成一个随机变量。这篇把机制、复现、门禁一次讲清。
一、现象与背景:没人改代码,线上却炸了
一个内部 B 端服务(Node 20 + Express + TypeScript,约 2.4k 行,依赖 147 个生产包)。某次例行发版,触发点很具体:
- CI(GitHub Actions)构建、单测、集成测试全绿;
- 本地同事
npm install && npm run dev也全绿; - 但生产容器起不来,报错
Cannot find module 'debug'——而package.json里根本没有显式依赖debug。
排查数据:对比 CI 的 package-lock.json 与生产实际安装的 node_modules/.package-lock.json,差异 6 个包,其中 debug@4.3.4 → 4.4.x、ms@2.1.2 → 2.1.3 等。生产镜像的构建脚本里写的是 npm install,而 CI 用的是 npm ci。同一次发版,两条链路装出了两棵不同的依赖树。
更隐蔽的是:本地开发者 npm install 时,npm 会优先复用本地 node_modules 里已满足范围的版本,所以本地"看起来"和 CI 一致——这是个假象,一旦清掉 node_modules 或换台机器,漂移立刻暴露。
二、原理:三条机制决定了"不治理就一定会漂"
npm install是"解析器",不是"复现器"。 它的语义是"把package.json里的 semver 范围(如^4.3.0)解析成当前 registry 里最新满足的版本"。只要上游发过新 patch,每次干净安装结果都可能变。^和~的语义边界(^允许 minor 升级、~允许 patch 升级)是漂移的主要来源。npm ci是"复现器"。 它的语义是:删除node_modules→ 严格按package-lock.json锁定版本安装 → lockfile 与package.json不一致直接报错退出。它不做任何重新解析,产物确定。- 幽灵依赖(phantom dependency)+ ESM/CJS 解析差异。 你代码里
require('debug'),但package.json没声明它——它能跑,是因为某个上层包(如ms的传递依赖链)把它"顺带"装进了node_modules的扁平目录里。npm ci严格按 lockfile 装,debug的落盘位置取决于依赖图;pnpm 的隔离式.pnpm结构 + 严格依赖(strict-peer-dependencies)会直接把它"藏"起来,于是Cannot find module就出现了。也就是说:同一个 bug,npm 下可能"碰巧能跑",pnpm 下必然暴露——这不是 pnpm 的错,是它把隐藏问题翻了出来。
一句话:依赖树 = f(package.json, lockfile, registry 当前状态, 安装命令, 包管理器隔离策略)。只要有任何一个变量不固定,"构建"就不是一个可重复的观测值,而是一个随机变量。
三、具体做法:把变量一个个钉死(可直接抄)
1) 统一安装命令:CI 与生产构建一律 npm ci
# 生产 Dockerfile(BuildKit)—— 禁止出现 npm install
FROM node:20.11-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund # 只认 lockfile
COPY . .
RUN npm run build
# GitHub Actions
- run: npm ci # 而不是 npm install
- run: npm test
反直觉点:很多人以为"CI 用 ci、本地用 install"是常态。请彻底放弃本地
npm install,本地也改npm ci。本地与 CI 同命令,漂移窗口才真正归零。
2) 把幽灵依赖显式化(治本)
- 用
npx depcheck扫未声明依赖,把代码里直接 import 但没声明的包补进package.json:npx depcheck --reporter compact # 示例输出: debug used in src/logger.ts but not declared npm pkg set dependencies.debug=^4.3.4 - 用
npx npm-check-updates定期看范围漂移,但升级必须走 PR + lockfile 变更,禁止在构建脚本里npm install pkg@latest。
3) 锁定 registry 与禁用 install 脚本(供应链视角)
# .npmrc(仓库内提交,全员生效)
registry=https://npm.example.com/ # 私有源,不回退公网
audit=false
fund=false
# 供应链关键:默认禁止任意 install 脚本执行(呼应依赖混淆/恶意 postinstall)
ignore-scripts=true
供应链视角:
npm install与ignore-scripts=false组合,正是恶意postinstall的执行入口——这与社区里"私有包被公共源 999.0.0 顶替"的依赖混淆攻击是同一类根因的不同侧面(见引用)。把"构建命令漂移"和"安装脚本执行"一起治理,才是完整的一条供应链防线。
4) 门禁:CI 里断言"lockfile 与 package.json 一致"
# 在 CI 前置一步,确保 lockfile 没被手改/缺失
npm ci --dry-run || { echo "lockfile 与 package.json 不一致或 lockfile 缺失"; exit 1; }
git diff --exit-code package-lock.json || echo "lockfile 有未提交变更"
四、踩过的坑 / 反直觉之处
npm ci不读node_modules现状——它会先删掉再装。所以"CI 比本地慢 40s"是正常的,别为了快换回install。- pnpm 切 npm 后"依赖消失":不是 pnpm 删了包,而是它一直把幽灵依赖隔离着,切回扁平结构后你以为"好了",其实只是问题被掩盖了。
^范围 + 上游发 patch = 定时炸弹:同一个^4.3.0,周一和周五装出的版本可能不同;lockfile 才是唯一事实源(source of truth)。npm install会"顺手更新 lockfile":一旦有人在装了新包后没提交 lockfile,下一个跑npm ci的人就会因"不一致"而失败——这类报错的锅永远在"谁改了 package.json 没提交 lockfile",门禁里要显式断言这一点。ignore-scripts=true的副作用:少数原生模块(如某些node-gyp包)需要 postinstall 编译。需按包白名单放行,而不是全局打开。
五、结论与可复用清单
- 构建命令即供应链:
npm install是解析器、npm ci是复现器,CI/生产/本地三处统一npm ci,漂移窗口归零。 - 幽灵依赖是时间炸弹:用
depcheck显式化所有直接 import 的包,别让依赖树"碰巧能跑"。 - 锁死三个变量:安装命令、registry、lockfile 一致性——三者固定,构建才是一个可重复的观测值。
- 供应链联动:
ignore-scripts=true+ 私有源不回退公网,与依赖混淆/恶意 postinstall 防线是同一套思路。 - 门禁四件套(可直接抄进 CI):①
npm ci --dry-run断言一致;②git diff --exit-code package-lock.json;③depcheck拦幽灵依赖;④npm audit(私有源下可关,但要有豁免清单)。
一句话:让"构建"从随机变量变成确定性函数,靠的不是运气,而是把"安装命令 + registry + lockfile"三个变量在 CI、本地、生产三处钉成同一个值。
参考:温若衡-759「私有 npm 包被公共源 999.0.0 顶替:依赖混淆攻击的原理与三道防线」——npm 默认执行 install 脚本这一点,与本文的"安装命令漂移"是同一供应链防线的两个侧面。