跳到主要内容

2 篇博文 含有标签「PM2」

查看所有标签

GitHub Actions 部署失败却上线成功?pm2 竞争与 lockfile 漂移

· 阅读需 7 分钟

在 push 代码触发自动部署时,GitHub Actions 的 Deploy 步骤标红退出,登服务器一看服务却已经是最新版本;另一天则反过来——本地一切正常,CI 的 pnpm install --frozen-lockfile 每次必挂。这两个方向相反的信号失真,都出在部署链路本身,而不是代码。

在为客户开发电商自动化数据采集工具时遇到此类问题——批量抓取商品图片、SKU、价格与评价,清洗后导出结构化数据,支撑库存管理与竞品分析。该工具的服务端走 PM2 + GitHub Actions 部署,下面两类假故障都在这条链路上踩过。

TL;DR​

  • CI 红但部署实际生效:两条部署通道并发执行 pm2 restart,一方在重启瞬间查不到进程而误报失败。识别特征是 pm2.log 同一秒内既有成功重启记录、又有 process already online 报错。修法:部署收敛到唯一通道。
  • 本地绿但 CI 必挂:pnpm add 在子目录执行,只改了子包 package.json,根 pnpm-lock.yaml 没同步入库。修法:回到工作区根执行 pnpm install 重新生成 lockfile 并提交。

场景一:本地全绿,CI 必挂——pnpm lockfile 漂移​

这类失败与代码质量无关,纯粹是 pnpm-lock.yaml 与 package.json 失去同步——而 CI 是唯一会严格核对两者的环境。

现象​

CI 的 pnpm install --frozen-lockfile 每次都失败,报错固定:

ERR_PNPM_OUTDATED_LOCKFILE

而本地执行 install、build、测试全部通过。这种「本地构建正常但 CI 挂了」的组合很容易让人先怀疑 CI 缓存或 Node 版本,实际都无关。

根因:pnpm add 跑在了错误的目录​

这个项目是 pnpm workspace monorepo,lockfile 只有一份、位于工作区根。当时为了装一个依赖,执行了:

# 在 client/ 子目录里执行
pnpm --filter @ccl-ext/client add <pkg>

结果 client/package.json 更新了,但根目录的 pnpm-lock.yaml 没有同步重新生成——也就是没有入库。提交后,CI 拿到的 lockfile 与 package.json 不一致,--frozen-lockfile 校验直接拒绝安装。

本地测不出来的原因很直接:node_modules 已经物理装好了包,本地 install 直接复用现有产物,不会走到 frozen 校验;CI 是全新环境,每一次都严格比对 lockfile。

还有一个伴随症状可以当证据用:在错误的 cwd 下执行 pnpm 命令,还会在子目录生成一个游离的 client/pnpm-lock.yaml。看到这个文件出现,基本就是又跑错目录了。

修法​

两步:

  1. 回到工作区根,执行 pnpm install 重新生成根 lockfile,随代码一起提交;
  2. 今后 pnpm add / pnpm remove 一律在工作区根执行,不在子目录里跑。

CI 日志里失败原因的 specifiers diff(依赖声明差异对比)会明确指出哪个包的依赖范围变了,可以用来确认修的就是这一处。

多包工作区里还有一类相似的「归因错位」问题,见这篇:npm audit 报警归因错目录?多包部署先按 audited N 对包树。

场景二:GitHub Actions 报失败,部署实际成功——pm2 restart 并发竞争​

部署本身没有失败,失败的是并发撞车的第二次 restart——PM2 把这场竞争误报成了部署错误。

现象​

push 后 GitHub Actions 的「Deploy Server」失败在 pm2 restart 步骤:

[PM2][ERROR] Process 3 not found → exit 1

但登服务器核对:代码是最新、pm2 list 显示进程 online、健康检查返回 200。部署三要素全部正常,只有 Actions 认为自己失败了。

根因:两条部署通道同时 restart 同一个进程​

当时部署有两条触发路径:

  1. deploy.yml 的 push 自动触发;
  2. 本地的 /deploy 部署脚本(deploy.sh)。

同一次 push 会让两条路径各执行一次 pm2 restart ccl-ext-api。两个 restart 并发撞车时,一方恰好在另一方重启的瞬间去查询进程,查不到就报 Process not found 并以 exit 1 退出——PM2 把这次竞争当成了失败。

验证过程:pm2.log 里的同一秒​

判断是不是竞争,pm2.log 是最硬的证据。翻开日志,同一秒内能看到两类记录并存:

# 一边:重启完整走完,进程上线
Stopping → starting → online

# 另一边:撞车的那个 restart 查不到进程
PM2 error: process already online

一次重启「正在进行中」与另一次「查询进程」交错在同 1 秒内,就是竞争特征。加上代码最新、进程 online、健康检查 200 三项核对,可以确定部署实际生效,Actions 的失败是误报。

修法:移除 push 触发,部署收敛到唯一通道​

改法是删掉 deploy.yml 的 push 自动触发,只保留 workflow_dispatch 手动触发作为兜底:

on:
workflow_dispatch:

两个方案里选收敛通道而不是加并发锁,因为加锁只是让两条通道排队,部署路径依然有两条;而部署本来就应该只有一个入口,谁在什么时间部署了什么,只从一处发生。收敛后这类误报再没出现过。

部署后如何确认线上真的更新了(而不是构建缓存作祟),这篇有另一组排查思路:排查前端部署后线上未更新的问题。

注意事项

  • 保留的 workflow_dispatch 手动触发同样会与本地部署竞争,只在确认当前没有本地部署进行时使用。
  • 竞争窗口极短(重启的 1 秒内),但只要两条通道并存,push 频率越高撞上的概率越大,不是「偶发」而是「必现只是难复现」。
  • 判断假失败的顺序:先核对服务器三要素(代码、进程、健康检查),再看 pm2.log 有无同秒双记录,最后才考虑重跑。

根因对照:两类假故障的共同点是信号源被污染​

把两个场景放在一起看,共同点立刻浮现:出问题的都不是部署结果,而是产生信号的链路本身。

场景一场景二
表面信号CI 必挂,本地全绿Actions 失败,服务器已更新
真实状态lockfile 确实不同步部署已完成
污染源错误 cwd 的 pnpm 操作并发的第二条部署通道
硬证据specifiers diff + 游离子 lockfilepm2.log 同秒双记录

CI 的红与绿只是部署链路的输出,链路本身被污染(两份 lockfile、两条通道)时,信号就不再可信。先修链路,再看信号。

常见问题​

pm2 restart 会加载最新代码吗?​

会。pm2 restart 以磁盘上当前代码重启进程,所以竞争误报时服务器代码已经是最新。判断真假失败不看重启输出,而看 pm2.log 与健康检查:同一秒内同时出现成功重启记录和 process already online 报错,即竞争误报。

GitHub Actions 显示部署失败,怎么判断是不是误报?​

核对 3 项:服务器代码是否最新、pm2 list 进程是否 online、健康检查是否返回 200。三项都正常而失败点恰在 pm2 restart 步骤,基本可断定是并发竞争误报;把部署收敛到唯一通道后误报即消失。

pnpm install 本地成功,CI 报 ERR_PNPM_OUTDATED_LOCKFILE 怎么办?​

根因是 pnpm-lock.yaml 与 package.json 不同步:在子目录执行 pnpm add 只改了子包声明,根 lockfile 没有更新入库。回到工作区根执行 1 次 pnpm install 重新生成 lockfile 并提交即可;CI 日志里的 specifiers diff 会指出哪个包的依赖变了。

CCLEE

独立开发者,24年电商行业实战经验,专注将AI能力落地于真实商业场景。

合作咨询

Node.js ESM 动态 import 报模块找不到?检查文件扩展名

· 阅读需 4 分钟

在为客户构建 SaaS 数据分析平台时遇到此问题,记录根因与解法。

TL;DR​

Node.js ESM 模式下,import('./path/to/module') 不会自动解析 ./path/to/module.js。TypeScript 编译的 dist 产物如果遗漏 .js 后缀,模块加载时抛出 ERR_MODULE_NOT_FOUND。如果这个 import 在延迟逻辑中(如定时器、条件分支),应用启动正常但运行一段时间后崩溃,PM2 表现为重启计数飙升。

解法:确保所有 ESM 动态 import 路径包含 .js 扩展名,并在 build 流程中自动修复。

问题现象​

PM2 状态显示应用不断重启:

│ name             │ ↺    │ status │ uptime │
│ analytics-api │ 9 │ online │ 28m │

错误日志每几分钟重复:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/dist/domains/video/cleanup'
imported from /app/dist/server.js

但文件实际存在:

$ ls dist/domains/video/
cleanup.js executor.js queue.js

根因​

ESM 不自动解析文件扩展名​

Node.js 的 CommonJS (require()) 会自动尝试 .js、.json 等后缀。ESM (import) 不会。

// ❌ ESM 模式下找不到模块
import('./domains/video/cleanup')
// Node.js 查找: ./domains/video/cleanup (精确路径,无扩展名)
// 实际文件: ./domains/video/cleanup.js

// ✅ 必须带 .js 后缀
import('./domains/video/cleanup.js')

延迟 import 隐藏了问题​

这个 import 在定时器中延迟执行:

// server.ts — 启动时不立即执行
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler(); // 几秒后才触发
});

应用启动成功(DB 连接、端口监听都正常),定时器触发时 import 报错 → 进程崩溃 → PM2 重启 → 再次启动成功 → 定时器再次触发 → 再次崩溃。形成崩溃循环。

为什么之前没问题?​

旧版本的 dist 产物是通过手动 build 生成的,build 脚本包含 .js 后缀修复步骤。某次部署时跳过了 build 后的修复步骤,直接用 tsc 输出的产物部署,tsc 不修改 import 路径。

解决方案​

1. TypeScript 源码中写 .js 后缀​

TypeScript 官方推荐:即使在 .ts 文件中,也写 .js 后缀:

// ✅ TypeScript 源码中也写 .js
import('./domains/video/cleanup.js').then(({ startCleanupScheduler }) => {
startCleanupScheduler();
});

2. Build 后自动修复(推荐)​

在 build 流程中添加后缀修复脚本,自动为没有扩展名的 import 补上 .js:

{
"scripts": {
"build": "tsc && node fix-imports.js"
}
}

fix-imports.js 核心逻辑:

import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';

function fixImports(dir) {
for (const file of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, file.name);
if (file.isDirectory()) {
fixImports(fullPath);
} else if (file.name.endsWith('.js')) {
let content = readFileSync(fullPath, 'utf8');
// 修复动态 import: from 'xxx' → from 'xxx.js'
const fixed = content.replace(
/import\(['"](\.[^'"]+)['"]\)/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
// 修复静态 import: from 'xxx' → from 'xxx.js'
const fixed2 = fixed.replace(
/from\s+['"](\.[^'"]+)['"]/g,
(match, path) => path.endsWith('.js') ? match : match.replace(path, path + '.js')
);
if (fixed2 !== content) {
writeFileSync(fullPath, fixed2);
}
}
}
}

build 流程自动为所有相对路径 import 补全 .js 后缀,TypeScript 源码保持无后缀写法,ESM 部署不再出现模块找不到的错误。

注意事项

  • 此问题只在 Node.js ESM 模式("type": "module" 或 .mjs 文件)下出现,CommonJS 不受影响
  • 静态 import(import ... from './foo')同样受此限制,不仅限于动态 import();ESM 的 import 提升还会导致另一个常见问题——dotenv 在 import 链之后才执行,环境变量读不到
  • 使用 tsx、ts-node 开发时不报错(它们会自动解析后缀),但 node dist/server.js 生产运行时出错
  • PM2 崩溃循环的典型特征:重启计数(↺)持续增长,每次 uptime 不超过几分钟