从 PM2、Supervisor 到 Docker:老项目部署现代化实战(以 HOJ 评测机为例)
很多老项目并不是"部署不了",而是经过多年维护后,逐渐变成了谁也不敢动的状态:一部分服务由 PM2 管理,一部分交给 Supervisor,数据库和缓存跑在 Docker 里,还有几个进程直接启动在宿主机上。
它们平时都能工作,真正麻烦的是发布、重启和故障恢复:你很难立即回答"到底谁在拉起这个进程""服务器重启后旧服务会不会复活""新旧实例会不会抢同一个端口"。
本文以 HOJ 评测机为例,记录一次从 PM2、Supervisor 混合部署迁移到 Docker 的完整过程。重点不是 HOJ 本身,而是一套适用于多数老项目的迁移方法:先预检、再切流、随后验证,最后清理旧自启动,并且全程保留回滚路径。

PM2 和 Supervisor 并没有"过时"
先澄清一个容易引战的点:PM2 和 Supervisor 都不是不能用。
- PM2 很适合管理 Node.js 进程,也能管理普通命令;
- Supervisor 是成熟的宿主机进程守护工具;
- Docker 则更擅长统一程序、依赖、资源限制、网络和生命周期。
问题通常不在某个工具,而在于同一套服务没有统一边界。当 PM2、Supervisor、systemd 和 Docker 同时负责相互依赖的组件时,维护者需要在脑中拼出完整拓扑,交接和恢复成本会迅速上升。
这次迁移前,同一台机器上同时存在:
- PM2 管理的 JudgeServer;
- Supervisor 管理的 Sandbox;
- Supervisor 中已经启动失败但配置仍保留的旧服务;
- 即将上线、同时包含 JudgeServer 与 Sandbox 的 Docker 容器。
如果直接执行 docker compose up -d,轻则端口冲突,重则新旧实例同时注册、任务被随机分发,最后连"到底哪套服务处理了请求"都无法证明。
第一步:不要急着停旧服务,先把现状查清
老项目迁移最危险的动作,是看到一个进程后立刻停止它。更稳妥的做法是先建立证据链。
查 Docker
bash
docker ps
docker compose ps
查宿主机进程和监听端口
bash
ps -eo pid,user,ppid,lstart,etime,args --sort=-%cpu | head -30
sudo ss -lntp
分别检查普通用户和 root 的 PM2
bash
pm2 list
sudo -H pm2 list
PM2 的进程清单跟用户绑定。pm2 list 为空,不代表 root 的 PM2 也为空;这也是很多"幽灵进程"判断错误的来源。
查 Supervisor
bash
sudo supervisorctl status
sudo grep -RIl '^\[program:' /etc/supervisor /etc/supervisord.conf 2>/dev/null
还可以用下面的命令判断某个 PID 属于哪个 systemd 服务:
bash
sudo systemctl status <PID> --no-pager
完成这些检查后,至少应该知道:当前服务由谁启动、占用了哪些端口、重启后由谁自动恢复,以及配置文件放在哪里。
第二步:明确新的容器边界
HOJ 评测机包含两个核心组件:
text
JudgeServer:对外提供评测服务
Sandbox:执行不可信代码
旧部署把两者直接运行在宿主机上,因此宿主机同时暴露 JudgeServer 和 Sandbox 端口。新镜像把两者放进同一个容器网络空间,JudgeServer 可以直接访问容器内部的 Sandbox,所以宿主机只需映射 JudgeServer 的服务端口。
一个简化后的 Compose 配置如下:
yaml
services:
judge-server:
image: example/hoj-judgeserver:<固定版本或摘要>
container_name: judge-server
restart: unless-stopped
privileged: true
cpus: 4.0
mem_limit: 6g
shm_size: 512mb
stop_grace_period: 60s
ports:
- "8088:8088"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /srv/hoj/testcase:/judge/test_case
- /srv/hoj/log:/judge/log
- /srv/hoj/run:/judge/run
这里有几个值得保留的设计:
- 镜像固定版本或 digest,不让
latest在未来悄悄变化; - 对 CPU 和内存设置上限,避免评测任务拖垮同机数据库、缓存或前端;
- 使用
stop_grace_period给正在执行的任务留出退出时间; - 日志、测试数据和运行目录挂载到宿主机,重建容器时不会丢失;
- Sandbox 端口不对宿主机映射,减少冲突和攻击面。
第三步:在旧服务在线时完成预检
迁移不等于先停机。只要不映射正式端口,就可以用一次性容器验证镜像、配置中心和数据目录。
校验 Compose
bash
docker compose config -q
docker compose pull
config -q 只检查配置是否合法,不会把展开后的密码打印到终端。
验证配置中心和测试数据
bash
docker compose run --rm --no-deps --entrypoint sh judge-server -c '
curl -fsS http://host.docker.internal:8848/nacos/ >/dev/null &&
test -d /judge/test_case &&
find /judge/test_case -mindepth 1 -maxdepth 1 -print -quit | grep -q . &&
echo NACOS_AND_TESTCASE_OK
'
这条命令只启动临时容器,不长期占用 8088。只有出现 NACOS_AND_TESTCASE_OK,才说明新容器至少能访问配置中心,并且看得到真实测试数据。
数据库和 Redis 也应该分别做端口连通性检查。注意:网络能通不代表账号、密码和库名一定正确,最终仍需以应用启动和真实业务验证为准。
第四步:按"切流---停旧---启新---验证"执行

如果系统有两台评测机,应先确认备用实例健康,再切换目标机器。一个可控的迁移窗口可以这样安排:
1. 在注册中心下线目标旧实例
这里只是让注册中心不再把新任务发给目标实例,并不是停止 Nacos 本身。等待一小段时间,让已经派发的任务尽量结束。
2. 停止旧进程
bash
sudo -H pm2 stop hoj-judgeServer
sudo supervisorctl stop sandbox
3. 确认端口真的释放
bash
sudo ss -lntp | grep -E ':(8088|5050)\b'
预期没有输出。如果仍有监听进程,先确定 PID 的归属,不能带着疑问继续启动新容器。
4. 启动新容器并等待健康
bash
docker compose up -d
docker compose ps
docker inspect -f '{{.State.Status}} {{.State.Health.Status}}' judge-server
刚启动时显示 health: starting 很正常。最终应该变成:
text
running healthy
随后检查启动日志和注册中心,确认新实例名称、并发参数、端口和健康状态都符合预期。
第五步:用真实请求证明流量走了新容器
"容器是 healthy"只能证明健康检查通过,不能证明完整业务链路可用。
当两台实例同时在线时,一次任务可能被任意实例处理。为了获得确定证据,可以暂时在注册中心下线另一台评测机,只保留新容器实例,然后提交一个执行时间很短的真实任务。
如果任务正常返回,就证明下面这条链路已经打通:
text
业务后端 → 注册中心 → 新 JudgeServer → 容器内 Sandbox → 测试数据 → 结果回传
验证完成后立即恢复另一台实例。
有些应用不会把每个任务写到 docker logs,而是写入挂载的文件日志。因此"标准输出里没有业务日志"不等于任务没有执行。注册中心只保留新实例、真实任务仍然成功,才是更强的证据。
第六步:成功以后再清理旧自启动
迁移最容易漏掉的是这一步:仅仅执行 stop,并不会删除开机恢复配置。服务器重启后,旧服务可能再次复活,与 Docker 争抢端口和资源。
清理 PM2 恢复清单
bash
sudo -H pm2 describe hoj-judgeServer > legacy-pm2-hoj-judgeServer.txt
sudo -H pm2 delete hoj-judgeServer
sudo -H pm2 save --force
sudo -H pm2 list
当 PM2 清单为空时,普通 pm2 save 可能跳过保存,所以需要 --force 用空清单覆盖旧的恢复文件。
禁用 Supervisor 旧配置
不要迁移当天直接删除配置,先改名保留:
bash
sudo mv /etc/supervisor/conf.d/sandbox.ini \
/etc/supervisor/conf.d/sandbox.ini.disabled
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status
JudgeServer 或其他历史组件如果也有 Supervisor 配置,确认内容后使用同样方式禁用。
回滚一定要比上线步骤更短
新容器无法完成真实任务时,优先恢复服务能力,不要在生产窗口里执着于现场调试:
bash
docker compose down
sudo supervisorctl start sandbox
sudo -H pm2 start hoj-judgeServer
如果旧配置已经改名,则先把 .disabled 文件恢复,再执行 reread 和 update。最后重新在注册中心上线旧实例。
回滚时最重要的规则只有一条:不要让新旧两套服务同时监听同一个宿主机端口。
这次迁移真正解决了什么
迁移后的收益并不只是"命令从 pm2 start 变成了 docker compose up -d",而是部署边界终于变得清楚:
- JudgeServer 与 Sandbox 使用同一个镜像和生命周期;
- CPU、内存、共享内存和退出时间都有明确约束;
- 端口、目录和配置来源能从 Compose 中直接审计;
- 服务器重启后不会被旧 PM2 或 Supervisor 配置重新拉起;
- 故障时可以按固定命令快速回滚;
- 新维护者不用先理解三套进程管理工具,才能判断线上到底运行了什么。
所以,老项目部署现代化的核心不是"把所有东西塞进 Docker",而是把运行边界、配置来源、资源上限、验证方法和回滚路径一起标准化。
如果只能记住一个迁移顺序,可以记住这八个字:
先查、预检、切流、验证;成功以后,再清旧账。