PM2 cron_restart 是「先杀再起」:调度进程自杀断更两天的复盘
现象
一条每天早晨定时发布的内容管线(生成 → 发公众号 → 发邮件),08-28、08-29 连续两天全部没发。两天的日志特征一模一样:
- 生成步骤(intel-generate)的输出停在 07:04:xx,没有任何报错,就是没有下文了
- 07:05 整,日志里出现两条 dispatcher 进程的启动头行
- 之后一整天,调度器每次唤醒都打同一句:「今天已有记录(running),跳过」
- 07:40 下游的公众号草稿步骤报
guard:no-today-queue——没稿可发
没有 crash 日志,没有 OOM,进程列表里 dispatcher 活得好好的。任务就像凭空蒸发了。
根因
这条管线的调度器 pipeline-dispatcher 当时是一个一次性 PM2 进程:跑一轮判定、该执行的步骤执行完就退出,靠 PM2 的 cron_restart: '*/5 * * * *' 每 5 分钟拉起来一次。
坑就在这里:cron_restart 到点时如果发现实例还在运行,不是「等它跑完」也不是「跳过这轮」,而是先杀再起——SIGINT 掐掉在跑的进程(连同它 spawn 的子进程一起),然后启动新实例。
平时无感,因为 generate 一轮只要 42~92 秒,永远赶在下一个 5 分钟边界前退出。出事那两天,候选池 24 小时上限打满后老条目在 07:00 前后滚出窗口、配额突然释放,现场补写量暴增(一次 LLM 聚类 1 分 43 秒 + 6 条简报校对),generate 被拖过了 07:05——于是:
- 07:05 整,PM2 按 cron 表把 dispatcher 连同正在写稿的 generate 子进程一起杀掉
- 数据库里的执行记录 PipelineRun 停在
running状态,没人给它收尸 - 幂等键(step + 当天日期)看到「今天已有记录」,全天不再重试
- 下游每一步都等不到上游产物,整条链路静默瘫痪
一句话:给「每 N 分钟唤醒」的调度进程挂 cron_restart,等于给它所有子任务设了 N 分钟硬超时——而且这个超时不报错、不告警,杀完还留下一条僵尸 running 记录把重试通道也堵死。
解法
三步,前两步是修复,第三步是当日抢救:
- dispatcher 改常驻进程:脚本加
--loop模式(内部每 5 分钟一个 tick,按墙钟对齐),PM2 配置去掉cron_restart、改autorestart: true。tick 串行执行,长任务只会推迟下一个 tick,永远不会被杀。 - 僵尸记录自动收尸:tick 里对
running超过 30 分钟无结果的记录判定为「进程被杀」(多为部署/容器重启),标记failed。这个判定放在时刻判断之前,不受补跑窗口限制——否则窗口过后才发现的僵尸会以 running 挂一整天。 - 当日抢救:容器内
docker exec直接跑各步骤脚本(不经 dispatcher),修正僵尸 PipelineRun 后,当天后续步骤按点恢复自动执行。
修完在 PM2 配置里留了行注释,防止未来有人「优化」回去:
// ⚠ 不要改回 cron_restart 一次性进程:cron_restart 对在跑实例是先杀再起,
// 08-28/08-29 两天 intel-generate 跑过 07:05 被下一班连子进程一起杀死,
// PipelineRun 卡在 running,当天整条发布链路瘫痪。
验证:本机 8 分钟对照复现
「先杀再起」不是文档里的显眼行为,值得本机实锤一次。最小 demo:一个 90 秒的长任务(起跑/心跳/完成/被杀各打一条带时间戳日志),分别交给两种调度形态跑(PM2 7.0.4,独立 PM2_HOME 隔离):
- 事故组
demo-cron-dispatcher:一次性进程 spawn 长任务,cron_restart: '* * * * *'(每分钟,对应生产的每 5 分钟,等比缩小) - 对照组
demo-loop-dispatcher:常驻进程,tick 串行 spawn 同一个长任务,跑完才排下一个 tick(修复后的形态)
同时起跑,跑 8 分 06 秒(10:10:48 ~ 10:18:54)。事故组日志:

每到整分准时一轮:任务被 SIGINT 掐死(第一次死在 12s——起跑点离整分只有 12 秒;之后每轮都死在 60s/90s),dispatcher 本体跟着被杀,新实例立刻起跑,循环往复。90 秒的任务在 60 秒的 cron 周期下,永远到不了 DONE。
对照组同一时段:

8 分钟统计(grep -c 原始输出):

| 事故组(cron_restart */1) | 对照组(--loop 常驻) | |
|---|---|---|
| 任务起跑(START) | 9 次 | 4 次 |
| 跑完(DONE) | 0 次 | 4 次(每次 90s 跑满) |
| 被杀(KILLED) | 9 次(8 次 cron 整分击杀 + 1 次实验收尾手动停止) | 0 次 |
| PM2 面板重启计数 ↺ | 8 | 0 |
对照组的 tick 周期同样设 60 秒,长任务只是把实际班次自然推迟成约 2 分钟一班——推迟,而不是被杀,这就是两种形态的全部区别。
带走的教训
- cron_restart 只适合「跑得远比周期短」的任务。 它的语义是「到点保证有个新实例在跑」,不是「到点触发一次」。任务时长与周期之间必须留出数倍余量,且要按最坏情况(偶发的慢路径)估,不是按日常均值估。
- 调度器和任务执行器不要共生死。 dispatcher 自己带着子任务被杀,是「调度」和「执行」耦合在同一个进程树里的直接后果。常驻调度 + 串行 tick 的形态下,慢任务的代价只是推迟,不是全灭。
- 幂等键必须配套「僵尸记录」的收尸机制。 「当天已有记录就跳过」防重复执行没错,但记录卡在 running 时它就从保险变成了封条。任何用状态记录做幂等的系统,都要回答「写记录的进程死在半路怎么办」——超时判 stale 是最低配。
- 排查这类「跳过」死循环,先看 running 记录的 startedAt。 一条起始于几小时前、至今没有结果的 running,基本就是进程被杀的尸检报告。