---
date: 2026-05-08
type: learning
tags: [node, ops, env-drift, ci, docs, deploy]
---

# 跨环境 Node 版本漂移：本地/CI/UAT/PROD 四套不同 Node 同时跑

## 背景

调查工单 #242 (UAT @otplib ESM crash loop) 时翻出的副发现，比工单本身的 bug 更值得固化。

## 现状（修复前）

| 环境 | Node | 来源 | docs 写 |
|---|---|---|---|
| 本地（开发机） | 24.14.0（自由） | 个人选 | — |
| CI quality-gates | **20**（`actions/setup-node@v4`，`node-version: 20`） | `.gitea/workflows/quality-gates.yml:67/154` | — |
| CI deploy-* | 跟服务器 nvm default 一致 | shell 用 nvm.sh | — |
| UAT (FFAI) | **20.20.2**（2026-04-02 装机选 20） | 装机时手动 nvm install | `docs/ops/01-server-infrastructure.md:25` 写 "Node.js 20.x" |
| PROD (FFAI) | **22.21.1**（2025-12-09 装机直接装 22） | 同上 | 同 docs，但 docs 跟实际不符 |

bash_history 在 PROD 上找到 `nvm install 22   # 或者 20` —— 运维装机时就在版本之间犹豫，没有形成约定，docs 也只写"20.x"模糊带过。UAT 4 个月后建机时选了另一边，漂移成立。

## 后果（实际撞过的坑）

- otplib transitive dep `@scure/base@2.x` ESM 化后，本地 (Node 24 默认 require-module) / PROD (Node 22 同) 跑得起来，UAT (Node 20 默认禁止) reload 直接 `ERR_REQUIRE_ESM` crash loop —— "本地 + 生产都跑得通，唯独 UAT 爆"，第一反应往代码差异查，**真凶其实是 Node 版本默认策略差异**。
- CI quality-gates 在 Node 20 下 contract-check 没触发 mfa.service 加载，漏检了。
- 治理上：每次有人问"我们的 Node 版本是多少？"答案要看哪个环境，没法一句话回答。

## 本次落地（PR `fix/uat-node-upgrade-to-22`）

1. **统一基线 Node 22**，理由：
   - PROD 已用 27h，业务证据可行
   - Node 22 默认开启 `require()` ESM 兼容，cover 未来 transitive dep ESM 化
   - 22 是 active LTS（至 2025-10）
2. **多点钉死防漂移**：
   - `backend/package.json` + `frontend/package.json` 加 `"engines": {"node": ">=22"}`（npm 启动时校验）
   - `.gitea/workflows/quality-gates.yml` `node-version: 22`（CI 校验）
   - `docs/ops/01-server-infrastructure.md` Node 版本写实，固化基线
3. **UAT 服务器手动升级**（不进 git）：
   ```bash
   nvm install 22.21.1 && nvm alias default 22.21.1
   cd /srv/apps/ffworkspace-test/backend && rm -rf node_modules && npm ci
   cd /srv/apps/ffworkspace-test/frontend && rm -rf node_modules && npm ci
   pm2 restart ffws-uat-backend ffws-uat-frontend ffws-uat-backend-temporal-worker --update-env
   ```

## 通用教训（适用项目级所有环境治理）

1. **"Node 版本"是契约面，必须有唯一真相源**：
   - 唯一真相源 = `package.json` 的 `engines.node`
   - 所有运行入口（CI、装机脚本、docs、部署脚本）都从这个真相源派生
   - docs 写"20.x"这种模糊范围 = 没固化，相当于没写
2. **装机脚本必须固化版本**：避免运维注释里 `# 或者 20` 这种漂移源
3. **本地开发机版本 ≥ 生产即可**（Node 向后兼容好），但**CI 必须等于生产**——CI 不通过 = 生产能跑通完全是巧合
4. **每次升级真相源后，必须扫一遍所有派生位置**（CI / docs / 装机脚本 / 部署脚本 / Dockerfile / .nvmrc），缺一不可
5. **跨环境同一版本性质漂移检查可以做成小脚本**：定期 SSH 各环境 `node -v` 跟真相源对比，写进 `scripts/ops/`。是否值得做要看再撞几次

## 跟 self-improvement skill 的关联

这条价值高（项目特有 + 重复出现潜质 + 适用范围广），可以提炼进 `docs/standards/` 或单独写个"运行时版本治理"小节。先沉淀为 learning，下次跨环境定位问题再撞到一次就提 skill。

## 关联

- 工单 #242
- `.learnings/ERRORS/ERR-20260507-002.md`（直接受益方）
- `docs/ops/01-server-infrastructure.md`（被本 PR 修正）
