一、为什么 PM2 在小站运维里反复出现
个人站长的技术栈通常很杂:一个 Typecho 或 WordPress 跑在 PHP-FPM 上,旁边挂着一两个 Node.js 写的内部工具、Telegram 通知机器人、定时抓取脚本,偶尔还有个小 Python 服务。前面那套 PHP 的部分不用管,Nginx 和 PHP-FPM 天然会把进程管好;真正让人头疼的是后面那几个「临时跑起来」的 Node 或 Python 进程——它们没有 systemd unit,没有日志切割,宕机了没人知道,重启服务器之后全部消失。
这时候就轮到 PM2 出场了。它的卖点很直接:一条命令把进程拉到后台,挂了自动拉起,日志自动按文件切,还能一条命令重启所有进程。这些功能 systemd 也能做,但 PM2 更贴近写业务代码的人的直觉,不用背 unit 文件的语法。问题在于很多人只用了 PM2 最表面的功能:pm2 start app.js,然后 pm2 save,就以为万事大吉。真出问题时才发现,日志目录把磁盘写满了,进程重启了一百多次却没人知道,或者机器重启后 PM2 自己没起来,所有进程一起消失。
这篇按「一台单机、五到十个 Node 进程」这个典型规模来写,讲清楚 PM2 的进程模型怎么选、日志怎么配、开机自启怎么验、以及哪些事 PM2 做不了必须补上。
二、先分清 fork 与 cluster:单核小机器别无脑开集群
PM2 有两种启动模式。默认的 fork 模式就是一个实例对应一个操作系统进程,代码逻辑完全不变,适合定时任务、队列消费者、机器人这类不需要并发处理 HTTP 的进程。另一种是 cluster 模式,PM2 会拉起 N 个实例,Node 内置的 cluster 模块负责把进来的连接分发到各实例上,等于把单进程的 Node 变成多核并行。
选择标准只有一条:这个进程是不是在监听端口、并且 CPU 是瓶颈。一个 2 核 4G 的小机器,跑一个只处理几千个请求的 API,用 fork 单实例完全够用,开 cluster 反而带来新的麻烦——多个实例共享同一个端口,如果代码里用了进程内的内存缓存、进程内的定时器、或者 express-session 的默认文件存储,多实例之后缓存不共享、定时任务会执行 N 次、用户请求落到不同实例时会话丢失。这些坑在小机器上完全不值得踩。
真正该用 cluster 的场景是:单进程的 CPU 已经打满,而机器还有剩余核心;或者有明确的峰值场景,需要多实例兜住瞬时并发。pm2 start app.js -i max 里的 max 会按 CPU 核数拉起实例,更稳的写法是 -i 2 这种固定值,因为很多云主机的 nproc 包含了超线程,人眼看着是 4 核,实际物理核心可能只有 2 个,按 max 拉满容易出现争抢。
三、用 ecosystem 配置文件,别用一长串命令行参数
命令行启动的缺点是它不会留在任何地方:三个月后你看到 pm2 list 里有个进程在跑,却不知道它当时是怎么启动的、工作目录是哪个、环境变量设了什么。正确做法是写一个 ecosystem.config.js,把每个进程的启动参数固化下来,纳入版本管理。
module.exports = {
apps: [
{
name: 'notify-bot',
script: './dist/bot.js',
cwd: '/srv/notify-bot',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_restarts: 10,
min_uptime: '20s',
restart_delay: 4000,
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
PORT: 3100
},
out_file: '/var/log/pm2/notify-bot.out.log',
error_file: '/var/log/pm2/notify-bot.err.log',
merge_logs: true,
time: true
}
]
};
几个参数值得单独解释。min_uptime 配合 max_restarts 是一组防雪崩配置:进程启动后 20 秒内就退出,PM2 认为这是「启动失败」而不是「运行中崩溃」,连续失败 10 次就停下来标记为 errored,不再无限重启。restart_delay 让每次重启前等 4 秒,避免代码里某个依赖没准备好时疯狂重启把 CPU 打满。max_memory_restart 是 Node 进程最实用的一道保险——上线十几年积累下来的模板代码里难免有内存泄漏,设一个 300M 的阈值,超过就自动重启,虽然治不了根,但能保住机器。
time: true 会给每行日志加上时间戳,PM2 默认是不加的,排查问题时没有时间戳几乎没法对齐日志和报警时间。merge_logs: true 在 cluster 模式下把所有实例的日志写到同一个文件,单实例进程用它也无害。
四、日志目录:把磁盘写满的最常见原因
PM2 的日志默认写在 ~/.pm2/logs/,并且默认不轮转、不限大小。一个每秒钟打一行访问日志的机器人,一天就能堆出几百 MB,一个月后把系统盘写满,然后 MySQL 报 No space left on device,Nginx 返回 500,全站挂掉——而根因只是一个小小的 PM2 日志。
PM2 自带一个日志轮转模块,装一次就能管所有进程:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true
pm2 set pm2-logrotate:dateFormat YYYY-MM-DD
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
max_size 是单个文件的上限,retain 是保留几份,compress 打开后旧文件会被 gzip 压缩,一般能压到原来的十分之一。rotateInterval 是定时轮转的 cron 表达式,配合 max_size 两条规则同时生效,谁先触发就按谁切。这几个设置写在 pm2 set 里是持久化的,会存到 ~/.pm2/module_conf.json,重启不丢。
如果不想装模块,也可以把日志直接交给系统,让系统的 logrotate 管。要点是在 ecosystem 里把 out_file 指到 /var/log/pm2/,然后在 logrotate 配置里加 copytruncate,因为 PM2 持有文件句柄,直接 mv 掉旧文件之后进程还在往已经删除的文件里写,磁盘空间释放不了,只有 copytruncate(复制后清空原文件)才不会丢句柄。这两种方式选一种就行,两套同时上会互相干扰。
五、开机自启:pm2 save 之后必须验一次
把进程从 PM2 拉起之后,新机器重启会全部消失。标准的三步是:pm2 start ecosystem.config.js 启动所有进程,pm2 save 把当前进程列表写进 ~/.pm2/dump.pm2,pm2 startup 生成一个开机自启的系统服务。
pm2 startup 会在屏幕上打印一条带 sudo 的命令,必须复制粘贴执行那一条,它才会真正把 systemd unit 装到 /etc/systemd/system/pm2-<用户>.service。很多人看到「To setup the Startup Script, copy/paste the following command」就直接退出了,以为已经装好——重启后全部进程消失,这个坑极其常见。
# 生成并安装开机自启(按提示复制执行那条 sudo 命令)
pm2 startup systemd -u www --hp /home/www
# 验证 unit 真的存在且已 enable
systemctl is-enabled pm2-www
systemctl status pm2-www --no-pager
# 模拟重启后的恢复过程(不影响正在跑的进程)
systemctl restart pm2-www
pm2 list
最后一步 systemctl restart pm2-www 是关键:它会杀掉 PM2 守护进程再用 dump 文件恢复,如果 pm2 save 之后又 pm2 start 了新进程却忘了再次 save,这次重启就会把那几个新进程漏掉。发布流程里应该固定成「改完 ecosystem → pm2 reload → pm2 save」三步,save 永远放在最后。
六、端口与进程名:多项目共存时的命名纪律
一台机器上跑五个 Node 项目,最容易出的事不是代码 bug,而是端口冲突和进程名混乱。两条纪律就够了:进程名带上用途前缀,端口从一份清单里分配并在 ecosystem 里写死,不依赖 process.env.PORT 的默认值。
看端口占用用 ss -lntp,看某个进程实际监听哪个端口用 pm2 describe <名字>,输出里的 script path、exec cwd、restarts 三个字段最有价值——重启次数异常高说明进程在反复崩溃,而 pm2 list 里的状态列只显示 online,看不出这个信号。
pm2 list
pm2 describe notify-bot | grep -E 'status|restarts|uptime|script path|exec cwd'
ss -lntp | grep node
还有一个常被忽略的细节:PM2 的守护进程本身也要占内存,而且它默认以启动它的那个用户身份运行。如果用 root 跑 pm2 start,所有业务进程就都是 root 权限,一旦代码里有命令注入,等于把整台机器交出去。应该用一个专用的普通用户(比如 www 或 deploy)启动 PM2,日志目录和项目目录的属主也交给它,这样即使出问题,损害也局限在文件权限能碰到的地方。
七、PM2 做不了的四件事
PM2 很实用,但它不是运维体系的全部。下面四件事它不负责,必须自己补:
- 存活告警:PM2 能在进程退出时拉起它,但如果进程活着却卡死(比如连接池耗尽、事件循环被阻塞),PM2 看不出任何异常。需要一个外部的健康检查:本机 cron 每两分钟
curl一次健康接口,失败两次就发通知。 - 依赖服务的顺序:开机时 PM2 和 MySQL、Redis 同时启动,业务进程可能比数据库先就绪,导致启动失败然后被 max_restarts 判死。解决办法是在 systemd unit 里加
After=mysql.service redis-server.service,或者让应用自己带重试。 - 资源隔离:PM2 不做 cgroup 限制。某个进程吃满内存会把整机拖死,前面说的
max_memory_restart只能按阈值重启,管不住瞬时暴涨。需要硬限制就用systemd-run --scope -p MemoryMax=500M包一层,或者干脆换成 systemd 管理。 - 日志检索与集中:文件日志适合单机排查,不适合跨机器查问题。规模上去之后要把日志送到中心,但那是另一套东西的事,别指望 PM2。
八、什么时候该从 PM2 迁到 systemd
PM2 的价值在于「开发者的启动器」,systemd 的价值在于「操作系统的服务管理器」。判断标准很朴素:如果这台机器上只有个别 Node 服务,PM2 让部署变简单;如果服务已经成了业务主干、需要精细的资源限制、依赖顺序、日志进 journald、统一的 systemctl restart 操作习惯,那就该迁。
迁移本身不复杂:把 ecosystem 的启动参数翻译成 unit 的 ExecStart、WorkingDirectory、Environment、Restart=always、RestartSec,日志交给 journald 用 journalctl -u 查。真正的成本在于习惯——发布脚本里所有的 pm2 reload 都要改。稳妥的做法是两台机器并行跑一周,对比重启次数、内存曲线和日志完整性,确认新方案不漏进程、不吃内存,再切生产。
九、验收:五个数字
配完之后不要只看 pm2 list 里一片绿色的 online。真正的验收是重启一次、写满一次、挂掉一次:
systemctl is-enabled pm2-<用户>返回enabled,且systemctl restart之后pm2 list进程数与 save 时一致。pm2 describe里的restarts在正常运行一小时后不再增长。- 日志目录
du -sh /var/log/pm2,三天后总量应稳定在max_size × retain附近,不再持续上涨。 - 手动
kill -9一个业务进程的 PID,10 秒内 PM2 能把它拉回来,uptime归零但状态仍是 online。 - 把
/var/log/pm2所在分区填到 95%,确认告警能发出(而不是等它自己满)。
这五项里,第 1 和第 3 是踩坑率最高的。前者对应「忘记执行 sudo 那条命令」,后者对应「PM2 日志把磁盘写满」。两件事都属于「配置时觉得不可能发生、三个月后准时发生」的类型,验收时一次性测掉,比日后半夜被电话叫醒便宜得多。
十、小结
PM2 的定位要摆正:它是让小项目快速获得「后台运行 + 崩溃拉起 + 日志落盘」这三件基础能力的工具,不是完整的进程管理方案。用的时候把四件事做对——fork 和 cluster 按需选、用 ecosystem 文件固化参数、日志一定要限制大小和份数、开机自启一定要实际重启验一次;再把四件事补上——健康检查、启动依赖顺序、内存硬限制、日志集中。这样一台小机器上跑七八个进程,半年不用管它也不会出事。