我服务器上挂着 6 个 pm2 进程,一直觉得 pm2 就是「起个服务然后不管了」。直到某天 df -h 看到磁盘 85%,翻日志发现一个早就删掉的应用,toolsite-error.log 还在那儿躺着,1.8M,32690 行,全是同一个报错:

Error: Cannot find module '/opt/toolsite/server.js'
    at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
    at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
    at Function._load (node:internal/modules/cjs/loader:1216:25)

应用目录 /opt/toolsite 早就没了,pm2 里也没这个进程了,但日志还在一直刷。

下面每条都是隔离 PM2_HOME 在 49.233.191.177 上实测的。

坑一:崩溃循环会把你刷到没话说

pm2 的重启保护逻辑写在 lib/God.js 里,核心是这段:

var min_uptime = typeof(proc.pm2_env.min_uptime) !== 'undefined' ? proc.pm2_env.min_uptime : 1000;
var max_restarts = typeof(proc.pm2_env.max_restarts) !== 'undefined' ? proc.pm2_env.max_restarts : 16;

if ((Date.now() - proc.pm2_env.created_at) < (min_uptime * max_restarts)) {
  if ((Date.now() - proc.pm2_env.pm_uptime) < min_uptime) {
    proc.pm2_env.unstable_restarts += 1;
  }
}
if (proc.pm2_env.unstable_restarts >= max_restarts) {
  proc.pm2_env.status = cst.ERRORED_STATUS;
  proc.process.pid = 0;
  console.log('Script %s had too many unstable restarts (%d). Stopped. %j', ...);
}

注意这个条件链:unstable_restarts 只在进程活不到 1 秒(min_uptime)时才累加。

我先写了个延迟 1 秒再抛异常的脚本,按理 uptime 超过 1 秒,不该计入 unstable。结果 20 秒内重启了 51 次,状态还是 online:

│ 0 │ lab-crash  │ default │ N/A │ fork │ 4089382 │ 0s │ 35 │ online │ 0% │ 59.5mb │ ubuntu │ disabled │

换成启动就崩的脚本,判定立刻生效:

throw new Error('instant crash');
│ 0 │ lab-crash2 │ default │ N/A │ fork │ 0 │ 0 │ 15 │ errored │ 0% │ 0b │ ubuntu │ disabled │

15 次之后状态变 errored,pid 归零,pm2 停止拉起。日志里留下 176 行堆栈。

这就是 pm2 的设计意图:崩溃得「够快」才算不稳定,偶发重启不会误伤。但反过来说,一个每 1.05 秒崩一次的进程能永远重试下去。真实场景就是「端口没释放」「配置没加载」这种每次启动都必崩、但启动耗时略超 1 秒的情况——服务起不来,日志还在疯长,磁盘就是这么被吃掉的。

我给这类服务加了 min_uptime 把判定基线拉高:

pm2 start app.js --name myapp --min-uptime 5000 --max-restarts 10

--min-uptime 5000 之后,进程要活过 5 秒才算「稳定启动」,10 秒内崩 10 次就停手。日常看三个字段基本能判断健康度:status 是 online 还是 errored,restarts 涨得多快,unstable restarts 是不是在往 16 顶。

坑二:内存泄漏进程不会自己死,但会把服务器拖垮

shortlink 用了 better-sqlite3,跑久了内存只涨不降。pm2 有个参数专门治这个:

pm2 start app.js --name myapp --max-memory-restart 60M

我写了个持续吃内存的脚本验证:

const blocks = [];
setInterval(() => {
  for (let i = 0; i < 8; i++) blocks.push(Buffer.alloc(8 * 1024 * 1024, i));
  console.log('mem MB', Math.round(process.memoryUsage().rss / 1024 / 1024));
}, 300);

启动时内存 17mb,一路涨到 396mb,pm2 在越过阈值后自动重启了它:

│ status             │ online                                   │
│ restarts           │ 1                                        │
│ max memory restart │ 62914560                                 │

62914560 就是 60 × 1024 × 1024,pm2 内部按字节存这个值。注意日志里同时有 mem MB 396 的输出——pm2 是发现超限后才重启的,不是提前拦住的,阈值设 60M 的话峰值可能冲到几百 M。

我这服务器就 3.6G 内存,多跑几个服务就得出问题,所以给每个服务都加了上限:

pm2 start app.js --name myapp --max-memory-restart 512M --min-uptime 5000 --max-restarts 10

pm2 describe 里还能看实时堆内存:

│ Heap Size              │ 21.35 MiB │
│ Heap Usage             │ 53.65 %   │
│ Used Heap Size         │ 11.45 MiB │

坑三:日志只增不减,删了应用日志还在

pm2 默认不装 pm2-logrotate,我服务器上 ~/.pm2/ 已经 7.5M,ls ~/.pm2/logs/ 里躺着一堆早已下线的应用:

-rw-r--r-- 1 ubuntu ubuntu 1.8M Jun 25 16:51 toolsite-error.log
-rw-r--r-- 1 ubuntu ubuntu  14K Jul 10 15:25 groups-api-error.log
-rw-r--r-- 1 ubuntu ubuntu 5.8K Jul  1 09:07 bagua-fortune-error.log

原因很简单:pm2 delete <name> 只删进程,不删日志。程序目录被 rm -rf 了,pm2 那边也摘干净了,日志就成了孤儿。

第一种是 pm2 flush,一条命令清空所有进程的日志:

pm2 flush
[PM2] /home/ubuntu/.pm2/logs/lab-A-out.log
[PM2] /home/ubuntu/.pm2/logs/lab-A-error.log
[PM2] Logs flushed

实测 17 字节的日志被截成 0 字节,进程没重启。但它是「清零」不是「轮转」,历史日志全没了,没法回溯。

第二种是真正的轮转,用系统自带的 logrotate(我服务器上 3.21.0,logrotate.timer 已 enabled)。配置文件放 /etc/logrotate.d/pm2:

/home/ubuntu/.pm2/logs/*.log {
    size 50M
    rotate 5
    copytruncate
    missingok
    notifempty
    compress
    delaycompress
    su ubuntu ubuntu
}

几个参数的意思:

  • size 50M 而不是 daily——日志量波动大的服务,按大小切更可控。
  • copytruncate 是关键。pm2 一直持有日志文件的 fd,单纯 mv 走文件句柄不会断,进程还会往老文件里写。只有 copy + truncate 能让 pm2 继续写同一个 inode。
  • su ubuntu ubuntu 告诉 logrotate 以谁的身份轮转。不加这行,压缩出来的文件属主会变成 root。

验证轮转效果:

sudo logrotate -f -v /etc/logrotate.d/pm2
copying /home/ubuntu/.pm2/logs/lab-A-out.log to /home/ubuntu/.pm2/logs/lab-A-out.log.1
truncating /home/ubuntu/.pm2/logs/lab-A-out.log
compressing log with: /bin/gzip

轮转后出现 lab-A-out.log.1.gz,原文件归零,进程在线:

-rw-rw-r-- 1 ubuntu ubuntu   0 Nov 11 08:25 lab-A-out.log
-rw-rw-r-- 1 ubuntu ubuntu 524 Nov 11 08:24 lab-A-out.log.1.gz

我用普通用户身份跑 logrotate -f,报了这么个错:

error: error opening state file /var/lib/logrotate/status: Permission denied

state 文件归 root,普通用户没权限写。加 sudo 就好,测试时也可以用独立 state 文件绕过:

logrotate -f -v -s /tmp/lr.state /tmp/lr.conf

删应用时顺手清掉孤儿日志:

pm2 delete <name> && rm -f ~/.pm2/logs/<name>-*.log

坑四:pm2 save 之后再加的进程,重启服务器就没了

这条最容易踩:服务在跑好好的,服务器一重启,全没了。

先起 lab-A,保存,再起 lab-B:

pm2 start /tmp/pm2-lab/ok.js --name lab-A
pm2 save --force
pm2 start /tmp/pm2-lab/ok.js --name lab-B

然后模拟机器重启(pm2 kill 干掉整个 daemon)再 pm2 resurrect:

[PM2] [v] All Applications Stopped
[PM2] [v] PM2 Daemon Stopped

resurrect 之后只有 lab-A:

│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │
│ 0  │ lab-A │ default  │ N/A     │ fork │ 4092336 │ 3s │ 0 │ online │ 0% │ 59.4mb │ ubuntu │ disabled │

lab-B 没了。 原因在 pm2 save 的语义:它把当前进程列表快照写进 ~/.pm2/dump.pm2,pm2 resurrect 只读这个快照。快照之后新增的进程不在文件里,自然恢复不出来。

开机自启的链路是这样串起来的(pm2 startup systemd),我服务器上生成的是 /etc/systemd/system/pm2-ubuntu.service:

[Unit]
Description=PM2 process manager
After=network.target

[Service]
Type=forking
User=ubuntu
Environment=PM2_HOME=/home/ubuntu/.pm2
PIDFile=/home/ubuntu/.pm2/pm2.pid
Restart=on-failure

ExecStart=/usr/lib/node_modules/pm2/bin/pm2 resurrect
ExecReload=/usr/lib/node_modules/pm2/bin/pm2 reload all
ExecStop=/usr/lib/node_modules/pm2/bin/pm2 kill

[Install]
WantedBy=multi-user.target

ExecStart 就是 pm2 resurrect,读的是 dump.pm2。所以 每次新增或修改进程配置后,必须重新 pm2 save:

pm2 save
pm2 save --force

--force 会跳过那些被标记为「不需要保存」的进程。另外有个坑:pm2 save 默认存的是当前用户,sudo pm2 save 存的是 root 的,两份 dump 互相不通用。

检查 dump 里都有谁:

python3 -c "
import json
d = json.load(open('/home/ubuntu/.pm2/dump.pm2'))
[print(p['name'], '| watch =', repr(p.get('watch'))) for p in d]
"

dump 的内容:

shortlink       watch = False
wechat-webhook  watch = None
blog-admin      watch = False
blog-stats      watch = False
article-qa      watch = ['false']
filehub         watch = False

看到 article-qa 那行的 ['false'] 了吗?pm2 start x.js --watch false 里的 false 被当成字符串存进了数组,而非空数组在 JS 里是真值,结果 watch 模式被意外打开了,pm2 list 的 watching 列显示 enabled。同一行的 ↺ 列显示 article-qa 已经重启过 6 次。

判断 dump 是否过期,看时间戳对不对得上:

stat -c '%y %n' ~/.pm2/dump.pm2
2026-10-21 10:54:56 +0800  /home/ubuntu/.pm2/dump.pm2

再和每个进程的 created_at 比一比。filehub 的创建时间戳 1792551296452 正好是 10-21 10:54,也就是最后一次 pm2 save 的时刻——说明之后没再新增进程。

我现在的配置

我现在给每个服务都用这套参数启动:

pm2 start app.js --name myapp \
  --max-memory-restart 512M \
  --min-uptime 5000 \
  --max-restarts 10

这三个参数各管一件事:内存超限重启、启动 5 秒内崩算不稳定、10 次不稳定就彻底停手。

顺带一个提醒:pm2 5.x 时代有个 --expire 参数(超过 N 毫秒没重启就主动重启一次)。我在 pm2 7.0.1 源码里全局搜过,expire 已经不存在了,照着老教程敲 --expire 不会报错,但也不会生效。想要定时探活,交给 systemd 的 Restart= 更靠谱。

再加一个保存脚本,防止改完配置忘记 save:

#!/bin/bash
set -euo pipefail
pm2 save
pm2 describe --all | grep -E 'name|status|restarts|max memory'

重点是每次 pm2 start / pm2 delete 后跑一次。

日常巡检就看这几条:

pm2 list                                    # status 和 ↺ 重启次数
pm2 describe <name> | grep -E 'status|restarts|unstable|max memory'
du -sh ~/.pm2/                              # 日志目录有没有失控
pm2 jlist | python3 -c "import json,sys; [print(p['name'], p['monit']['memory'], p['pm2_env']['restart_time']) for p in json.load(sys.stdin)]"

最后一条能列出内存占用和重启次数,适合写进监控脚本。

收尾

pm2 本身不复杂,坑都在「默认行为不告诉你」上:崩溃了会无限重启、日志永远不轮转、save 只是快照、--watch false 反而开 watch。这些都不会报错,只会慢慢把服务器拖垮,得自己盯着 status、restarts、~/.pm2 体积这三样东西。

我服务器上那些孤儿日志就是最好的例子——应用都删干净了,日志还在那儿躺着提醒我当初没做对。