Featured image of post 我把 45 条定时任务搬进了 Windmill:一次迁移教会我的五件事

我把 45 条定时任务搬进了 Windmill:一次迁移教会我的五件事

45 条散落在 crontab 和 systemd 定时器里的自动任务,全部迁进自托管的 Windmill。这篇文章复盘两天的迁移:邮箱协议桥怎么搭、假绿事故怎么发生的、常驻进程为什么不能当任务迁移,以及凌晨四点被删身份的事故。

45 条定时任务。25 条躺在 crontab 里,18 条挂在用户级 systemd 定时器上,还有 2 条是挂在原 cron 上的试点和拆分档位。2026-08-24 凌晨 05:40,我把它们全部迁进了自托管的 Windmill,45/45 冒烟真绿、原调度全部移除

如果我早点做这件事,会省下很多半夜翻日志的时间。但真做起来才发现,迁移本身的坑,比 cron 本身的坑还多。

迁移时间线:23:59 前一日收尾狩猎,到 05:40 终验
两天两夜的真实时间线:绿=里程碑,橙=两起事故

一、为什么要清这摊账

这 45 条任务本来没有"账"。它们散在三个地方:

  • crontab 里 25 行,格式裸奔,没有任何历史记录;改错了没有回滚,删错了没有回收站;
  • systemd 定时器 18 个,分散在 ~/.config/systemd/user/ 下,要一个个 list-timers 才知道哪些活着;
  • 频率从每分钟到每月首周日都有,有些任务已经不知道是谁、为什么加的了。

最要命的是失败不可见:一条 cron 跑挂了,没有通知,没有 dashboard,可能一个月后才发现某条数据停更了一个月。

我对这套东西只有三个诉求:看得见、改得动、挂得响。自托管 Windmill CE 恰好三条全中——网页 UI、可编辑的 flow、失败告警。但要把它落地,先得解决一个物理问题:Windmill 的 flow 跑在 Docker 容器里,它碰不到宿主机的 crontab 脚本,也执行不了宿主机上的任何命令。

零散 cron 定时任务汇入统一调度面板的插画
零散 cron 定时任务汇入统一调度面板的插画|AI 生成示意图

二、核心架构:一条"邮箱协议"桥

解法是把"执行"和"调度"拆开:调度和流程定义留给 Windmill,真正跑命令的还是宿主机。中间靠 Windmill 自带的 variables(变量)当信箱,一条宿主守护进程当邮差:

邮箱协议桥:容器内投递命令,宿主桥取件执行、回写回执
邮箱协议桥:flow 步骤把命令 POST 成一条信箱变量,宿主桥 10 秒轮询取件执行,再把 rc 和日志尾写回回执信箱

流程是这样的:

  1. Windmill schedule 按 crontab 语法到点触发 flow;
  2. flow 里唯一一个步骤 wm_exec(我自己写的小模块)把要执行的命令 POST 成一条信箱变量,命名 u/admin/wm_cmd_<任务ID>;
  3. 宿主机上的桥守护(systemd 用户单元,开机常驻)每 10 秒扫一遍信箱,FIFO 取件,按一张注册表(命令、工作目录、锁文件、超时、白名单)用原生 shell 执行;
  4. 执行完把 rc 和输出尾写进回执信箱 u/admin/wm_job_<任务ID>,步骤读到后自己删掉变量,完事。

几个关键细节:

  • 变量 inbox 天然原子:往变量里创建一条命令没有"覆盖竞态"问题,天然当一个队列用;
  • 删除变量必须用 HTTP DELETE 且带 /w/admins/ 前缀,否则命令变量残留,桥会每 10 秒重放一遍;
  • 桥只有 3 个并发工位,长任务排队时别的任务要等——这个限制后来逼我对一个特殊任务重新设计了方案(见第五节);
  • 每个脚本还有 ok_rc 白名单:审计扫描类脚本 rc=1 表示"有发现",这种情况不是失败,自动归一为 0。

三、铁律:只有"真绿"算迁移完成

迁移过程中我给自己立了一条铁律:冒烟不真绿,不许删原来的 cron/timer。“真绿"的定义很苛刻——flow 跑出来,result 必须是字典且 rc == 0,并且 tail(输出尾部)里能看到真实脚本的输出。

一条真冒烟长什么样:44 分钟全链跑完,rc=0
lynxhouse 房产数据管线的真实冒烟结果:44 分钟跑完爬取、导入、导出、部署全链,rc=0

为什么这么苛刻?因为如果判定条件放宽一点点,你的迁移就会变成一场幻觉。下一节说的事故就是证据。

四、第一件事:失败是可以被判成成功的

迁移第一晚,我半夜做全量验收,发现一部分 flow 显示"迁完了”,但宿主机上一个任务都没执行过

排查下去,是执行模块被改坏了一版:flow 里的投递路径指向了容器内部,所有命令投递必败。但失败之后,flow 里的 failure 兜底模块会以退出码 0 结束,还把"已派发告警"这几个字当成功信号返回。当时的验收驱动一看 “success” 就画钩——19 条假绿就是这么来的。

修复是两件事:执行模块重写成信箱协议版;判定逻辑收紧成 result 是 dict 且 rc == 0。然后 19 条全部重跑,这次直接看作业表里的真实执行记录。

这件事的教训很朴素:**成功必须是一个唯一的、苛刻的信号,而不是一个字符串。**任何"大概算成功"的兜底,都会在半夜变成一张假的交接清单。

五、第二件事:凌晨四点,身份被删了

比假绿更离谱的是第二天凌晨四点零八分。桥守护突然全线报 401,我的管理员令牌和 admin 用户被从数据库的 token 表、用户表里双双删掉了。审计表干干净净,没有任何一条记录能说明是谁删的、经谁的手

当时的桥对接的是一个完全瘫痪的 API。恢复办法是数据库手术:重建 admin 用户行,再造一个令牌行,吃满 token 表的隐藏规则——

  • 表的 token_hash 存的是原始令牌的 sha256 十六进制,不是令牌本身;
  • 认证查询要求 token 行同时满足 owner = 'u/admin'(带 u/ 前缀的工程 ID 形式)且 email 非空,错一个字段就永远 401。

这套规则是我一行行啃认证源码才搞明白的。恢复之后我把令牌续期时间、备份路径全部落进了交接文档,并给这个单点打了"要加审计"的标记。

这件事给自托管爱好者的教训:**别把调度器本身的账号当成"不会出事"的东西。**它和你的 crontab 一样,是基础设施的一部分,需要同样的备份和审计。

六、第三件事:常驻进程不是"任务"

最顽固的一条是 ghwebfollow:一个 Playwright 驱动的 GitHub 关注引擎,while True,开机就跑到天荒地老——它从来没有"完成"这个概念

把它当普通任务迁进 Windmill 会死两次:

  1. 桥给它 900 秒超时,时间一到它被判定失败——一个健康运行的引擎,因为"活得太久"被判失败;
  2. 调度每 10 分钟触发一次,前面 15 分钟的任务还没死,后面的任务开始排队——3 个并发工位很快被同一个任务吃满,其它所有 flow 全部饿死。

正确的解法是把语义想清楚:**原来的 systemd 定时器根本不是"每 10 分钟跑一次",它是"每 10 分钟看一眼,死了就拉起来"——一个探测门控。**于是我给引擎配了一个守卫小脚本:Windmill 每 10 分钟触发,守卫看一眼——

  • 引擎活着、心跳新鲜 → 什么都不做,秒退 rc=0;
  • 引擎死了或失去心跳 → 拉起新引擎,rc=0。

每次巡检 5 秒内结束,再也不占工位。跑不完的是服务,不是任务;服务要配的是看门狗式的巡检,而不是把服务本身塞进任务队列。

七、第四件事:1G 内存的容器,能把自己杀死

公众号草稿任务迁移时,冒烟连续三次失败,退出码都是 137。137 的意思是进程被信号 9 杀死——在容器里,十有八九是被 cgroup 的内存上限掐死的。

查宿主内核日志,实锤:任务是 Hugo 构建三篇文章的静态站,构建时进程内存峰值冲到 1.16GiB,而它的容器 mem_limit 只给了 1g。**构建本身产生的内存需求,超过了容器给自己定的规矩。**把 mem_limit 提到 3g,冒烟立即真绿,422 秒真实产出了三篇草稿。

这条教训实操性最强:**迁移冒烟看到 rc=137,先查内存限制,别上来就怀疑迁移逻辑。**137 是告诉你"我死了,但不是自然死亡"。

八、第五件事,以及一堆小坑

第五件事是三合一,每件都不起眼,但都真实耗了我时间:

**代理候选列表里的死口。**一个自动更新脚本从多个本地代理里挑可用的,候选列表第一位是个早就死掉的端口。每次执行先撞一次死口,再退而求其次——慢,但不报错。迁移排障时我一开始还以为是 git 的问题,而问题只是排序:活口优先,死口沉底。

**并行删 cron 行会互相踩踏。**多条并行迁移各自读 crontab、删自己那行、再写回——后写的人把先写的人复活了。有 3 行 cron 就是这样死而复生的。教训:crontab 的读-改-写必须串行。

**cron 的星期 0 陷阱。**Windmill 的 crontab 语法从秒开始,共 6 段;星期天要写 7 不能写 0。迁移工具里没有这个转换,一个月度任务会直接报错拒收。

还有一条纯属排障技巧:某天桥日志全是报错,查了半天,最后发现那个日志文件本身是五天前的化石——桥的输出改走 journal 之后,没人删老文件。排障先确认自己在看活的日志,这一条值半小时。

九、现在长什么样

迁移终态:

  • 45 条全部真绿启用,速度从每分钟到每月首周日都有,统一看到站内 UI 和一张台账;
  • 原 crontab 只剩 6 条我有意不迁的看门狗(自愈式探活任务,和系统强绑定,不迁才是对的);
  • 两起事故(假绿、令牌被删)全部修复并写入交接文档;
  • 遗留两件事:9 个死容器等确认后清理;管理员令牌 2026-09-22 到期前续签,提醒已挂进文档。

如果你也有一摊散着的 cron,想不想动手是另一回事,但这次迁移给出了一个可以复用的判断框架:

  • **先有运行台账,再有迁移:**45 条全是先盘点后迁移。任务自己会游泳,别在泥水里拍脑袋;
  • **判定信号只认 rc==0 + 真实输出,**别接受任何字符串形式的成功;
  • **先分层,再动手:**跑完就退的是任务,直接迁;一直跑的是服务,配守护巡检迁;自愈看门狗,不迁;
  • **删原调度是最后一步,**前面全绿才动手,rollback 路径每一条都留好。

定时任务是基础设施,不是桌角文件。它值得一个网页、一张台账和一套能响的告警。