45 条定时任务。25 条躺在 crontab 里,18 条挂在用户级 systemd 定时器上,还有 2 条是挂在原 cron 上的试点和拆分档位。2026-08-24 凌晨 05:40,我把它们全部迁进了自托管的 Windmill,45/45 冒烟真绿、原调度全部移除。
如果我早点做这件事,会省下很多半夜翻日志的时间。但真做起来才发现,迁移本身的坑,比 cron 本身的坑还多。
一、为什么要清这摊账
这 45 条任务本来没有"账"。它们散在三个地方:
- crontab 里 25 行,格式裸奔,没有任何历史记录;改错了没有回滚,删错了没有回收站;
- systemd 定时器 18 个,分散在
~/.config/systemd/user/下,要一个个list-timers才知道哪些活着; - 频率从每分钟到每月首周日都有,有些任务已经不知道是谁、为什么加的了。
最要命的是失败不可见:一条 cron 跑挂了,没有通知,没有 dashboard,可能一个月后才发现某条数据停更了一个月。
我对这套东西只有三个诉求:看得见、改得动、挂得响。自托管 Windmill CE 恰好三条全中——网页 UI、可编辑的 flow、失败告警。但要把它落地,先得解决一个物理问题:Windmill 的 flow 跑在 Docker 容器里,它碰不到宿主机的 crontab 脚本,也执行不了宿主机上的任何命令。

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

流程是这样的:
- Windmill schedule 按 crontab 语法到点触发 flow;
- flow 里唯一一个步骤
wm_exec(我自己写的小模块)把要执行的命令 POST 成一条信箱变量,命名u/admin/wm_cmd_<任务ID>; - 宿主机上的桥守护(systemd 用户单元,开机常驻)每 10 秒扫一遍信箱,FIFO 取件,按一张注册表(命令、工作目录、锁文件、超时、白名单)用原生 shell 执行;
- 执行完把
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(输出尾部)里能看到真实脚本的输出。

为什么这么苛刻?因为如果判定条件放宽一点点,你的迁移就会变成一场幻觉。下一节说的事故就是证据。
四、第一件事:失败是可以被判成成功的
迁移第一晚,我半夜做全量验收,发现一部分 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 会死两次:
- 桥给它 900 秒超时,时间一到它被判定失败——一个健康运行的引擎,因为"活得太久"被判失败;
- 调度每 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 路径每一条都留好。
定时任务是基础设施,不是桌角文件。它值得一个网页、一张台账和一套能响的告警。

