npm在CI/CD中的最佳实践:缓存策略、npm ci和安全审计
本地 npm install 跑得好好的,推到 CI 就各种失败——超时、依赖不一致、缓存不命中、构建产物丢失。这篇文章把 npm 在 CI/CD 里的常见坑和最佳实践都过一遍,以 GitHub Actions 为主,其他 CI 工具的思路一样。
npm ci vs npm install:CI 里永远用 ci
bash# ❌ 错误:CI 里用 npm install npm install # ✅ 正确:CI 里用 npm ci npm ci
两者区别:
| npm install | npm ci | |
|---|---|---|
| 依赖来源 | 参考 package-lock,但可能更新它 | 严格按 package-lock,不一致则报错 |
| node_modules | 增量安装,不清除 | 先删 node_modules 再装 |
| 速度 | 较慢(要解析依赖树) | 更快(直接按 lock 文件装) |
| 确定性 | 不保证(可能偷偷升级) | 保证(锁文件必须和 package.json 一致) |
CI 环境的核心要求是可重现——同样的代码两次构建结果必须一样。npm install 可能悄悄修改 lock 文件,npm ci 不允许。
前提:npm ci 要求 package-lock.json 必须存在且和 package.json 一致。如果不一致直接报错,不会偷偷修——这正是 CI 需要的行为。
缓存策略
依赖安装是 CI 里最耗时的步骤之一。缓存 node_modules 或 npm 全局缓存可以节省 80% 以上的安装时间。
GitHub Actions
yaml- uses: actions/setup-node@v4 with: node-version: 20 cache: npm # 自动缓存 ~/.npm 目录 - run: npm ci # 从缓存安装,速度极快
cache: npm 是最简单的方案——actions/setup-node 自动根据 package-lock.json 的 hash 生成缓存 key,lock 文件变了缓存自动失效。
手动配置缓存(更精细控制):
yaml- name: Cache node modules uses: actions/cache@v3 with: path: | ~/.npm node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - run: npm ci
缓存 node_modules 还是 ~/.npm?
| 策略 | 优点 | 缺点 |
|---|---|---|
缓存 ~/.npm | 缓存体积小,命中率高 | 还需要跑 npm ci(但只从本地缓存读) |
缓存 node_modules | 跳过安装步骤 | 缓存体积大,不同 job 间可能不兼容 |
| 都缓存 | 最快 | 缓存体积最大 |
推荐:只缓存 ~/.npm。npm ci 配合本地缓存的安装速度已经够快(通常 5-15 秒),而缓存 node_modules 的缓存体积大且跨 job 兼容性差。
GitLab CI
yamlcache: key: files: - package-lock.json paths: - .npm/ install: script: - npm ci --cache .npm --prefer-offline
--cache .npm 指定缓存目录,--prefer-offline 优先从缓存读,缓存没有再从网络下载。
环境变量
CI 环境下几个关键的环境变量:
bash# 跳过 npm fund 和 npm audit 输出(CI 里不需要) export npm_config_fund=false export npm_config_audit=false # 不生成 package-lock.json(npm ci 不需要) export npm_config_package_lock=false # 设置日志级别(减少 CI 日志噪音) export npm_config_loglevel=warn
在 GitHub Actions 里:
yamlenv: npm_config_fund: false npm_config_audit: false
安全审计集成
在 CI 里自动检测安全漏洞:
yaml- name: Security audit run: npm audit --audit-level=high continue-on-error: true # 先不阻断,只报告 - name: Block on critical run: | critical=$(npm audit --json | jq '.metadata.vulnerabilities.critical // 0') if [ "$critical" -gt 0 ]; then echo "::error::Found $critical critical vulnerabilities" exit 1 fi
--audit-level=high 只在发现 high 或 critical 漏洞时返回非零退出码。low 和 moderate 不阻断构建但会输出警告。
完整的 GitHub Actions 工作流
yamlname: CI on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm run lint - run: npm run build - run: npm test - name: Upload coverage if: matrix.node-version == 20 uses: actions/upload-artifact@v4 with: name: coverage path: coverage/
常见坑
package-lock.json 和 package.json 不一致
本地 npm install 后忘了提交 lock 文件,CI 里 npm ci 就会报错。解决:每次 npm install 后检查 lock 文件是否有变化,有就提交。
yaml- name: Check lock file run: | npm install --package-lock-only git diff --exit-code package-lock.json
monorepo 下缓存 key 不对
monorepo 有多个 package-lock.json,缓存 key 只用了根目录的。解决:hashFiles 支持通配符:
yamlkey: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
CI 里的 Node 版本和本地不一致
本地用 Node 20,CI 默认跑 Node 18。npm ci 可能在 Node 18 下装的依赖和 Node 20 不兼容。解决:用 matrix.node-version 跑多个版本,或锁定 CI 的 Node 版本。