这一章涵盖了以下内容:
- 与Airflow之外的系统进行交互的方法
- 应用于特定外部系统的操作器
- 在Airflow中实现A到B的操作器
- 测试与外部系统连接的任务
在之前的章节中,我们专注于编写Airflow代码的各个方面,主要通过使用通用的操作器如BashOperator和PythonOperator进行示例演示。虽然这些操作器可以运行任意代码,因此可以处理任何工作负载,但Airflow项目还提供了其他操作器,用于更特定的用例,例如在Postgres数据库上运行查询。这些操作器只有一个特定的用例,比如运行查询。因此,通过简单地向操作器提供查询,操作器会在内部处理查询逻辑,而使用PythonOperator则需要自己编写此查询逻辑。
在这里,所谓的"external system"是指除了Airflow及其运行的机器之外的任何技术。例如,Microsoft Azure Blob Storage、Apache Spark集群或Google BigQuery数据仓库等都属于外部系统。
为了了解何时以及如何使用这些操作器,在本章中,我们将开发两个与外部系统连接的DAG,将数据在这些系统之间进行移动和转换。我们将检查Airflow处理此用例和外部系统的各种选项。
在第7.1节中,我们将在AWS上开发一个机器学习模型,与AWS S3存储桶和AWS SageMaker合作,后者是用于开发和部署机器学习模型的解决方案。接下来,在第7.2节中,我们将演示如何在包含阿姆斯特丹Airbnb住宿信息的Postgres数据库之间移动数据。这些数据来自Inside Airbnb(insideairbnb.com),是由Airbnb管理的一个网站和公共数据,包含有关房源、评论等的记录。每天,我们将从Postgres数据库下载最新数据到AWS S3存储桶中。然后,在Docker容器中运行一个Pandas任务来确定价格波动,并将结果保存回S3中。
连接云服务
现如今,大部分软件都运行在云服务上。这类服务通常可以通过API(应用程序接口)进行控制,即连接并向云服务提供商发送请求的接口。通常,API会有一个客户端,以Python包的形式提供,例如AWS的客户端称为boto3(github.com/boto/boto3),GCP的客户端称为Cloud SDK(cloud.google.com/sdk),Azure的客户端则名为Azure SDK for Python(docs.microsoft.com/azure/pytho…)。这些客户端提供了方便的函数,您只需输入请求所需的详细信息,客户端会处理请求和响应的技术细节。
在Airflow的上下文中,对于程序员来说,接口是一个操作器(operator)。操作器是方便的类,您可以提供必要的详细信息来向云服务发出请求,而操作器在内部处理技术实现。这些操作器在内部使用Cloud SDK来发送请求,并提供对Cloud SDK的一小层封装,从而为程序员提供特定功能(图7.1)。
安装额外的依赖
apache-airflow Python包包含一些基本的操作器,但不包括与任何云服务连接的组件。对于云服务,您可以安装表格7.1中列出的提供商(provider)包。
这不仅适用于云服务提供商,还适用于其他外部服务。例如,要安装运行PostgresOperator所需的操作器及其相关依赖,需要安装apache-airflow-providers-postgres包。要查看所有可用附加包的完整列表,请参阅Airflow文档(airflow.apache.org/docs)。
让我们来看一个在AWS上执行操作的操作器。以S3CopyObjectOperator为例,该操作器将一个对象从一个存储桶复制到另一个存储桶。它接受几个参数(本示例中跳过了不相关的参数)。
➥ from airflow.providers.amazon.aws.operators.s3_copy_object import S3CopyObjectOperator
S3CopyObjectOperator(
task_id="...",
source_bucket_name="databucket", ❶
source_bucket_key="/data/{{ ds }}.json", ❷
dest_bucket_name="backupbucket", ❸
dest_bucket_key="/data/{{ ds }}-backup.json", ❹
)
❶ 复制源存储桶
❷ 要复制的对象名称
❸ 复制目标存储桶
❹ 目标对象名称
这个操作器使得在S3上将一个对象复制到另一个位置变得简单,只需填写上述提到的参数即可,而无需深入了解AWS的boto3客户端的细节。
开发一个机器学习模型
让我们来看一个更复杂的示例,并通过开发一个数据管道来使用多个AWS操作器构建手写数字分类器。该模型将在MNIST(yann.lecun.com/exdb/mnist)数据集上进行训练,该数据集包含大约70,000个手写数字0-9(图7.2)。
在训练模型后,我们应该能够将一个新的、之前未见过的手写数字输入到模型中,然后模型应该对这个手写数字进行分类(图7.3)。
模型分为两个部分:离线部分和在线部分。离线部分将大量的手写数字数据进行训练,生成用于分类这些手写数字的模型参数,并将结果(模型参数集)存储起来。这个过程可以定期执行,用于处理新收集的数据。在线部分负责加载模型并对之前未见过的数字进行分类。这部分需要立即运行,因为用户期望得到即时反馈。
Airflow工作流通常负责模型的离线部分。训练一个模型包括数据加载、预处理为模型可用的格式,并训练模型,这可能变得复杂。而且,周期性地重新训练模型与Airflow的批处理范式相适应。在线部分通常是一个API,例如一个REST API或者带有REST API调用的HTML页面。这样的API通常只会部署一次,或作为CI/CD流程的一部分。没有每周重新部署API的用例,因此通常不包含在Airflow工作流中。
为了训练手写数字分类器,我们将开发一个Airflow管道。该管道将使用AWS SageMaker,这是一个AWS服务,用于促进机器学习模型的开发和部署。在管道中,我们首先将样本数据从公共位置复制到我们自己的S3存储桶中。接下来,我们将数据转换为适用于模型的格式,在AWS SageMaker上训练模型,最后将模型部署以对给定的手写数字进行分类。管道的示意图见图7.4。
所示的管道可能只需运行一次,并且SageMaker模型可能只需部署一次。Airflow的强大之处在于可以调度此类管道,并在需要时重新运行(部分)管道,以处理新数据或模型的更改。如果原始数据不断更新,Airflow管道将定期重新加载原始数据并部署在新数据上训练的模型。此外,数据科学家可以根据自己的喜好调整模型,Airflow管道可以自动重新部署模型,而无需手动触发任何操作。
Airflow在AWS平台的各种服务上拥有多个操作器。虽然列表永远不会完整,因为服务不断添加、更改或删除,但大多数AWS服务都由Airflow操作器支持。AWS操作器由apache-airflow-providers-amazon包提供。
让我们来看一下这个管道(图7.5)。
尽管只有四个任务,但在AWS SageMaker上有很多配置要做,因此DAG代码会很长。不过别担心,我们稍后会对其进行拆解解释。
import gzip
import io
import pickle
import airflow.utils.dates
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
➥ from airflow.providers.amazon.aws.operators.s3_copy_object import S3CopyObjectOperator
➥ from airflow.providers.amazon.aws.operators.sagemaker_endpoint import SageMakerEndpointOperator
➥ from airflow.providers.amazon.aws.operators.sagemaker_training import SageMakerTrainingOperator
from sagemaker.amazon.common import write_numpy_to_dense_tensor
dag = DAG(
dag_id="chapter7_aws_handwritten_digits_classifier",
schedule_interval=None,
start_date=airflow.utils.dates.days_ago(3),
)
download_mnist_data = S3CopyObjectOperator( ❶
task_id="download_mnist_data",
source_bucket_name="sagemaker-sample-data-eu-west-1",
source_bucket_key="algorithms/kmeans/mnist/mnist.pkl.gz",
dest_bucket_name="[your-bucket]",
dest_bucket_key="mnist.pkl.gz",
dag=dag,
)
def _extract_mnist_data(): ❷
s3hook = S3Hook() ❸
# Download S3 dataset into memory
mnist_buffer = io.BytesIO()
mnist_obj = s3hook.get_key( ❹
bucket_name="[your-bucket]",
key="mnist.pkl.gz",
)
mnist_obj.download_fileobj(mnist_buffer)
# Unpack gzip file, extract dataset, convert, upload back to S3
mnist_buffer.seek(0)
with gzip.GzipFile(fileobj=mnist_buffer, mode="rb") as f:
train_set, _, _ = pickle.loads(f.read(), encoding="latin1")
output_buffer = io.BytesIO()
write_numpy_to_dense_tensor(
file=output_buffer,
array=train_set[0],
labels=train_set[1],
)
output_buffer.seek(0)
s3hook.load_file_obj( ❺
output_buffer,
key="mnist_data",
bucket_name="[your-bucket]",
replace=True,
)
extract_mnist_data = PythonOperator( ❻
task_id="extract_mnist_data",
python_callable=_extract_mnist_data,
dag=dag,
)
sagemaker_train_model = SageMakerTrainingOperator( ❼
task_id="sagemaker_train_model",
config={ ❽
➥ "TrainingJobName": "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}",
"AlgorithmSpecification": {
➥ "TrainingImage": "438346466558.dkr.ecr.eu-west-1.amazonaws.com/kmeans:1",
"TrainingInputMode": "File",
},
"HyperParameters": {"k": "10", "feature_dim": "784"},
"InputDataConfig": [
{
"ChannelName": "train",
"DataSource": {
"S3DataSource": {
"S3DataType": "S3Prefix",
"S3Uri": "s3://[your-bucket]/mnist_data",
"S3DataDistributionType": "FullyReplicated",
}
},
}
],
➥ "OutputDataConfig": {"S3OutputPath": "s3://[your-bucket]/
mnistclassifier-output"},
"ResourceConfig": {
"InstanceType": "ml.c4.xlarge",
"InstanceCount": 1,
"VolumeSizeInGB": 10,
},
➥ "RoleArn": "arn:aws:iam::297623009465:role/service-role/
AmazonSageMaker-ExecutionRole-20180905T153196",
"StoppingCondition": {"MaxRuntimeInSeconds": 24 * 60 * 60},
},
wait_for_completion=True, ❾
print_log=True, ❾
check_interval=10,
dag=dag,
)
sagemaker_deploy_model = SageMakerEndpointOperator( ❿
task_id="sagemaker_deploy_model",
wait_for_completion=True,
config={
"Model": {
➥ "ModelName": "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}",
"PrimaryContainer": {
➥ "Image": "438346466558.dkr.ecr.eu-west-1.amazonaws.com/
kmeans:1",
"ModelDataUrl": (
"s3://[your-bucket]/mnistclassifier-output/"
➥ "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}/"
"output/model.tar.gz"
), # this will link the model and the training job
},
➥ "ExecutionRoleArn": "arn:aws:iam::297623009465:role/service-role/AmazonSageMaker-ExecutionRole-20180905T153196",
},
"EndpointConfig": {
➥ "EndpointConfigName": "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}",
"ProductionVariants": [
{
"InitialInstanceCount": 1,
"InstanceType": "ml.t2.medium",
"ModelName": "mnistclassifier",
"VariantName": "AllTraffic",
}
],
},
"Endpoint": {
➥ "EndpointConfigName": "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}",
"EndpointName": "mnistclassifier",
},
},
dag=dag,
)
➥ download_mnist_data >> extract_mnist_data >> sagemaker_train_model >> sagemaker_deploy_model
❶ S3CopyObjectOperator可以在两个S3位置之间复制对象。
❷ 有时候你希望的功能在任何操作器中都不受支持,你必须自己实现逻辑。
❸ 我们可以使用S3Hook来执行S3上的操作。
❹ 下载S3对象。
❺ 将提取的数据重新上传到S3。
❻ 有时候你希望的功能在任何操作器中都不受支持,你必须自己实现它并使用PythonOperator进行调用。
❼ SageMakerTrainingOperator用于创建SageMaker训练作业。
❽ config是一个包含训练作业配置的JSON。
❾ 该操作器会方便地等待训练作业完成,并在训练过程中打印CloudWatch日志。
❿ SageMakerEndpointOperator用于部署已训练的模型,并通过HTTP端点提供访问。
在使用外部服务时,复杂性通常不在于Airflow本身,而在于确保在您的管道中正确集成各个组件。SageMaker涉及到很多配置,因此让我们逐步拆分任务,逐一解释。
download_mnist_data = S3CopyObjectOperator(
task_id="download_mnist_data",
source_bucket_name="sagemaker-sample-data-eu-west-1",
source_bucket_key="algorithms/kmeans/mnist/mnist.pkl.gz",
dest_bucket_name="[your-bucket]",
dest_bucket_key="mnist.pkl.gz",
dag=dag,
)
在初始化DAG之后,第一个任务会将MNIST数据集从公共存储桶复制到我们自己的存储桶中。我们将其存储在我们自己的存储桶中以供进一步处理。S3CopyObjectOperator要求提供源存储桶和对象名称,以及目标存储桶和对象名称,然后它将帮助您复制所选的对象。那么,在开发过程中,我们如何验证这个任务是否正确工作,而不是先编写完整的管道代码,然后交叉手指希望它在生产环境中能够正常运行呢?
在本地开发与外部系统的集成
针对AWS,如果您从开发机器上有访问密钥访问云资源,您可以在本地运行Airflow任务。借助CLI命令airflow tasks test,我们可以为指定的执行日期运行单个任务。由于download_mnist_data任务不使用执行日期,我们提供的值并不重要。但是,假设dest_bucket_key被设置为mnist-{{ ds }}.pkl.gz;那么我们需要明智地考虑我们要测试哪个执行日期。请从您的命令行完成以下步骤的列表。
# Add secrets in ~/.aws/credentials:
# [myaws]
# aws_access_key_id=AKIAEXAMPLE123456789
# aws_secret_access_key=supersecretaccesskeydonotshare!123456789
export AWS_PROFILE=myaws
export AWS_DEFAULT_REGION=eu-west-1
export AIRFLOW_HOME=[your project dir]
airflow db init ❶
airflow tasks test chapter7_aws_handwritten_digits_classifier download_mnist_data 2020-01-01 ❷
❶ 初始化本地Airflow元数据存储。
❷ 运行单个任务。
这将运行任务download_mnist_data并显示日志。
➥ $ airflow tasks test chapter7_aws_handwritten_digits_classifier download_mnist_data 2019-01-01
INFO - Using executor SequentialExecutor
INFO - Filling up the DagBag from .../dags
➥ INFO - Dependencies all met for <TaskInstance: chapter7_aws_handwritten_digits_classifier.download_mnist_data 2019-01-01T00:00:00+00:00 [None]>
---------------------------------------------------------------------------
INFO - Starting attempt 1 of 1
---------------------------------------------------------------------------
➥ INFO - Executing <Task(PythonOperator): download_mnist_data> on 2019-01-01T00:00:00+00:00
INFO - Found credentials in shared credentials file: ~/.aws/credentials
INFO - Done. Returned value was: None
➥ INFO - Marking task as SUCCESS.dag_id=chapter7_aws_handwritten_digits
_classifier, task_id=download_mnist_data, execution_date=20190101T000000, start_date=20200208T110436, end_date=20200208T110541
在此之后,我们可以看到数据已经被复制到我们自己的存储桶中(图7.6)。
发生了什么?我们配置了AWS凭据,以便允许我们从本地机器访问云资源。虽然这是针对AWS的特定设置,但类似的身份验证方法也适用于GCP和Azure。Airflow操作器内部使用的AWS boto3客户端会在运行任务的机器上的各个位置搜索凭据。在7.4中的列表中,我们设置了AWS_PROFILE环境变量,boto3客户端会使用它进行身份验证。然后,我们设置了另一个环境变量:AIRFLOW_HOME。这是Airflow存储日志等内容的位置。在这个目录中,Airflow会搜索/dags目录。如果该目录位于其他位置,您可以使用另一个环境变量AIRFLOW__CORE__DAGS_FOLDER来指定Airflow的目录。
接下来,我们运行airflow db init命令。在执行此操作之前,请确保您要么未设置AIRFLOW__CORE__SQL_ALCHEMY_CONN(指向用于存储所有状态的数据库的URI),要么将其设置为专门用于测试目的的数据库URI。如果没有设置AIRFLOW__CORE__SQL_ALCHEMY_CONN,airflow db init将在AIRFLOW_HOME内初始化一个本地SQLite数据库(单文件、无需配置的数据库)。airflow tasks test用于运行和验证单个任务,并且不会在数据库中记录任何状态;然而,它需要一个数据库来存储日志,因此我们必须通过airflow db init来初始化数据库。
在完成所有这些操作后,我们可以在命令行中使用airflow tasks test chapter7_aws_handwritten_digits_classifier extract_mnist_data 2020-01-01来运行任务。在我们将文件复制到我们自己的S3存储桶之后,我们需要将其转换为SageMaker KMeans模型期望的格式,即RecordIO格式。
import gzip
import io
import pickle
from airflow.operators.python import PythonOperator
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from sagemaker.amazon.common import write_numpy_to_dense_tensor
def _extract_mnist_data():
s3hook = S3Hook() ❶
# Download S3 dataset into memory
mnist_buffer = io.BytesIO()
mnist_obj = s3hook.get_key( ❷
bucket_name="your-bucket",
key="mnist.pkl.gz",
)
mnist_obj.download_fileobj(mnist_buffer)
# Unpack gzip file, extract dataset, convert, upload back to S3
mnist_buffer.seek(0)
with gzip.GzipFile(fileobj=mnist_buffer, mode="rb") as f: ❸
train_set, _, _ = pickle.loads(f.read(), encoding="latin1")
output_buffer = io.BytesIO()
write_numpy_to_dense_tensor( ❹
file=output_buffer,
array=train_set[0],
labels=train_set[1],
)
output_buffer.seek(0)
s3hook.load_file_obj( ❺
output_buffer,
key="mnist_data",
bucket_name="your-bucket",
replace=True,
)
extract_mnist_data = PythonOperator(
task_id="extract_mnist_data",
python_callable=_extract_mnist_data,
dag=dag,
)
❶ 初始化S3Hook以与S3通信。
❷ 将数据下载到内存中的二进制流。
❸ 解压缩和解析pickle文件。
❹ 将Numpy数组转换为RecordIO格式的记录。
❺ 将结果上传到S3。
Airflow本身是一个通用的编排框架,拥有可管理的一组功能,需要一定的时间和经验才能了解所有技术,并知道以哪种方式连接各个技术点。在数据领域工作通常需要时间和经验,才能了解所有技术,并知道以哪种方式连接各个技术点。您不会单独开发Airflow;通常您会连接到其他系统并阅读该特定系统的文档。虽然Airflow会触发这样的任务,但开发数据管道的难点通常不在于Airflow本身,而在于您与之通信的系统。虽然本书专注于Airflow,但由于使用其他数据处理工具的特性,我们尝试通过这些示例演示开发数据管道的过程。
对于这个任务,在Airflow中并没有现成的功能来下载数据、提取、转换并将结果上传回S3。因此,我们必须实现自己的函数。该函数将数据下载到内存中的二进制流(io.BytesIO),因此数据永远不会存储在文件系统中,也不会在任务执行后留下任何剩余文件。MNIST数据集很小(15 MB),因此可以在任何机器上运行。但是,请明智地考虑实现方式;对于更大的数据,最好选择将数据存储在磁盘上并分块处理。
同样,这个任务也可以在本地运行/测试。
airflow tasks test chapter7_aws_handwritten_digits_classifier extract_mnist_data 2020-01-01
一旦完成,数据将会在S3中可见(图7.7)。
接下来的两个任务是训练和部署SageMaker模型。SageMaker操作器接受一个config参数,其中包含了特定于SageMaker的配置,不在本书的范围内。让我们专注于其他参数。
sagemaker_train_model = SageMakerTrainingOperator(
task_id="sagemaker_train_model",
config={
➥ "TrainingJobName": "mnistclassifier-{{ execution_date.strftime('%Y-%m-%d-%H-%M-%S') }}",
...
},
wait_for_completion=True,
print_log=True,
check_interval=10,
dag=dag,
)
config中的许多细节是特定于SageMaker的,可以通过阅读SageMaker文档来了解。然而,对于与任何外部系统一起工作,有两个适用的经验教训。
首先,AWS要求TrainingJobName在AWS账户和区域内必须是唯一的。使用相同的TrainingJobName运行此操作两次会返回错误。假设我们为TrainingJobName提供了一个固定的值mnistclassifier;第二次运行会导致失败:
botocore.errorfactory.ResourceInUse: An error occurred (ResourceInUse) when calling the CreateTrainingJob operation: Training job names must be unique within an AWS account and region, and a training job with this name already exists (arn:aws:sagemaker:eu-west-1:[account]:training-job/mnistclassifier)
config参数可以进行模板化,因此,如果您计划定期重新训练模型,必须为其提供一个唯一的TrainingJobName,可以通过使用execution_date进行模板化来实现。这样我们可以确保任务是幂等的,而现有的训练作业不会导致冲突的名称。
其次,请注意参数wait_for_completion和check_interval。如果将wait_for_completion设置为false,该命令将只是启动并忘记(这是boto3客户端的工作方式):AWS将启动一个训练作业,但我们将永远不知道训练作业是否成功完成。因此,所有SageMaker操作器等待(默认为wait_for_completion=True)给定的任务完成。在内部,操作器每隔X秒轮询,检查作业是否仍在运行。这确保了我们的Airflow任务只有在完成后才完成(图7.8)。如果您有下游任务,并且希望确保管道的正确行为和顺序,那么您将希望等待完成。
完成整个管道后,我们成功地部署了一个SageMaker模型,并通过端点将其公开(图7.9)。
然而,在AWS中,SageMaker端点不会对外界公开。它可以通过AWS API访问,但不能通过全球可访问的HTTP端点进行访问。当然,为了完成数据管道,我们希望有一个漂亮的界面或API来输入手写数字并接收结果。在AWS中,为了使其对互联网可访问,我们可以部署一个Lambda(aws.amazon.com/lambda)来触发SageMaker端点,以及一个API网关(aws.amazon.com/api-gateway)来创建一个HTTP端点,将请求转发给Lambda,因此为什么不将它们集成到我们的管道中(图7.10)?
不部署基础架构的原因是Lambda和API Gateway将作为一次性部署,而不是周期性部署。它们在模型的在线阶段运行,因此最好作为CI/CD管道的一部分进行部署。为了完整起见,API可以使用Chalice实现。
import json
from io import BytesIO
import boto3
import numpy as np
from PIL import Image
from chalice import Chalice, Response
from sagemaker.amazon.common import numpy_to_record_serializer
app = Chalice(app_name="number-classifier")
@app.route("/", methods=["POST"], content_types=["image/jpeg"])
def predict():
"""
Provide this endpoint an image in jpeg format.
The image should be equal in size to the training images (28x28).
"""
img = Image.open(BytesIO(app.current_request.raw_body)).convert("L") ❶
img_arr = np.array(img, dtype=np.float32) ❶
runtime = boto3.Session().client(
service_name="sagemaker-runtime",
region_name="eu-west-1",
)
response = runtime.invoke_endpoint( ❷
EndpointName="mnistclassifier",
ContentType="application/x-recordio-protobuf",
Body=numpy_to_record_serializer()(img_arr.flatten()),
)
result = json.loads(response["Body"].read().decode("utf-8")) ❸
return Response(
result,
status_code=200,
headers={“Content-Type”: “application/json”},
)
❶ 将输入图像转换为灰度numpy数组。
❷ 调用由Airflow DAG部署的SageMaker端点。
❸ SageMaker响应以字节形式返回。
该API只有一个单一的端点,它接受一个JPEG图像。
curl --request POST \
--url http://localhost:8000/ \
--header 'content-type: image/jpeg' \
--data-binary @'/path/to/image.jpeg'
如果训练正确,结果将如图7.11所示。
该API将给定的图像转换为与SageMaker模型训练时相同的RecordIO格式。然后将RecordIO对象转发给由Airflow管道部署的SageMaker端点,并最终返回给定图像的预测结果。
将数据在系统之间迁移
Airflow的一个经典用例是周期性的ETL作业,其中数据每天下载并在其他地方进行转换。这样的作业通常用于分析目的,其中数据从生产数据库导出,并在其他地方存储以供稍后处理。生产数据库通常(根据数据模型)无法返回历史数据(例如,数据库一个月前的状态)。因此,通常会创建定期导出并存储以供稍后处理的历史数据。历史数据转储会快速增加存储需求,并且需要分布式处理来处理所有数据。在本节中,我们将探讨如何使用Airflow协调这样的任务。
我们开发了一个GitHub仓库,其中包含与本书配套的代码示例。它包含一个Docker Compose文件,用于部署和运行下一个用例,其中我们提取Airbnb列表数据,并在Docker容器中使用Pandas进行处理。在大规模的数据处理作业中,Docker容器可以被Spark作业所取代,Spark作业将工作分布在多台机器上。Docker Compose文件包含以下内容:
- 一个Postgres容器,保存Airbnb阿姆斯特丹的列表数据。
- 一个AWS S3-API兼容的容器。由于Docker中没有AWS S3,我们创建了一个MinIO容器(与AWS S3 API兼容的对象存储)用于读写数据。
- 一个Airflow容器。
在视觉上,流程将如图7.12所示。
Airflow充当“蜘蛛在网络中”,启动和管理作业,并确保所有作业按正确顺序成功完成,如果不成功,则使管道失败。
Postgres容器是一个自定义构建的Postgres镜像,其中包含一个填充了Inside Airbnb数据的数据库,可在Docker Hub上作为airflowbook/insideairbnb提供。数据库中只有一个名为“listings”的表,其中包含了2015年4月至2019年12月之间在阿姆斯特丹上的Airbnb列表记录(图7.13)。
首先,让我们查询数据库并将数据导出到S3。从那里,我们将使用Pandas读取和处理数据。
在Airflow中,常见的任务是在两个系统之间进行数据传输,可能在中间进行转换。例如,查询MySQL数据库并将结果存储在Google Cloud Storage中,从SFTP服务器复制数据到AWS S3的数据湖,或调用HTTP REST API并存储输出,它们有一个共同点,即它们涉及两个系统:一个用于输入,另一个用于输出。
在Airflow生态系统中,这导致开发了许多这样的A到B操作器。对于这些示例,我们有MySqlToGoogleCloudStorageOperator,SFTPToS3Operator和SimpleHttpOperator。虽然Airflow生态系统中的操作器涵盖了许多用例,但目前(写本书时)还没有Postgres查询到AWS S3的操作器。那么该怎么办呢?
实现一个PostgresToS3Operator
首先,我们可以注意其他类似的操作器是如何工作的,并开发我们自己的PostgresToS3Operator。让我们仔细看一下与我们用例密切相关的操作器,即在airflow.providers.amazon.aws.transfers.mongo_to_s3中的MongoToS3Operator(在安装apache-airflow-providers-amazon后可用)。该操作器在MongoDB数据库上运行查询,并将结果存储在AWS S3存储桶中。让我们查看它并找出如何用Postgres替换MongoDB。execute()方法的实现如下(某些代码已模糊处理):
def execute(self, context):
s3_conn = S3Hook(self.s3_conn_id) ❶
results = MongoHook(self.mongo_conn_id).find( ❷
mongo_collection=self.mongo_collection,
query=self.mongo_query,
mongo_db=self.mongo_db
)
docs_str = self._stringify(self.transform(results)) ❸
# Load Into S3
s3_conn.load_string( ❹
string_data=docs_str,
key=self.s3_key,
bucket_name=self.s3_bucket,
replace=self.replace
)
❶ 实例化一个S3Hook。
❷ 实例化一个MongoHook,并用它来查询数据。
❸ 对结果进行转换。
❹ 调用S3Hook的load_string()方法将转换后的结果写入。
值得注意的是,该操作器不使用Airflow机器上的任何文件系统,而是将所有结果保存在内存中。流程基本上是:
MongoDB ® Airflow in operator memory ® AWS S3
由于该操作器将中间结果保存在内存中,请在运行非常大的查询时,明智考虑内存影响,因为非常大的结果可能会耗尽Airflow机器上的可用内存。现在,让我们牢记MongoToS3Operator的实现,并看看另一个A到B操作器,即S3ToSFTPOperator。
def execute(self, context):
ssh_hook = SSHHook(ssh_conn_id=self.sftp_conn_id)
s3_hook = S3Hook(self.s3_conn_id)
s3_client = s3_hook.get_conn()
sftp_client = ssh_hook.get_conn().open_sftp()
with NamedTemporaryFile("w") as f: ❶
s3_client.download_file(self.s3_bucket, self.s3_key, f.name)
sftp_client.put(f.name, self.sftp_path)
❶ 使用NamedTemporaryFile用于临时下载文件,在上下文退出后将其删除。
这个操作器再次实例化了两个钩子:SSHHook(SFTP是FTP over SSH)和S3Hook。然而,在这个操作器中,中间结果被写入到NamedTemporaryFile,它是Airflow实例的本地文件系统上的临时位置。在这种情况下,我们不会将整个结果保存在内存中,但我们必须确保有足够的磁盘空间可用。
这两个操作器都有两个共同的钩子:一个用于与系统A通信,另一个用于与系统B通信。然而,数据如何在系统A和B之间检索和传输取决于实现特定操作器的人。在特定的Postgres情况下,数据库游标可以迭代以获取和上传结果的块。然而,这个实现细节不在本书的范围内。保持简单,假设中间结果在Airflow实例的资源边界内。
一个非常简单的PostgresToS3Operator的实现可能如下所示。
def execute(self, context):
postgres_hook = PostgresHook(postgres_conn_id=self._postgres_conn_id)
s3_hook = S3Hook(aws_conn_id=self._s3_conn_id)
results = postgres_hook.get_records(self._query) ❶
s3_hook.load_string( ❷
string_data=str(results),
bucket_name=self._s3_bucket,
key=self._s3_key,
)
❶ 从PostgreSQL数据库中提取记录。
❷ 将记录上传到S3对象。
让我们检查这段代码。两个钩子的初始化很简单;我们初始化它们,提供用户提供的连接ID的名称。虽然使用关键字参数并不是必需的,但你可能会注意到S3Hook采用了参数aws_conn_id(而不是你可能期望的s3_conn_id)。在开发这样的操作器和使用这样的钩子时,有时不可避免地需要深入查看源代码或仔细阅读文档,以查看所有可用的参数,并理解如何将这些参数传递到类中。在S3Hook的情况下,它是AwsHook的子类,并继承了一些方法和属性,比如aws_conn_id。
PostgresHook也是一个子类,具体是DbApiHook的子类。通过这样做,它继承了几个方法,比如get_records(),它执行给定的查询并返回结果。返回类型是一个序列的序列(更准确地说是一个元组的列表)。然后,我们将结果转换为字符串,并调用load_string(),它将编码数据写入到AWS S3上的给定存储桶/键。你可能会认为这样做不太实用,你是正确的。虽然这是在Postgres上运行查询并将结果写入AWS S3的最简单流程,但元组列表被转换为字符串,而没有任何数据处理框架能够将其解释为普通的文件格式,比如CSV或JSON(图7.14)。
开发数据管道的棘手部分通常不是使用Airflow进行作业编排,而是确保各个作业的所有组件正确配置,并像拼图一样配合得当。因此,让我们将结果写入CSV文件;这将使Apache Pandas和Spark等数据处理框架能够轻松解释输出数据。
对于上传数据到S3,S3Hook提供了各种便利方法。对于文件类似的对象,我们可以使用load_file_obj()方法。
def execute(self, context):
postgres_hook = PostgresHook(postgres_conn_id=self._postgres_conn_id)
s3_hook = S3Hook(aws_conn_id=self._s3_conn_id)
results = postgres_hook.get_records(self.query)
data_buffer = io.StringIO() ❶
csv_writer = csv.writer(data_buffer, lineterminator=os.linesep)
csv_writer.writerows(results)
data_buffer_binary = io.BytesIO(data_buffer.getvalue().encode())
s3_hook.load_file_obj(
file_obj=data_buffer_binary, ❷
bucket_name=self._s3_bucket,
key=self._s3_key,
replace=True, ❸
)
❶ 为了方便起见,我们首先创建一个字符串缓冲区,它类似于内存中的文件,可以向其写入字符串。在写入后,我们将其转换为二进制。
❷ 它需要一个二进制模式下的文件类似的对象。
❸ 通过替换文件来确保幂等性,如果文件已经存在的话。
缓冲区存在于内存中,这可能很方便,因为在处理后内存不会在文件系统上留下任何剩余文件。然而,我们必须意识到Postgres查询的输出必须适合内存。幂等性的关键是设置replace=True。这确保现有文件被覆盖。例如,我们可以在代码更改后重新运行我们的流水线,如果没有设置replace=True,由于现有文件,流水线将失败。
通过这几行额外的代码,我们现在可以将CSV文件存储在S3上。让我们看看它在实践中是如何运行的。
download_from_postgres = PostgresToS3Operator(
task_id="download_from_postgres",
postgres_conn_id="inside_airbnb",
query="SELECT * FROM listings WHERE download_date={{ ds }}",
s3_conn_id="s3",
s3_bucket="inside_airbnb",
s3_key="listing-{{ ds }}.csv",
dag=dag,
)
有了这段代码,我们现在有了一个方便的操作器,它使得查询Postgres并将结果写入S3上的CSV成为填写空白的练习。
外包重活
Airflow社区中一个常见的讨论是将Airflow视为不仅是一个任务编排系统,还是一个任务执行系统。因为许多DAG是使用BashOperator和PythonOperator编写的,这些操作符在同一个Python运行时中执行任务。反对这种观点的人认为,应该将Airflow仅视为一个任务触发系统,并建议不要在Airflow本身内部执行实际的工作。相反,所有的工作都应该被外包到一个专门用于处理数据的系统,比如Apache Spark。
假设我们有一个非常大的作业,会占用Airflow运行的机器上的所有资源。在这种情况下,最好将作业运行在其他地方;Airflow将启动作业并等待其完成。这样做的想法是,编排和执行之间应该有一个很强的分离,我们可以通过Airflow启动作业并等待完成,而由像Spark这样的数据处理框架来执行实际的工作。
在Spark中,有多种启动作业的方式:
- 使用SparkSubmitOperator——这需要在Airflow机器上有一个spark-submit二进制文件和YARN客户端配置,以便找到Spark实例。
- 使用SSHOperator——这需要对Spark实例进行SSH访问,但不需要在Airflow实例上配置Spark客户端。
- 使用SimpleHTTPOperator——这需要运行Livy,一个用于访问Spark的Apache Spark的REST API。
使用任何Airflow操作符的关键是阅读文档,并弄清楚要提供哪些参数。让我们来看一下DockerOperator,它使用Pandas启动Docker容器来处理Inside Airbnb数据。
crunch_numbers = DockerOperator(
task_id="crunch_numbers",
image="airflowbook/numbercruncher",
api_version="auto",
auto_remove=True, ❶
docker_url="unix://var/run/docker.sock",
network_mode="host", ❷
environment={
"S3_ENDPOINT": "localhost:9000", ❷
"S3_ACCESS_KEY": "[insert access key]",
"S3_SECRET_KEY": "[insert secret key]",
},
dag=dag,
)
❶ 在完成后删除容器。
❷ 为了通过 http://localhost 连接到主机上的其他服务,我们必须使用主机网络命名空间共享主机网络模式。
DockerOperator包装了Python Docker客户端,并通过给定的参数列表启动Docker容器。在列表7.15中,docker_url被设置为Unix套接字,这需要本地机器上运行Docker。它启动了Docker镜像airflowbook/numbercruncher,其中包含一个Pandas脚本,该脚本从S3加载Inside Airbnb数据,处理数据,并将结果写回S3。
[ { "id": 5530273, "download_date_min": 1428192000000, "download_date_max": 1441238400000, "oldest_price": 48, "latest_price": 350, "price_diff_per_day": 2 }, { "id": 5411434, "download_date_min": 1428192000000, "download_date_max": 1441238400000, "oldest_price": 48, "latest_price": 250, "price_diff_per_day": 1.3377483444 }, ...]
Airflow管理容器的启动、获取日志,并在需要时最终删除容器。关键是确保不会留下任何状态,使得您的任务可以幂等运行,并且不会留下任何遗留物。
总结
- 用于外部系统的操作符通过调用给定系统的客户端来公开功能。
- 有时,这些操作符仅仅是将参数传递给Python客户端。
- 其他时候,它们提供额外的功能,比如SageMakerTrainingOperator,它会持续轮询AWS并在完成之前阻塞。
- 如果从本地机器可以访问外部服务,我们可以使用CLI命令airflow tasks test来测试任务。