尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零实现轻量级任务调度器:YAML配置、Cron表达式与自动化实战

从零实现轻量级任务调度器:YAML配置、Cron表达式与自动化实战 1. 为什么叫Colibri一只蜂鸟决定了这个项目的产品方向Colibri这名字我第一次看到时第一反应不是某个框架而是法语和西班牙语里的蜂鸟。蜂鸟的特点很有意思体积小、翅膀扇动频率高、能在空中悬停还能极快地变换方向。当时我正准备把一个困扰自己很久的问题彻底解决掉电脑和服务器上的定时脚本太多了散落在crontab、systemd timer、Windows计划任务甚至两台笔记本的启动项里有的脚本连日志都没留跑没跑成功全靠运气。我想做一个小工具把这些任务收敛到一个入口里统一管理。这个工具不需要像那些大型调度平台一样拥有复杂界面和分布式能力它应该像蜂鸟一样轻巧、敏捷、不吵不闹需要的时候随时能给出明确反馈。于是项目名就这么定了Colibri。这个项目本质上是一个以YAML为配置的轻量任务自动编排工具你用一段配置描述什么时间、做什么事、失败之后怎么办剩下的交给Colibri去执行、记录和通知。它不绑定具体的运维平台也不要求你必须会某种编程语言。配置写清楚之后一条命令启动它就会安静地在后台按照计划把任务跑完。什么人适合用这个东西如果你是个人开发者手上有几台云服务器或者一台家里常年开着的NAS经常需要做一些重复事情比如清理临时文件、拉取远端数据、备份目录、定时检测某个网站是否可用那么Colibri这个思路值得参考。如果你只是对任务调度系统到底是怎么跑起来的感兴趣这篇内容同样适用因为我们会把从配置解析、调度计算、子进程执行、结果记录到告警通知的整条链路拆开来讲。下面我按自己实际开发的顺序把Colibri从想法到落地过程完整讲一遍。有些地方我先给结论再解释为什么这么做这样你拿去用的时候可以直接抄作业出了问题时也能顺着思路自己排查。2. 功能边界先想清楚Colibri不做什么比做什么更重要2.1 要解决的痛点脚本四散无法统一管理在动手写代码之前我先把自己手头的真实场景列了一遍。大概有五六类任务每天都在跑日志清理、数据库备份、价格监控、目录同步、健康检查。这些任务分散在不同的机器上有的写在crontab里有的用systemd timer管理还有一两个是放在Python脚本里的while True循环靠nohup挂在后台。最难受的是没有一个统一的地方能回答这几个问题这个脚本上一次跑是什么时候跑了多长时间输出是什么失败之后通知到谁我只能挨个服务器登录上去翻日志运气不好时连日志都没有只能靠人工重跑一遍再观察。这些具体问题翻译成产品需求就是四件事任务描述、调度执行、结果记录、失败通知。任务描述要足够简单不能为了让一个清理目录的任务去写一堆Java类调度执行要能覆盖cron这种标准能力结果记录要能回溯不能跑完就没了失败通知要能主动推送而不是让人定期去看。2.2 明确不做的范围单机、轻量、无状态功能边界这件事我是先从不做什么开始划的。首先是明确不做分布式不做多机协同Colibri只在单台机器上跑。个人使用场景下需要多机联动的情况本来就少一旦扯上分布式配置、网络、一致性全部会变成新的复杂度。其次是明确不做GUI不搞网页管理端所有操作都通过命令行完成。我的目标是让配置可版本化、可审计一条命令能启动能停止图形界面反而会让日志和配置变得不可控。第三是明确不做通用工作流引擎不支持复杂的DAG依赖编排、人工审批、重试队列、租户权限这些功能。那些能力对于一个个人任务管家来说不是增值是负担。核心可交付的能力我整理成了一张表开发和测试时都按这张表来验收能力维度Colibri的做法对应场景任务描述YAML文件一个job代表一个任务清理、备份、检查、通知调度规则标准五段cron表达式每分钟、每天凌晨、周一等执行动作本地shell命令或Python回调自由度高不限制语言结果记录SQLite数据库保存每次运行记录回溯历史、排查问题失败通知Webhook推送或邮件跑挂了第一时间知道防误操作dry-run预览、配置校验改配置后先看再跑这张表还有一层含义Colibri关心的不是任务内部怎么实现而是任务的外部契约——什么时候触发、超时多久算失败、失败了几次以后放弃、通知发给谁。至于命令里是写Shell还是写Python那是用户自己的自由。2.3 配置文件长什么样先有样例再有实现我习惯先写一份理想中的配置文件样例再去想代码怎么实现。Colibri的配置大概长这样tasks: - name: clear-temp desc: 清理七天前的临时文件 cron: 0 2 * * * action: type: shell command: find /tmp -type f -mtime 7 -delete timeout: 120 retries: 2 notify: on_failure: true on_success: false写这段样例的时候我反复提醒自己一件事配置的语法边界就是产品的边界。如果配置里出现分布式队列历史回溯窗口这些词说明我在自己骗自己。上面这份配置只包含任务名、描述、调度时间、执行动作、超时、重试和通知策略没有任何多余的修饰。这给后期实现省了特别多麻烦。3. 技术选型每一个依赖进入之前都要回答一个问题3.1 语言选择先用最快的时间换正确性Colibri选Python作为实现语言很多人可能会觉得Python能叫轻量吗其实轻量不轻量要看使用场景。Colibri定位的是常驻内存的进程启动一次之后长时间运行Python解释器的内存开销在个人服务器上完全可以接受而且我用到的库很少体积并不大。选Python的三个原因第一开发效率高配置解析、subprocess调用、SQLite操作都有成熟的库可以快速把想法变成可运行的东西第二脚本生态好用户写shell命令最多偶尔想写点Python回调也不会觉得别扭第三跨平台能力稳同一套代码在Linux服务器和macOS笔记本上都能跑Windows上通过PowerShell也能覆盖大部分场景。我不否认Go和Rust可以做静态编译、内存更小但Colibri目前最值钱的东西是调度逻辑和配置约定不是极致内存优化。过早用低效开发效率换性能在小项目里不划算。3.2 YAML作为配置格式好用但要提防自动类型转换配置格式我比较过JSON、TOML和YAML。JSON写起来太啰嗦不支持注释TOML虽然注释友好但结构一嵌套就变得冗长YAML缩进风格直观注释随便写适合把任务配置写出给人看的感觉。所以最终选了YAML。但YAML有一个著名的坑自动类型转换。比如2024-06-01 10:00:00这类字符串解析出来不是字符串而是datetime对象yes、no、on、off在一些解析器里会被当成布尔值0123这种带前导零的数字也可能变成八进制数或被转成别的类型。这个问题在配置解析阶段不炸通常要等到执行阶段才会暴露排查起来非常隐蔽。我们的解决方案会在后面踩坑一节里详细展开这里先记住一个原则凡是你不确定属于什么类型的字段一律显式加引号让YAML把它当字符串处理。3.3 状态存储选中SQLite个人项目不需要独立的数据库任务执行历史如果用纯文件保存查起来会很痛苦。日志文件只能顺序读想查上周三的清理任务到底跑没跑得写一长串grep。SQLite在这个场景下几乎是完美选择单文件数据库不需要单独安装服务Python标准库自带支持事务机制足够可靠。一个几百KB的文件就能存下数万条运行记录。我设计了两张表。一张存任务定义另一张存每次运行的结果。任务定义表保存当前生效的配置快照目的是让历史记录即使配置文件改过也能还原当时的运行参数。运行记录表记录每次运行的开始时间、结束时间、退出码、输出摘要和错误信息。这样排查问题时可以只通过一条SQL语句就得到某个任务的完整历史比如最近24小时哪些任务失败过SELECT task_name, started_at, exit_code, error_message FROM run_history WHERE started_at datetime(now, -1 day) AND exit_code ! 0;3.4 调度器不重复造轮子也不让轮子绑架项目调度逻辑是Colibri最核心的部分。我一开始考虑直接用APScheduler这样的成熟库后来仔细评估之后决定只用一个解析cron表达式的库croniter调度主循环自己写。原因是APScheduler虽然功能多但很多能力我用不上而且它的线程模型、持久化、序列化方式一旦引入出了问题反而要花更多时间去理解它而不是理解自己的业务。croniter只负责一件事给定一个cron表达式和一个基准时间算出下一次执行时间。这个计算确实容易出错没必要自己实现交给专业库主循环、任务并发、超时控制、重试逻辑则完全掌握在自己手里出问题我能直接定位。依赖清单最终只有三个PyYAML解析配置croniter计算调度时间requests发送HTTP通知。这个清单我特意控制得很严每一个进来都要先回答一个问句这个库是不是能用一个很小的自实现函数替换如果答案是可以那就先不引。最后保留的三个都是替换成本很高或者轮子已经很成熟的组件。4. 核心实现一条任务从YAML到通知的完整旅程4.1 入口设计一条命令读懂项目Colibri的命令行入口需要覆盖三个基本场景校验配置、预演计划、正式启动。用Python标准库argparse实现没有引入复杂的命令行框架。入口代码大概是这样的结构def main(): args parse_args() config load_config(args.config) if args.command check: ok, errors validate_config(config) if not ok: for err in errors: print(f[ERROR] {err}) sys.exit(1) print(fconfig ok, tasks{len(config.tasks)}) elif args.command dry-run: preview_next_runs(config, lookaheadargs.lookahead) elif args.command run: scheduler Scheduler(config) scheduler.run()这里有一个设计细节我特别坚持所有命令都必须先经过配置校验。哪怕是dry-run预览如果配置本身有错误也不应该给出任何计划因为基于错误配置去预览毫无意义。校验失败时直接非零退出方便接在CI或git hooks里。4.2 配置加载与校验能出错的地方全部提前失败配置加载不只是YAML解析还包含一套语义校验。比如任务名字不能重复、cron表达式必须能被croniter解析、timeout必须大于零、notify字段只能是布尔值。我用一个独立的validate_config函数处理所有校验把错误全部收集起来一次性反馈而不是遇到第一个错误就退出。这样做的好处是用户改完配置以后能一次性看到所有问题不用一遍遍试错。校验逻辑里有一条容易被忽略但很重要的规则action必须至少为shell或python之一但不能同时为空。这看起来是废话实际开发中真的有人会写一个空任务进去然后怎么查都查不出来为什么不执行。与其让这种问题在运行时隐藏不如在校验阶段就明确报错。4.3 调度循环从当前时间到下一个触发点调度主循环是Colibri的心脏我把它设计成每次醒来只处理该处理的任务然后直接睡到下一个可能触发的时间点而不是固定一秒醒来一次。后者的好处是代码简单坏处是CPU空转、日志刷屏、每分钟都要遍历一次所有任务哪怕大部分时间什么都不需要做。核心逻辑大概是这样的def run(self): self.setup_single_instance_lock() while not self.stop_event.is_set(): now timezone.now() for task in self.tasks: if task.should_run(now): self.execute_task_async(task) next_time min(t.next_run_after(now) for t in self.tasks) wait_seconds (next_time - timezone.now()).total_seconds() self.stop_event.wait(max(0, min(wait_seconds, 60)))这里有个小技巧每次循环都重新计算最近的未来触发点然后把这个时间点作为本次睡眠长度。Cron表达式可能出现每五秒一次这种短周期任务sleep上限设为60秒避免了某个任务明明需要在十秒后触发却因为睡得太久被错过。stop_event.wait而不是sleep的原因是可以被CtrlC及时打断不需要等到睡眠结束才能响应退出。4.4 任务执行和失败重试不能让一个任务卡死整个进程任务执行模块我使用了ThreadPoolExecutor每个任务一个线程去跑互不阻塞。为什么要用线程而不是asyncio协程因为很多用户的任务是shell命令执行时会有阻塞式系统调用如果混进同一个事件循环里一个命令卡住会导致所有任务全部停摆。线程模型虽然资源开销大一点但隔离性好、心智负担低。执行器处理超时和重试的代码思路def run_task_with_retry(task): last_err None for attempt in range(task.retries 1): try: result run_subprocess_with_timeout(task) notify_if_needed(task, result) return result except TimeoutExpired: last_err TimeoutExpired() except SubprocessFailed as e: last_err e time.sleep(min(2 ** attempt, 30)) notify_failure(task, last_err)这里两个细节值得展开。第一run_subprocess_with_timeout内部使用了subprocess.run(..., timeouttask.timeout, capture_outputTrue)时间一到会直接抛出异常不会让任务无限挂起。第二如果任务派生了自己的子进程光靠subprocess.run的timeout可能杀不干净我在执行时加了start_new_sessionTrue让被执行的命令独自成一个进程组超时之后可以把整棵进程树都杀掉。这个细节在跑备份脚本或启动其他脚本时特别重要否则你杀的是父进程它的子进程还在后台偷偷跑。4.5 通知与日志人不需要一直盯着通知模块的思路是不绑定任何特定IM服务。Colibri发通知时只是往一个webhook URL发起POST请求至于这个地址是钉钉机器人、微信群机器人还是自建的通知服务完全由用户配置决定。这样Colibri本身没有任何厂商依赖。消息体用标准JSON格式包含任务名、状态、开始时间、结束时间和输出摘要。日志方面我没用传统logging模块的纯文本格式而是输出JSON Lines格式每一行是一个JSON对象。这样无论用jq还是直接在终端来看都能快速过滤关键字段。一条日志长这样{ts: 2024-06-01T02:00:01Z, task: clear-temp, event: finished, exit_code: 0, duration_ms: 832}这种结构化日志在个人项目里看起来有点重但一旦任务数量超过十个它的好处会立刻体现出来。排查问题时一句grep exit_code: 1 colibri.log就能把失败记录全捞出来。5. 实测与调优蜂鸟不是只能快而是要稳得住5.1 一组真实的实测数据Colibri跑了一段时间以后我记录了它在三台不同环境下的表现。机器A是一台2核4G的Linux服务器机器B是macOS笔记本机器C是树莓派4。测试负载设定为20个任务每5秒调度一次。数据大致如下指标Linux服务器macOS笔记本树莓派4启动到进入调度约0.15秒约0.18秒约0.35秒空闲常驻内存约34MB约38MB约32MB单次调度判定耗时小于1ms小于1ms约2ms任务并发执行正常正常正常这个数据对个人项目来说完全够用。空闲内存和轻量两个字是匹配的运行时的CPU占用几乎为0。树莓派上的调度判定耗时高一些但也在合理范围内毕竟它的CPU性能摆在那。还有一项更重要的指标是长时间运行稳定性。我让它在一台服务器上连续跑了三周期间没有重启进程没有出现内存持续增长任务触发延迟始终小于1秒。这个稳定性主要得益于调度主循环里没有持续创建对象、SQLite写库时没有用固定长事务以及日志按天滚动。5.2 调优从能跑到长时间跑也不烦实际调优过程中我做了几件事每一项都是从真实运行问题里来的。第一把固定休眠改成动态计算下一次触发时间。最初版本是每秒醒一次日志里会有大量无意义的循环记录而且树莓派上CPU占用会漂到2%左右。改成计算下一次触发时间以后CPU占用几乎归零日志安静了很多。第二为shell任务做了完整的超时和进程组隔离。这个前面提过不重复但它确实是稳得住的关键。没有进程组隔离之前曾出现过一次备份任务超时后实际tar子进程还在继续写磁盘导致磁盘被写满的情况。加上start_new_sessionTrue之后再也没出现过类似问题。第三用SQLite写结果时统一使用短连接。每次执行完任务就connect、写入、close不再维护一个长期持久的连接对象。这样避免SQLite在长连接情况下可能出现database is locked问题也避免进程长期占用数据库文件句柄磁盘快照和备份时可以更安全地复制数据库文件。第四加了单实例锁。这个很重要Colibri自己管理调度如果手抖执行了两次colibri run会出现两个进程同时调度同一批任务轻则重复执行重则触发数据冲突。单实例锁我用了Linux上很常见的flock机制锁文件放在/var/lib/colibri/或~/.colibri/下第二个进程启动后检测到锁就直接退出并提示。实现很简单但避免的是一整个类别的灾难。6. 开发中踩过的三个坑症状、定位过程和最终修正6.1 YAML自动类型转换把时间字符串变成了时间对象第一个坑发生在配置解析阶段症状非常隐蔽。当时我配置了一个任务cron字段正常但在某一次任务日志里发现命令参数变成了一个看起来像2024-06-01 10:00:00的datetime对象模板渲染时怎么转字符串都不对。一开始我以为是模板函数的问题反复调试了很久最后才意识到问题根本不在代码里而是YAML解析的行为2024-06-01 10:00:00在没有引号时会自动解析成datetime对象。定位过程其实也简单我把yaml.safe_load之后的结果打印出来用type()看了每个字段的类型才发现配置里的字符串已经不是字符串了。修复方案是在配置规范里明确要求所有非结构化的字符串值必须加引号尤其是时间、日期、数字和on/off、yes/no这类词。同时在校验函数里针对任务描述、命令等字段做了一次强制类型检查一旦发现字段类型不是str就直接报错并给出提示信息告诉用户这里需要加引号。这个坑让我明白一个道理配置解析器不是一句用YAML就完事你必须在文档和校验逻辑里告诉使用者YAML的自动类型转换陷阱在哪里。测试里也应该把这类边界用例写进去。6.2 在异步流程里直接塞同步网络请求第二个坑发生在通知模块接入的早期。我最初用asyncio写了一套并发执行框架发现每次有任务失败要发通知时整个调度就卡住了几秒钟。症状是系统里有二十多个任务突然全部延迟日志显示明明任务执行完了但下一个任务的开始时间却晚了好几秒。我一开始怀疑是subprocess的问题后来在代码里加了一行计时日志才发现卡顿发生在调用requests.post发送通知的瞬间。原因很直白requests.post是一个同步阻塞操作它会阻塞整个事件循环。只要有一个通知慢所有协程全部等待。定位到这个原因后我的方案是彻底放弃asyncio改用ThreadPoolExecutor。任务调度本身是IO混合型用线程池更符合直觉也不容易再踩同步库混进异步循环这种坑。这个经验想表达的是异步不是银弹。对于一个小型任务编排工具线程的额外开销完全可以接受但事件循环一旦被一个不懂事的同步调用堵住整个进程就废了。如果你确实要用asyncio请务必记住所有IO操作必须走异步版本或者在run_in_executor里包装同步调用。6.3 cron表达式的第几秒陷阱第三个坑是最隐蔽的发生在cron表达式解析上。Colibri对外定义的是标准五段cron即分、时、日、月、星期。但有一个用户在配置里这样写*/5 * * * * *六段表达式。他以为这是每五秒执行的意思实际上我的解析器把它当成五分时日月年还是类似的东西处理结果任务的行为变得非常怪异。还有一个场景是我自己调试时写了一个秒级任务但croniter默认会忽略秒字段导致任务完全不在预期时间触发。问题的本质是cron表达式有5段和6段两种常见形式而用户根本不知道这个区别。修复方案是在配置校验阶段明确判断表达式的段数五段按标准cron处理六段则先剥离秒字段并且通过配置项interval_seconds来支持秒级调度而不是让用户自己去拼6段cron。也就是说普通用户只要写五段cron需要高频率执行时用interval_seconds字段单独声明。这个坑给了我一个很强的信号项目文档里不能默认所有人都理解cron的细节必须把最简单的那条路径画出来告诉用户你只需要知道五段就够了。7. 沉淀下来的设计习惯与下一步怎么走7.1 配置即代码但必须配上校验器Colibri开发到后期我最大的收获不是代码写得多好而是对配置即代码这件事有了新的理解。配置确实应该放在Git里版本化也能被review但它本质上是人类和程序之间的契约。如果没有一套严格又清晰的校验器这个契约很快就会变成每个用户各自有一套心得项目也会变得不可维护。所以Colibri的配置校验不只是技术组件的正确性校验还包括对用户意图的校验。比如发现一个任务既设置了cron又设置了interval_seconds我就直接报错而不是让带bug的配置继续运行。7.2 设计的克制让Colibri保持小复盘整个项目我最感谢自己当初做了不做什么的那张清单。Colibri没有变成一个有Web界面、有用户权限、有复杂依赖关系的大杂烩它到今天依然是个只需要一条命令就能跑起来的工具。对一个个人项目来说长期维护的意愿比功能数量重要得多。如果一开始就想着覆盖所有使用场景我可能写到一半就放弃。先做小再迭代这可能是Colibri能活下来的最大原因。7.3 后续扩展方向哪些可以做哪些我暂时不做后续如果要继续扩展我比较看好的方向有三个第一个是把webhook通知协议升级成可自定义模板这样通知消息可以区分简单失败摘要和完整输出片段不同任务通知到不同群第二个是增加一个简单的suspend/resume命令让任务可以在不修改配置文件的前提下临时暂停或恢复处理维护窗口时会很方便第三个是做配置文件热加载检测到YAML文件变动后自动reload省去重启进程的动作。分布式、多租户、可视化工作流编辑器这些短期内我依然不打算碰因为那会立刻破坏掉Colibri最核心的轻量和简单。我自己现在的使用习惯是所有任务配置文件都放在一个私有Git仓库里Colibri本身用系统服务方式常驻日志单独存一份每周简单看一眼统计。遇到问题先跑colibri check再跑colibri dry-run确认没有误配置才放行。这套工作流已经稳定用了很久我最大的体会是好工具不是功能堆出来的而是把最基础的那条路径做到无摩擦然后安静地待在那里不打扰你。
返回列表