定时任务面板

一台常驻机器每天替你跑几十个脚本。难的从来不是让它跑起来,而是它不跑的时候你能不能知道

先说清楚:调度面板本身不是自研的。 线上跑的是第三方开源项目 whyour/qinglong(Web UI、调度、日志、订阅拉库、通知渠道都是它自带的), 自研的是自部署、准入控制、探活告警与排障那一层。
下面第 2 节是一个可操作的任务面板:任务、日志、成败全是合成数据,随便点。 但里面那个 cron 解析器是真的 —— 输入什么它就现算什么,包括接下来 5 次执行时刻, 没有一条是写死的。

1 · 这条线要解决什么问题

场景:几十个脚本要按点跑、要带一份会过期的登录态、跑在一台会睡觉的机器上、面板还带一个公网可达的 Web UI。真正的难点全在「看不见」这一侧:

面板是别人的软件带登录的 Web 控制台 + 能执行任意脚本 = 一个完整的攻击面。旧版本有过免密登录类漏洞。取舍:镜像不锁版本、跟随 latest(宁可承担上游变更,也不留已知洞)。
端口一发布就是裸奔本机那台端口只绑回环地址,不发布到 0.0.0.0;需要远程用的那台不直接开端口,放在自建访问闸后面(密码 + 邮箱验证码双通道,cookie 做全子域单点)。不用 IP:端口 访问任何面板。
容器挂载写在运行时里,仓库看不见手工 docker run 起的容器,宿主路径只存在于容器的运行时配置里。目录一搬家,容器还挂着旧路径,Docker 自动新建一个空目录,面板初始化出一个 0 任务的白板 —— 而它照样 200、照样 healthy。取舍:改声明式 compose,挂载写相对路径,以后搬到哪挂载跟到哪。
「面板绿着」不等于「线在跑」容器 Up、端口 200、healthy 三个绿灯可以同时亮着而一个任务都没跑。存活判据换成任务数 + 最后执行时间,直接查面板的库,不看颜色。
排程写的是容器时区容器时钟是东八区,承载它的机器在另一个时区、晚上还会睡;睡过去的任务不补跑。所以「排在凌晨 2 点」好不好看不算数,算它落到本地几点才算数 —— 下面面板里的「下次执行」全部按你本机时区现算,就是这件事的最小形态。
依赖一份会过期的登录态凭证死了,当天所有任务照常触发、照常「成功」,只是全在空转。取舍:独立探针每小时探一次,死了当场推一条;死着不动每 12 小时再催一条 —— 因为一条通知划掉就没了,而重登只能人来。
同一套脚本部署在两台机器同一个任务两台都跑 = 双倍请求、双倍风控。切分判据按脚本库名,不按任务 ID 区间 —— 订阅每天拉库会自动给新脚本建任务,ID 顺着往上长,写死区间的规则会被增量绕过。

下面这个面板就是把上面几条做成能点的:任务列表、启停、手动跑一次、看日志,外加一个真的 cron 解析器。

2 · 可操作演示

任务、日志、成败都是合成的,随便点。cron 解析、下次执行时刻、倒计时是真算的 —— 页面开着,任务会自己按点触发。

新建任务

运行日志

3 · 关键设计决策与踩过的坑

决策为什么这么定 / 代价是什么
面板只绑回环,远程那台走自建闸边缘型访问控制拦在路径上,它罩住哪条路径,源站自己的登录页在那条路径上就永远轮不到出场 —— 两者只能各占一条路径。所以准入方式写在一张注册表里由机器对账(vhost 真 include ⇔ 表里标记,双向),不写在文档里等它慢慢和现实漂移。文档里那句「走某邮箱过门」后来实测就是错的。
启停一律走 compose,禁裸 docker run相对挂载让目录迁移不再产生空数据卷。代价:compose 文件本身成了新的单点 —— 有一次它漏了 DNS 那几行,照着它 up -d 一次就把下面那个网络坑重新踩了回来。∴ 声明式文件里每一行「看起来可省」的配置都要写清为什么不能省。
判据从排程本身派生,不写 ID 名单补跑哪些任务、切分哪台跑什么,全部由 schedule 字段和 command 里的库名算出来。占位型排程因为日/月字段不是 * 天然被排除,不用手写黑名单。手写名单会被订阅增量绕过,这是实测过的。
成败不看面板颜色,只认账本脚本库收尾硬编码 process.exit(1),拿到收成的任务照样标红;反过来什么都没干、直接打印「默认不执行」的标绿。退出码在这里承载零信息。判「今天到底跑成没有」只读账本文件。
凭证死活只留一条判定通路面板自带的检测把「这一次接口没取到用户信息」等同于「凭证死了」,并自动禁用凭证条目,一误报当天后面全变「无可用凭证」。换成独立探针:只发一个 GET、零副作用、三态退出码(0 活 / 1 死 / 2 判不出),判不出 ≠ 死。
探针走 docker exec 在容器内发请求出口链路必须和真正跑任务的那一跳一致。在宿主上 curl 通、容器里不通,是这套东西最常见的形态 —— 用宿主验证等于测了个替身。
面板访问层收成一个模块登录 + token 缓存 + 4 次指数退避重试 + 日志去噪,只此一份。之前它长在一个「一 import 就开跑」的脚本里,于是每个临时查询都靠偷源码前缀复用,一天手搓了 9 个一次性脚本、每个还各推一条登录通知刷手机。同一件事手搓第 3 次就该收敛成引擎。

五个「守卫看着是绿的」的坑

① 空挂载:0 任务的白板面板,对外表现完全健康

症状 容器 Up、端口 200、healthy,面板打开一切正常,只是任务列表是空的,线已经静默停摆两个多小时。

真因 目录搬家后,手工 run 起的容器仍挂旧宿主路径,Docker 在旧路径自动建了个空目录,面板把它当全新安装初始化了一遍。

判据 别问「容器起来了吗」,问「任务有几个、最后一次执行是什么时候」——直接查面板的 SQLite。注意时区:宿主与容器时区不同、库里存的是 Unix 时间戳,换算错会得出「停了 17 小时」这种假结论。

根治 compose + 相对挂载。这类事故从此在结构上不可能再发生,不靠「记得改路径」。

② 容器 DNS 假地址:被误判成「账号被风控」整整三天

症状 两种,都不像网络问题:全断时是所有任务 30 秒超时 + 「无可用凭证」;半死时是成片 Client network socket disconnected before secure TLS connection was established 加 503。

真因 宿主的代理是 fake-ip 模式,容器继承宿主 DNS,把目标域名解析成保留段里的假地址,而容器自己并不走那个代理 → TLS 握手必然失败。

判据(一条命令) 在容器内取一次目标 API 的 HTTP 码,返回 000 就是它。别急着归因风控、别急着换凭证 —— 换凭证还会把上一把顶死,越换越确信自己被封了。

试过没用的做法 进容器改 /etc/resolv.conf:改完解析和连通立刻全正常,restart 一次就被改回去。文件头那句「可以编辑、引擎不会再改」在这个场景下不成立。唯一有效的是重建容器时显式指定 DNS。

③ 管道吃掉退出码:告警恒绿的假守卫

形态 out=$(probe.py | tr '\n' ' ') 之后取 $? —— 拿到的是管道末端 tr 的退出码,恒为 0。

后果 探针每小时忠实地报「死了」,而守卫每小时忠实地判成「活着」,一条告警都不会推。哑掉的守卫和它要防的故障是同一类东西,而且更隐蔽:它看起来一直在工作。

怎么发现的 写完做反向验证 —— 把该抓的故障放回去,确认真被拦下。没做过反向验证的守卫不算数。发现后把这个形态钉进了提交前的静态检查,不只修这一处。

④ 固定 sleep 等异步结果:任务一慢就假红

形态 触发面板任务 → 等到「最后执行时间」上跳就认为跑完了 → 固定 sleep 2 去读日志找结论关键词。

真相 时间戳上跳只说明任务开始执行,而那个任务自己中间还要等几秒,判定行那时根本没落盘 → 关键词匹配不上 → 判成失败并中止整轮。事后翻日志,结论明明白白写在那儿。

教训 用固定 sleep 等异步任务出结果就是竞态。要么等终态标记,要么换一条零副作用、同步返回的判定通路 —— 后者才是最终采用的做法。

⑤ 同一个问题留了三份判据,三天后咬回来

经过 「凭证死活以独立探针为准」这条规矩立下时,只改了一个调用点。另外两处仍在跑那条会误报、还会自动禁用凭证的老路。三天后:重登拿到的新登录态明明是活的,另一处却打印「仍失效」并退出 1,把整个重登流程报成失败。

为什么假红比不验更坏 它会让人以为要再登一次,而这类登录态是单点的 —— 每重抓一次,前一把立刻作废,于是「失败→重试」变成了自己顶自己的循环。

落法 收敛成一条通路,其余两处只做转发,同一个问题不留第二份判据。立规矩时当场 grep 全部调用点,否则规矩只落在文档上。

4 · 技术上怎么做的

调度面板第三方开源whyour/qinglong,Docker 镜像跟随 latest。任务调度、Web UI、日志、订阅拉库、通知渠道都用它自带的,不重写。
运行形态双机:本机容器引擎一台 + 一台海外 VPS,同一任务绝不两处启用,切分判据是脚本库名。重启策略 unless-stopped + 引擎登录自启,双保险,缺一则重启后整条线不跑。
声明式配置docker-compose.yml:相对路径挂载 ./data、显式 DNS、端口只绑回环。启停一律走 compose。
准入本机侧只有回环可达;远程侧 nginx 前置 + 自建访问闸(密码页 / 邮箱验证码双通道,cookie 全子域单点)。准入方式登记在站群注册表里,由机器双向对账。
面板访问层一个 Python 模块作唯一入口:登录、token 本地缓存(面板每次登录都会推一条通知,缓存不只是省时间)、瞬断 4 次指数退避重试、日志横幅去噪。CLI 与探针都从它派生。
凭证探针独立脚本,走 docker exec 在容器内发一个 GET,零写操作。三态退出码 0/1/2。附带 --fix:凭证明明活着却被面板自带检测误禁时,就地救回来。
定时探活系统级 launchd agent 每小时一次,一次干四件事:探活 → 误禁自愈 → 机器一醒就补跑当天漏掉的任务(错峰 45 秒逐个跑,单轮封顶,被丢下的必须报出来,不静默截断)→ 每月提醒复测一次被禁任务。
告警推送到手机;推送 key 运行时从容器配置里读,不进版本库。规则见上面演示区第三个页签。
凭证与隐私数据卷、凭证文件、登录结果、联系方式全部 gitignore。仓库里只有声明式配置与脚本,克隆下来跑不出任何身份。

5 · 现状与一本诚实的账

在跑两台面板长期在线,几十个每日任务,探活每小时一次,告警链路验证过(把故障放回去确认真被拦下)。
收益零花钱级别,说白了不值一提。这条线真正的产出是那套「探活 → 告警 → 自愈 → 补跑」的骨架,以及上面那一列踩坑记录 —— 它们换个业务场景照样成立。
还没解决登录态寿命不可预测:四个观测点分别是 8.7 天、7 小时、2 天、22 小时,差 30 倍。∴ 不按周期排期重登,看到告警再动。真要根治,要解决的是「重登需要人解验证码」这件事,而不是去优化排期。
「每小时探活」名不副实机器睡眠时定时器不补跑,实测 54 小时只落了 37 次,漏约 17 次。想要真正的小时级粒度得换一种定时触发方式或整体挪到常醒的机器上 —— 先想清楚为这个粒度值不值得付那份工程量,目前的结论是不值。
残余敞口机器整天不开机,当天就补不上(补跑只在同一自然日内有效)。已知、接受、写在这里。
哪些是合成的:第 2 节面板里的任务名、运行历史、日志正文、成败与耗时全部是编的, 只存在于你自己的浏览器(localStorage),点「重置演示」即还原;成败由随机数掷出,不代表任何真实系统的稳定性。 哪些是真的:cron 解析、频率描述、下次执行时刻、倒计时与到点自动触发,都是本页 JavaScript 现算的。 真实系统在哪:线上跑的是第三方开源面板 whyour/qinglong 的自部署实例, 两台,只在回环地址与自建访问闸后面可达,不对外开放、本页也不与它通信。 本页是纯静态单文件,不加载任何外部资源、不向任何服务器发送数据。