使用 Apache Airflow 构建数据流水线——Airflow DAG 的结构解析

0 阅读24分钟

本章将介绍

  • 如何在自己的机器上运行 Airflow
  • 如何编写并运行第一个工作流
  • 如何查看 Airflow 界面的第一个视图
  • 如何在 Airflow 中处理失败任务

到这里,你已经对什么是数据流水线,以及 Airflow 如何帮助你管理它们,有了一个不错的整体认识。为了真正体会它在实践中是怎么工作的,我们接下来要动手做一个小型示例流水线,用它来演示许多工作流中最基础的构建块。

2.1 从众多来源收集数据

火箭是人类工程学的奇迹之一,每一次火箭发射都会吸引全世界的关注。我们的朋友 John 是一位火箭爱好者,他会追踪并关注每一次火箭发射。关于火箭发射的新闻会出现在许多不同的新闻来源中,而 John 一直在关注这些来源;理想情况下,他希望把所有与火箭相关的新闻聚合到一个地方。John 最近开始学习编程,并希望能有一种自动化方式来收集所有火箭发射的信息,最终从最新的火箭新闻中获得一些属于自己的洞察。为了从小处入手,他决定先收集火箭的图片。

在数据来源方面,我们将使用 Launch Library 2thespacedevs.com/llapi),这是一个在线数据仓库,收录了来自多个来源的历史与未来火箭发射数据。它是一个免费 API,任何地球上的人都可以使用(当然会受到速率限制)。

目前,John 只对即将到来的火箭发射感兴趣。幸运的是,Launch Library 正好提供了他所需要的数据(ll.thespacedevs.com/2.0.0/launc…)。它提供即将发射的火箭数据,以及对应火箭图片的 URL。下面的代码清单展示了这个 URL 返回的数据。

代码清单 2.1 通过 curl 请求 Launch Library API 的示例及其返回结果

$ curl -L "https://ll.thespacedevs.com/2.0.0/launch/upcoming"      #1

{                    #2
 ...
  "results": [       #3
  {                   #4
    "id": "9603b3c2-da94-41c6-8012-30e990fdc999",
    "url": "https://.../9603b3c2-da94-41c6-8012-30e990fdc999/",
    "launch_library_id": null,
    "slug": "falcon-9-block-5-starlink-group-7-14",
    "name": "Falcon 9 Block 5 | Starlink Group 7-14",
    "status": { "id": 2, "name": "TBD"},             #5
    "net": "2024-02-13T22:17:00Z",                    #5
    "window_end": "2024-02-14T02:46:00Z",             #5
    "window_start": "2024-02-13T22:17:00Z"            #5
    "image": "https://spacelaunchnow-prod-east.nyc3.digitaloceanspaces.com
➥ /media/launcher_images/falcon_9_image_20230807133459.jpeg",      #6
    ...
  },
  {
    "id": "d9a3c8e1-bc0d-4fab-a04c-218ffe44a026",
    „url": „https://.../d9a3c8e1-bc0d-4fab-a04c-218ffe44a026/",
    "launch_library_id": null,
    "slug": "gslv-mk-ii-insat-3ds",
    "name": "GSLV Mk II | INSAT-3DS",
    "status": { "id": 1, "name": "Go" },
    "net": "2024-02-17T12:00:00Z",
    "window_end": "2024-02-17T15:30:00Z",
    "window_start": "2024-02-17T11:30:00Z",  
    "image": "https://spacelaunchnow-prod-east.nyc3.digitaloceanspaces.com
➥/media/launcher_images/gslv2520mk2520ii_image_20190825171642.jpg",  
    ...
  },
  ...
  ]
}
#1 使用命令行中的 curl 查看该 URL 的响应
#2 响应是一个 JSON 文档,从它的结构就可以看出来
#3 方括号表示一个列表
#4 这些花括号中的所有值都对应一次单独的火箭发射
#5 这里可以看到诸如火箭 ID、发射窗口开始和结束时间等信息
#6 发射火箭图片的 URL

如你所见,数据格式是 JSON,其中提供了火箭发射信息,并且对于每一次发射,都包含该火箭的具体信息,比如 ID、名称和图片 URL。这些数据正是 John 所需要的。最开始,他画出了图 2.1 中这样的计划,用来收集即将发射的火箭图片(例如,可以把他的屏保指向存放这些图片的目录)。

image.png

图 2.1 John 对下载火箭图片的心智模型

根据图 2.1 里的示意,我们可以看出,John 最终的目标是得到一个装满火箭图片的目录,例如图 2.2 中的 Ariane 5 ECA 火箭图片。

image.png

图 2.2 Ariane 5 ECA 火箭图片示例

2.2 编写你的第一个 Airflow DAG

John 的这个用例范围很清晰,所以我们来看看怎样把他的计划编写出来。它只包含几个步骤,理论上说,只要你 Bash 足够熟练,甚至可以把它写成一条单行命令。那么,为什么我们还需要像 Airflow 这样的系统来做这件事呢?

Airflow 的好处在于:它允许我们把一个由一个或多个步骤构成的大任务拆分成多个独立任务,这些任务共同组成一个有向无环图(DAG)。多个任务可以并行运行,而且任务之间还可以使用不同技术来实现。例如,我们可以先运行一个 Bash 脚本,再运行一个 Python 脚本。图 2.3 把 John 对自己工作流的心智模型拆解成了 Airflow 中的 3 个逻辑任务。

image.png

图 2.3 把 John 的心智模型映射为 Airflow 中的任务

你可能会问:为什么是这 3 个任务?为什么不把下载发射数据和对应图片放在一个任务里,或者拆成 5 个任务(因为 John 的计划里有 5 根箭头)?这些问题在开发工作流时都非常值得思考,但事实是,这里并不存在绝对正确或错误的答案。不过,我们确实需要综合考虑多个因素。本书中我们会通过大量案例,逐步总结出在定义新数据流水线时,如何更好地设置任务边界的最佳实践。

下面就是这个工作流的代码(dags/01_download_rocket_launches.py)。如果你现在还不能完全理解代码中的每一部分,也没关系。随着本章的推进,我们会逐步解释。

代码清单 2.2 用于下载并处理火箭发射数据的 DAG

import json
import pathlib

import pendulum
import requests
from airflow.providers.standard.operators.bash import BashOperator
from airflow.providers.standard.operators.python import PythonOperator
from airflow.sdk import DAG
from requests.exceptions import ConnectionError, MissingSchema



def _get_pictures():             #1
    # Ensure directory exists
    pathlib.Path("/tmp/images").mkdir(parents=True, exist_ok=True)

    # Download all pictures in launches.json
    with open("/tmp/launches.json") as f:
        launches = json.load(f)
        image_urls = [launch["image"] for launch in launches["results"]]
        for image_url in image_urls:
            try:
                response = requests.get(image_url)
                image_filename = image_url.split("/")[-1]
                target_file = f"/tmp/images/{image_filename}"
                with open(target_file, "wb") as f:
                    f.write(response.content)
                print(f"Downloaded {image_url} to {target_file}")
            except MissingSchema:
                print(f"{image_url} appears to be an invalid URL.")
            except ConnectionError:
                raise ConnectionError(f"Could not connect to {image_url}.")


with DAG(                                           #2
    dag_id="01_download_rocket_launches",           #3
    start_date=pendulum.today('UTC').add(days=-14), #4
    schedule=None,                                  #5
):

    download_launches = BashOperator(               #6
        task_id="download_launches",                #7
        bash_command="curl -o /tmp/launches.json
        ➥ -L 'https://ll.thespacedevs.com/2.0.0/launch/upcoming'",
    )

    get_pictures = PythonOperator(               #8
        task_id="get_pictures", #8
        python_callable=_get_pictures,            #8
    )

    notify = BashOperator(
        task_id="notify",
        bash_command='echo "There are $(ls /tmp/images/ | wc -l) images."',
    )

download_launches >> get_pictures >> notify  #9
#1 解析响应并下载所有火箭图片的 Python 函数
#2 实例化一个 DAG 对象。任何工作流都从这里开始。
#3 DAG 的名称
#4 DAG 开始运行的日期
#5 DAG 运行的间隔
#6 使用 Bash 通过 curl 下载 URL 响应的任务
#7 任务名称
#8 调用 Python 函数 _get_images 的任务
#9 Airflow 语法,用来设置任务执行顺序

我们来拆解这个工作流。DAG 是任何工作流的起点。工作流中的所有任务都会引用这个 DAG 对象,这样 Airflow 才知道哪些任务属于哪个 DAG。

从大的角度看,在 Airflow 中定义 DAG,主流有三种方式:显式定义上下文管理器方式以及 Taskflow API。每种方式都有其优缺点;具体使用哪一种,通常取决于用户的具体需求和偏好。

显式定义 DAG(见图 2.4)是一种非常直接的做法,尤其是对完全不懂 Python 的用户来说,这种方式能更清楚地看出哪个任务属于哪个 DAG。但它也更冗长,因为你必须把 DAG 对象传给每一个任务,这会让代码显得杂乱,而且也存在忘记传递 DAG 参数的风险,从而可能导致工作流出错。

image.png

图 2.4 显式定义 DAG,与使用上下文管理器定义同一个 DAG 的对比

另一方面,使用上下文管理器来定义 DAG,可以让代码更简洁,也降低了出错风险,因为你不必记得把 DAG 对象传给每一个任务。不过,这种方式也并非没有缺点。上下文管理器的作用域仅限于定义一个 DAG;如果你需要定义多个工作流,它就会显得比较受限。而且,对于不熟悉这一 Python 特性的读者来说,它的可读性可能没那么直观。

注意
在 Python 中,上下文管理器的一个典型标志是它以 with 关键字开头。它本质上是一个 Python 类,通过 __enter__()__exit__() 两个方法来管理资源:前者负责准备资源,后者负责释放资源。即使在发生错误时,它也能确保资源被安全处理。它通常用于管理文件、线程或数据库连接等资源。

在 Airflow 中,定义 DAG 的第三种方式是较新的 Taskflow API,我们会在第 6 章中详细介绍。就本书而言,我们主要采用上下文管理器这种方式。下面的代码清单使用上下文管理器语法,为我们的项目定义 DAG 对象(dags/02_download_rocket_launches.py)。

代码清单 2.3 实例化一个 DAG 对象

with DAG(                                           #1
   dag_id=02_download_rocket_launches",             #2
   start_date=pendulum.today('UTC').add(days=-14),  #3
   schedule=None,
):
#1 DAG 类需要两个必填参数
#2 DAGAirflow 用户界面(UI)中显示的名称
#3 工作流开始运行的时间点

我们把这段 DAG 代码拆开来看,解释一下关键部分。首先,我们需要给 DAG 起个名字,这通过提供 dag_id 来完成。还要注意,我们把 schedule 设为了 None,这样这个 DAG 就不会自动运行。当前阶段,我们可以先从 Airflow UI 手动触发它。关于调度,我们会在 2.4 节中讨论。

接下来,一个 Airflow 工作流脚本由一个或多个任务组成,这些任务负责执行真正的工作。在下面这段代码里,我们使用 BashOperator 运行一条 Bash 命令,通过调用 API 下载即将发射的火箭数据(dags/03_BashOperator.py)。

代码清单 2.4 实例化一个 BashOperator 来运行 Bash 命令

download_launches = BashOperator(
   task_id="download_launches",                     #1
   bash_command="curl -o /tmp/launches.json
➥ ‚https://ll.thespacedevs.com/2.0.0/launch/upcoming'",        #2
)
#1 任务名称
#2 要执行的 Bash 命令

每个任务都执行一个独立的工作单元,而多个任务组合起来,就在 Airflow 中构成了一个工作流或 DAG。任务彼此独立运行,不过我们可以定义它们的执行顺序——在 Airflow 中,这被称为依赖关系(dependencies) 。毕竟,如果 John 在还没有拿到图片位置数据时就去下载图片,那他的工作流显然没有意义。为了确保任务按正确顺序运行,我们可以在任务之间设置依赖关系。

代码清单 2.5 定义任务执行顺序

download_launches >> get_pictures >> notify       #1
#1 箭头定义任务的执行顺序

在 Airflow 中,我们可以使用按位右移运算符rshift [>>])或按位左移运算符lshift [<<])来定义任务之间的依赖。这就确保了 get_pictures 任务只有在 download_launches 成功完成后才会运行,而 notify 任务则只有在 get_pictures 成功完成后才会运行。

注意
在 Python 中,rshift (>>)lshift (<<) 运算符原本用于位移操作,这在密码学类库中很常见。而在 Airflow 中,并没有位移操作的使用场景,所以这些运算符被重载为一种可读性很好的方式,用来定义任务依赖关系。

2.2.1 任务(tasks)与算子(operators)

你可能会好奇:任务和算子到底有什么区别?毕竟它们看起来都在执行一段代码。

在 Airflow 中,operator(算子) 只有一个职责:执行一项单独的工作。有些 operator 执行的是通用工作,比如 BashOperator(运行 Bash 脚本)和 PythonOperator(运行 Python 函数);有些则有更具体的用途,比如 EmailOperator(发送邮件)和 HTTPOperator(调用 HTTP 端点或 API)。无论如何,一个 operator 都只负责完成一项具体工作。

注意
完整的 operator 列表,请参见 Airflow 的 Operators 参考文档(mng.bz/eB0q)。

而 DAG 的职责是编排一组 operator 的执行,包括启动和停止 operator、在某个 operator 完成后启动后续任务,以及确保 operator 之间的依赖得到满足。在这个语境下,以及在 Airflow 文档中,operatortask 这两个术语经常会被交替使用。从用户视角看,它们常常指的是同一个东西,因此在讨论里也会经常混用。Operator 提供的是“工作内容”的实现。Airflow 中有一个叫 BaseOperator 的类,而很多具体的 operator 类都继承自它,比如 PythonOperatorEmailOperatorOracleOperator

不过,两者之间确实还是有区别的。Task(任务) 负责管理一个 operator 的执行;你可以把它理解成 operator 外面的一层小包装器,或者一个执行管理器,用来确保 operator 被正确运行。这个“包装器”职责包括:处理错误、管理依赖、安排 operator 的执行。通过这种职责分离,用户只需要专注于通过 operator 定义“要做什么工作”,而执行细节则由 Airflow 借助 task 来负责(见图 2.5)。

image.png

图 2.5 Airflow 用户会同时使用 DAG 和 operator。Task 是内部组件,用于管理 operator 的状态,并向用户展示状态变化(例如 started / finished)。

2.2.2 运行任意 Python 代码

获取下一次火箭发射的数据,只需要用 curl 执行一条 Bash 命令,因此很容易通过 BashOperator 来完成。但如果要进一步解析 JSON 结果、从中挑选出图片 URL,并下载对应图片,就要稍微复杂一些了。虽然这些工作理论上也可以用一条 Bash 单行命令完成,但通常使用几行 Python(或者你喜欢的其他语言)会更容易,也更可读。由于 Airflow 本身就是用 Python 编写工作流代码的,所以把工作流定义和执行逻辑放在同一个脚本里会很方便。我们用下面这段代码来实现火箭图片下载(dags/04_PythonOperator_get_pictures.py)。

代码清单 2.6 使用 PythonOperator 运行一个 Python 函数

def _get_pictures():                           #1
   # Ensure directory exists
   pathlib.Path("/tmp/images").mkdir(parents=True, exist_ok=True) #2

   # Download all pictures in launches.json
   with open("/tmp/launches.json") as f:                    #3
       launches = json.load(f)
       image_urls = [l["image"] for l in launches["results"]]
       for image_url in image_urls:
           try:
               response = requests.get(image_url)                 #4
               image_filename = image_url.split("/")[-1]
               target_file = f"/tmp/images/{image_filename}"
               with open(target_file, "wb") as f:
                   f.write(response.content)                      #5
               print(f"Downloaded {image_url} to {target_file}")  #6
           except MissingSchema:
               print(f"{image_url} appears to be an invalid URL.")
           except ConnectionError:
               raise ConnectionError(f"Could not connect to {image_url}.")


get_pictures = PythonOperator(                                  #7
   task_id="get_pictures",
   python_callable=_get_pictures,                               #8
)
#1 要调用的 Python 函数
#2 如果图片目录不存在,则创建它
#3 打开上一个任务的结果
#4 下载每张图片
#5 保存每张图片
#6 打印到标准输出。这些内容会被 Airflow 收集到日志中
#7 实例化一个 PythonOperator 来调用该 Python 函数
#8 指向要执行的 Python 函数

Airflow 中的 PythonOperator 负责运行任意 Python 代码。和 BashOperator 一样,这个 operator 以及其他所有 operator 都需要一个 task_idtask_id 是 Airflow 各种操作(日志、依赖、API 调用等)中的主要任务标识符,同时也会显示在 UI 中。使用 PythonOperator 时,总是包含两个层面:

  1. 我们先定义 operator(这里是 get_pictures
  2. python_callable 参数指向一个可调用对象,通常就是一个函数(这里是 _get_pictures

当我们运行这个 operator 时,对应的 Python 函数就会被调用并执行。我们来拆开看。PythonOperator 的基本用法总是如图 2.6 所示。

image.png

图 2.6 PythonOperator 中的 python_callable 参数,指向一个待执行的函数

虽然这并不是强制要求,但为了方便起见,我们会让变量名 get_picturestask_id 保持一致。与此同时,通常也建议让被调用的 Python 函数名与变量名保持相近。由于你不能让一个变量和一个函数同名,我们就用下划线(_)作为函数名前缀。这个可调用对象中的第一步,是确保用于存放图片的目录已经存在。

代码清单 2.7 确保输出目录存在;如果不存在则创建

# Ensure directory exists
pathlib.Path("/tmp/images").mkdir(parents=True, exist_ok=True)

接下来,我们打开从 Launch Library API 下载到的结果,并提取每一次火箭发射对应的图片 URL。

代码清单 2.8 提取每次火箭发射对应的图片 URL

with open("/tmp/launches.json") as f:         #1
    launches = json.load(f)                          #2
    image_urls = [l["image"] for l in launches["results"]]  #3
#1 打开火箭发射 JSON 文件
#2 读取成 dict,以便我们处理这些数据
#3 对于每次发射,提取其中的 “image” 字段

随后,对每一个图片 URL 发起请求,把图片下载下来并保存到 /tmp/images 中。

代码清单 2.9 从提取出的图片 URL 下载所有图片

for url in image_urls:          #1
   try:
       response = requests.get(url)       #2
       image_name = url.split("/")[-1]           #3
       target_file = f"/tmp/images/{image_name}"       #4
       with open(target_file, "wb") as f:                  #5
           f.write(response.content)                       #6
       print(f"Downloaded {url} to {target_file}")   #7
   except MissingSchema:               #8
       print(f"{url} appears to be an invalid URL.")  #8
   except ConnectionError:              #8
       raise ConnectionError(f"Could not connect to {url}.")          #8
#1 遍历所有图片 URL
#2 获取图片
#3 只取文件名,也就是斜杠之后的所有内容。例如:
#  https://host/RocketImages/Electron.jpg_1440.jpg
#  会变成 Electron.jpg_1440.jpg
#4 构造目标文件路径
#5 打开目标文件句柄
#6 把图片内容写入文件路径
#7 打印结果
#8 捕获并处理潜在错误

2.3 在 Airflow 中运行一个 DAG

现在我们已经有了一个基础的火箭发射 DAG,接下来把它真正运行起来,然后在 Airflow UI 中查看它。最小可用的 Airflow 由 5 个核心组件组成:DAG processor、scheduler、API server、triggerer 和 database。要运行 Airflow,你可以把它安装到本地 Python 环境中,也可以使用 Docker。

2.3.1 在 Python 环境中运行 Airflow

最直接的方式,是在 Python 里安装 Airflow 包。为此,我们会使用一个虚拟环境。做 Python 项目时,最好让每个项目都运行在各自独立的 Python 环境中,这样才能实现可复现的安装,并避免依赖冲突。创建虚拟环境常见的工具包括:

当你准备好一个 Python 环境后,就可以使用包管理工具来安装 Airflow。如果你使用的是 virtualenv,那么可以通过 pip 来安装。请务必先激活你刚才创建的环境,再执行下面的命令:

pip install apache-airflow

警告
一定要安装的是 apache-airflow,而不是单独的 airflow。在 2016 年加入 Apache 基金会之后,PyPI 上的 airflow 仓库被重命名为 apache-airflow。因为很多人仍然误装 airflow,旧仓库被保留成了一个占位包,只是为了提示大家去安装正确的新仓库。

在虚拟环境中安装完 Airflow 之后,可以使用下面这个非常方便的 standalone 命令来启动 Airflow:

airflow standalone

这条命令会在后台完成很多事情:初始化 metastore(用于存储 Airflow 状态的数据库)、创建一个用户、并启动 api-serverschedulerdag-processortriggerer。初始化完成后,访问 http://localhost:8080,并使用用户名 admin 以及日志输出中生成的密码登录 Airflow UI:

standalone | Starting Airflow Standalone
Simple auth manager | Password for user 'admin': FBzzMdy6BYNM97zZ
standalone | Checking database is initialized
standalone | Database ready

2.3.2 使用 Docker 运行 Airflow

虽然用 Python 环境来运行 Airflow 很方便,但当 Airflow 配置变得更复杂时,它未必是最佳选择。为了获得更顺畅、更灵活的学习体验,我们建议你使用 Docker 来运行 Airflow。Docker 是一种非常流行的方式,用于创建隔离环境并运行可复现的代码。它在操作系统层面提供隔离,因此你不仅可以在 Docker 容器中安装一组 Python 包,还可以包含其他依赖,比如数据库驱动或编译器。要运行 Docker 容器,你的机器上必须先安装 Docker Engine,通常可以通过安装 Docker Desktop(www.docker.com/products/do…)来完成。

在本书中,我们主要会用 Docker 来运行示例中的 Airflow。为了定义 Airflow 的安装配置,我们会使用 Docker Compose,它是一种通过 YAML 文件来帮助我们管理和定义多容器 Docker 应用的工具。对于 Airflow 来说,它尤其合适,因为我们需要定义多个服务(数据库、workers、scheduler 等)以及它们之间的配置关系。

注意
YAML(yaml.org)是一种所谓的“对人类友好”的数据序列化语言,被广泛用于编写配置文件。

Docker Compose 能帮助我们高效组织这些配置。请查看本书代码仓库根目录中 Apache Airflow 官方提供的配置文件(official-airflow-docker-compose.yml)。此外,仓库中的每一章还都附带一个额外配置文件(compose.override.yaml),它会在基础配置上增加额外功能。

运行 Airflow 的第一步,是进入当前章节对应的目录。然后执行下面这条命令启动 Airflow。

代码清单 2.10 运行 docker compose up,把 Airflow 启动起来

@user $ cd chapter02

@user $ docker compose up

这条命令会初始化数据库、创建第一个用户账号,并配置所有服务,使 Airflow 能正常运行。你需要等待几分钟,让 Docker 完成整个配置过程。当服务全部成功启动后,可以通过下一段代码中的 docker ps 命令查看这些服务。

代码清单 2.11 正在运行中的 Airflow 服务列表

@user $ docker ps

CONTAINER ID   IMAGE                  NAMES
8e2d4649dfbd   apache/airflow:3.1.0   chapter02-airflow-worker-1
2229516745e6   apache/airflow:3.1.0   chapter02-airflow-apiserver-1
1e9ee7e33ec6   apache/airflow:3.1.0   chapter02-airflow-dag-processor-1
1f57735aa785   apache/airflow:3.1.0   chapter02-airflow-scheduler-1
ac23f91faab2   apache/airflow:3.1.0   chapter02-airflow-triggerer-1
7cd704c8fce4   postgres:16            chapter02-postgres-1
5b867091e894   redis:7.2-bookworm     chapter02-redis-1

现在你已经拥有了一个可以使用的 Airflow 实例。访问 http://localhost:8080,并使用用户名 airflow、密码 airflow 登录即可。

2.3.3 在 Airflow 中查看 DAG

登录之后,你就可以看到火箭发射相关的 DAG,如图 2.7 所示。

image.png

图 2.7 Airflow 的 DAG 总览页面

图中展示的是 Airflow 在 dags 目录下发现的所有 DAG。这个主界面会提供很多信息,但我们先来看 01_download_rocket_launches 这个 DAG。点击 DAG 的名字将其打开,然后查看 graph view(图视图) (见图 2.8)。

image.png

图 2.8 图视图中的 Airflow 火箭发射 DAG

这个视图展示了提供给 Airflow 的 DAG 脚本结构。当脚本被放进 dags 目录后,Airflow 会读取它,并提取出构成 DAG 的各个部分,从而在 UI 中进行可视化。图视图展示了 DAG 的结构,包括所有任务是如何连接的,以及它们将按照什么顺序运行。在开发工作流时,这可能会是你使用频率最高的视图。

首先,把 DAG 设置为 On,这样它才能运行;你可以通过切换 DAG 名字旁边的开关来完成。然后点击 Trigger 按钮运行它。触发 DAG 之后,工作流就会开始运行,你会看到工作流当前状态通过颜色展示出来(见图 2.9)。由于你已经设置了任务依赖关系,因此后续任务只有在前一个任务完成后才会开始执行。

image.png

图 2.9 正在运行中的 DAG 的图视图

我们来查看一下 notify 任务的结果。在真实场景中,你大概会想通过邮件或 Slack 通知用户,告诉他们有新的图片了。为了简化处理,这里这个任务只是打印下载完成的图片数量。图 2.9 展示的是 DAG 正在运行的状态。

所有任务日志都会被 Airflow 收集,因此无论是想查看输出,还是在任务失败时排查问题,都可以直接在 UI 中完成。点击已经完成的 notify 任务,你就会进入任务详情页,在那里可以看到它对应的日志(见图 2.10)。

image.png

图 2.10 任务详情页面

点击 Logs 按钮查看日志(如果默认还没选中的话),如图 2.11 所示。

image.png

图 2.11 日志中展示的 print 输出

默认情况下,日志会比较详细,但你仍然可以从中看到下载图片的数量。最后,打开 /tmp/images 目录,就能看到这些图片了。这里需要注意:如果你是在 Docker 中运行 Airflow,那么这个目录只存在于 worker 对应的 Docker 容器内部,而不在你的宿主机文件系统中。因此,你需要先进入 worker 容器,如下一段代码所示。之后你会进入容器中的 Bash 终端,然后就可以在 /tmp/images 里查看这些图片(见图 2.12)。

image.png

图 2.12 最终下载得到的火箭图片

代码清单 2.12 进入 worker 实例以查看其文件系统

# 先查看 worker 的容器名称
@user $ docker ps

CONTAINER ID   IMAGE                  NAMES
8e2d4649dfbd   apache/airflow:3.1.0   chapter02-airflow-worker-1
2229516745e6   apache/airflow:3.1.0   chapter02-airflow-apiserver-1
1e9ee7e33ec6   apache/airflow:3.1.0   chapter02-airflow-dag-processor-1
1f57735aa785   apache/airflow:3.1.0   chapter02-airflow-scheduler-1
ac23f91faab2   apache/airflow:3.1.0   chapter02-airflow-triggerer-1
7cd704c8fce4   postgres:16            chapter02-postgres-1
5b867091e894   redis:7.2-bookworm     chapter02-redis-1

@user $ docker exec -it chapter02-airflow-worker-1 /bin/bash
    default@8e2d4649dfbd:/opt/airflow$

2.4 按固定间隔运行

火箭迷 John 现在很高兴,因为他终于把工作流跑在 Airflow 上了;他可以隔一段时间手动触发一次,来收集最新的火箭图片。他还能在 Airflow UI 中看到工作流的状态,这比他以前在命令行里跑脚本已经进步很多了。但问题是,他仍然需要定期手动触发这个工作流——而这件事本来完全可以自动化。毕竟,没有人喜欢反复做那些计算机明明更擅长的重复劳动。

在 Airflow 中,我们可以把一个 DAG 调度为按某种固定间隔运行,比如每小时、每天、每月运行一次。这个间隔由 DAG 中的 schedule 参数控制,如下面的代码所示。除了这种固定间隔调度之外,Airflow 还支持更复杂的调度方式,比如使用 cron 表达式、通过 timetable 调度不规则事件,或者在外部数据库更新时触发工作流。第 3 章会详细介绍这些高级调度特性。

代码清单 2.13 让一个 DAG 每天运行一次(dags/05_download_rocket_launches.py

with DAG(
   dag_id="L11_download_rocket_launches",
   start_date=pendulum.today('UTC').add(days=-14),
   schedule="@daily",                  #1
   catchup=True,
):
#1 Airflow 对 0 0 * * * 的别名(即每天午夜)

schedule 设为 @daily,就表示让 Airflow 每天运行一次这个工作流,这样 John 就不必再手动触发它了。这个效果最适合在 grid view(网格视图) 中查看(见图 2.13)。

image.png

图 2.13 图视图与网格视图之间的关系

在这个视图中,DAG 的结构会以“行和列”的方式展示:具体来说,每一列代表这个 DAG 在某个时间点的一次运行状态。当我们把 schedule 设为 @daily 时,Airflow 就知道这个 DAG 应该每天运行一次。由于我们给这个 DAG 设置的 start_date 是 14 天前,因此从 14 天前到现在的时间,可以被划分为 14 个长度相等的一天时间区间。因为这 14 个区间的起止时间都已经落在过去,所以一旦你为 DAG 配置了 schedule,它们就会开始运行。关于调度区间的语义,以及如何以不同方式配置它们,第 3 章会做详细讨论。

2.5 处理失败任务

到目前为止,我们在 Airflow UI 中看到的基本都是一片绿色。但如果有任务失败了,会发生什么呢?实际上,任务失败一点也不罕见,它们可能出于各种原因而失败(例如外部服务不可用、代码里有 bug、网络连接问题、或者磁盘损坏等)。

假设某个时刻,我们在获取 John 的火箭图片时遇到了一次网络中断。结果就是,我们会在 Airflow UI 中看到 get_picture 任务失败了(见图 2.14)。

image.png

图 2.14 在网格视图中显示的失败状态

由于图片无法从互联网下载,Airflow 会提示我们这个任务没有成功完成。失败的具体任务会在图视图和网格视图中都被标记为 failed,从而非常明确地指出流水线中的问题位置。后续的 notify 任务则根本不会运行,因为它依赖于 get_pictures 任务成功完成;它会被标记为 upstream_failed。这类任务实例通常会显示为橙色。默认情况下,所有前置任务都必须成功执行;一旦某个任务失败,它后面的所有任务都不会再运行。

现在我们来通过查看日志定位问题。打开 get_pictures 任务的日志(见图 2.15)。

image.png

图 2.15 失败的 get_pictures 任务的堆栈跟踪信息

在这个堆栈跟踪中,我们发现了可能的原因:

ConnectionError: Could not connect to https://thespacedevs-
➥prod.nyc3.digitaloceanspaces.com/media/images/
➥long_march_2_image_20210908195835.jpeg.

这条信息表明,任务正在尝试建立连接,但无法连接成功,这可能暗示存在防火墙规则阻止了该连接,或者当前没有互联网连通性。假设我们能够修复这个问题(比如把网线重新插好),那接下来就来重启这个任务。

注意
没有必要重启整个工作流。Airflow 允许你从失败点开始重新执行,并继续往后运行,而无需重新运行之前已经成功完成的任务。

点击这个失败的任务,然后点击任务信息视图右上角的 Clear Task Instance 按钮(参见图 2.15)。点击后会弹出一个选择菜单,列出你即将清除状态的任务范围选项。

你可以只清除某一个特定任务;也可以通过在弹出菜单中选择 UpstreamDownstream,把同一次运行中它前面的任务或后面的任务一起选中(见图 2.16)。你还可以清除同一个任务在过去或未来其他运行中的状态。这样做的本质,是把这些任务“重置”为未执行状态,从而让 Airflow 自动重新运行它们。

image.png

图 2.16 清除 get_pictures 及其后续下游任务的状态

点击 Clear 按钮,清除这个失败任务以及它后面的后续任务,如图 2.17 所示。假设网络连接问题已经解决,那么这些任务现在就能成功运行,整个网格视图也会重新变成绿色(见图 2.18)。

image.png

图 2.17 图视图中显示的已清除任务状态

image.png

图 2.18 失败任务被清除后重新成功完成的任务状态

任何软件系统都可能因为很多不同原因而失败。在 Airflow 工作流中,有时失败是可以接受的,有时不能接受,还有时则是“在特定条件下可接受”。对于失败应该如何处理的规则,可以在工作流的不同层级上进行配置;第 6 章会详细讨论这一点。

当你清除之前失败的任务后,Airflow 会自动重新运行这些任务。如果一切顺利,John 就会下载到之前失败任务对应的火箭图片。在 download_launches 任务中调用的 URL,本质上只是去请求“下一批火箭发射”数据,因此它在每次 API 被调用时,返回的都会是当前最近的一批即将发射任务。第 5 章会讨论如何把 DAG 运行时的上下文信息整合进你的代码中。

2.6 DAG 版本控制

Airflow 3 引入的一个新特性是 DAG versioning(DAG 版本控制) 。现在,DAG 结构上的变化(例如任务重命名、依赖关系调整等)都会被直接记录在元数据数据库中,从而允许用户通过 UI 和 API 查看历史上的 DAG 结构。

DAG processor 能识别出代码发生了变化,并自动为你创建一个新版本。默认情况下,Airflow 运行 DAG 时会使用最新版本的代码。不过,你也可以通过 UI 中的 Versions 下拉菜单查看旧版本的代码和任务运行情况(见图 2.19)。目前,Airflow 3 中引入的 DAG 版本控制功能影响还比较有限。你已经可以查看不同版本之间的差异,但在首个正式实现版本(3.0.0)中,无论用户是运行还是重跑某个 DAG,始终都会使用最新版本的代码。尽管如此,它已经为后续版本中的更安全回填、更强可观测性以及基于运行时决定 DAG 逻辑等能力打下了基础。

image.png

图 2.19 DAG 版本控制中的 version 1

如果你想在 Airflow UI 中看到这个 DAG 的一个新版本,就需要手动对 DAG 做一些修改。首先,确保这个 DAG 至少已经用 v1 版本运行过一次,这样当你再给 DAG 增加一个额外任务时,就能更清楚地看到差异。一个最简单的手动修改方式,就是在 notify 任务后面再加一个新任务。

代码清单 2.14 给 DAG 增加一个额外任务

...
    versioning_example = BashOperator(
        task_id="versioning_example_task",
        bash_command='echo "Hey Airflow DAG versioning is here!"',
    )

    download_launches >> get_pictures >> notify >> versioning_example

当 DAG processor 重新处理了这个新的 DAG 文件之后,你会在 UI 的 DAG 页面右上角看到一个新版本(v2)。如果你想更清楚地查看差异,可以切到 UI 中的 Code 标签页;那里有一个下拉菜单,会列出所有可用版本(见图 2.20)。往下滚动后,你就能看到自己手动做的代码修改。

image.png

图 2.20 代码视图中的 DAG 版本选择器

为了展示这些变化对 DAG 运行结果的影响,你可以点击 Trigger 按钮,用最新版本的 DAG 代码来执行它。图 2.21 展示了运行结果:新增的那个任务只会出现在最新一次 DAG run 中。

image.png

图 2.21 DAG 版本控制效果:最新一次运行中可见新增任务

注意
DAG versioning 是建立在一个新的概念之上的,这个概念叫 DAG bundles。为了向后兼容,本地 dag 目录会被转换为一种类型为 LocalDagBundle 的 DAG bundle。另一个内置选项是 GitDagBundle。第 17 章会详细讲解如何配置和使用 DAG bundles。

小结

  • 在 Airflow 中,我们可以把一个由两个或更多步骤组成的大任务拆分成多个独立任务,这些任务共同组成一个 DAG。DAG 的职责是编排 operator 的执行、启动 / 停止任务,以及管理任务依赖关系。

  • Airflow 提供了 3 种主要的 DAG 定义方式:显式使用 DAG 对象、使用上下文管理器、以及使用 Taskflow API。

  • Airflow 中的 operator 是执行单个任务的基础构件。通过把这些 operator 连接起来,用户就可以借助 DAG 定义工作流。

  • Airflow 提供了丰富的 operator,可用于通用任务和特定任务,包括:

    • BashOperator —— 在任务中执行 Bash 命令,适合运行 shell 命令
    • PythonOperator —— 在任务中执行 Python 代码
    • EmailOperator —— 向一个或多个收件人发送邮件,可用于通知任务进度或失败状态
    • OracleOperator —— 在 Oracle 数据库中执行 SQL 命令,适合完成数据抽取、加载等 Oracle 环境下的操作
  • 在 DAG 中,你通过建立任务依赖关系来管理任务执行顺序。在 Airflow 中,定义任务执行顺序最常见的方式,就是使用 rshift (>>)lshift (<<) 运算符来设置任务依赖。Airflow UI 会展示所有已创建的 DAG;允许用户触发 DAG 执行;并提供每个任务执行状态(成功或失败)的详细信息,以及相应日志,帮助用户诊断和排查问题。

  • 在 Airflow 中,任务失败处理是工作流执行的关键部分。当任务失败时,Airflow 提供了多种机制来优雅处理这类事件。用户可以配置重试,让任务在失败后自动重新运行。如果需要人工介入,用户也可以清除失败任务的状态,从而让 Airflow 重新调度并再次执行该任务。

  • Airflow 3 引入了 DAG 版本控制。就写作本书时的现状而言,这项能力更多还是“展示型”的,但它已经为更安全的 backfill、更强的可观测性、以及运行时决定 DAG 逻辑等特性铺好了路。