一台小服务器上,几个自己写的服务挂在 pm2 下面,另一个交给 systemd 管。同一件事——「进程死了得有人拉起来」——两套做法并存了一阵子,各自的好处和各自坑我的方式都挺清楚了。这篇是记录,不是评测。

🎯 它们回答的是同一组问题

不管用哪个,你都躲不过这五个问题:

  • 进程死了谁拉起来、隔多久拉、拉几次就放弃?
  • 机器重启之后它自己会回来吗?
  • 日志写到哪,怎么按时间捞?
  • 环境变量从哪儿来?
  • 以哪个用户、在哪个目录跑?

pm2 和 systemd 的差别,说白了就是这五个答案存在什么地方:systemd 存在一个 ini 文本里,由 PID 1 亲自执行;pm2 存在自己 home 目录下的一份 JSON 快照里,由一个常驻的 Node 守护进程执行。


🔧 systemd 的强项

一个普通的 unit 长这样:

[Unit]
Description=demo-api
After=network-online.target
StartLimitIntervalSec=120
StartLimitBurst=8

[Service]
Type=simple
User=demo
WorkingDirectory=/srv/demo
EnvironmentFile=-/srv/demo/demo-api.env
ExecStart=/srv/demo/bin/demo-api
Restart=always
RestartSec=15
RestartPreventExitStatus=78

[Install]
WantedBy=multi-user.target

我离不开的几点:

  1. 开机自启是一等公民WantedBy=multi-user.target 加一次 systemctl enable,没有第三方参与。
  2. 依赖能声明After=network-online.target 这种顺序约束是内建语义,不用在启动脚本里 sleep 赌运气。
  3. 日志白拿:stdout 直接进 journald,journalctl -u demo-api --since "-2h" 就能按时间切,轮转也是内建的。
  4. 表达力RestartPreventExitStatus=78 这种「78 号退出码表示配置写错了,别再重试」的意思,一行就说完了。

顺便说个我实测出来、特别容易误会的语义:Restart=always 不是「永远」。 用瞬态 unit 起一个立刻自杀的进程试试(这种 unit 不落盘、stop 就消失,拿来做实验很干净):

systemd-run --unit=demo-probe --property=Restart=always \
  --property=RestartSec=0 /bin/sh -c 'echo boom; exit 1'

journal 里数到第 5 次就撂挑子了:

demo-probe.service: Scheduled restart job, restart counter is at 5.
demo-probe.service: Start request repeated too quickly.
demo-probe.service: Failed with result 'exit-code'.

默认 DefaultStartLimitBurst=5,短窗口内超了就彻底进 failed,而且之后不会自己恢复,得 systemctl reset-failed 才能重来。所以对一个「启动阶段就可能崩」的服务,RestartSec 给大一点反而更安全——给 0 的话,一波闪崩就能把自己永久锁死。

🧰 pm2 的强项

  • 不需要 root:以哪个用户跑 pm2,进程就是那个用户的,不用碰 /etc
  • 改配置不用 daemon-reload,改完直接 restart。
  • pm2 describe 一屏解决好奇心:pid、重启次数、uptime、exec cwd、三个日志路径全在,不用去别处凑。
  • Node 项目零配置pm2 start app.js 就完事了。
  • 自启能接回 systemdpm2 save + pm2 startup 会生成一个 unit,它的 ExecStart 就是 pm2 resurrect,从 dump 文件里把上次 save 的进程列表复活。注意是「上次 save 的」——忘了 save 就等于没配自启。

🕳 坑一:工作目录不会跟着你

同一个瞬态 unit 实验,让脚本打印自己的 cwd:

probe start pid=3490796 cwd=/ MY_VAR=<unset>

我明明是在自己的目录里敲的命令,进程拿到的 cwd 是 /。systemd 起的服务默认工作目录就是根目录,跟你敲命令时站在哪一点关系都没有。

这个坑最恶心的地方是它不报错。程序照样起来,端口照样监听,只是 ./frontend./config.yaml 这类相对路径全落空——表现成「服务活着但功能不对」,于是你会先去怀疑自己的代码。

两边都得显式写:systemd 用 WorkingDirectory=/srv/demo,pm2 用 ecosystem 文件里的 cwd。补上之后同一个脚本打出来的就对了:

probe start pid=3491215 cwd=/tmp MY_VAR=from-unit-file

🕳 坑二:环境变量的两种「不继承」

上面那行里的 MY_VAR=<unset> 是同一次实验的另一半收获。我在 shell 里 export MY_VAR=... 过,unit 里就是没有。systemd 交给进程的环境几乎是空的,只有它自己塞的那几个。这是「手动跑没问题、做成服务就挂」的头号原因。

pm2 是反过来的,但更阴。pm2 start 的时候,它会把你当时那个 shell 的整个环境快照下来存进 dump 文件。我翻了自己机器上那份,一个条目里躺着 36 个环境变量,PWDOLDPWDSHLVL_ 全在,连一个只在某个启动脚本里临时 export 的内部标记都被一起腌进去了。

后果是三连:

  • 当时能跑,因为你的 shell 里刚好有;
  • 换个 shell 重新 start,行为可能就变了;
  • 机器重启后 pm2 resurrect 复活的是那份旧快照,跟你现在 shell 里有什么毫无关系。

结论跟 systemd 一样:别靠继承。 pm2 这边写进 ecosystem 文件:

module.exports = {
  apps: [{
    name: 'demo-api',
    script: './bin/demo-api',
    cwd: '/srv/demo',
    env: { PORT: '8080', API_BASE: 'https://example.com' },
  }],
};

配置进文件、进版本控制,总比进你的 bash history 靠得住。

🕳 坑三:restart 不等于 rebuild

自己写的那种 restart 脚本,十个里有九个只是 stop + start 现有产物。改完代码直接 restart,跑的还是旧二进制。我在这上面栽过一次:一个已经提交的修复迟迟不生效,最后发现部署链条里根本没人负责编译。

pm2 restartsystemctl restart 都只管进程,不管产物。两条出路,最好都走:

  • 把构建放进部署流程(systemd 这边也可以塞 ExecStartPre=);
  • 让「现在跑的是哪一版」可见——启动时打印版本号和构建时间:
go build -ldflags "-X main.buildID=$(date +%Y%m%d-%H%M%S)" -o bin/demo-api ./cmd/api

有了这个,验证就从「猜」变成 curl -s 127.0.0.1:8080/version

🕳 坑四:秒回不等于起好了

有些启动脚本开头会把自己丢到后台,然后立刻返回:

_BG=1 nohup bash "$0" "$@" &
echo "[deploy] 后台启动 PID=$!"

命令行秒回,后台其实还在拉依赖、编译,前后几十秒。我在它返回的一瞬间就去查产物的 mtime,理所当然地得出了「没编译」这个错误结论。

别把「命令返回了」当成「服务好了」的信号,去轮询一个真的健康检查:

for i in $(seq 1 20); do
  code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/healthz)
  [ "$code" = 200 ] && { echo "up"; break; }
  sleep 3
done

🕳 坑五:这个进程到底归谁管

两套并存要交的最大一笔税就在这。排查的第一步不是看日志,是先确认归属——否则你会对着 systemctl status 找一个 pm2 起的进程,收到 Unit not found,然后开始怀疑整台机器。

够用的判定手段:

systemctl status <pid>      # 归 systemd 的话会直接报出 unit 名
cat /proc/<pid>/cgroup      # 看它落在哪个 slice / service 下
pm2 list                    # 让 pm2 认领一下

日志也分两摊:systemd 的进 journald,按时间过滤;pm2 的落成 *-out.log*-error.log 两个文件。顺手记一个实测教训:pm2 默认不轮转日志。 我那个日志目录躺着 250 多 MB,里头还有几个早就不在 pm2 list 里的进程留下的孤儿日志。想省事就装上 pm2-logrotate 模块,别等磁盘报警才想起来。


🪜 顺带一提:谁看着 pm2 自己

这是我一开始没想明白的一层。pm2 能拉起你的进程,那 pm2 这个守护进程自己崩了、或者机器重启了,谁拉它?答案是 systemd——pm2 startup 生成的那个 unit 大概是这个骨架:

[Service]
Type=forking
PIDFile=/home/demo/.pm2/pm2.pid
Environment=PM2_HOME=/home/demo/.pm2
ExecStart=/usr/lib/node_modules/pm2/bin/pm2 resurrect
ExecStop=/usr/lib/node_modules/pm2/bin/pm2 kill
Restart=on-failure

三个细节值得看清楚:

  • Type=forking + PIDFile=:因为 pm2 启动后会自己 fork 成守护进程,前台那个命令会退出。这时候必须告诉 systemd 去哪儿找真正的主进程 pid,否则它以为服务已经结束了。(自己写服务时如果不 daemonize,就用 Type=simple,省掉这套。)
  • PM2_HOME:pm2 的全部状态(dump 文件、日志、pid 文件)都挂在这个目录下。不显式指定它,换个用户跑 pm2 list 看到的就是另一个世界的进程列表——「我明明起了服务但列表是空的」多半是这个。
  • 所以真实的层级是 systemd 看着 pm2,pm2 看着你的服务。用 pm2 并没有绕开 systemd,只是在上面加了一层。 这一层给你换来了免 root 和快迭代;代价是多一个可能出问题的环节,以及故障时多一跳要排查。

🧭 怎么选

我现在的分法很粗暴:

  • 需要 root、需要开机顺序、需要跟系统里别的单元有依赖关系,或者不想再多养一个常驻守护进程——用 systemd。它本来就在跑,不额外欠一份运维。
  • 一堆 Node 服务、想快速改配置重启、不想碰 root、想一眼看到所有进程状态——用 pm2
  • 两个都行的时候,选这台机器上已经在用的那个。两套管理方式并存的隐性成本,比它们之间任何功能差异都大。

至于选哪个都躲不掉的,是这三件事必须显式写下来,不能靠继承、不能靠记忆:工作目录、环境变量、以及跑的是哪一版。它们的共同点是出问题时都不报错,只让服务「活着但不对」——而这是最贵的一种故障。