《基于Apache Airflow的数据流管道》第七章:与外部系统通信

541 阅读26分钟

这一章涵盖了以下内容:

  1. 与Airflow之外的系统进行交互的方法
  2. 应用于特定外部系统的操作器
  3. 在Airflow中实现A到B的操作器
  4. 测试与外部系统连接的任务

在之前的章节中,我们专注于编写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)。

image.png

安装额外的依赖

apache-airflow Python包包含一些基本的操作器,但不包括与任何云服务连接的组件。对于云服务,您可以安装表格7.1中列出的提供商(provider)包。

截屏2023-08-02 00.40.50.png

这不仅适用于云服务提供商,还适用于其他外部服务。例如,要安装运行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)。

image.png

在训练模型后,我们应该能够将一个新的、之前未见过的手写数字输入到模型中,然后模型应该对这个手写数字进行分类(图7.3)。

image.png

模型分为两个部分:离线部分和在线部分。离线部分将大量的手写数字数据进行训练,生成用于分类这些手写数字的模型参数,并将结果(模型参数集)存储起来。这个过程可以定期执行,用于处理新收集的数据。在线部分负责加载模型并对之前未见过的数字进行分类。这部分需要立即运行,因为用户期望得到即时反馈。

Airflow工作流通常负责模型的离线部分。训练一个模型包括数据加载、预处理为模型可用的格式,并训练模型,这可能变得复杂。而且,周期性地重新训练模型与Airflow的批处理范式相适应。在线部分通常是一个API,例如一个REST API或者带有REST API调用的HTML页面。这样的API通常只会部署一次,或作为CI/CD流程的一部分。没有每周重新部署API的用例,因此通常不包含在Airflow工作流中。

为了训练手写数字分类器,我们将开发一个Airflow管道。该管道将使用AWS SageMaker,这是一个AWS服务,用于促进机器学习模型的开发和部署。在管道中,我们首先将样本数据从公共位置复制到我们自己的S3存储桶中。接下来,我们将数据转换为适用于模型的格式,在AWS SageMaker上训练模型,最后将模型部署以对给定的手写数字进行分类。管道的示意图见图7.4。

image.png

所示的管道可能只需运行一次,并且SageMaker模型可能只需部署一次。Airflow的强大之处在于可以调度此类管道,并在需要时重新运行(部分)管道,以处理新数据或模型的更改。如果原始数据不断更新,Airflow管道将定期重新加载原始数据并部署在新数据上训练的模型。此外,数据科学家可以根据自己的喜好调整模型,Airflow管道可以自动重新部署模型,而无需手动触发任何操作。

Airflow在AWS平台的各种服务上拥有多个操作器。虽然列表永远不会完整,因为服务不断添加、更改或删除,但大多数AWS服务都由Airflow操作器支持。AWS操作器由apache-airflow-providers-amazon包提供。

让我们来看一下这个管道(图7.5)。

image.png

尽管只有四个任务,但在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)。

image.png

发生了什么?我们配置了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)。

image.png

接下来的两个任务是训练和部署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)。如果您有下游任务,并且希望确保管道的正确行为和顺序,那么您将希望等待完成。

image.png

完成整个管道后,我们成功地部署了一个SageMaker模型,并通过端点将其公开(图7.9)。

image.png

然而,在AWS中,SageMaker端点不会对外界公开。它可以通过AWS API访问,但不能通过全球可访问的HTTP端点进行访问。当然,为了完成数据管道,我们希望有一个漂亮的界面或API来输入手写数字并接收结果。在AWS中,为了使其对互联网可访问,我们可以部署一个Lambda(aws.amazon.com/lambda)来触发SageMaker端点,以及一个API网关(aws.amazon.com/api-gateway)来创建一个HTTP端点,将请求转发给Lambda,因此为什么不将它们集成到我们的管道中(图7.10)?

image.png

不部署基础架构的原因是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所示。

image.png

该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所示。

image.png

Airflow充当“蜘蛛在网络中”,启动和管理作业,并确保所有作业按正确顺序成功完成,如果不成功,则使管道失败。

Postgres容器是一个自定义构建的Postgres镜像,其中包含一个填充了Inside Airbnb数据的数据库,可在Docker Hub上作为airflowbook/insideairbnb提供。数据库中只有一个名为“listings”的表,其中包含了2015年4月至2019年12月之间在阿姆斯特丹上的Airbnb列表记录(图7.13)。

image.png

首先,让我们查询数据库并将数据导出到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)。

image.png

开发数据管道的棘手部分通常不是使用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来测试任务。