很多站长的服务器上,关键进程是这样一个状态:命令行手动敲 php-fpm -F 起的,或者 nohup ./worker & 扔在后台,重启一次机器就全没了,密码改了要重新登录,进程挂了没人知道。这篇文章讲的是把这些「野进程」收进正式守护体系的具体做法——不是简单地装一个 supervisor 就完事,而是讲清 systemd 与 supervisor 各自适合什么、配置怎么写、以及那些只有踩过才知道的坑。
一、先分清:什么该被守护,什么不该
守护进程(supervisor / systemd unit)解决的是三件事:进程挂了自动拉起、开机自启、日志统一收口。它不负责负载均衡,也不负责编排。所以判断标准很简单:
- 常驻型服务——队列消费者、定时拉取器、WebSocket 网关、自研的 HTTP 服务:必须守护。
- 有状态、会分裂的进程——一个 consumer 开 4 个进程手动跑:先用守护统一成一个,再靠
numprocs或 systemd 模板扩展,不要两个体系混着来。 - 一次性任务——备份脚本、日志切割:用 cron 或 systemd timer,不要写成常驻守护,否则「跑完退出」会被守护当成崩溃,无限重生。
我在自己的小站上见过最典型的一种事故:一个「每小时同步一次数据」的脚本被配成了 restart=always 的 systemd 服务,脚本正常执行完退出,systemd 立刻拉起,又跑一次,结果一小时里同步了 3000 多次,把上游接口直接打到限流封禁。守护配置里,「正常退出」和「异常退出」必须区分对待。
二、systemd:新机器的默认答案
Debian 10 以后全是 systemd,能不用额外装东西就不要装。一个最小可用的 unit 长这样:
[Unit]
Description=Site worker queue consumer
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/www/wwwroot/worker
ExecStart=/usr/bin/php /www/wwwroot/worker/queue.php consume
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
StandardOutput=append:/var/log/worker/out.log
StandardError=append:/var/log/worker/err.log
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
这里面几个参数值得单独说:
Restart=on-failure而不是always:正常退出(exit 0)不再拉起,正好避开上面那个无限循环的坑。StartLimitIntervalSec与StartLimitBurst:5 分钟内最多重启 5 次,超过就进入failed状态不再尝试。没有这两个,一个配置错误导致启动即崩的服务,会让你在journalctl里滚出几万行日志,把磁盘写满。LimitNOFILE:高并发连接的服务如果不设,默认 1024 很快不够,表现是「连接随机失败」而不是明确的报错,非常难查。After=mysql.service:注意After只管启动顺序,不管依赖是否可用。要等 MySQL 真正就绪,得在程序内部做重试,或在ExecStartPre里加一个探测脚本。
三、ExecStartPre 做就绪探测
程序自己带重试逻辑最可靠,但老代码改不动时,用一个探测前置脚本兜底:
#!/bin/bash
set -uo pipefail
for i in $(seq 1 30); do
if mysqladmin ping --silent 2>/dev/null; then
exit 0
fi
sleep 1
done
echo "mysql not ready after 30s" >&2
exit 1
放成 ExecStartPre=/usr/local/bin/wait-mysql.sh。ExecStartPre 失败会阻止主进程启动,这比主进程带着「连不上数据库」的错误状态跑起来要好得多——后者往往会触发守护重启循环,反反复复把错误日志刷爆。
四、多个实例:模板 unit 而不是复制粘贴
一个 worker 要开 4 个进程,最笨的做法是写 4 个 unit 文件。正确做法是用 @ 模板:
# /etc/systemd/system/worker@.service
[Unit]
Description=Worker instance %i
After=network-online.target
[Service]
User=www-data
ExecStart=/usr/bin/php /www/wwwroot/worker/queue.php consume --id=%i
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
然后 systemctl enable --now worker@1 worker@2 worker@3 worker@4,%i 会被替换成实例名。好处不只是少写文件:systemctl status 'worker@*' 一次看全部,systemctl restart worker@3 只重启一个实例,滚动重启不需要停机。日志也会自动按 worker@3.service 分开,journalctl -u worker@3 -f 就能单独跟一个进程。
五、日志:append 还是 journal
上面我用了 StandardOutput=append:/var/log/worker/out.log。这与 journalctl 二选一,需要想清楚:
- append 到文件:日志格式程序自己控制,可以直接被现有分析脚本读;但没有自动轮转,必须配
logrotate,否则半年后单文件几个 G。 - journal:自动轮转、有时间戳和优先级、能按 unit 过滤;但默认存
/var/log/journal,磁盘紧张时会被 systemd 按 10% 上限悄悄丢弃,历史日志查不到。
我自己的做法是:业务日志 append 到文件 + logrotate 管;启动失败的诊断信息用 journal。查启动问题时 journalctl -u worker@1 -n 100 --no-pager 比翻业务日志快得多,因为 systemd 会把「启动超时」「退出码 1」这类事件也记进去。
/var/log/worker/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
copytruncate 是给不支持信号重开文件的程序用的:拷贝后清空原文件,文件描述符不失效。代价是拷贝瞬间可能丢几行日志。如果程序支持 SIGHUP 重开日志,就用 postrotate 发信号,更干净。
六、supervisor:多语言混跑与进程组更顺手
什么时候该用 supervisor 而不是 systemd?我的判断是两种场景:
- 一台机器上要管十几个不同语言的短命令(Python 爬虫、Node 定时任务、PHP consumer),写成十几个 unit 文件太碎,supervisor 的一个 ini 文件加一个
[program:x]段更直观。 - 需要进程组级别的顺序与优先级:supervisor 的
priority与group语义比 systemd 的After更符合「先启动 A 组,再启动 B 组」这种直觉。
一个典型配置:
[program:fetcher]
command=/usr/bin/python3 /opt/fetcher/main.py
directory=/opt/fetcher
user=www-data
autostart=true
autorestart=unexpected
startsecs=5
startretries=3
stopsignal=TERM
stopwaitsecs=10
redirect_stderr=true
stdout_logfile=/var/log/fetcher.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
environment=PYTHONUNBUFFERED="1"
几个参数的实际含义:
autorestart=unexpected:只有非预期退出才重启,等价于 systemd 的on-failure。startsecs=5:启动后必须存活 5 秒才算启动成功,否则算启动失败并重试。这个参数非常关键,很多脚本「启动就崩」,没有startsecs的话 supervisor 会认为它已经 RUNNING,然后反复重启却显示正常。PYTHONUNBUFFERED=1:Python 输出到管道时是块缓冲,不设这个,日志会滞后几分钟才出现,排查时你会以为程序卡死了。stopwaitsecs=10:发 TERM 后等 10 秒,还不退就 KILL。队列消费者一定要处理 SIGTERM,否则每次重启都丢正在处理的任务。
七、supervisor 自己的坑:别用 root 跑、别忘了它也会挂
supervisord 本身是个普通进程,它自己崩了,所有子进程一起失去守护。所以正式环境里应该让 systemd 来守护 supervisord:
[Unit]
Description=Supervisor daemon
After=network.target
[Service]
Type=forking
ExecStart=/usr/bin/supervisord -c /etc/supervisor/supervisord.conf
ExecStop=/usr/bin/supervisorctl $OPTIONS shutdown
ExecReload=/usr/bin/supervisorctl $OPTIONS reload
KillMode=process
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
KillMode=process 必须要有。默认的 KillMode=control-group 会在 systemd 停止服务时把整个 cgroup 里的进程全部杀掉,包括 supervisord 管理的所有子进程——看起来像是「重启 supervisor 结果业务全断」。KillMode=process 只杀主进程,让 supervisord 自己按配置去优雅关闭子进程。
另一个常见问题是权限:用 root 跑 supervisord,再在 [program:x] 里写 user=www-data,看起来没问题,但如果 supervisord 以 root 启动且文件属主混乱,子进程仍可能以错误身份写文件。更稳的做法是 supervisord 本身以 root 跑(因为它需要切换用户),但每个 program 都显式写 user=,且 directory 指向属主正确的目录。supervisorctl status 输出的进程状态要配合 ps -o user,cmd -C php 核对一次实际身份。
八、守护之外还得做的一件事:健康检查
守护系统只能判断「进程是否存活」,判断不了「进程是否还能干活」。一个死锁的 consumer 进程照样活着,systemd 认为一切正常,队列却越积越多。所以还需要一个独立的健康检查:
#!/bin/bash
set -uo pipefail
qlen=$(redis-cli LLEN queue:pending)
last=$(redis-cli GET worker:last_heartbeat)
now=$(date +%s)
if [ "$qlen" -gt 1000 ] || [ $((now - last)) -gt 300 ]; then
logger -t healthcheck "worker unhealthy qlen=$qlen idle=$((now-last))"
systemctl restart 'worker@*'
fi
把这段放进 /etc/cron.d/worker-health,每 5 分钟跑一次。注意 set -uo pipefail 不加 -e:redis-cli 连不上时返回空串,让 -e 生效会在第一行就退出,反而看不到后面的判断逻辑,这种「静默退出」的检查脚本比没有检查更危险。
另外 systemctl restart 'worker@*' 通配符要加引号,不加的话 shell 会尝试在本地做 glob 展开,展开失败时通配符可能被原样传递、也可能被吃掉,行为不确定。生产脚本里凡是带 * 的参数,一律加引号。
九、守护配置的验收:三个数字
配置写完不能只看「服务起来了」就收工。我习惯在上线后立刻抓三个数字,任何一个不对就说明守护体系还有漏洞:
systemctl show worker@1 -p NRestarts -p ActiveState -p Result
journalctl -u worker@1 --since "1 hour ago" --no-pager | grep -c "Scheduled restart"NRestarts 是累计重启次数,刚上线就非 0,说明进程在崩了又被拉起;Result 是最近一次退出的原因,exit-code 与 signal 意义完全不同——前者是程序自己报错退出,后者是被 OOM killer 或外部信号干掉,排查方向分别是看业务日志和看内核日志。Scheduled restart 的条数则是这一小时内实际发生的重启,和 NRestarts 增量对不上时,多半是有人手工重启过,值得问一句。
还有一个常被忽略的检查:systemctl cat worker@1 会打印出实际生效的完整 unit 内容,包括被 .d/ 目录里的 drop-in 覆盖后的结果。曾经有一次我改的是 /etc/systemd/system/worker.service,但 worker.service.d/override.conf 里有一个更靠后的 Restart=always,改了半天都不生效。改完配置用 systemctl cat 而不是 cat 看,这一条能省掉很多「为什么我的配置没生效」的时间。
顺带说一个和守护紧密相关的细节:RestartSec 不要太短。设成 1 秒,程序在启动阶段崩溃时会形成每秒一次的快速循环,CPU 被拉满、日志刷爆,而问题本身完全没被暴露。5 到 10 秒是更合理的间隔,既不至于让服务长时间不可用,也留出喘息空间让人去看日志。
十、小结:三件事定成败
把一个野进程收进守护体系,真正决定成败的不是配置文件写得多全,而是三件事:区分正常退出与异常退出(on-failure / unexpected)、限制重启频率(StartLimitBurst / startretries)、有独立的健康检查(守护只看存活,看不到业务是否卡死)。这三件做到了,剩下的参数调优都是锦上添花;这三件没做到,配得再漂亮的 unit 文件也只是把事故从「进程没了」变成「进程一直在重启」。
最后提醒一句:改动守护配置后,别只看 systemctl status 的绿点。真正该看的是 systemctl show worker@1 -p NRestarts,重启次数不为 0 就说明它在反复挂,绿点只是「此刻存活」的意思。
