事情经过
晚上八点多,一个 Node.js CLI 工具触发了全局升级。npm 在 reify 阶段执行目录重命名时,报了 ENOTEMPTY(目标目录非空),升级流程中断。
问题在于,npm 的全局升级不是原子操作。它会先把旧目录改名到临时路径,再把新版本落到正式路径。失败发生在中间——旧的已经被挪走,新的没有落地。
结果就是:命令入口丢了,包目录只剩残骸,服务拉不起来。
损坏现场
跑去看文件系统,典型的升级中间态:
- CLI 入口(软链)已经不存在,
command not found - 安装目录只剩
node_modules/子目录,核心文件(入口脚本、dist/index.js、package.json)全部缺失 - 多了几个
.tool-name-*临时目录,是 npm 重命名阶段的残留物
服务层面,依赖这个 CLI 的 gateway 进程反复重启失败,RPC 探测返回 1006 abnormal closure。
好消息是业务数据(配置、会话、认证信息)完整无损——npm 全局升级只动安装目录,不碰用户数据目录。
根因
npm 全局包升级的 reify 流程分三步:
- 把当前版本目录 rename 到临时路径(标记为 "retired")
- 把新版本安装到正式路径
- 清理临时路径
第 1 步成功了,第 2 步在 rename 时遇到目录冲突(ENOTEMPTY),整个事务中断。npm 没有回滚机制——第 1 步挪走的东西不会自动还原。
从 npm debug 日志能看到完整的证据链:reify mark retired → reify moves → ENOTEMPTY rename → 流程终止。紧接着,运行日志就出现了 command not found。
触发这个冲突的原因:升级时服务还在跑。gateway 进程持有安装目录下的文件句柄,导致 npm 在重命名目标路径时碰到了非空目录。
修复过程
修复本身不复杂:
- 定位到 npm 残留的临时目录,里面有完整的上一版本副本
- 把核心文件(入口脚本、
dist/index.js、package.json)从临时目录恢复到正式路径 - 重建 CLI 入口软链
- 重启 gateway,跑一遍健康检查,端口监听正常,消息通道正常
从发现到恢复,纯手工操作。整个过程没有数据丢失。
两个教训
第一,全局升级时必须先停服务。
npm 全局安装不是原子操作,升级过程中服务继续运行,等于在换轮胎的时候还踩油门。正确做法是升级前停服务,升级后验证三件套(入口脚本、dist、package.json),再拉起来。
第二,npm 全局安装本身就是脆弱的部署方式。
一次 npm install -g 失败就能让服务挂掉,说明部署链路的容错能力太差。更稳的做法是用固定版本目录 + 软链切换:每个版本装在独立目录,用软链指向当前版本,升级就是切软链,回滚也是切软链。这和 Nix、Docker 的思路一样——不做 in-place 覆盖,用不可变部署替代。
后续动作
排了三件事:
- 写一个升级脚本,内置停服 → 备份 → 升级 → 验证 → 回滚的完整流程
- 加监控规则,
command not found和ENOTEMPTY出现就报警 - 逐步迁移到版本目录 + 软链的部署模式,摆脱对 npm 全局安装的依赖
下次如果你也在用 npm install -g 管理线上服务,记得问自己一个问题:升级失败了,你能在 30 秒内回滚吗?