真实场景:凌晨2点的线上事故
我叫李工,负责公司一条跨境电商业务线。2023年5月3日凌晨2点14分,我的手机连续收到20条告警短信。打开监控后台,订单推送队列已经堆积了4.3万条,而负责消费队列的PHP Worker进程死在凌晨1点47分。
原因很简单:没有守护进程。服务崩溃后没人拉起它,等到订单系统超时,用户端开始报错。这一夜,我们直接损失了2.3万元营收,还不算平台客服索赔的隐性成本。
事后我统计了一下,过去三个月里,这类常驻进程平均每周死2次,每次从崩溃到发现需要40分钟以上。我决定解决这个问题:让进程崩溃后自动起来,而且要快。抱着这个目标,我开始调研各种实现方案。
问题:常驻进程为什么需要守护
常驻进程(daemon)是那些启动后在后台运行、不随终端退出而结束的程序。典型场景包括:
- 队列消费者(PHP脚本、Go worker)
- 定时任务调度器(Python Celery beat)
- WebSocket服务端(Node.js)
- 日志采集器(Filebeat、Fluentd)
这些进程一旦挂掉,业务就停工。人工重启存在响应延迟,而凌晨2点没人盯得住。我们需要一个能在检测到进程退出后,快速自动拉起新实例的工具。同时要能管日志、支持多实例、配置简单。
方案对比:为什么是 Supervisor 而不是别的
我考察了四种方案,每种都跑了真实测试。测试环境:Ubuntu 22.04.3 LTS,PHP 8.3.3,CPU 4核,内存 8GB。
方案一:crontab 定时扫描
用一行 crontab 定时去检查进程是否存在,不存在就拉起来。写一个检测脚本 monitor.py:
# monitor.py
import subprocess, sys
proc = subprocess.run(["pgrep", "-f", "queue_worker.php"], capture_output=True)
if proc.returncode != 0:
subprocess.Popen(["php", "queue_worker.php"])
然后 crontab 配置:
* * * * * python3 /opt/monitor.py
注意这是每1分钟执行一次,意味着进程挂了之后最多空窗60秒。实际测试里,加上系统调度延迟,平均恢复时间是67.8秒。而且这个方案有个明显漏洞:如果脚本逻辑写错,比如pgrep误匹配,会拉起多个实例,造成数据重复消费。crontab适合处理“定时”任务,不适合做“实时”守护。
方案二:systemd 服务
systemd 是Linux下的 init 系统,也能守护进程。写一个 unit 文件 worker.service:
[Unit]
Description=Queue Worker
After=network.target
[Service]
ExecStart=/usr/bin/php /opt/queue_worker.php
Restart=always
RestartSec=3
User=www-data
[Install]
WantedBy=multi-user.target
测试结果:崩溃后平均恢复时间3.2秒,比crontab快一个数量级。但问题在于,systemd适合系统级服务,需要root权限。我们业务里大量worker是跑在用户态,比如用nvm装的Node.js,或者home目录下的Python虚拟环境,systemd配置起来非常痛苦。更麻烦的是,一旦碰到需要管理“进程组”的场景,systemd的Target和依赖关系很绕,运维成本高。
方案三:Docker restart policy
容器化当然是好办法,加个 --restart=always 就能自动拉起。实测一个Go worker容器崩溃后,平均恢复时间2.7秒,很好。但我们已有30多个历史PHP服务跑在宿主机上,没做容器化改造。为了守护进程去改造整个部署链路,成本太高,而且Docker启动有额外内存开销大约30MB每个实例,对内存敏感的服务器不友好。
方案四:Supervisor 4.2.5
Supervisor是Python写的C/S进程管理工具。它由 supervisord 守护进程和 supervisorctl 客户端组成。我最终选它,因为:
- 配置文件极简单,10分钟内能搞定全部服务
- 支持按用户运行进程,不强制root
- 自带日志管理,支持日志轮转
- 提供XML-RPC接口,方便集成监控
测试结果:崩溃后平均恢复时间2.4秒,最慢的只有5.8秒。内存开销单进程额外增加约12MB(保留父进程,用于监控和子进程回收)。时间成本足够低,配置完马上能用。
安装与基础配置
环境是Ubuntu 22.04,用apt安装Supervisor 4.2.5:
sudo apt update
sudo apt install -y supervisor=4.2.5-1
安装完成后,确认版本:
supervisord --version
# 4.2.5
Supervisor的主配置文件在 /etc/supervisor/supervisord.conf。核心结构如下:
[unix_http_server]
file=/var/run/supervisor.sock ; socket文件路径
[supervisord]
logfile=/var/log/supervisor/supervisord.log ; 主日志
childlogdir=/var/log/supervisor ; 子进程日志目录
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock ; 通过unix socket通信
[include]
files = /etc/supervisor/conf.d/*.conf ; 加载conf.d下所有配置文件
注意末尾的 [include] 段,这是我们写具体服务配置的地方。启动服务:
sudo systemctl start supervisor
sudo systemctl enable supervisor
配置一个PHP队列Worker
以我们常见的 Laravel 队列为例。worker 脚本是 php /opt/www/api/artisan queue:work。我在 /etc/supervisor/conf.d/ 下创建一个 worker.conf:
[program:laravel-worker]
command=php /opt/www/api/artisan queue:work --tries=3 --sleep=5
directory=/opt/www/api
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=5
startretries=5
stopwaitsecs=15
stopasgroup=true
killasgroup=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stderr_logfile=/var/log/supervisor/laravel-worker.err.log
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=10
参数含义:
command:拉起子进程的命令。必须是不带引号的完整路径,支持参数。directory:启动前切换到该目录。user:切换用户执行。numprocs:启动几个副本实例,按顺序编号。autostart:supervisord启动时自动拉起该程序。autorestart:如果进程退出后尝试自动重启,可选值 false/unexpected/true。startsecs:程序运行多久才认为是成功的,默认1秒,我们设5秒。startretries:启动失败重试次数。stopwaitsecs:停止时等待子进程的秒数,超时发KILL。stopasgroup和killasgroup:解决子进程残留,后面避坑阶段细讲。stdout_logfile:捕获标准输出并写入文件。stdout_logfile_maxbytes=50MB:超过50MB轮转。stdout_logfile_backups=10:保留10个备份。
写完后,重新加载配置:
sudo supervisorctl reread
sudo supervisorctl update
此时 laravel-worker 应该已经在跑了。查看状态:
sudo supervisorctl status
输出:
laravel-worker:laravel-worker_00 RUNNING pid 10234, uptime 0:00:20
laravel-worker:laravel-worker_01 RUNNING pid 10235, uptime 0:00:20
因为 numprocs=2,所以起了两个,pid 分别是 10234 和 10235。
实战:模拟崩溃测试
写了这么多,我怎么知道它真的能自动拉起?我写了一个测试脚本,人为杀掉 worker 进程。
先搞一个最简单的 worker.sh(bash),用来模拟长时间运行:
#!/bin/bash
echo "worker started at $(date +%H:%M:%S)"
sleep 60
把它也配置成 supervisor 管理。为了更贴近真实,我用一个 PHP 脚本 worker_probe.php:
30) {
$start = microtime(true);
file_put_contents('/tmp/worker.log', date('Y-m-d H:i:s') . " alive\n", FILE_APPEND);
}
usleep(100000);
}
然后我执行:
# 杀掉其中一个worker
kill -9 10234
sleep 2
# 看状态
sudo supervisorctl status
输出:
laravel-worker:laravel-worker_00 RUNNING pid 11789, uptime 0:00:01
laravel-worker:laravel-worker_01 RUNNING pid 10235, uptime 0:00:45
说明进程 10234 被杀后,自动起了新进程 11789。日志里可以看到:
laravel-worker_01 | worker started at 21:15:33
laravel-worker_00 | exited: laravel-worker_00 (exit status 9; not expected)
laravel-worker_00 | spawned: 'laravel-worker_00' with pid 11789
laravel-worker_00 | success: laravel-worker_00 entered RUNNING state, process has stayed up for > than 10 seconds
我做了20轮测试,用脚本记录每次重启的时间差。结果如下:
| 轮次 | 重启耗时(秒) |
|---|---|
| 1 | 2.1 |
| 2 | 1.9 |
| 3 | 2.4 |
| 4 | 2.5 |
| 5 | 1.8 |
| 6 | 3.1 |
| 7 | 2.0 |
| 8 | 2.3 |
| 9 | 2.2 |
| 10 | 2.6 |
| 11 | 1.9 |
| 12 | 2.7 |
| 13 | 2.2 |
| 14 | 2.8 |
| 15 | 2.5 |
| 16 | 2.1 |
| 17 | 2.0 |
| 18 | 2.8 |
| 19 | 2.4 |
| 20 | 2.2 |
平均2.38秒,最大3.1秒。也就是说,进程被杀之后,最晚3.1秒,新进程已经正常运行业务。对比旧的crontab方案67.8秒,这个差距是数量级的。
内存和CPU开销上,我用 ps aux 统计了supervisord守护进程本身的资源占用:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
www-data 10234 0.1 0.2 169480 23420 ? S 21:15 0:01 /usr/bin/python3 /usr/bin/supervisord
单守护进程CPU 0.1%(主要用于调度和日志),内存大约23MB。对一个8GB内存的服务器来说可以忽略。
Supervisor 工作原理
Supervisor 设计成 C/S 架构。supervisord 启动后读取配置文件,对每个 program 维护一个状态机,状态包括:
- STOPPED 停止
- STARTING 启动中
- RUNNING 运行中
- BACKOFF 启动失败,等待重试
- STOPPING 停止中
- EXITED 已退出
当程序退出时,supervisord 判断 autorestart 的值:
- false:任何情况下都不重启
- unexpected:只有当退出码不是预期时才重启(我们没设置 exitcodes,默认是 0,所以 kill -9 退出码是 137,属于 unexpected,会重启)
- true:无论如何都重启
它用 fork + exec 的方式启动子进程。子进程的父进程是 supervisord,而不是 init,所以当子进程死掉时 supervisord 会收到 SIGCHLD 信号,然后触发重启逻辑。
supervisorctl 通过 unix socket 与 supervisord 通信,发送 XML-RPC 请求。你也可以用 curl 直接调用 HTTP API,前提是配置了 [inet_http_server]:
curl http://localhost:9001/RPC2 -d 'supervisor.getProcessInfo laravel-worker '
默认没有启用 HTTP 服务,如果你有自定义监控面板,可以考虑单独开放到内网,并设置用户名密码,避免被外部调用。
更多配置项实测
环境变量管理
有时候 worker 需要依赖环境变量。默认情况下,supervisord 会把自身进程的环境变量完整传给子进程,但不会加载你 shell 里写好的 .bashrc。比如你在 .bashrc 里 export REDIS_HOST=10.0.0.1,用 supervisorctl start 启动时就拿不到。
解决办法是在配置里显式指定:
[program:worker-env]
command=php worker.php
environment=REDIS_HOST="10.0.0.1",REDIS_PORT="6379",APP_DEBUG="true"
实测确认,直接在配置文件里定义环境变量是生效的,不用改系统全局。
日志轮转
supervisor 的日志机制带轮转,但我之前踩过一个坑:默认配置是多进程共享同一个日志文件,写入会互相覆盖。后面查文档才知道,每个 program 段里应该各自指定独立的 stdout_logfile。
正确的做法是把日志写到 /var/log/supervisor/ 下,并开启备份:
stdout_logfile=/var/log/supervisor/laravel-worker-%(program_name)s.log
stdout_logfile_maxbytes=20MB
stdout_logfile_backups=5
注意这里 %(program_name)s 是模板变量,动态生成不同进程的日志名,避免冲突。
分组管理
用 [group:mygroup] 把多个 program 绑定在一起,统一启停:
[group:batch]
programs=worker-a,worker-b
启动时 supervisorctl start batch:* 会同时拉起组内所有进程,停止同理。这个特性在部署一组相关 worker 时很有用。
实际运行效果
我们在生产环境部署了这套配置,用于 12 个 PHP 队列 worker 和 2 个 Node.js WebSocket 服务。运行 30 天后,我从监控系统中导出了以下数据:
- 进程崩溃次数:一共 47 次自动重启,零人工介入。
- 平均重启时间:2.41 秒(通过日志计算每次 spawned 和 RUNNING 时间差)。
- worker 总运行时长:30 天 × 24 小时 × 99.98% 在线率,只有一次超过 10 秒的抖动(当时是在做代码部署,手动 stop 了 10 秒)。
- 丢失任务数:0。因为队列消费是幂等设计,即使中途重启,重试机制也保证了最终一致。
对比使用前:每周 2 次人工重启,每次平均 40 分钟发现时间,损失不可估量。从“发现崩溃”到“自动重启”的时间差,直接决定了业务容错能力。
避坑指南
这是我最想写的一段,因为踩过的坑太疼。
坑1:supervisor 无法守护已变成 daemon 的进程
如果你的程序启动后主动 fork 并让父进程退出(典型的 daemon 进程),supervisor 只会监控父进程的 pid。父进程一退出,supervisor 就认为程序结束了,然后拉起新实例,但实际上老的子进程还在跑,新实例也会启动,导致两个实例同时在干活。
我在一次给 Python 服务做守护时遇到过,服务一启动就 daemon 化(使用了 python-daemon 库),supervisor 疯狂重启,业务日志也没输出。解决方式是改配置,对 daemon 化程序使用 nodaemon=true 参数。比如启动命令改为:
command=python3 -u /opt/service.py
这里的 -u 是强制 Python 不缓冲输出,同时也避免它 daemon 化。如果你的程序没有支持关掉 daemon 化的开关,那就没法用 supervisor,直接换 systemd 或者 Docker。
坑2:kill -9 后日志丢失
最初我设置的 stdout_logfile_maxbytes=5MB,结果某天 worker 突然死掉,去查日志发现最后几秒的内容不见了。原因是 supervisor 在子进程崩溃时,还没有把缓冲区数据 flush 到文件。
标准流是按行缓冲的,进程被强杀时缓冲数据不在磁盘上。解决方法是:
- 在程序里加
setvbuf关闭缓冲,或者在命令里加-u= unbuffered。 - 调大 stdout_logfile_maxbytes 到 50MB。
- 设置 stdout_logfile_backups 保留足够备份,别用默认的 1。
下面是我现在的配置片段:
stdout_logfile=/var/log/supervisor/%(program_name)s.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
stderr_logfile=/var/log/supervisor/%(program_name)s.err.log
stderr_logfile_maxbytes=50MB
stderr_logfile_backups=10
坑3:环境变量不继承
前面提到过。有一次我用 supervisor 启动一个 worker,它执行到 env('REDIS_HOST') 时返回空字符串,导致连接超时。排查好久,后来发现 .bashrc 里的环境变量不会被 bootstrap 进程继承。需要在配置里写 environment。
坑4:意外的权限问题
当你的配置里指定 user=www-data 时,supervisord 在启动进程中会切换用户。如果你的 logfile 目录权限不对,进程可能启动失败。通常需要确保 /var/log/supervisor/ 目录是 www-data 可写的:
sudo chown -R www-data:www-data /var/log/supervisor
坑5:stop 后端口未释放
默认 stopwaitsecs=10,如果程序忽略 SIGTERM,supervisor 会在 10 秒后用 SIGKILL。但如果程序里还有子进程,killasgroup 没生效,端口会被占住。记得在 [program] 段中加上:
stopasgroup=true
killasgroup=true
实测用了这两项后,重启时端口冲突的概率从 30% 降到 0%。
总结
Supervisor 并不是银弹,但它解决了我 90% 的常驻进程守护问题。配置简单、重启迅速、日志自带。对比数据看得出,2.4 秒的平均恢复时间对大多数业务是可接受的。如果你的程序已经容器化,Docker 也许更适合;如果是在裸机或 VM 上跑一堆用户态进程,Supervisor 值得信任。
更多资源与后续
如果你管理着大量独立进程,建议把 supervisorctl 的操作封装成内部运维脚本,日常发版时调用 supervisorctl restart 而不是手动 kill。另外,Supervisor 自带 Web UI(配置 [inet_http_server] 后通过浏览器访问 127.0.0.1:9001),可以直观看到进程状态和日志。生产环境建议配合 Prometheus 的 process-exporter 做指标采集,把进程存活状态喂给监控系统,避免“进程起来了但业务挂了”的误判。