一台小服务器上,几个自己写的服务挂在 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
我离不开的几点:
- 开机自启是一等公民:
WantedBy=multi-user.target加一次systemctl enable,没有第三方参与。 - 依赖能声明:
After=network-online.target这种顺序约束是内建语义,不用在启动脚本里 sleep 赌运气。 - 日志白拿:stdout 直接进 journald,
journalctl -u demo-api --since "-2h"就能按时间切,轮转也是内建的。 - 表达力:
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就完事了。 - 自启能接回 systemd:
pm2 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 个环境变量,PWD、OLDPWD、SHLVL、_ 全在,连一个只在某个启动脚本里临时 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 restart 和 systemctl 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。
- 两个都行的时候,选这台机器上已经在用的那个。两套管理方式并存的隐性成本,比它们之间任何功能差异都大。
至于选哪个都躲不掉的,是这三件事必须显式写下来,不能靠继承、不能靠记忆:工作目录、环境变量、以及跑的是哪一版。它们的共同点是出问题时都不报错,只让服务「活着但不对」——而这是最贵的一种故障。
💬 评论
加载评论中...
发表评论