3、WDL基础:工作流与任务定义、输入输出声明、运行时配置

好,我们正式开始写WDL了。

如果你是第一次接触WDL,可能会觉得它跟常见的脚本语言不太一样。它更像是在描述「谁先做、谁后做、谁依赖谁」的依赖关系图。说白了,你把每个步骤都封装成一个任务,然后用工作流把它们串起来。

这一章,我们就来拆解WDL的三大核心零件:工作流定义、任务定义、输入输出声明、运行时配置。掌握了这些,你就能写出第一个能跑的WDL脚本了。

3.1 工作流与任务定义

先来看一个最简单的WDL骨架。不用害怕,代码其实很规整——嗯,就像搭积木一样。

version 1.0

workflow my_workflow {
    input {
        File my_file
    }
    call my_task {
        input:
            input_file = my_file
    }
    output {
        File result = my_task.output_file
    }
}

task my_task {
    input {
        File input_file
    }
    command {
        cat ${input_file} > output.txt
    }
    output {
        File output_file = "output.txt"
    }
    runtime {
        docker: "ubuntu:latest"
        memory: "1 GB"
        cpu: 1
    }
}

注意两个东西:workflow 和 task。

  • workflow:就是整个管线的总指挥。它只负责编排,不干活。
  • task:具体的干活单元。你所有的 shell 命令、软件调用、参数配置,都写在 task 里。

我个人习惯把 workflow 理解成「生产调度室」,task 理解成「车间里的机器」。调度室只管说「机器A做完传给机器B」,机器才管具体怎么操作。

关键点:workflow 中通过 call 语句调用 task。如果需要传递输入,通过 input: 块传入。这就是 WDL 的依赖表达方式。

3.2 输入输出声明

WDL 是强类型的。每一个输入、输出变量,都必须先声明类型。

为什么?因为 Cromwell 需要知道传进来的是文件还是字符串,是做路径挂载还是直接传值。我刚开始写的时候常常漏掉类型声明,结果 Cromwell 报错说找不到输入——其实就是没告诉它这是个 File。

3.2.1 常见数据类型

类型 描述 示例
String 字符串 "chr1"
Int 整数 42
Float 浮点数 3.14
Boolean 布尔值 true
File 文件路径(最常用) "gs://bucket/sample.fastq"
Array[File] 文件数组 ["a.fastq", "b.fastq"]
Map[String, String] 键值对 {"key": "val"}
Pair[File, File] 成对文件 (read1, read2)

3.2.2 输入声明的写法

在 task 顶部的 input {} 块中声明。写的时候想清楚:这个任务需要哪些参数?哪些是必须的,哪些有默认值?

task fastqc {
    input {
        File read1
        File? read2           # 可选的(问号表示可选)
        String sample_name    # 必须提供
        Int threads = 4       # 带默认值
    }
    command {
        fastqc -t ${threads} ${read1} ${read2}
    }
}
小技巧:用 ? 标记可选输入,用 = 设置默认值。Cromwell 会在运行时自动补全。我曾经碰到过一个案例,忘记给线程数设默认值,结果每次跑都卡在同一个错误上,排查半天才发现是 Cromwell 在等用户输入一个没传的参数。

3.2.3 输出声明的写法

输出声明在 task 底部的 output {} 块中。关键点:输出的文件路径,要与 command 块中实际生成的文件名一致。

task bwa_mem {
    input {
        File ref
        File read1
        File read2
    }
    command {
        bwa mem ${ref} ${read1} ${read2} > aligned.sam
    }
    output {
        File aligned_sam = "aligned.sam"
    }
}

注意这里的 aligned.sam。WDL 会认为这个文件就是任务的最终产物。如果 command 里生成的文件名跟 output 声明的不一致,Cromwell 会报错说输出文件找不到。

避坑指南:我最开始犯过的错——在 output 里写了绝对路径,比如 /output/result.bam。但 Cromwell 有自己的工作目录,你把路径写死了,它就找不到了。正确的做法是只写相对路径或文件名,Cromwell 会自动处理工作目录挂载。

3.3 运行时配置

运行时配置,就是告诉 Cromwell 这个任务需要用多少内存、多少 CPU、跑在什么 Docker 镜像里。这部分写在 task 的 runtime {} 块中。

3.3.1 常用运行时属性

属性 说明 示例
docker 指定 Docker 镜像 "ubuntu:latest"
memory 内存大小 "4 GB"
cpu CPU 核数 2
disks 附加磁盘 "local-disk 20 HDD"
maxRetries 任务失败后的重试次数 3
continueOnReturnCode 允许非 0 退出码继续执行 true

3.3.2 典型运行时配置示例

task star_alignment {
    input {
        File genomeDir
        Array[File] readFiles
        String prefix
    }
    command {
        STAR --genomeDir ${genomeDir} \
             --readFilesIn ${sep=' ' readFiles} \
             --outFileNamePrefix ${prefix} \
             --runThreadN ${cpu} \
             --outSAMtype BAM SortedByCoordinate
    }
    output {
        File aligned_bam = "${prefix}Aligned.sortedByCoord.out.bam"
    }
    runtime {
        docker: "quay.io/biocontainers/star:2.7.10a--h9ee0642_0"
        memory: "32 GB"
        cpu: 8
        disks: "local-disk 100 SSD"
        maxRetries: 2
    }
}
注意到没:runtime 中的 cpu 变量,可以直接在 command 里用 ${cpu} 引用。这样你只需要在 runtime 里改数字,command 里的参数会自动跟着变。我在项目里就靠这个,一次性调优了所有任务的资源配置。

3.3.3 为什么推荐用 Docker 镜像?

想想看,如果没有 Docker,你要在每台机器上装 STAR、BWA、FastQC……版本还得一致。有了 Docker 镜像,Cromwell 会在任务开始前自动拉取镜像,跑完自动清理,干净利落。

我个人习惯把所有工具打包成一个镜像,比如 bioinfo-pipeline:v1.0,然后在所有 task 里共用这个镜像。这样做的好处是:你只需要维护一个 Dockerfile,不用为每个任务单独写镜像。

经验之谈:如果你的工作流里用到了多个不同工具,可以把它们写进同一个 Docker 镜像中。Cromwell 在运行每个 task 时都会用 docker run 启动容器,任务之间互不干扰。

3.4 本章知识结构 SVG

下面这张图总结了我们刚刚讲的核心概念。它展示了 WDL 脚本的骨架:工作流如何包含任务,任务如何由输入、命令、输出和运行时四个部分组成,以及它们之间的数据流向。

WDL 脚本结构(工作流 + 任务) workflow call call my_task 引用 task my_task input { File ... } command { ... } output { File } runtime { ... } 数据流:workflow 通过 call 引用 task 任务的输入 → command 执行 → 生成输出 → runtime 指定资源 一个 workflow 可以 call 多个 task,形成 DAG 依赖

3.5 避坑总结

写 WDL 的时候,有三个地方最容易出问题,我列出来提醒一下自己,也提醒你:

  1. 路径问题:永远不要在 output 里写绝对路径。用相对路径,Cromwell 会自动处理。
  2. 类型不匹配:声明了 File 却传了 String,Cromwell 不会帮你隐式转换。严格匹配类型。
  3. 命令换行:WDL 里用反斜杠 \ 续行,不要用 ^ 或别的符号。我曾经在一个 STAP 脚本里用了分号分隔命令,结果 Cromwell 把整行当成一条命令执行了。
注意:如果你在 runtime 里设置了 continueOnReturnCode: true,即使 command 跑炸了(非 0 退出),Cromwell 也不会停。这对于有些临时错误(比如网络抖动)很有用,但不要滥用,否则会掩盖真正的 bug。