1. 从代号到工程:Apollo-11 项目的整体设计思路
第一次看到“Apollo-11”这个代号,很多人脑子里蹦出来的可能是那段著名的登月历史。但在我这里,它指的是一套我反复打磨过的高可靠任务编排与状态回放系统的代号。取这个名字,纯粹是因为它的设计目标跟当年那次任务一样:在资源极度受限、通信链路不稳定、任何一个环节出错都可能全盘皆输的前提下,把一件复杂的事拆成可验证、可回放、可降级的小步骤,最终稳稳落地。
这套东西解决的核心问题很具体:当一个任务链路超过十几个步骤、涉及多个执行节点、且中间任何一步失败都需要精确知道“卡在哪、为什么卡、怎么续跑”的时候,传统的脚本堆叠和简单重试机制就彻底不够用了。它适合那些需要长时间运行、对状态一致性要求高、且运维人员不可能24小时盯着的场景,比如批量数据处理流水线、多阶段构建部署、跨系统的数据同步任务等。不管你是刚接触任务编排的新手,还是被各种“跑一半挂了不知道怎么恢复”折磨过的老手,这套思路都能直接拿去用。
我之所以选择“状态机 + 事件日志 + 幂等执行”这个组合作为底层骨架,而不是用现成的重型工作流引擎,原因有三。第一,重型引擎的抽象层太厚,出问题的时候排查链路太长,一个超时可能牵扯到调度器、执行器、消息队列、存储层四五个组件,定位成本极高。第二,很多引擎的状态存储是黑盒,你没法直接查“第三步的输出到底写进去了没有”,只能通过它提供的接口去问,这在紧急恢复时非常被动。第三,也是最关键的,Apollo-11 的设计哲学是“每一步都可独立验证”,这意味着每个步骤执行完必须留下可检查的痕迹,而不是把状态藏在内存或某个不可见的数据库里。
具体来说,整个系统的设计围绕三个核心原则展开。原则一:状态外置。任何一步的执行结果、输入参数、输出摘要、时间戳、重试次数,全部落到一个结构化的日志文件里,格式就是简单的 JSON Lines,一行一条记录。这样做的好处是,你用一个tail命令就能看到任务当前跑到哪了,不需要任何额外工具。原则二:步骤幂等。每个步骤被设计成“执行一次”和“执行多次”的结果一致,这样续跑的时候不用纠结“这一步到底做没做”,直接重跑就行。原则三:失败可降级。不是所有步骤失败了都要整个任务停掉,有些步骤可以标记为“可选”,失败了记录原因后继续往下走,最后统一汇总。
这套思路跟常见的“重试三次然后告警”有本质区别。重试解决的是瞬时故障,比如网络抖动;但 Apollo-11 要解决的是状态不确定的问题。举个例子,一个步骤执行到一半进程被杀了,你不知道它写了一半的数据是完整的还是残缺的。传统做法是加事务,但跨系统的操作很难有统一事务。Apollo-11 的做法是:每个步骤在开始前先写一条“开始”记录,执行完写一条“完成”记录,如果恢复时发现只有“开始”没有“完成”,就认为这一步是脏的,触发清理逻辑后再重跑。这个清理逻辑需要每个步骤自己实现,但框架提供了统一的钩子。
注意:状态外置的日志文件一定要放在持久化存储上,别放临时目录。我踩过一次坑,任务跑了六个小时,中间机器重启,临时目录被清空,所有状态丢失,只能从头再来。后来改成挂载持久卷,再也没出过这个问题。
在工具选型上,我刻意避开了那些需要独立部署的中间件。整个系统就是一个单二进制文件加一个日志目录,没有数据库,没有消息队列,没有额外的服务进程。这样做的好处是部署极其简单,拷贝过去就能跑;坏处是并发能力有限,适合单机或少量节点串行执行的场景。如果你的任务需要几百个节点并行,那这套方案需要改造,但核心的状态管理思路仍然适用。我个人的经验是,百分之八十的任务编排需求其实不需要分布式调度器,一个设计良好的单机状态机就能覆盖,而且运维成本低一个数量级。
2. 核心细节拆解:状态机、日志与幂等设计
2.1 状态机的定义与流转规则
Apollo-11 的状态机非常朴素,只有五个状态:PENDING、RUNNING、SUCCESS、FAILED、SKIPPED。每个步骤在任意时刻只能处于其中一个状态。流转规则是单向的,不允许回退:PENDING -> RUNNING -> SUCCESS或FAILED,PENDING -> SKIPPED是唯一的旁路。这个设计看起来简单,但实际用起来非常够用,因为复杂的业务逻辑应该放在步骤内部,而不是状态机本身。
为什么不用更复杂的状态?比如加一个RETRYING状态?我试过,后来去掉了。原因是重试本质上还是RUNNING,只是多了一个重试计数器。如果把重试做成独立状态,状态流转图会变得复杂,而且恢复逻辑要处理更多分支。现在的做法是:步骤失败后,框架根据配置决定是否重试,重试时状态仍然是RUNNING,只是日志里多一条retry_count递增的记录。这样恢复的时候只需要看最后一条记录的状态和重试次数,逻辑非常清晰。
状态流转的触发条件也很明确。一个步骤从PENDING变成RUNNING,唯一的条件是它的所有前置依赖步骤都是SUCCESS或SKIPPED。这个判断在每次任务启动或恢复时执行一遍,扫描所有步骤的当前状态,找出所有“依赖已满足但还没跑”的步骤,按顺序执行。这里有个细节:依赖检查必须基于日志里的最终状态,而不是内存里的状态。因为恢复场景下内存是空的,只能靠日志重建。
实操心得:依赖关系建议用显式的列表声明,不要用隐式的顺序推断。我见过有人用“步骤在文件里的排列顺序”来代表依赖,结果后来插入一个新步骤,把顺序打乱了,整个依赖链全错。显式声明虽然多写几行,但可读性和可维护性好太多。
2.2 事件日志的格式与写入策略
日志格式我选的是 JSON Lines,每行一个独立的 JSON 对象,包含以下字段:timestamp、step_id、event_type、status、retry_count、input_digest、output_digest、error_message、duration_ms。其中event_type只有三种:STEP_START、STEP_END、TASK_SUMMARY。input_digest和output_digest是对输入输出内容做哈希后的摘要,用来快速判断两次执行的结果是否一致,不用把完整数据写进日志。
写入策略上,我采用的是同步追加写 + 定期 fsync。每写一条日志,先追加到文件末尾,然后根据配置决定是否立即 fsync。对于关键步骤,配置成每条都 fsync,保证掉电不丢;对于非关键步骤,可以攒几条再 fsync,提升吞吐。这个权衡点需要根据你的任务对状态丢失的容忍度来定。我的经验是,状态日志的写入开销通常远小于步骤本身的计算开销,所以默认全部 fsync 也不会成为瓶颈,除非你的步骤是毫秒级的微操作。
日志文件按任务实例分文件,命名规则是task_<task_id>_<start_timestamp>.jsonl。这样每个任务的日志独立,排查的时候直接打开对应文件就行,不会互相干扰。任务完成后,日志文件可以归档或删除,取决于你是否需要保留审计记录。我一般保留最近三十天的日志,更早的压缩后转存到冷存储。
2.3 幂等执行的实现模式
幂等是 Apollo-11 最核心的要求,也是最容易出问题的地方。框架层面能做的只是“检测到脏状态后触发清理再重跑”,但清理逻辑必须由每个步骤自己实现。我总结了几种常见的幂等模式,基本能覆盖大部分场景。
模式一:覆盖写。如果步骤的输出是写一个文件或一条数据库记录,那么直接覆盖写天然幂等。执行前不需要检查是否存在,执行后也不需要清理,重跑就是再覆盖一次。这种模式最简单,优先采用。
模式二:先删后写。如果输出是追加写,比如往一个列表里加元素,那就需要先删除之前可能写入的部分,再重新写。删除的依据可以是input_digest,比如“删除所有 input_digest 等于当前值的记录”,这样即使重跑多次,最终结果也一致。
模式三:标记位。如果步骤的副作用是调用一个外部接口,比如发送通知,那就要在调用前先检查标记位,调用后设置标记位。标记位可以存在日志里,也可以存在外部存储里。关键是检查和设置之间要有原子性保证,通常用“先写标记再调用,调用失败回滚标记”的方式实现。
模式四:幂等键。对于创建资源的操作,比如创建一个临时目录或一个云资源,可以用一个确定的幂等键去创建,如果资源已存在则直接复用。幂等键通常由task_id + step_id + input_digest组合而成,保证同一次任务的同一步骤多次执行只会创建一个资源。
常见坑:很多人以为“重跑一次”就是幂等,其实不是。幂等要求的是“执行 N 次和执行 1 次的结果完全一致”,包括副作用。比如一个步骤往消息队列发了一条消息,重跑又发一条,下游就收到两条,这就不幂等。解决办法是在消息里带一个唯一键,下游做去重,或者步骤内部先查再发。
3. 实操过程:从零搭建一个可续跑的任务链路
3.1 环境准备与目录结构
这套系统对运行环境的要求极低,一个 Linux 或 macOS 环境,加上一个持久化目录就行。我通常会在项目根目录下建一个.apollo文件夹,里面放三个子目录:logs存事件日志,state存步骤的中间状态快照,artifacts存步骤产出的文件。目录结构大概是这样:
project/ .apollo/ logs/ task_001_1700000000.jsonl state/ step_001.json step_002.json artifacts/ step_001_output.csv steps/ step_001.sh step_002.py task_config.yamltask_config.yaml是任务定义文件,里面声明了所有步骤、依赖关系、重试策略、超时时间。我选 YAML 而不是 JSON,是因为 YAML 支持注释,写配置的时候可以标注每个步骤的用途和注意事项,对后续维护非常友好。
3.2 任务配置的编写要点
配置文件的顶层结构包含task_name、max_retries、retry_delay_seconds、steps四个字段。steps是一个列表,每个元素定义一个步骤,包含id、command、depends_on、optional、timeout_seconds、retry_policy。下面是一个实际用过的配置片段:
task_name: daily_data_pipeline max_retries: 3 retry_delay_seconds: 10 steps: - id: fetch_raw command: "python steps/fetch_raw.py --date {{task_date}}" depends_on: [] optional: false timeout_seconds: 600 retry_policy: exponential - id: validate command: "python steps/validate.py --input .apollo/artifacts/fetch_raw_output.csv" depends_on: [fetch_raw] optional: false timeout_seconds: 300 retry_policy: fixed - id: transform command: "python steps/transform.py --input .apollo/artifacts/fetch_raw_output.csv --output .apollo/artifacts/transform_output.parquet" depends_on: [validate] optional: false timeout_seconds: 1800 retry_policy: exponential - id: notify command: "python steps/notify.py --status success" depends_on: [transform] optional: true timeout_seconds: 60 retry_policy: none这里有几个关键点。{{task_date}}是模板变量,在任务启动时由框架替换成实际日期,这样同一个配置可以复用于不同日期的任务。retry_policy支持none、fixed、exponential三种,exponential的退避基数是retry_delay_seconds,第 N 次重试的等待时间是base * 2^(N-1)。optional: true的步骤失败后不会阻塞后续步骤,但会在最终汇总里标记出来。
注意:
depends_on里引用的步骤 ID 必须存在,框架在启动时会做一次静态检查,发现循环依赖或引用不存在的 ID 会直接报错退出。这个检查很有必要,我见过有人手写配置时把depends_on写成了步骤的序号而不是 ID,结果依赖关系全乱,任务跑起来才发现。
3.3 执行引擎的核心逻辑
执行引擎的主循环其实就是一个 while 循环,每次迭代做四件事:加载日志重建状态、找出可执行的步骤、执行步骤、写日志。伪代码大概是这样:
def run_task(config): log = load_log(config.task_id) state = rebuild_state(log) while not all_done(state): ready_steps = find_ready_steps(config.steps, state) if not ready_steps: if has_failed_required(state): break else: sleep(1) continue for step in ready_steps: execute_step(step, state, log) write_summary(log, state)rebuild_state函数负责扫描日志,对每个步骤取最后一条记录的状态作为当前状态。如果最后一条是STEP_START而没有对应的STEP_END,就认为这一步是脏的,标记为FAILED并触发清理钩子。find_ready_steps遍历所有步骤,找出状态为PENDING且所有依赖都是SUCCESS或SKIPPED的步骤。execute_step负责写STEP_START日志、执行命令、捕获输出、写STEP_END日志。
这里有个性能优化点:rebuild_state不需要每次迭代都全量扫描日志,可以维护一个内存中的状态缓存,只在启动时全量扫描一次,后续增量更新。但为了简单和可靠,我最初实现的是全量扫描,实测在日志文件几十万行的时候也就几百毫秒,完全可以接受。先保证正确性,再考虑性能,这是我在这个项目里始终坚持的原则。
3.4 续跑与恢复的实操演示
假设一个任务跑到第三步transform的时候机器挂了,日志里最后一条记录是transform的STEP_START。重新启动任务后,rebuild_state会发现transform处于脏状态,将其标记为FAILED,然后触发transform步骤的清理钩子。清理钩子通常是一个可选的脚本,配置在步骤定义的cleanup_command字段里。如果没配,框架就跳过清理,直接重跑。
重跑的时候,find_ready_steps会发现transform的依赖validate已经是SUCCESS,所以transform变成可执行状态,重新执行。执行前会写一条新的STEP_START,retry_count加一。如果这次执行成功,写STEP_END状态为SUCCESS,然后继续往下走notify。
整个过程不需要人工干预,也不需要手动改状态文件。我实测过多次,从任意步骤中断后重启,任务都能正确恢复。唯一需要注意的是,清理钩子必须幂等,因为恢复时可能触发多次清理。比如清理钩子是“删除临时目录”,那就要写成“如果目录存在则删除”,而不是“删除目录并报错如果不存在”。
实操心得:建议在任务配置里加一个
resume_from字段,允许手动指定从哪个步骤开始跑。有时候自动恢复的逻辑不符合预期,比如你想跳过某个已经确认有问题的步骤,手动指定会更灵活。这个字段的优先级高于自动状态重建,但框架会记录一条警告日志,提醒你这是手动干预。
4. 常见问题与排查技巧实录
4.1 状态不一致的典型场景与修复
场景一:日志写入成功但步骤实际没执行。这种情况通常发生在写STEP_START之后、执行命令之前进程被杀。恢复时框架看到STEP_START没有STEP_END,会认为步骤是脏的,触发清理后重跑。但如果步骤本身没有副作用,重跑就是再执行一次,没问题。如果有副作用,就需要清理钩子来兜底。
场景二:步骤执行成功但日志写入失败。比如命令执行完了,写STEP_END的时候磁盘满了。恢复时框架看到只有STEP_START,会认为步骤失败,重跑。这时候如果步骤不幂等,就会出问题。解决办法是先写日志再执行,或者用两阶段提交:先写一个“准备完成”的标记,执行完再写“确认完成”。但这样会增加复杂度,我的选择是接受这个风险,因为磁盘满的概率远低于进程被杀,而且可以通过监控磁盘使用率来提前预警。
场景三:多个步骤并发执行时日志交错。如果框架支持并发执行多个无依赖的步骤,日志写入需要加锁,否则两条日志可能交错写入,导致 JSON 解析失败。我的做法是每个步骤写日志时先获取文件锁,写完释放。文件锁用flock系统调用实现,简单可靠。并发度不高的时候,锁竞争可以忽略。
4.2 排查工具与速查表
我整理了一个常见问题速查表,贴在下面,方便遇到问题时快速定位。
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 任务卡住不动 | 有步骤处于 RUNNING 但进程已死 | 检查日志最后一条记录,对比进程列表 | 手动标记该步骤为 FAILED,或重启任务触发自动恢复 |
| 步骤反复重试 | 命令本身有 bug 或依赖的外部服务不可用 | 查看error_message字段和重试次数 | 修复命令或等待外部服务恢复,必要时临时调低重试次数 |
| 日志文件过大 | 步骤输出被完整写入日志 | 检查output_digest是否只存了摘要 | 确认输出重定向到了 artifacts 目录,日志只存摘要 |
| 恢复后重复执行副作用 | 步骤不幂等且没有清理钩子 | 检查步骤的副作用类型 | 实现幂等逻辑或添加清理钩子 |
| 依赖检查失败 | depends_on引用了不存在的步骤 ID | 运行配置静态检查 | 修正配置中的步骤 ID |
除了速查表,我还写了一个小工具叫apollo-inspect,输入任务 ID 就能打印出当前所有步骤的状态、最后一条日志的时间戳、以及下一步可执行的步骤列表。这个工具在排查的时候非常有用,省去了手动翻日志的麻烦。工具本身就是一个 Python 脚本,依赖标准库,不需要额外安装。
4.3 性能调优与扩展建议
当步骤数量增加到几十个、日志文件达到几兆的时候,全量扫描日志重建状态会变慢。这时候可以引入一个状态快照机制:每隔 N 条日志写一个快照文件,记录当前所有步骤的状态。恢复时先加载最近的快照,再扫描快照之后的日志做增量更新。快照的写入频率可以根据任务时长调整,短任务不需要快照,长任务可以每五分钟写一次。
另一个扩展点是分布式执行。如果单机跑不下,可以把步骤分发到多个节点执行,但状态日志仍然集中存储。这需要引入一个简单的协调服务,负责分配步骤和收集结果。我的建议是不到万不得已不要上分布式,因为分布式带来的复杂度是指数级的。先优化单机性能,比如用更快的磁盘、减少日志写入频率、并行执行无依赖步骤,通常能撑很久。
最后分享一个小技巧:在任务配置里加一个
dry_run模式,只打印将要执行的步骤和命令,不实际执行。这个模式在调试配置的时候非常有用,可以快速验证依赖关系和命令拼接是否正确,避免跑了一半才发现配置写错了。实现起来也简单,就是在execute_step里判断dry_run标志,如果是就只打印不执行。
这套 Apollo-11 的思路我用了快两年,从最初的一个简单脚本演化到现在,中间踩过的坑基本都记录在上面了。它不是什么高大上的框架,但胜在透明、可控、容易排查。如果你也在被任务续跑和状态管理的问题困扰,不妨按这个思路搭一个最小可用版本,跑起来之后再逐步加功能。我个人在实际操作中的体会是,状态管理的复杂度应该与任务的复杂度匹配,过度设计反而会引入更多问题。先从最简单的日志加状态机开始,遇到瓶颈再优化,这条路走得最稳。