说实话,第一次做“硅基聊天室”——一个让大家挤在同一个网页里,对着同一个大模型接口提问、实时看到流式回复的小型聊天服务——我压根没把进程管理当回事。刚开始我就用nohup ... &挂一个 Web 接口进程,觉得能跑就行。可日子久了问题就冒出来了:凌晨队列 worker 静默崩掉、聊天室没人在线却不代表服务健康、重启时旧进程还在占端口,最头疼的是日志散落各处,出问题都不知道先看哪份文件。后来我把所有服务进程统一交给了 supervisor 来管,才算是把这一摊事情理顺。
supervisor 不是新东西,它在 Linux 服务管理里算是老资格了,核心说白就一句话:帮你盯着后台进程,崩了自动拉起来,日志统一攒起来,命令按组批量控制。这篇文章我会从一个真实的小项目出发,把“硅基聊天室”这类多进程应用拆开看,讲清楚为什么需要进程管理,再手把手把 supervisor 的配置、参数、坑点都过一遍。无论你是后端新手,还是接手了别人一堆nohup脚本的老开发,这篇都能给你省不少事。
1. 整体思路:先弄清楚聊天室有哪些进程在负重前行
1.1 先把“硅基聊天室”的进程画像画出来
很多人以为聊天室就是一个后端服务,顶多再挂个数据库。真跑到业务里,尤其是接了模型推理、流式返回、异步任务之后,你会发现一个看起来小小的项目,背后其实是一群进程在各自干活。我这个聊天室大致拆成了四块:
chatroom-web:面向浏览器的 HTTP / WebSocket 服务。用户发消息,它负责把消息转发给模型接口,再把流式回答推回页面。这个进程最核心,挂了用户立刻骂娘。chatroom-worker:异步消费队列,处理消息历史的持久化、敏感词过滤、会话归档、离线通知这类不着急返回给用户的任务。它不是流量入口,但也很关键,因为 Web 进程会把脏活丢给它。chatroom-scheduler:定时任务,比如每小时清理一次过期会话、每天凌晨算一遍用量统计、定期给模型网关刷新访问凭证。- 周边依赖:数据库、缓存中间件这类外部服务,这里不归 supervisor 管,因为它们的运行方式更贴近系统级服务,被发行版的包管理器管着更合适。
这种拆分模式在很多项目里都很典型。好处是解耦:聊天高峰时多开几个 worker,凌晨低谷缩容,不会因为后台任务把用户请求拖垮。但代价是:你有了多个进程,就得面对多个进程的拉起、重启、崩溃恢复、日志聚合问题。
1.2 聊天室对进程管理的真实需求是什么
需求不是“能跑就行”,而是要把“跑着”这件事做到可预期。我总结下来大概五条:
- 崩溃自动拉起:worker 进程经常由于偶发异常退出,而且退出时不一定留下明显错误。没有进程管理的话,可能整个早上都没人发现历史消息没入库。
- 有序启停:Web、worker、scheduler 之间有依赖顺序。启动时最好先保证依赖的基础服务可用,再拉起上层服务;升级部署时,得先停 group 里的全部进程,再做代码更新,再统一启动。
- 日志统一:如果每个进程自己往不同文件写日志,排查一个问题要在三个终端里来回切。更理想的做法是让进程把 stdout/stderr 都交给 supervisor 接管,统一滚动、统一轮转。
- 非 root 运行:安全一点,不该用 root 身份跑业务进程。supervisor 支持指定启动用户,避免业务代码一旦出问题影响面扩大。
- 可重启、可追踪:能一键批量重启、能看每个进程当前状态、能随时看最新日志,这些在日常发布和排障中缺一不可。
1.3 为什么选 supervisord:和 docker、systemd、pm2 的取舍
选型是必经之路。我周围几种主流方案都有人用,简单对比一下:
| 方案 | 优点 | 缺点 | 我能接受的场景 |
|---|---|---|---|
nohup + & | 简单,零学习成本 | 不会自动重启,PID 文件容易脏,日志要自己管 | 临时调试 |
pm2 | 命令友好,自带负载均衡 | 更贴近 Node 生态;管理 Python 进程也能用,但总有点隔靴搔痒 | 前后端纯 Node 场景 |
systemd unit | 系统级标准,开机自启完善 | 写多个 unit 文件比较繁琐,普通用户不一定有权限,调试 context 是系统层 | 发行版自带服务 |
docker compose | 隔离完整,开发/生产一致性好 | 整体重量大,维护 compose 之外还要维护镜像构建;小项目有点杀鸡用牛刀 | 容器化部署团队 |
supervisord | 配置直观,按项目分组,日志内置,轻量 | 不解决跨机器问题,没有容器隔离,需要自己配开机自启 | 单机多进程的传统部署 |
最后我选了 supervisord,原因是它把问题模型做得刚刚好:一个后台守护进程负责派生子进程,每个子进程叫一个 program;program 可以按应用场景组成 group;日志、重启策略、环境变量都是配置项。你不需要为每个进程写一整套 systemd unit,也不需要引入整个容器开篇。它就是进程管理领域的“瑞士军刀”,不 fancy,但好用。
2. 核心概念与配置逐条拆解:知道为什么才能调得对
2.1 先分清楚 supervisor 里四个主角
很多初学者会把 supervisord、supervisorctl、program、group 混在一起。其实它们各自角色很清楚:
supervisord:后台守护服务,是真正干活的进程。它读取配置文件,根据配置拉起和管理子进程。supervisorctl:命令行客户端,负责和 supervisord 通信,执行status、start、stop、restart、tail等操作。program:一个具体被管理的进程描述,包含启动命令、目录、用户、重启策略等。group:多个 program 组成的逻辑分组,方便一次性操作一组进程。
理解这四个概念后,配置文件的组织方式就很自然了:全局配置写在 supervisord 的主配置文件里,每个业务进程单独建立一个小配置文件,再用include机制把目录下的所有分片合并起来。这种设计和 Nginx 的conf.d很像,好处是每个进程维护独立文件,互相不干扰,也便于用 git 管理。
2.2 program 常用参数:每一个都必须知道“为什么”
下面的配置是一份典型 program 声明,我拿它拆开讲:
[program:chatroom-web] command=/opt/silichat/venv/bin/gunicorn chatroom.wsgi:application -b 127.0.0.1:8000 --workers 2 --threads 4 --timeout 60 directory=/opt/silichat/app user=www-data numprocs=1 autostart=true autorestart=true startsecs=5 startretries=3 stopasgroup=false stopsignal=TERM stopwaitsecs=20 redirect_stderr=true stdout_logfile=/var/log/silichat/web.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 environment=SETTINGS="production",MODEL_API_KEY="change_me"command:要启动的真实命令。必须写绝对路径。因为 supervisor 启动子进程时环境变量和你在终端里不一样,PATH 很可能没有虚拟环境目录。把解释器和脚本都写绝对路径,能避免一堆“找不到命令”的怪问题。directory:工作目录。让进程在指定目录下启动,保证相对路径的文件、配置都能找到。user:以哪个系统用户启动。我会用普通用户而不是 root。这一条配合权限配置,能有效减少安全事故。numprocs:启动几个相同进程副本。如果只有一个程序实例,写 1 就行。某些场景想多开 worker,可以配合process_name=%(process_num)d区分。autostart:supervisord 启动时是否自动拉起该进程。一般写 true。autorestart:进程异常退出后是否自动重启。注意有两个附加参数:startsecs和startretries。startsecs:至少要稳定运行多少秒,才认为这个服务是“成功启动”。如果进程在 5 秒内就退出,supervisor 会认为启动失败,并计入startretries。这个参数的语义非常重要,它防止业务进程因“启动时环境缺包但立刻闪退”而被误判为正常。startretries:连续启动失败多少次后放弃,进入 FATAL 状态。我是做监控告警的,所以不希望 supervisor 无限重试把进程反复唤醒,3 次比较合适。stopasgroup/killasgroup:这两个参数专门处理“子进程没被杀干净”的问题。如果 command 是个sh -c '... &'或者启动了子脚本,supervisor 只杀 PID 对应的主进程,子进程可能变成孤儿。此时设stopasgroup=true,会让停止信号发给整个进程组;killasgroup=true则是最后强制杀掉整个组。对这类服务,我会在配置里把这两个开关打开。stopsignal:先发什么信号让进程退出。大多数 Web 服务用 TERM 就能优雅退出;特殊情况比如依赖 SIGINT 的脚本,可以单独改。stopwaitsecs:发停止信号后,等多久才算超时。超时后 supervisor 会用 KILL 强杀。如果服务优雅退出要跑很久,这个值要放宽。redirect_stderr:是否把 stderr 重定向到 stdout 的日志文件。我建议开 true,否则得分别配置 stderr 日志文件,排查效率低很多。stdout_logfile:日志文件路径。注意日志目录必须存在,且进程用户要有写权限,不然启动直接失败。stdout_logfile_maxbytes/stdout_logfile_backups:单个日志文件多大时滚动,保留几份历史。相当于内置了小号 logrotate。environment:以逗号分隔的键值对环境变量。要写进子进程环境里的配置,比如运行环境、脱敏后的密钥。这里踩过坑:值里有逗号或百分号要特别处理,必要时用双引号包起来。
2.3 include 机制、ini 格式和那些不起眼的小毛病
supervisor 主配置文件通常位于/etc/supervisor/supervisord.conf或项目目录下的自定义路径。我习惯在项目里维护一份deploy/supervisor/目录,里面的文件最后软链到配置目录,这样可以随项目走。
配置格式是 INI,但有几个注意事项:
[program:x]、[group:x]这类 section 名不能重复,不然覆盖行为会让人很困惑。- 不要在一个 program 配置里顺手写
[supervisord]或[rpcinterface:supervisor],这些属于全局配置,要放主文件或 include 的全局配置片段。 - 配置文件编码建议用 UTF-8,路径里的中文尽量别用,省得在不同环境之间迁移时出幺蛾子。
include目录不要让配置文件排序影响启动顺序。进程启动顺序可以用priority控制,数字越小越先启动。Web 可以设为 900,worker 设为 901,让资源依赖在后。
3. 实测:把“硅基聊天室”三个子服务交给 supervisor 管起来
3.1 动手之前的预检查:别让 supervisor 替你背锅
写配置之前,一定要先在命令行手动把每个命令跑通。这一步没人会提醒你,但省下来的全是排障时间。我做三件事:
- 确认可执行文件路径:
which gunicorn、which python,如果是虚拟环境就把完整路径记下来。 - 确认工作目录:在项目目录下手动启动一次,验证相对路径读取是否正常。
- 确认系统用户:看看项目文件属主是谁,
user=配置必须有权限访问项目目录和日志目录。
如果是新机器,还需要先安装 supervisor。常见 Linux 发行版一般都有现成安装包:
# 以 Debian 系为例 apt update apt install supervisor supervisord -v安装后先确认主配置文件是否被正常加载,再往下走。
3.2 给三个进程写一份能直接炒作业的配置
项目目录假设是/opt/silichat/app,虚拟环境在/opt/silichat/venv,业务账号叫www-data。日志统一放/var/log/silichat/。我建了一个deploy/supervisor/chatroom.ini:
[group:chatroom] programs=web_api,worker,scheduler priority=900 [program:chatroom-web] command=/opt/silichat/venv/bin/gunicorn chatroom.wsgi:application -b 127.0.0.1:8000 --workers 2 --threads 4 --timeout 60 directory=/opt/silichat/app user=www-data numprocs=1 autostart=true autorestart=true startsecs=5 startretries=3 stopasgroup=false stopsignal=TERM stopwaitsecs=20 redirect_stderr=true stdout_logfile=/var/log/silichat/web.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 environment=CHAT_ENV="production",MODEL_API_BASE="http://127.0.0.1:9000",MODEL_API_KEY="change_me",PYTHONUNBUFFERED="1" [program:chatroom-worker] command=/opt/silichat/venv/bin/python worker.py directory=/opt/silichat/app user=www-data numprocs=1 autostart=true autorestart=true startsecs=5 startretries=3 stopasgroup=true killasgroup=true stopsignal=TERM stopwaitsecs=15 redirect_stderr=true stdout_logfile=/var/log/silichat/worker.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 environment=CHAT_ENV="production",MODEL_API_BASE="http://127.0.0.1:9000",MODEL_API_KEY="change_me",PYTHONUNBUFFERED="1" [program:chatroom-scheduler] command=/opt/silichat/venv/bin/python scheduler.py directory=/opt/silichat/app user=www-data numprocs=1 autostart=true autorestart=true startsecs=5 startretries=3 stopasgroup=true killasgroup=true stopsignal=TERM stopwaitsecs=15 redirect_stderr=true stdout_logfile=/var/log/silichat/scheduler.log stdout_logfile_maxbytes=50MB stdout_logfile_backups=10 environment=CHAT_ENV="production",MODEL_API_BASE="http://127.0.0.1:9000",MODEL_API_KEY="change_me",PYTHONUNBUFFERED="1"写完后软链到配置目录:
mkdir -p /etc/supervisor/conf.d ln -sf /opt/silichat/deploy/supervisor/chatroom.ini /etc/supervisor/conf.d/chatroom.ini这里的PYTHONUNBUFFERED=1是给 Python 进程用的,强制 stdout 无缓冲,日志才能实时落到文件。我早期没加这个,经常等了半天不见日志,误以为服务卡死。
3.3 从加载到重启:每天都要用到的命令节奏
配置文件写完,先让 supervisord 重新读取配置再更新运行状态,顺序不能乱:
supervisorctl reread supervisorctl update supervisorctl statusreread是让 supervisord 重新扫描配置目录,发现新配置;update才真正让变化生效——启动新增进程、更新有变化的进程、删除已失效配置。如果改的是全局配置,就要用supervisorctl reload重启 supervisor 自身。
常用操作我列在这儿:
supervisorctl status chatroom: supervisorctl start chatroom:web_api supervisorctl stop chatroom:worker supervisorctl restart chatroom:scheduler supervisorctl tail -f chatroom:web_api supervisorctl tail -f chatroom:web_api stderr注意status chatroom:带冒号可以只看这个 group;不带冒号看全部。发布新版本时,我习惯的顺序是:
supervisorctl stop chatroom: git pull supervisorctl update supervisorctl start chatroom: supervisorctl status chatroom:git pull之前先停服务,能避免代码文件被运行时占用导致半更新状态;如果项目本身有自动化热加载,也可以只做supervisorctl restart chatroom-web,省得整组停。这个看团队发布窗口,没有绝对对错。
3.4 日志滚动:不设置好,磁盘迟早被写爆
supervisor 自带的滚动机制是stdout_logfile_maxbytes和stdout_logfile_backups。比如我的配置里是 50MB × 10 份,等于每个进程最多占用约 550MB 磁盘。这个参数不能只拍脑袋定,要先估一下单进程日志量。假设 Web 进程平均每天写 200MB,那 50MB × 10 份大概只能覆盖两三天。生产环境我会按“至少保留一周”的标准去算:
日志总量 ≈ 日均日志量 × 保留天数 ≈ 200MB × 7 ≈ 1.4GB # 可以设成 stdout_logfile_maxbytes=200MB,保持 backups=7而且要特别注意:就算配置了滚动,也不能用mv手动移动日志文件。因为 supervisor 已经把文件句柄打开了,mv之后句柄还指到旧文件,新日志继续写进旧 inode,磁盘甚至可能一点都没释放。正确做法是truncate -s 0 日志文件,或者干脆用logrotate配copytruncate。
注意:如果程序自己输出到别的日志文件,supervisor 的
tail是看不到的,只有 stdout/stderr 才能被 supervisor 捕获。
4. 现场排障:把常见问题一次聊透
4.1 服务频繁 FATAL,第一眼应该看什么
supervisorctl status显示FATAL,意味着进程反复启动失败后被放弃。很多人的第一反应是改startretries,但真正原因通常在日志里。先看日志:
supervisorctl tail -f chatroom:web_api stderr如果 stdout 和 stderr 都看了还是找不到线索,要看 supervisor 自身日志。一个容易被忽略的点:command里的可执行文件是否存在,或者虽然存在但没有执行权限。比如虚拟环境被重建过,路径里只剩个空目录或系统 Python 被替换,都会导致 FATAL。排查顺序建议是:可执行文件存在性 → 启动用户权限 → 命令目录 → 环境变量 → 依赖包是否齐全。绝大多数启动失败都跑不出这个范围。
4.2 stopasgroup 幽灵进程:为什么 stop 之后端口还活着
这是 supervisor 踩坑高频点。假设一个 worker 脚本在内部又fork了一个子进程,或者用subprocess.Popen起了别的任务。默认情况下 supervisor 只跟踪它直接拉起的那个 PID,停止时只给这个 PID 发信号。结果就是:主进程退了,子进程还在后台跑,端口被占住,心态直接爆炸。
解决办法很简单,在 program 配置里加上:
stopasgroup=true killasgroup=truestopasgroup=true让 stop 信号发给整个进程组;killasgroup=true是如果超时还没退干净,直接 kill 进程组。但也要注意:如果你不想把同组的其他进程一起杀掉,这个开关就不能乱开。对“需要清掉所有派生物”的业务进程(worker、scheduler),我一般都会开;对提前确认没有子进程的纯 Web 服务,就不开。
4.3 环境变量和 PATH:为什么手动跑得好好的,supervisor 一跑就挂
这是典型问题。你在终端里手动执行时,终端加载了.bashrc、.profile等一堆初始化脚本,PATH 自然带了/usr/local/bin之类。supervisor 启动子进程时,会使用它自身继承的环境,不会读你的 shell 初始化脚本,因此 PATH 往往非常克制。
解决办法我在前面也提了:command里写绝对路径,需要什么环境变量就用environment显式补充。比如MODEL_API_KEY、CHAT_ENV这类,不要指望从外面带进去。有更极端的情况是密钥里包含逗号或双引号,此时环境变量值要用引号包住,必要时还得转义。
4.4 状态是 RUNNING,但访问 502 或 504,问题出在哪
RUNNING只表示进程活着,不代表它健康。比如 Web 进程启动成功,但绑定地址写成了127.0.0.1而代理服务从另一个网段请求,就会一直不通。这时先看监听地址:
ss -lntp | grep 8000另一个高频问题:gunicorn 的 worker 数目固定,短时间并发上来后全卡在某个模型接口的超时等待上,新请求全排队,表现就是 504。这不是 supervisor 的问题,但排查范围会先落到“进程状态看起来一切正常”的表象。调整--timeout或增加 worker 数才有效。
4.5 开机自启:supervisord 自己也得有人管
supervisor 负责管理业务进程,但它自己如果没被设置为开机自启,机器一重启,一切都白搭。传统做法是交给 systemd 来托管 supervisord。多数发行版安装包会自带现成 unit,只要开启:
systemctl enable supervisord systemctl start supervisord如果没法用 systemd,也可以放rc.local或写 cron @reboot 规则,但我不推荐,可靠性差一截。注意,systemd 管理的是 supervisord 本身,supervisord 再管理业务进程,这是两层结构,不要试图让业务进程直接挂在 systemd 下。分层之后,发布时用supervisorctl,系统重启时由 systemd 拉起,各司其职。
4.6 web 面板要不要开:图省事和安全性之间的取舍
supervisor 自带一个基于 XML-RPC 的 Web 控制界面,配置开在[inet_http_server]里。能用是能用,但我个人生产环境里很少开,原因有两个:一是默认没有认证,裸奔在公网等于把进程控制权交给路人;二是 supervisorctl 命令行已经足够好用,没必要再暴露一个攻击面。如果你确实需要面板,最少要做到:绑定内网 IP、设置用户名密码、禁止公网端口转发。
注意:
[inet_http_server]一定不要绑定0.0.0.0。真要远程管理,用 SSH 隧道访问127.0.0.1是最稳的方式。
4.7 日常发布节奏:让 supervisor 帮忙,而不是觉得它碍事
最后分享一个我自己的操作习惯。我把常用操作封装成一个deploy.sh,内容大概是先supervisorctl status看一遍全貌,然后依次 stop、更新代码、update、start、再看状态。这套流程跑熟之后,发布任何一个服务的心理负担都会小很多。日常排查也固定从状态和日志入手:先status看谁倒下,再tail看日志,绝大多数问题都能定位,不用瞎猜。
我在这个项目里学到最深的体会是:进程管理不是“上线之后才考虑的事”。你可以在项目第一天就把 supervisor 配置写好,后面每次加新进程,只是往 conf.d 里丢一个 ini 文件、执行一次 update 的事。它不会让业务代码变聪明,但能让运维这位“守夜人”时刻清醒地替你把进程盯住。