PM2 与 Node.js 进程管理:守护、集群与日志实践
本文目录
Node.js 服务直接用 node app.js 跑在生产上,意味着一次未捕获异常就让服务归零重启、单进程只能吃一个 CPU 核、日志靠 shell 重定向越滚越大。PM2 解决的就是这三件事:守护、多进程、日志。但每个能力都有边界——集群模式下内存翻倍、进程内状态丢失、优雅停机不是自动的。这篇文章把 PM2 的用法与其背后的机制一起讲清楚,配置项都给出可验证的效果。
PM2 解决什么
裸跑进程的三个缺口,对应 PM2 的三个能力:
| 裸跑的问题 | PM2 的对应能力 | 验证方式 |
|---|---|---|
| 异常退出即服务中断 | 守护进程:崩溃自动拉起,指数退避重启 | pm2 kill 模拟崩溃,观察状态从 online 恢复 |
| 单进程最多用一个核 | cluster 模式多进程分摊负载 | 压测时观察多核 CPU 利用率 |
| 日志、环境变量、重启策略靠手工脚本 | 配置文件统一声明,日志落盘分文件 | pm2 ls、~/.pm2/logs/ |
要强调的第一条边界:PM2 拉起崩溃的进程是"止损"不是"修复"。它把不可用时间从"人工发现-重启"缩短到秒级,但崩溃根因仍要靠错误日志与监控定位。重启退避(backoff)能防止一个必然崩溃的进程进入每秒重启的死循环拖垮机器。
cluster 模块原理:master、端口共享与调度
PM2 的 -i 集群模式建立在 Node.js 内置 cluster 模块之上。理解它的三层机制,后面的坑才有解释:
- master 进程:PM2 启动一个 master,由它 fork 出 N 个 worker。master 自己不处理业务请求。
- 端口共享:worker 监听同一个端口不会冲突——监听 socket 由 master 持有,worker 通过 IPC 把句柄"上交"(如果代码里用了
cluster),PM2 场景下则是 worker 各自 listen 同一端口、由 master 代理请求。效果都是:任意 worker 挂掉,其他 worker 继续服务。 - 调度:master 按 round-robin(除 Windows 外的默认策略)把新连接轮流交给 worker。负载在连接层面均衡,不在 CPU 层面精确均衡——长连接业务(如 WebSocket)会让先建立的连接全部压在个别 worker 上。
# 按核数启动,假设 8 核机器
pm2 start dist/main.js -i max # worker 数 = CPU 核数
pm2 start dist/main.js -i 4 # 明确指定 4 个(核多但内存紧张时降配)
pm2 ls # 查看每个 worker 的 pid、内存、重启次数、CPU
worker 数不是越大越好:每个 worker 是完整的 V8 实例,内存随 worker 数线性增长。8 核 16 GiB 的机器跑 8 个 worker,如果单 worker 常驻 400 MiB,就是 3.2 GiB 起步,再叠加依赖服务的连接池(每个 worker 独立连数据库),下游压力也线性放大。经验起点是 核数 与 核数 - 1 之间,配合内存监控再调。
与直接用 Node 原生 cluster 的差别在于职责分层:原生 cluster 模块要求代码自己写 master 逻辑(fork、监听 exit 重新拉起);PM2 把这层做成了通用守护,业务代码只写单进程逻辑,exec_mode: "cluster" 由 PM2 负责 fork 与守护。代码里可以感知但不应依赖 worker 身份——判断自己是几号实例用环境变量 process.env.NODE_APP_INSTANCE,PM2 会注入到每个 worker。
配置文件:ecosystem.config.js
命令行参数在生产上应该全部落进配置文件,进代码库,环境差异用 env 块区分:
// ecosystem.config.js
module.exports = {
apps: [
{
name: "blog-api",
script: "dist/main.js",
instances: 4, // 数字或 "max";配合 instances 单独控制 worker 数
exec_mode: "cluster", // 集群模式;fork 模式则单进程语义
max_memory_restart: "512M", // 内存阈值:超过即重启该 worker(止损用)
autorestart: true,
max_restarts: 10, // 最多重启次数(与 min_uptime 配合防死循环)
min_uptime: "30s", // 启动后 30 秒内崩掉才算"不健康重启"
restart_delay: 4000, // 重启间隔,代替指数退避的简化版
kill_timeout: 5000, // 发 SIGINT 后等多久才 SIGKILL:见优雅停机
wait_ready: true, // 等应用 process.send('ready') 才算启动完成
listen_timeout: 8000,
env: {
NODE_ENV: "development",
},
env_production: {
NODE_ENV: "production", // pm2 start --env production 时生效
},
error_file: "logs/api-error.log",
out_file: "logs/api-out.log",
merge_logs: true,
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
},
],
};
pm2 start ecosystem.config.js --env production
pm2 restart ecosystem.config.js --env production
max_memory_restart 的定位要清楚:它处理的是"慢泄漏最终撑爆"的止损,不能掩盖泄漏本身。阈值应设为正常常驻内存的 1.5–2 倍,且触发重启时要有告警——如果每周都触发,说明泄漏才是要解决的问题。
reload 与 restart:增量重启的区别
| 命令 | 行为 | 适用场景 |
|---|---|---|
pm2 restart | 杀掉全部 worker 再统一启动 | 环境变量变更、依赖大版本变化 |
pm2 reload | 滚动重启:逐个 worker "先起新再停旧" | 常规发布,保持服务不中断 |
pm2 graceful | reload + 等待 in-flight 请求结束 | 与 kill_timeout 配合的长请求场景 |
reload 不中断的前提是两个配置正确:kill_timeout 给了旧 worker 处理在飞请求的时间(默认只有 1600 毫秒,长请求必须调大);应用本身要正确响应关闭信号(下一节)。restart 会造成秒级服务不可用,发布脚本统一用 reload,把 restart 留给环境变更——这条规则建议写进部署文档并做进 CI。
一次发布流程的最小化脚本形态,把"构建 → 前后端同窗发布 → 验证"固化下来:
#!/usr/bin/env bash
set -euo pipefail
npm ci && npm run build # 1. 构建新产物
pm2 reload ecosystem.config.js --env production # 2. 滚动重启:旧 worker 处理完在飞请求再退出
pm2 save # 3. 进程拓扑不变,但把列表快照刷新(防止漂移)
curl -fsS http://127.0.0.1:3000/health # 4. 健康检查失败即脚本非零退出,CI 侧立即回滚
这套顺序覆盖了前面所有规则:reload 而非 restart、save 防快照漂移、健康检查兜底验证。每一步都是可观测的命令,比一个"内部自动"的黑盒发布工具更容易排障。
优雅停机:SIGINT/SIGTERM 与 in-flight 请求
PM2 停止进程时先发 SIGINT(reload/restart 流程),等 kill_timeout 到期再发 SIGKILL。应用必须在这段时间里:停止接新请求、等在飞请求返回、关掉连接与定时器。用 NestJS 举例(Express/Fastify 同理,注册对应的 shutdown hook):
// main.ts:开启生命周期钩子,NestJS 在启停时触发 onModuleDestroy
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // 让 SIGINT/SIGTERM 走到容器销毁流程
await app.listen(3000, () => {
process.send?.("ready"); // 配合 wait_ready:告诉 PM2 我已就绪
});
process.on("SIGTERM", () => void app.close()); // 平台标准信号,也处理
}
// 服务里实现清理:关闭数据库连接、清定时器
@Injectable()
export class CleanableService implements OnModuleDestroy {
private timer: NodeJS.Timeout | null = null;
onModuleDestroy(): Promise<void> {
clearInterval(this.timer!); // 定时器不清理,进程退不出
return this.prisma.$disconnect(); // 连接不释放,连接池被下游累积占满
}
}
自建 HTTP 服务时的模板:收到信号 → server.close()(停止接新连接)→ 等既有请求响应完或超时 → 退出。没有这层处理,reload 的"不中断"是假象:旧 worker 被 SIGKILL 时,正在处理的请求直接断掉,客户端表现为随机失败。验证方法很简单:发布期间持续压测,观察错误率是否为零。
日志:logrotate 与结构化
PM2 把每个应用的 stdout/stderr 收集到 ~/.pm2/logs/,但它不会自动切割,长期运行日志文件会无限增长。两件事必须做:
- 装 pm2-logrotate 模块,按大小切割并控制保留数量:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M # 单文件 50 MiB 切割
pm2 set pm2-logrotate:retain 30 # 保留 30 份
pm2 set pm2-logrotate:compress true # 旧日志压缩
- 应用侧输出 JSON 结构化日志,每行一条,便于采集与检索:
const log = (level: string, msg: string, extra?: Record<string, unknown>): void =>
process.stdout.write(
`${JSON.stringify({ ts: new Date().toISOString(), level, msg, ...extra })}\n`,
);
// 单条日志必须一行写完:多行日志会被逐行切散,检索时无法还原
log("info", "order created", { order_id: 9001, user_id: "u_123", trace_id: "req_abc" });
结构化日志的字段约定(ts/level/trace_id)与指标告警直接挂钩,是"重启次数上升"之外定位问题的主要证据链。error 日志单独文件只是便于人看,采集管道认的还是 JSON。
cluster 模式下还有一条纪律:日志必须能区分 worker。PM2 在 merge_logs: false 时会按实例分文件,开 merge 后要在日志字段里带上 process.env.NODE_APP_INSTANCE,否则 4 个 worker 的日志交错在一起,一次报错无法定位到具体进程:
const log = (level: string, msg: string, extra?: Record<string, unknown>): void =>
process.stdout.write(
`${JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
instance: process.env.NODE_APP_INSTANCE ?? "0", // 多 worker 下定位进程的证据
...extra,
})}\n`,
);
状态监控与指标暴露
排障先用自带工具,常态化监控用指标系统:
pm2 ls # 每个应用的在线状态、内存、CPU、重启次数
pm2 monit # 终端实时面板:进程级 CPU/内存曲线
pm2 describe blog-api # 单应用详情:启动时间、重启次数、错误日志路径
pm2 logs blog-api --lines 200 # 跟踪最近 200 行日志,排障时人肉看
pm2 flush # 清空日志文件内容(不删文件)
告警要盯三个量:重启次数(健康度的直接信号,持续增长说明异常退出或内存阈值触发)、内存趋势(缓慢上升即泄漏嫌疑,别等 max_memory_restart 兜底)、CPU 分布(一个 worker 满载其余空闲,多半是长连接或调度不均)。更进一步可接入 pm2.io 或自建指标端点,把进程事件(重启、内存超限)转发到告警渠道——重启不被发现,守护就只是把事故藏起来。
自建指标暴露走 Prometheus 约定:应用自身挂一个 /metrics 端点,输出 worker 内的业务指标(QPS、延迟分位数、连接数),PM2 层面的进程指标(内存、重启)由独立的 exporter 或定时任务采集 pm2 jlist 的 JSON 输出。两层指标在同一个面板对齐,才能把"业务延迟上升"与"某个 worker 刚重启"这两类事实关联起来。
systemd 托管:PM2 startup 与守护的守护
PM2 的守护进程(pm2 daemon)本身也是进程,服务器重启后谁来拉起它?官方答案是 pm2 startup:生成一条 systemd 服务单元,开机时以指定用户身份恢复 PM2 进程列表:
pm2 startup # 按提示执行输出的 sudo 命令,注册 systemd 单元
pm2 save # 把当前进程列表快照存盘,开机恢复的就是这份列表
pm2 resurrect # 手动按快照恢复
pm2 unstartup systemd # 取消开机自启
pm2 save 的时机是个坑:在开发机上 save 会把不相关进程也写进快照。快照是显式操作,发布流程里改了进程列表(加应用、改实例数)必须重新 save,否则下次机器重启回到旧拓扑。
两种部署形态的取舍:
| 形态 | 结构 | 适合 |
|---|---|---|
| systemd → PM2 → 应用 | PM2 管应用,systemd 管 PM2 | 单机多应用、需要 PM2 的 reload 与日志 |
| systemd → 应用(每应用一个单元) | 不用 PM2,纯 systemd 单元 + Restart=always | 追求最小依赖、有 K8s 类编排系统的团队 |
systemd 直管的单元可以做到 RestartSec、资源限制(MemoryMax)、journald 日志统一管理;PM2 的价值集中在 cluster 模式、reload 与跨应用统一操作。容器编排(K8s)环境里这两层都不需要——副本数与滚动发布由编排系统负责,把 PM2 装进容器属于把两套进程管理叠着用。
常见坑清单
- 多进程内存翻倍:worker 数 × 单进程常驻内存,评估配置时按乘法算,不是按一个进程算。
- 进程内状态丢失:缓存、在线连接表、限流计数器放在 worker 内存里,4 个 worker 就是 4 份互不相通的状态。需要跨 worker 一致的(会话、限流、在线表)放 Redis,本地内存只放可丢失的缓存。
- 一次性任务被执行 N 次:
setInterval定时任务、进程启动时执行的迁移脚本,在 cluster 模式下每个 worker 都会跑一遍。判断条件:process.env.NODE_APP_INSTANCE === "0"只让一个 worker 执行(PM2 注入该变量),或改用外部队列/调度器。 - 文件句柄随 worker 数放大:日志文件、数据库连接池每个 worker 独立持有,连接池上限要按"总连接数 = worker 数 × 单池上限"对照下游许可评估。
fork与cluster模式混用:fork 模式下 instances 无效且无端口共享语义,WebSocket 等需要独占语义的服务才用 fork;混着部署前先确认 exec_mode。- 内存重启掩盖泄漏:max_memory_restart 触发是止损信号,配合告警把泄漏当问题修,而不是把阈值越调越高。
- 发布脚本直接 restart:环境变量没改也习惯性 restart,造成秒级服务不可用;发布统一 reload,restart 留给 env 变更与故障恢复。
pm2 delete后忘记 save:进程列表快照与现状不一致,机器重启后应用缺失;把pm2 save放进发布脚本的收尾步骤。
注意事项与边界
- PM2 集群模式不提供进程间通信的业务封装:worker 之间要协同(广播、任务分片)得自己上 Redis 或消息队列。
- 环境变量变更 reload 不生效(env 在 fork 时注入),改 env 必须用 restart,且
--update-env只对当前命令行有效,稳妥做法是改配置文件后重启。 - 优雅停机的
kill_timeout要大于最慢请求的正常耗时(P99),否则长请求总被 SIGKILL 截断,等于没做优雅停机。 - 日志敏感信息在应用层脱敏,pm2-logrotate 只管切割不管内容;stdout 打印 token 的服务,日志文件就是密钥库。
- Windows 上 cluster 调度策略不同(非 round-robin),跨平台部署行为差异要实测,不要按 Linux 结论直接推。
总结
PM2 的三个能力(守护、集群、日志)每个都解决裸跑的真实问题,也都带来需要主动管理的新边界:守护对应重启告警与退避,集群对应内存乘法、共享状态外置与一次性任务去重,日志对应切割与结构化。真正让发布稳定的两个组合是:reload + kill_timeout + 应用侧 shutdown hook 组成的优雅停机闭环,以及 pm2 startup + pm2 save 组成的开机恢复闭环。把这两条闭环验证通过,PM2 的其余配置大多是参数调优。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。