Cromwell 运行机制与日志生成原理

想用好日志分析工具,首先得弄明白日志是怎么来的。

Cromwell 不是个黑盒子,它内部有一套清晰的执行逻辑。我刚开始接触时也犯过迷糊——日志文件一大堆,不知道看哪个,也不知道每个字段代表什么。后来花时间捋了一遍它的运行机制,嗯,再回头看日志就豁然开朗了。

这一章,我就带你拆开 Cromwell 看看。你搞清楚它怎么干活,日志就自然能读懂了。

工作流程的核心拆解

Cromwell 本质上是一个工作流引擎。它不直接跑任务,而是帮你协调任务。你写一个 WDL 文件,描述好“先做什么、再做什么、用什么镜像、传什么参数”,Cromwell 就按这个剧本去调度后端(比如本地机器、HPC 集群或者云上的 Batch 服务)。

我个人习惯把它的执行过程分成四个阶段:

  • 解析阶段:读取你的 WDL 和 JSON 输入文件,构建成一张内部的任务依赖图(DAG)。说白了,就是把你的“工作流剧本”翻译成引擎能懂的数据结构。
  • 调度阶段:根据 DAG 决定什么时候跑哪个任务。如果任务 B 依赖任务 A 的输出,Cromwell 会等 A 跑完才启动 B。
  • 执行阶段:把具体的 bash 命令或 Docker 命令扔给后端去跑。这一步最耗时,也最容易出问题。
  • 收尾阶段:收集每个任务的退出码、标准输出、标准错误,更新工作流的最终状态。

日志就在这四个阶段里源源不断地产生。

关键认知:每一阶段的日志记录点不同。解析失败查 WDL 语法日志;调度卡住查数据库连接日志;执行报错查后端日志和任务 stderr。你按阶段去定位,效率最高。

日志到底从哪来?

日志不是凭空生成的。每一条 Cromwell 日志,都对应着引擎内部某个组件的动作。我整理了一张图,帮你直观地理解日志的来源和流向。

Cromwell 日志生成与流向 用户提交 WDL + JSON 请求 WDL 解析器 → 生成 DAG 调度器 → 分配任务 本地执行器 (Local Backend) 后端执行器 (HPC / Cloud) 日志汇聚 → 数据库 + 文件系统

图里有个细节我想强调一下:日志产生后,最终会写入两个地方——数据库(存储工作流元数据)和文件系统(存储 stdout、stderr、cromwell.log)。所以排查问题时,这两个来源都得查。

日志的时间线与状态机

每条日志都带一个时间戳,这很好理解。但 Cromwell 内部有一个更重要的东西——工作流状态机。

工作流和任务的状态流转是固定的。我列了一个最常见的状态变化表,你对照着看日志里的状态字段,基本就知道当前卡在哪个环节了。

状态 含义 常见日志关键词
Submitted 工作流/任务已提交,等待调度 WorkflowSubmitted, JobSubmitted
Running 正在执行中 WorkflowRunning, JobRunning
WaitingForOutputs 任务已完成,等待收集输出 WaitingForOutputs
Failed 执行失败,退出码非零 WorkflowFailed, JobFailed, exitcode
Succeeded 执行成功,退出码为零 WorkflowComplete, JobSucceeded
Aborted 被用户或系统终止 Aborted, abort

我印象很深的一次,有个任务一直是 Running 状态却不往下走。我查了日志,发现它卡在 WaitingForOutputs 阶段——原因是任务脚本里有一个大文件输出,Cromwell 在等它落盘,但磁盘满了。这个案例让我养成了一个习惯:先查任务的状态,再去看对应阶段的日志,缩小范围比你逐行翻日志快得多。

日志文件的层次结构

Cromwell 的日志文件不是随便扔的。默认情况下,你会在工作目录下看到这些文件:

  • cromwell.log — 引擎自身运行日志,包含解析、调度、连接数据库等全局信息。
  • workflow.log — 按工作流实例生成的日志,通常在 metadata 输出目录下。
  • job-{hash}/stdout — 单个任务的标准输出。
  • job-{hash}/stderr — 单个任务的标准错误,这是最常看的文件。
  • job-{hash}/rc — 任务的退出码,纯数字。
  • job-{hash}/script — 实际执行的那段 bash 脚本,用于核对命令是否正确。

我的一个小技巧:别每次都进目录翻。直接用 grep 配合时间范围查询。例如:grep "WorkflowExecutionState" cromwell.log | grep "Failed",一秒定位所有失败任务的状态变更。

为什么日志有时不完整?

这个问题我经常被问到。

Cromwell 默认有一个日志缓冲区,不是每产生一行日志就立刻刷到磁盘。这么做是为了性能。但如果 Cromwell 进程突然崩溃(比如被 kill -9 杀掉),缓冲区里的日志就丢了。

我曾经踩过一个大坑:一个跑了 3 天的 WDL 任务突然挂了,我跑去查日志,发现最后一条记录停在任务运行中,没有任何错误信息。后来才发现是宿主机 OOM,Cromwell 直接被系统 kill 了,缓冲区里的错误日志根本没来得及写。

所以,生产环境中建议开启即时刷写。配置方式很简单,在启动 Cromwell 时加上 JVM 参数:

-Dlogback.statusListenerClass=ch.qos.logback.core.status.OnConsoleStatusListener
-Dlogback.configurationFile=/path/to/logback.xml

然后在 logback.xml 里把 <appender> 的 immediateFlush 设为 true:

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
  <file>cromwell.log</file>
  <immediateFlush>true</immediateFlush>
  ...
</appender>

开了这个之后,日志实时落盘。性能会有轻微影响,但在绝大多数场景下完全可以接受。与其在崩溃后一头雾水,不如牺牲一点点 IO 换取及时的可观测性——这笔账我觉得很划算。

日志里的时间到底是谁的时间?

嗯,这个问题不说清楚,你会被时间戳搞疯。

Cromwell 会在日志里记录多个时间:

  • 系统时间(System.currentTimeMillis): 也就是引擎所在服务器的时间,用于记录事件发生时刻。这是最主要的时间戳。
  • 工作流时间(WorkflowStart / WorkflowEnd): 从 WDL 元数据里提取的时间,由 Cromwell 自己计算。
  • 后端任务时间: 比如在 HPC 集群上,任务实际开始和结束的时间可能和 Cromwell 记录的不一致(因为调度队列有延迟)。

我个人习惯以 Cromwell 日志里的系统时间为准来分析事件顺序。后端任务的实际时间只用来判断执行时长,不作为事件排序的依据。因为后端任务有排队等待时间,按 Cromwell 日志排出来的顺序才是真正的调度顺序。

举个例子你就明白了:你提交了两个任务 A 和 B,A 分到了空闲节点,B 排队等了 10 分钟。后端日志里 B 的开始时间比 A 晚 10 分钟,但在 Cromwell 日志里,A 和 B 的 Submitted 时间只差几秒。这时候如果你按后端时间分析流程,会误以为调度有问题。其实没问题,只是队列延迟。

注意:如果你的 Cromwell 服务器和后端执行器不在同一台机器上,且时间不同步,日志里的时间对比会出现偏差。建议全集群使用 NTP 同步时钟。我踩过的坑里,有一半是因为时间没同步导致的“灵异事件”。

小结一下

这一章我们拆了 Cromwell 的“黑盒子”,明白了一条日志产生背后的四个阶段:解析、调度、执行、收尾。你看,每个阶段产出的日志都不同,状态机帮你定位阶段,时间戳帮你还原事件顺序,日志文件的层次结构告诉你去哪里找具体信息。