第一个工作流:WDL 语法入门、编写简单 task、运行与监控

好,咱们开始动手写第一个真实能跑的工作流。前面两章铺垫了不少理论,我知道你可能已经等不及了。我个人习惯是“先跑通一个最小用例,再逐步加复杂度”,所以这一章咱们就聚焦在:用 WDL 写一个最简单的 task,然后用 Cromwell 跑起来,最后看监控数据验证它确实在干活。

WDL 到底是什么?

WDL(Workflow Description Language)是 Broad Institute 推出的领域特定语言,专门用来描述生物信息学分析流程。说白了,它定义的是“我想要什么输入、用什么工具、拿什么产出”,至于在哪台机器上跑、用多少内存、怎么并行,这些是 Cromwell 的调度层去管的。

我第一次接触 WDL 时,感觉这货语法挺 C-like 的,但写起来又比 Python 严格。嗯,其实它的核心数据结构就三种:

  • Primitive types:String、Int、Float、Boolean、File
  • Compound types:Array、Map、Pair、Object
  • Optional & nullable:类型后面加个问号,比如 String?

另外还有两个顶层概念你一定要记牢——Workflow 和 Task。Workflow 是编排层,负责连接各 task 的输入输出;Task 是执行层,定义具体要跑的命令、需要的资源、输出的文件。

一个 task 该长什么样?

一个完整的 WDL task 由三个部分组成:input(声明输入变量和类型)、command(实际上是运行时的 shell 脚本块)、output(声明哪些文件/值是任务的结果)。别忘了还有 runtime 块,它是对 Cromwell 的资源请求——我建议你从一开始就养成写好 runtime 的习惯,否则默认值可能让你等很久。

来,我们先写一个最简单的 task:接收一个字符串作为输入,写入文件,然后返回文件名。你可以在本地随便找个目录放这个文件,就叫 hello.wdl:

task hello {
  input {
    String name
  }

  command {
    echo "Hello, ${name}!" > hello_output.txt
  }

  output {
    File result = "hello_output.txt"
  }

  runtime {
    docker: "ubuntu:latest"
    cpu: 1
    memory: "1 GB"
  }
}

你看,command 块里写的就是 shell 脚本,${name} 会自动被 WDL 引擎替换为输入变量的值。我曾见过有人把 command 写成多行 shell 脚本,甚至有循环和条件判断——WDL 都支持,因为它最终就是被 Cromwell 塞进一个 Bash 进程里执行。

一个小技巧:如果你不确定 command 里的语法是否正确,可以先在终端手动跑一遍同样的 shell 命令,确认无误后再粘贴进来。我过去因为少写了一个反斜杠导致 task 静默失败,排查了两个小时。

把 task 放在 workflow 里

单个 task 是没法独立运行的,你需要一个 workflow 来“调用”它。就像函数调用一样,你必须声明输入传给 task。看看这个完整的 main.wdl:

version 1.0

workflow my_first_workflow {
  input {
    String user_name
  }

  call hello {
    input:
      name = user_name
  }
}

task hello {
  input {
    String name
  }

  command {
    echo "Hello, ${name}!" > hello_output.txt
  }

  output {
    File result = "hello_output.txt"
  }

  runtime {
    docker: "ubuntu:latest"
    cpu: 1
    memory: "1 GB"
  }
}

这里有一点值得注意:workflow 本身也有一个 input 块,它定义了整个工作流的参数入口。调用 task 时用 call 关键字,并通过 input: 把 workflow 的变量映射给 task。为什么会这样设计?因为 Cromwell 需要在提交时就知道所有输入的值,然后才能构建 DAG。

提交任务:cromwell run 命令

写好 WDL,咱们得准备一个输入 JSON。Cromwell 不接受在命令行里直接传参数,你必须写一个 inputs.json:

{
  "my_first_workflow.user_name": "Cromwell Expert"
}

记住了,输入 key 的格式是 工作流名.变量名。然后打开终端,跑这个命令(假设 cromwell.jar 在 /opt/cromwell 下):

java -jar /opt/cromwell/cromwell-85.jar run \
  --inputs inputs.json \
  main.wdl

如果一切顺利,你会看到类似这样的输出:

...
[YYYY-MM-DD HH:mm:ss] WorkflowExecutionActor [info] Workflow my_first_workflow submitted
[YYYY-MM-DD HH:mm:ss] WorkflowExecutionActor [info] Workflow my_first_workflow starting
[YYYY-MM-DD HH:mm:ss] WorkflowExecutionActor [info] Workflow my_first_workflow Succeeded
[YYYY-MM-DD HH:mm:ss] WorkflowExecutionActor [info] Workflow my_first_workflow complete. Final status: Succeeded
  Output: {
    "my_first_workflow.hello.result": "/path/to/cromwell-executions/my_first_workflow/.../call-hello/hello_output.txt"
  }
避坑:我曾经在 Windows 上用 WSL 跑 Cromwell,结果 Docker 的 mounts 路径解析有问题,导致 task 一直卡在 Submitted 状态。如果你用本地后端且开了 Docker,请确保 Docker daemon 正在运行,且当前用户有权限执行 docker 命令。

跑起来了,怎么看它在干吗?

Cromwell 默认会在当前目录生成 cromwell-executions 文件夹,里面按工作流 ID 分目录,每个 task 又有一个子目录。结构如下:

cromwell-executions/
  my_first_workflow/
    abc-def-123-.../
      call-hello/
        execution/
          script                    # 实际执行的 shell 脚本
          stdout                    # 标准输出
          stderr                    # 标准错误
          rc                        # 退出码
          hello_output.txt          # 你 task 产生的文件
          memory_retry_attempt      # 如果内存不够会自动重试(如果配置了)
  ...

打开 stdout 看看:

Hello, Cromwell Expert!

完美。这说明我们的 task 确实执行了 echo,并且写入了文件。

我建议你在正式投产前,至少 手动读一次 script、stdout、stderr 三个文件。script 里可以看到 Cromwell 实际组装了什么 shell 命令,stdout 能帮你快速定位 task 是否如预期输出,stderr 则含有所有错误信息。我曾经在生产环境遇到过 task 跑了两小时才失败,后来发现只要看一眼 stderr 就能秒懂——是 Docker 镜像里缺了一个系统依赖。

监控任务状态

Cromwell 提供了 REST API,你可以通过 HTTP 查询工作流和 task 的状态。比如我们启动 Cromwell Server 模式(默认是 8000 端口):

java -jar /opt/cromwell/cromwell-85.jar server

然后打开另一个终端,用 curl 获取工作流列表:

curl -X GET "http://localhost:8000/api/workflows/v1/query?status=Succeeded"

返回格式是 JSON:

{
  "results": [
    {
      "id": "abc-def-123-...",
      "name": "my_first_workflow",
      "status": "Succeeded",
      "submission": "2025-01-15T10:30:00Z",
      "end": "2025-01-15T10:30:12Z"
    }
  ],
  "totalResultsCount": 1
}

你想想看,如果你的流程有几百个 task,每个 task 可能要一小时,靠看日志文件是没法实时监控的。用 API 查询 status 和 end 时间,可以快速定位哪一步卡住了。

还有一个常用接口是查看单个工作流的元数据,包括每个 task 的 CPU、内存、执行时间等:

curl -X GET "http://localhost:8000/api/workflows/v1/abc-def-123/metadata"

返回数据里我最关注的是 executionEvents 数组,里面记录着 task 从 WaitingForQueue 到 Running 到 Done 的时间戳。如果你的 task 在 WaitingForQueue 状态停留太久,说明 Cromwell 后端资源不足或者队列堵了;如果 Running 时间远超过预期,那就是代码或数据问题。

本章核心概念速览

概念 说明 你该记住的
WDL Task 定义命令、输入输出、资源需求的最小单元 input / command / output / runtime 四块缺一不可
WDL Workflow 编排 task 的顶层结构,定义 DAG 用 call 调用 task,用 input: 传参
Input JSON 启动时传给 Cromwell 的参数文件 key = "工作流名.变量名"
执行目录 cromwell-executions 下的层级结构 stdout / stderr / rc / script 是排障四件套
REST API Cromwell Server 暴露的监控接口 /query 查状态,/metadata 查详情

我的经验总结:第一次跑通工作流的时候,别急着上复杂的并行。先确保单个 task 的输入输出正确,再看 runtime 资源是否被 Cromwell 正确识别,最后用 API 查一下 task 的生命周期时间线。这三个步骤做完,你就基本掌握了 Cromwell 的“读、写、查”能力,后面学并行调优才有根基。