Python Ray 扩展指南(二)
原文:
annas-archive.org/md5/95872ff5b3ec96901f7e3cfb51cd271f译者:飞龙
第六章:实施流处理应用程序
到目前为止,在本书中,我们一直在使用 Ray 来实现无服务器批处理应用程序。在这种情况下,数据被收集或者从用户那里提供,然后用于计算。另一个重要的用例组是需要您实时处理数据的情况。我们使用过载的术语实时来表示在某些延迟约束条件下处理数据。这种类型的数据处理称为流处理。
在本书中,我们将流定义为在数据生成接近时间点时采取行动。
一些常见的流处理用例包括以下内容:
日志分析
一种了解硬件和软件状态的方式。通常作为流日志的分布式处理实现,即在产生日志时即时处理流。
欺诈检测
监控金融交易并查找实时信号的异常,以及停止欺诈交易。
网络安全
监控系统与用户的互动以检测异常,允许实时识别安全问题并隔离威胁。
流处理物流
实时监控汽车、卡车、车队和货物,以优化路由。
物联网数据处理
例如,收集关于引擎的数据,以便在问题变成主要问题之前检测到故障情况。
推荐引擎
用于根据在线行为了解用户兴趣,用于提供广告、推荐产品和服务等。
当谈到在 Ray 中实现流处理应用程序时,您目前有两个主要选项:
-
Ray 的生态系统提供了许多底层组件,这些组件在前几章中已经描述过,可以用于自定义实现流处理应用程序。
-
可以使用外部库和工具与 Ray 一起实现流处理。
Ray 并非作为一个流处理系统构建。它是一个生态系统,使公司能够在这些低级基元上构建流处理系统。你可以找到多个大公司和小公司用户构建基于 Ray 的流处理应用程序的故事。
说到这里,在 Ray 上构建一个小型流处理应用程序将为您提供一个完美的例子,展示如何思考 Ray 应用程序以及如何有效使用 Ray,并将帮助您理解流处理应用程序的基础知识以及如何利用 Ray 的能力来实现它。即使您决定使用外部库,这些材料也将帮助您更好地决定是否以及如何使用这些库。
实施流处理应用程序的一个最受欢迎的方法是使用Apache Kafka连接数据生产者和实施数据处理的消费者。在深入研究 Ray 的流处理实现之前,让我们先快速介绍一下 Kafka。
Apache Kafka
这里仅描述了对我们讨论有关的 Kafka 功能。有关详细信息,请参阅Kafka 文档。
Kafka 基础概念
尽管许多人认为 Kafka 是一种类似于例如RabbitMQ的消息系统,但实际上它是完全不同的东西。Kafka 是一个分布式日志,按顺序存储记录(见图 6-1)¹。
图 6-1. 分布式日志
Kafka 记录是键值对。(键和值都是可选的,可以使用空值来标记现有值的删除。)键和值都被表示为字节数组,并且对 Kafka 本身是不透明的。生产者始终写入日志的末尾,而消费者可以选择他们希望从哪个位置(偏移量)读取。
Kafka 等面向日志的系统与 RabbitMQ 等消息系统的主要区别如下:
-
队列系统中的消息是短暂的;它们只在系统中保留到被传递。另一方面,日志系统中的消息是持久的。因此,您可以在日志系统中重播消息,这在传统消息系统中是不可能的²。
-
在传统的消息代理中,管理消费者及其偏移量,而在日志系统中,消费者负责管理其偏移量。这使得基于日志的系统能够支持更多的消费者。
与消息系统类似,Kafka 将数据组织成主题。不同于消息系统的是,Kafka 中的主题是纯逻辑构造,由多个分区组成(见图 6-2)。
图 6-2. 主题的解剖
分区中的数据是顺序的,并可以在多个代理(broker)之间复制。分区是一种重要的可扩展性机制,允许单独的消费者并行读取专用分区,并允许 Kafka 将分区分开存储。
在向主题写入时,Kafka 支持两种主要的分区机制:如果未定义键,则使用轮询分区,将主题的消息均匀分布在分区中;如果定义了键,则写入的分区由键确定。默认情况下,Kafka 使用键哈希进行分区。您还可以使用 Kafka 实现自定义的分区机制。消息排序仅在分区内部进行,因此要按顺序处理的任何消息必须位于同一分区中。
将 Kafka 以多个(1 至 n)经纪人(服务器)组成的集群形式部署,以维护负载平衡。³ 根据配置的复制因子,每个分区可以存在于一个或多个经纪人上,这可以提高 Kafka 的吞吐量。Kafka 客户端可以连接到任何经纪人,并且经纪人会将请求透明地路由到正确的经纪人之一。
要理解应用程序如何与 Kafka 一起扩展,您需要理解 Kafka 消费者组的工作方式(图 6-3)。
图 6-3. Kafka 消费者组
您可以分配从相同主题集中读取的消费者到一个 消费者组。然后 Kafka 为组中的每个消费者分配一个分区子集。
例如,如果您有一个具有 10 个分区和单个消费者的主题,这个消费者将读取所有主题的分区。对于相同的主题,如果您的消费者组中有 5 个消费者,每个消费者将从主题中读取两个分区。如果您有 11 个消费者,则其中 10 个消费者将各自读取一个分区,而第 11 个将不读取任何数据。
如您所见,您可以扩展 Kafka 读取的两个主要因素是分区数量和消费者组中的消费者数量。向消费者组添加更多消费者比添加新分区更容易,因此超额配置分区数量是最佳实践。
Kafka API
如 Kafka 文档 所定义,Kafka 具有五个核心 API 组:
生产者 API
允许应用程序向 Kafka 集群中的主题发送数据流
消费者 API
允许应用程序从 Kafka 集群中的主题读取数据流
AdminClient API
允许管理和检查主题、经纪人和其他 Kafka 对象
流 API
允许将输入主题的数据流转换到输出主题
连接 API
允许实现连接器,持续地从源系统或应用程序拉取到 Kafka,或者从 Kafka 推送到接收系统或应用程序
这些 API 实现了多种 语言,包括 Java、C/C++、Go、C# 和 Python。我们将使用 Kafka 的 Python API 与 Ray 集成,实现前三个 API 组,这对我们的目的足够了。关于使用 Python Kafka API 的简单示例,请参阅本书的 GitHub 仓库。
与其他消息传递系统不同,Kafka 不保证不重复的消息。相反,每个 Kafka 消费者负责确保消息仅被处理一次。
注意
如果您有兴趣了解更多信息,Confluent 的“Kafka Python Client”文档提供了有关提交选项及其对交付保证的影响的更多信息。默认情况下,Python 客户端使用自动提交,这也是我们在示例中使用的。对于实际实现,请考虑您需要提供的交付保证(精确一次、至少一次等),并使用适当的提交方法。
使用 Kafka 与 Ray
现在您已经了解了 Kafka 及其基本 API,让我们来看看如何将 Kafka 与 Ray 集成的选项。我们将实现 Kafka 消费者和生产者都作为 Ray actor。⁴ 使用 Ray actor 与 Kafka 的原因如下:
-
Kafka 消费者在一个无限循环中运行,等待新记录的到来,并且需要跟踪已消费的消息。作为一个有状态的服务,Ray actor 提供了实现 Kafka 消费者的理想范式。
-
将 Kafka 生产者放入 actor 中,您可以异步地将记录写入任何 Kafka 主题,而无需创建单独的生产者。
一个简单的 Kafka 生产者 actor 的实现如示例 6-1 所示。
示例 6-1. Kafka 生产者 actor
@ray.remote
class KafkaProducer:
def __init__(self, broker: str = 'localhost:9092'):
from confluent_kafka import Producer
conf = {'bootstrap.servers': broker}
self.producer = Producer(**conf)
def produce(self, data: dict, key: str = None, topic: str = 'test'):
def delivery_callback(err, msg):
if err:
print('Message failed delivery: ', err)
else:
print(f"Message delivered to topic {msg.topic()} " +
f"partition {msg.partition()} offset {msg.offset()}')
binary_key = None
if key is not None:
binary_key = key.encode('UTF8')
self.producer.produce(topic=topic, value=json.dumps(data).encode('UTF8'),
key=binary_key, callback=delivery_callback)
self.producer.poll(0)
def destroy(self):
self.producer.flush(30)
此示例中的 actor 实现包括以下方法:
构造函数
此方法根据 Kafka 集群的位置初始化 Kafka 生产者。
produce
这是您调用以发送数据的方法。它将要写入 Kafka 的数据(作为 Python 字典)、可选键(作为字符串)和要写入的 Kafka 主题。在这里,我们选择使用字典来表示数据,因为这是一种通用的表示数据的方式,并且可以很容易地编组/解组为 JSON。为了调试,我们添加了一个内部的delivery_callback方法,用于在写入消息或发生错误时打印输出。
destroy
Ray 在退出应用程序之前调用此方法。我们的destroy方法等待最多 30 秒,以便交付任何未完成的消息并触发交付报告的回调。
示例 6-2 展示了一个 Kafka 消费者 actor 的简单实现。
示例 6-2. Kafka 消费者 actor
@ray.remote
class KafkaConsumer:
def __init__(self, callback, group: str = 'ray', broker: str = 'localhost:9092',
topic: str = 'test', restart: str = 'latest'):
from confluent_kafka import Consumer
from uuid import uuid4
# Configuration
consumer_conf = {'bootstrap.servers': broker, # Bootstrap server
'group.id': group, # Group ID
'session.timeout.ms': 6000, # Session tmout
'auto.offset.reset': restart} # Restart
# Create Consumer instance
self.consumer = Consumer(consumer_conf)
self.topic = topic
self.id = str(uuid4())
self.callback = callback
def start(self):
self.run = True
def print_assignment(consumer, partitions):
print(f'Consumer {self.id}')
print(f'Assignment: {partitions}')
# Subscribe to topics
self.consumer.subscribe([self.topic], on_assign = print_assignment)
while self.run:
msg = self.consumer.poll(timeout=1.0)
if msg is None:
continue
if msg.error():
print(f"Consumer error: {msg.error()}")
else:
# Proper message
self.callback(self.id, msg)
def stop(self):
self.run = False
def destroy(self):
self.consumer.close()
此示例中的消费者 actor 具有以下方法:
构造函数
初始化 Kafka 消费者。与生产者相比,这里有更多的参数。除了经纪人位置外,您还需要指定以下内容:
-
主题名称
-
消费者组名称(用于并行运行)
-
重新启动,配置客户端在从无偏移量启动或者当前偏移量在服务器上不存在时的行为⁵
-
回调函数,指向客户端用于处理消息的函数
start
运行一个无限循环,轮询记录。在我们的示例中,新记录只是被打印出来。为了调试,我们还打印了消费者的分区分配情况。
stop
更新停止无限循环的类属性。
destroy
在应用程序退出之前由 Ray 调用以终止消费者。
除了这两个参与者外,我们还需要设置 Kafka 主题。虽然 Kafka 在使用时会自动创建新的主题,但默认的分区数和复制因子可能不符合您的需求。我们将使用我们的首选设置在 示例 6-3 中创建主题。
示例 6-3. 主题设置函数
def setup_topics(broker: str = 'localhost:9092', topics: [] = ['test'],
partitions: int = 10, replication: int = 1):
# Re-create topic
# Wait for operation completion method
def wait_for_operation_completion(futures: dict, success: str, failure: str):
for topic, f in futures.items():
try:
f.result() # The result itself is None
print(f"Topic {topic} {success}")
except Exception as e:
print(f"{failure} {topic} error {e}")
admin = AdminClient({'bootstrap.servers': broker})
# Delete topics
fs = admin.delete_topics(topics)
# Wait for each operation to finish.
wait_for_operation_completion(fs, " is deleted", "Failed to delete topic ")
# Wait to make sure topic is deleted
sleep(3)
# Call create_topics to asynchronously create topics.
new_topics = [NewTopic(topic, num_partitions=partitions,
replication_factor=replication) for topic in topics]
fs = admin.create_topics(new_topics)
# Wait for each operation to finish.
wait_for_operation_completion(fs, " is created", "Failed to create topic ")
因为主题可能已经存在,所以代码首先删除它们。一旦删除完成,代码会等待一段时间,以确保删除在集群上生效,然后使用目标分区数和复制因子重新创建主题。
有了这三个组件,您现在可以创建一个 Ray 应用程序来发布和从 Kafka 中读取消息。您可以在本地或集群上运行此应用程序。Ray 应用程序本身看起来像 示例 6-4。
示例 6-4. 将所有内容整合在一起
# Simple callback function to print topics
def print_message(consumer_id: str, msg):
print(f"Consumer {consumer_id} new message: topic={msg.topic()} "
f"partition= {msg.partition()} offset={msg.offset()} "
f"key={msg.key().decode('UTF8')}")
print(json.loads(msg.value().decode('UTF8')))
# Set up topics
setup_topics()
# Set up random number generator
seed(1)
# Start Ray
ray.init()
# Start consumers and producers
n_consumers = 1 # Number of consumers
consumers = [KafkaConsumer.remote(print_message) for _ in range(n_consumers)]
producer = KafkaProducer.remote()
refs = [c.start.remote() for c in consumers]
# Publish messages
user_name = 'john'
user_favorite_color = 'blue'
# Loop forever publishing messages to Kafka
try:
while True:
user = {
'name': user_name,
'favorite_color': user_favorite_color,
'favorite_number': randint(0, 1000)
}
producer.produce.remote(user, str(randint(0, 100)))
sleep(1)
# End gracefully
except KeyboardInterrupt:
for c in consumers:
c.stop.remote()
finally:
for c in consumers:
c.destroy.remote()
producer.destroy.remote()
ray.kill(producer)
此代码执行以下操作:
-
定义了一个简单的回调函数,用于 Kafka 消费者,只是打印消息。
-
初始化 Ray。
-
创建所需的主题。
-
同时启动生产者和消费者(该代码允许我们指定要使用的消费者数量)。
-
对所有创建的消费者调用
start方法。 -
一旦所有消费者都创建完毕,生产者就开始每秒发送一次 Kafka 请求。
此外,代码还实现了优雅的终止,确保在作业被中断时清理所有资源。
一旦代码运行,它将生成 示例 6-5 中显示的输出。
示例 6-5. 单一消费者的执行结果
Topic test is deleted
Topic test is created
2021-08-23 17:00:57,951 INFO services.py:1264 -- View the Ray dashboard at http://...
(pid=19981) Consumer 04c698a5-db3a-4da9-86df-cd7d6fb7dc6d
(pid=19981) Assignment: TopicPartition{topic=test,partition=0,offset=-1001,error=...
…………………………………………………………………………………………..
(pid=19981) Consumer ... new message: topic= test partition= 8 offset= 0 key= 57
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 779}
(pid=19981) Consumer ... new message: topic= test partition= 2 offset= 0 key= 63
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 120}
(pid=19981) Consumer ... new message: topic= test partition= 8 offset= 1 key= 83
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 483}
(pid=19977) Message delivered to topic test partition 8 offset 0
(pid=19977) Message delivered to topic test partition 2 offset 0
(pid=19977) Message delivered to topic test partition 8 offset 1
(pid=19981) Consumer ... new message: topic= test partition= 8 offset= 2 key= 100
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 388}
(pid=19981) Consumer ... new message: topic= test partition= 5 offset= 0 key= 12
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 214}
(pid=19977) Message delivered to topic test partition 8 offset 2
(pid=19981) Consumer ... new message: topic= test partition= 1 offset= 0 key= 3
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 499}
(pid=19977) Message delivered to topic test partition 5 offset 0
(pid=19981) Consumer ... new message: topic= test partition= 6 offset= 0 key= 49
(pid=19981) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 914}
(pid=19977) Message delivered to topic test partition 1 offset 0
(pid=19977) Message delivered to topic test partition 6 offset 0
(pid=19981) Consumer ... new message: topic= test partition= 8 offset= 3 key= 77
…………………………...
如您从结果中可以看到的那样,执行以下操作:
-
删除并重新创建主题
test。 -
创建一个消费者,监听一个主题的所有分区(这里我们只运行一个消费者)。
-
处理消息。请注意,生产者的消息会被传递到不同的分区,但始终由单个消费者接收和处理。
扩展我们的实现
现在一切都正常运行,让我们看看如何扩展我们的实现。正如本章前面讨论的那样,扩展从 Kafka 读取消息的应用程序的基本方法是增加 Kafka 消费者的数量(假设主题有足够的分区)。幸运的是,代码([示例 6-4)已经支持这一点,所以我们可以通过设置 n_consumer=5 来轻松增加消费者的数量。一旦完成这个更新,重新运行代码将生成 示例 6-6 的输出。
Example 6-6. 五个消费者的执行结果
Topic test is deleted
Topic test is created
2021-08-23 17:15:12,353 INFO services.py:1264 -- View the Ray dashboard at http://...
(pid=20100) Message delivered to topic test partition 8 offset 0
(pid=20100) Message delivered to topic test partition 2 offset 0
(pid=20103) Consumer 9e2773d4-f006-4d4d-aac3-fe75ed27f44b
(pid=20103) Assignment: TopicPartition{topic=test,partition=0,offset=-1001,error=...
(pid=20107) Consumer bdedddd9-db16-4c24-a7ef-338e91b4e100
(pid=20107) Assignment: [TopicPartition{topic=test,partition=4,offset=-1001,error=...
(pid=20101) Consumer d76b7fad-0b98-4e03-92e3-510aac2fcb11
(pid=20101) Assignment: [TopicPartition{topic=test,partition=6,offset=-1001,error=...
(pid=20106) Consumer e3d181af-d095-4b7f-b3d6-830299c207a8
……………………………………………………………………………………..
(pid=20100) Message delivered to topic test partition 8 offset 1
(pid=20104) Consumer ... new message: topic= test partition= 8 offset= 2 key= 100
(pid=20104) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 388}
(pid=20100) Message delivered to topic test partition 8 offset 2
(pid=20107) Consumer ... new message: topic= test partition= 5 offset= 0 key= 12
(pid=20107) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 214}
(pid=20100) Message delivered to topic test partition 5 offset 0
(pid=20103) Consumer ... new message: topic= test partition= 1 offset= 0 key= 3
(pid=20103) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 499}
(pid=20100) Message delivered to topic test partition 1 offset 0
(pid=20101) Consumer ... new message: topic= test partition= 6 offset= 0 key= 49
(pid=20101) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 914}
(pid=20100) Message delivered to topic test partition 6 offset 0
(pid=20104) Consumer ... new message: topic= test partition= 8 offset= 3 key= 77
(pid=20104) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 443}
(pid=20100) Message delivered to topic test partition 8 offset 3
(pid=20103) Consumer ... new message: topic= test partition= 1 offset= 1 key= 98
(pid=20103) {'name': 'john', 'favorite_color': 'blue', 'favorite_number': 780}
……………………………………………………….
在这里,与[Example 6-5 不同,五个 Kafka 消费者中的每一个都开始监听 2 个分区(请记住,我们的主题使用 10 个分区)。您还可以看到消息被传递到不同分区时,它们由不同的消费者实例处理。因此,我们可以手动扩展我们的 Kafka 应用程序,但是自动扩展呢?
与本地 Kubernetes 自动扩展器不同—例如,KEDA,它基于queue depth来扩展消费者—Ray 使用了不同的方法。Ray 固定数量的消费者并将它们分布在节点上(如果需要,添加节点)。这为每个消费者提供了更好的性能,但是当分区不足时仍会遇到问题。
现在您已经知道如何将 Ray 与 Kafka 集成,让我们讨论如何利用这种技术来构建流应用程序。
使用 Ray 构建流处理应用程序
流处理有两个重要的类别:
无状态流处理
每个事件完全独立于任何先前事件或可变共享状态进行处理。给定一个事件,流处理器将无论先前到达的数据是什么或执行状态如何,每次都以完全相同的方式处理它。
有状态流处理
事件之间共享状态,可以影响当前事件的处理方式。在这种情况下,状态可以是先前事件的结果或由外部系统产生,用于控制流处理。
无状态流处理实现通常简单而直接。它们需要扩展 Kafka 消费者的start方法(见 Example 6-2)来实现对传入消息的任何必要转换。这些转换的结果可以发送到不同的 Kafka 主题或代码的任何其他部分。Javier Redondo 的“Serverless Kafka Stream Processing with Ray”描述了一个无状态流处理应用程序的示例。
实施有状态流处理通常更加复杂。让我们看看基于dynamically controlled streams实施有状态流处理的选项。
我们的样本实现使用了一个具有以下特征的加热器控制器示例:⁶
-
消息生产者提供传感器的温度测量的恒定流。
-
温控器的设置被定义为所需的温度 Td 和∆t。
-
温控器设置可以在任何时候到达。
-
当温度低于 Td - ∆t 时,实施会向加热器发送信号以启动。
-
当温度高于 Td + ∆t 时,会向加热器发送信号以停止。
-
这里使用了一个非常简单的加热器模型,当加热器开启时,每 N(可配置)分钟温度增加 1 度,关闭时每 M(可配置)分钟温度降低 1 度。
以下是我们对原始示例所做的简化:
-
我们不使用 Protobuf 编组,而是使用 JSON 编组(与前面的示例相同),这允许我们通用地编组/解组 Python 字典消息。
-
为了简化我们的实现,我们不像原始示例中那样使用两个队列,而是使用一个包含控制和传感器消息的单个队列,在接收它们时区分两者。尽管在我们的示例中可以工作,但在具有大量消息的实际实现中可能不是一个好的解决方案,因为它可能会减慢传感器消息的处理速度。
有了这些简化,我们现在将演示两种使用 Ray 实现有状态流处理的方法:基于键的方法和基于键独立的方法。
基于键的方法
许多有状态的流式应用程序依赖于 Kafka 消息键。请记住,Kafka 分区使用键散列来确定将消息写入到哪个分区。这意味着 Kafka 保证所有具有相同键的消息始终由同一个消费者接收。在这种情况下,可以在接收它们的 Kafka 消费者上本地实现有状态的流处理。因为消费者是作为 Ray actor 实现的,Ray 会跟踪 actor 内部的数据。⁷
对于这个实现,我们创建了一个小型的加热器模拟程序,您可以在附带的 GitHub 项目中找到,该程序基于加热器 ID 发布和获取数据。⁸有了这个,你可以像 示例 6-7 中那样实现温度控制器。
示例 6-7. 温度控制器的实现
from enum import Enum
class Action(Enum):
NONE = -1
OFF = 0
ON = 1
class BaseTemperatureController:
def __init__(self, id: str):
self.current_setting = None
self.previous_command = -1
self.id = id
# Process new message
def process_new_message(self, message: dict):
if 'measurement' in message: # Measurement request
self.process_sensor_data(message)
else: # Temp set request
self.set_temperature(message)
# Set new temperature
def set_temperature(self, setting: dict):
desired = setting['temperature']
updelta = setting['up_delta']
downdelta = setting['down_delta']
print(f'Controller {self.id} new temperature setting {desired} up '
f'delta {updelta} down delta {downdelta}')
self.current_setting = desired
self.up_delta = updelta
self.down_delta = down_delta
# Process new measurements
def process_sensor_data(self, sensor: dict) ->bool:
# Desired temperature is set, otherwise ignore
if self.current_setting is not None:
# Calculate desired action
measurement = sensor['measurement']
action = Action.NONE
if measurement > (self.current_setting + self.up_delta):
action = Action.ON
if measurement < (self.current_setting - self.down_delta):
action = Action.OFF
# New action
if action != Action.NONE and self.previous_command != action:
self.previous_command = action
# Publish new action to kafka
return True
else:
return False
else:
return False
实现是一个 Python 类,具有以下方法:
构造函数接受一个 Kafka 生产者 actor(示例 6-1)。
此类使用 set_temperature 将控制数据写入 Kafka,以及此温度控制器的 ID(与加热器设备 ID 相同)。
process_new_message
接收消息,并根据其内容调用 set_temperature 或 process_sensordata 中的一个。
set_temperature
处理来自恒温器的新设置温度方法。此消息包含新的期望温度以及额外的加热器特定参数(在其中控制被忽略的温度间隔)。
process_sensordata
处理温度控制。如果设置了期望温度,该方法将当前温度与期望温度进行比较,并计算所需的控制(加热器开/关)。为了避免重复发送相同的控制值,该方法还将计算的控制值与当前(缓存的)进行比较,并仅在更改时提交新的控制值。
由于 Kafka 根据键的哈希计算分区,同一个分区可以服务于多个键。为了管理每个分区的多个键,我们引入了一个 TemperatureControllerManager 类,其目的是管理各个温度控制器(Example 6-8)。
Example 6-8. 温度控制器管理实现
class TemperatureControllerManager:
def __init__(self, producer: KafkaProducer):
self.controllers = {}
self.producer = producer
def process_controller_message(self, key: str, request: dict):
if not key in self.controllers: # Create a new controller
print(f'Creating a new controller {controller_id}')
controller = TemperatureController(producer=self.producer, id=key)
self.controllers[key] = controller
self.controllers[key].process_new_message(request)
此实现基于一个字典来跟踪基于其 ID 的温度控制器。该类提供了两种方法:
构造函数接受一个 Kafka 生产者演员(Example 6-1)
创建一个新的空字典来管理各个温度控制器。
process_controller_message 函数
对于每个本地 Kafka 消费者接收到的新消息,根据一个键来决定是否存在所需的温度控制器。如果不存在,将创建一个新的温度控制器并存储对其的引用。找到或创建控制器后,然后将消息传递给它进行处理。
要将此实现链接到 Kafka 消费者,我们需要稍微修改 Kafka 消费者(Example 6-2)和温度控制器管理器集成(Example 6-9)。
Example 6-9. 将 Kafka 消费者与温度控制器管理器集成
@ray.remote
class KafkaConsumer:
def __init__(self, producer: KafkaProducer, group: str = 'ray',
broker: str = 'localhost:9092', topic: str = 'sensor', restart: str = 'earliest'):
from confluent_kafka import Consumer
import logging
# Configuration
consumer_conf = {'bootstrap.servers': broker, # Bootstrap server
'group.id': group, # Group ID
'session.timeout.ms': 6000, # Session tmout
'auto.offset.reset': restart} # Restart
# Create Consumer instance
self.consumer = Consumer(consumer_conf)
self.topic = topic
self.callback = TemperatureControllerManager(producer).
process_controller_message
def start(self):
self.run = True
def print_assignment(consumer, partitions):
print(f'Assignment: {partitions}')
# Subscribe to topics
self.consumer.subscribe([self.topic], on_assign = print_assignment)
while self.run:
msg = self.consumer.poll(timeout=1.0)
if msg is None:
continue
If msg.error():
print(f'Consumer error: {msg.error()}')
continue
else:
# Proper message
print(f"New message: topic={msg.topic()} " +
f"partition= {msg.partition()} offset={msg.offset()}")
key = None
if msg.key() != None:
key = msg.key().decode("UTF8")
print(f'key={key}')
value = json.loads(msg.value().decode("UTF8"))
print(f'value = {value}')
self.callback(key, value)
def stop(self):
self.run = False
def destroy(self):
self.consumer.close()
这种实现与原始实现之间存在一些显著差异:
-
构造函数接受一个额外的参数——Kafka 生产者——该参数在演员初始化时用于创建温度控制器管理器。
-
对于每个传入的消息,除了打印它之外,我们还调用温度控制器管理器来处理它。
通过这些变更,您可以实现类似于(Example 6-4)的主程序并启动执行。部分执行结果(在 Example 6-10)显示了处理的输出。
Example 6-10. 控制器执行结果
(pid=29041) New message: topic= sensor partition= 9 offset= 18
(pid=29041) key 1234 value {'measurement': 45.0}
(pid=29041) New message: topic= sensor partition= 9 offset= 19
(pid=29041) key 1234 value {'measurement': 45.2}
(pid=29041) New message: topic= sensor partition= 9 offset= 20
(pid=29041) key 1234 value {'measurement': 45.3}
(pid=29041) New message: topic= sensor partition= 9 offset= 21
(pid=29041) key 1234 value {'measurement': 45.5}
(pid=29041) New message: topic= sensor partition= 9 offset= 22
(pid=29041) key 1234 value {'measurement': 45.7}
(pid=29041) New message: topic= sensor partition= 9 offset= 23
(pid=29041) key 1234 value {'measurement': 45.9}
(pid=29041) New message: topic= sensor partition= 9 offset= 24
(pid=29041) key 1234 value {'measurement': 46.0}
(pid=29041) New message: topic= sensor partition= 9 offset= 25
(pid=29041) key 1234 value {'measurement': 46.2}
(pid=29040) Message delivered to topic heatercontrol partition 9 offset 0
(pid=29041) New message: topic= sensor partition= 9 offset= 26
(pid=29041) key 1234 value {'measurement': 46.1}
(pid=29041) New message: topic= sensor partition= 9 offset= 27
(pid=29041) key 1234 value {'measurement': 46.0}
(pid=29041) New message: topic= sensor partition= 9 offset= 28
(pid=29041) key 1234 value {'measurement': 46.0}
(pid=29041) New message: topic= sensor partition= 9 offset= 29
(pid=29041) key 1234 value {'measurement': 45.9}
(pid=29041) New message: topic= sensor partition= 9 offset= 30
(pid=29041) key 1234 value {'measurement': 45.7}
此列表显示了在温度接近期望值(45 度)时控制器的行为。正如预期的那样,温度会持续增加,直到超过 46 度(为了避免控制器的频繁开关,当期望和实际温度之间的差异小于 1 度时,不执行任何操作)。当测量值为 46.2 时,新消息将发送到加热器以关闭,温度开始下降。同时查看此列表,我们可以看到请求始终传递到同一分区(它们具有相同的键)。
对于许多实际的实现来说,基于键的方法是一个很好的选择。这种方法的优势在于所有的数据处理都在同一个 Kafka 消费者 Actor 内部完成。
这样的实现有两个潜在的缺陷:
-
随着键的数量增加,确保这些键均匀分布在 Kafka 主题分区中是必要的。确保这种键分布有时可能需要额外的键设计过程,但默认的哈希通常已经足够。
-
当执行是 CPU 和内存密集型时,执行局部性可能会成为问题。因为所有的执行都是 Kafka 消费者 Actor 的一部分,它的扩展性可能不足以应对高流量。
一些这些缺点可以在独立于键的方法中得到纠正。
独立于键的方法
这种方法与之前的方法的不同之处在于,温度控制器(示例 6-8)和温度控制器管理器(示例 6-9)都被转换为Ray actors。通过这样做,它们都变成了可单独寻址的,可以放置在任何地方。这种方法会失去执行局部性(可能会导致轻微的执行时间增加),但可以提高解决方案的整体可扩展性(每个 Actor 可以在单独的节点上运行)。如果需要,您可以通过利用 Actor 池(在第四章中描述)进一步提高可扩展性,从而允许 Ray 将执行分割到更多节点上。
超越 Kafka
在本章中,您学习了如何使用 Ray 的本地能力通过直接集成 Ray 与 Kafka 来实现流式处理。但是如果您需要使用不同的消息基础设施怎么办?如果您喜欢的通信支撑提供了 Python API,您可以像之前描述的 Kafka 集成一样将其与 Ray 集成。
另一种选择,正如本章开头提到的,是使用外部库——例如项目 Rayvens,它在内部利用 Apache Camel(一个通用集成框架),可以使用各种消息背景。您可以在 Gheorghe-Teodor Bercea 和 Olivier Tardieu 的文章 “使用 Rayvens 访问数百个事件源和接收器” 中找到对支持的消息背景的描述以及它们的使用示例。
类似于我们描述的 Kafka 集成,在底层,Rayvens 被实现为一组 Ray actors。Rayvens 的基类 Stream 是一个无状态、可序列化的包装器,封装了 Stream Ray actor 类,负责跟踪当前的 Rayvens 状态(详见第四章关于使用 actors 管理全局变量的内容),包括当前定义的源和接收器及其连接性。Stream 类隐藏了 Stream actor 的远程特性,并实现了内部实现所有与底层远程 actor 的通信的包装器。如果您希望在执行时控制更多(例如执行时机),可以直接调用 Stream actor 上的方法。当原始流句柄超出范围时,将回收 Stream actor。
由于 Rayvens 基于 Camel,它需要设置 Camel 以使其工作。Rayvens 支持两种主要的 Camel 使用选项:
本地模式
Camel 源或接收器与使用 Camel 客户端连接的 Stream actor 在同一个执行上下文中运行:同一容器,同一虚拟或物理机器。
运算符模式
Camel 源或接收器在 Kubernetes 集群内运行,依赖 Camel 运算符来管理专用的 Camel Pod。
结论
在本章中,您了解了使用 Ray 实现流处理的一种选项。您首先了解了 Kafka 的基础知识——今天最流行的流处理应用背骨,并学习了如何将其与 Ray 集成。然后,您学习了如何使用 Ray 扩展基于 Kafka 的应用程序。我们还概述了使用 Ray 实现无状态和有状态流应用程序的实现方法,这些方法可以作为自定义实现的基础。
最后,我们简要讨论了使用 Kafka 作为传输方式的替代方案。Rayvens 是一个基于 Apache Camel 的通用集成框架,可用于集成各种流式背景。您可以利用这些讨论来决定如何实现您的特定传输。
在下一章中,我们将介绍 Ray 的微服务框架及其用于模型服务的使用方法。
¹ 分布式日志实现的其他示例包括 Apache BookKeeper、Apache Pulsar 和 Pravega。
² 尽管我们倾向于思考无限日志,但实际上,Kafka 日志受限于相应 Kafka 服务器的磁盘空间。Kafka 引入了日志保留和清理策略,防止日志无限增长,从而导致 Kafka 服务器崩溃。因此,当我们在生产系统中谈论日志重放时,我们是在谈论在保留窗口内的重放。
³ 更多详细信息,请参阅Jason Bell 的“Kafka 集群的容量规划”。Kafka 也作为 Confluent Cloud 等供应商提供的无服务器产品。
⁴ 另一个采用同样方法的例子,请参阅Javier Redondo 的“使用 Ray 进行无服务器 Kafka 流处理”。
⁵ reset的允许值有earliest,它会自动将偏移重置到日志的开头,以及latest,它会自动将偏移重置到消费者组最近处理的偏移量。
⁶ 这个例子在Boris 的“如何使用动态控制流为机器学习模型提供服务”博客文章中进一步描述。
⁷ 如第四章所述,Ray 的 actors 不是持久化的。因此,在节点故障的情况下,actor 状态将丢失。我们可以实现持久性,如第四章中所述,以克服这一问题。
⁸ 注意使用线程来确保 Kafka 消费者无限运行,而不干扰测量计算。再次强调,这是我们为玩具示例而做的简化;在真实实现中,每个对温度控制器的请求应包含一个replyTo主题,以确保任何回复都能到达正确的加热器实例。
第七章:实现微服务
最初,Ray 被创建为一个实现 强化学习 的框架,但逐渐演变成了一个完整的无服务器平台。同样地,最初作为一种 更好的机器学习模型服务方式 被引入的 Ray Serve 最近演变成了一个完整的微服务框架。在本章中,你将学习如何使用 Ray Serve 来实现一个通用的微服务框架,以及如何使用该框架进行模型服务。
本章中使用的所有示例的完整代码可以在书籍的 GitHub 存储库的文件夹 /ray_examples/serving 中找到。
在 Ray 中理解微服务架构
Ray 微服务架构(Ray Serve)是基于 Ray actors 来实现的,通过利用 Ray 实现。有三种类型的 actors 被创建来组成一个 Serve 实例:
控制器
Serve 实例中的一个全局 actor,负责管理控制平面。它负责创建、更新和销毁其他 actors。所有 Serve API 调用(例如,创建或获取一个部署)都使用控制器进行执行。
路由器
每个节点有一个路由器。每个路由器是一个 Uvicorn HTTP 服务器,它接受传入的请求,将它们转发给副本,然后在它们完成后响应。
Worker 副本
Worker 副本根据请求执行用户定义的代码。每个副本从路由器处理单独的请求。
用户定义的代码使用 Ray deployment 实现,这是 Ray actor 的一个扩展,具有额外的功能。我们将从检查部署本身开始。
部署
Ray Serve 中的核心概念是部署,定义将处理传入请求的业务逻辑以及这个逻辑在 HTTP 或 Python 中的暴露方式。让我们从一个简单的部署开始,实现一个温度控制器(示例 7-1)。
示例 7-1. 温度控制器部署
@serve.deployment
class Converter:
def __call__(self, request):
if request.query_params["type"] == 'CF' :
return {"Fahrenheit temperature":
9.0/5.0 * float(request.query_params["temp"]) + 32.0}
elif request.query_params["type"] == 'FC' :
return {"Celsius temperature":
(float(request.query_params["temp"]) - 32.0) * 5.0/9.0 }
else:
return {"Unknown conversion code" : request.query_params["type"]}
Converter.deploy()
实现是通过 @serve.deployment 注解进行装饰的,告诉 Ray 这是一个部署。这个部署实现了一个名为 call 的单一方法,这个方法在部署中具有特殊的意义:它通过 HTTP 被调用。它是一个类方法,接受一个 starlette 请求,它为传入的 HTTP 请求提供了一个方便的接口。在温度控制器的情况下,请求包含两个参数:温度和转换类型。
定义部署后,你需要使用 Converter.deploy 进行部署,类似于在部署 actor 时使用 .remote。然后你可以立即通过 HTTP 接口访问它(示例 7-2)。
示例 7-2. 通过 HTTP 访问转换器
print(requests.get("http://127.0.0.1:8000/Converter?temp=100.0&type=CF").text)
print(requests.get("http://127.0.0.1:8000/Converter?temp=100.0&type=FC").text)
print(requests.get("http://127.0.0.1:8000/Converter?temp=100.0&type=CC").text)
注意这里我们使用 URL 参数(查询字符串)来指定参数。此外,由于服务通过 HTTP 对外开放,请求者可以运行在任何地方,包括在 Ray 外部运行的代码中。
示例 7-3 显示了此调用的结果。
示例 7-3. 部署的 HTTP 调用结果
{
"Fahrenheit temperature": 212.0
}
{
"Celsius temperature": 37.77777777777778
}
{
"Unknown conversion code": "CC"
}
除了能够通过 HTTP 调用部署之外,还可以直接使用 Python 进行调用。要做到这一点,您需要获取到部署的 handle 然后使用它进行调用,如 示例 7-4 所示。
示例 7-4. 通过句柄调用部署
from starlette.requests import Request
handle = serve.get_deployment('Converter').get_handle()
print(ray.get(handle.remote(Request(
{"type": "http", "query_string": b"temp=100.0&type=CF"}))))
print(ray.get(handle.remote(Request(
{"type": "http", "query_string": b"temp=100.0&type=FC"}))))
print(ray.get(handle.remote(Request(
{"type": "http", "query_string": b"temp=100.0&type=CC"}))))
请注意,在此代码中,我们通过指定请求类型和查询字符串手动创建 starlette 请求。
一旦执行,此代码将返回与 示例 7-3 中相同的结果。此示例同时为 HTTP 和 Python 请求使用相同的 call 方法。虽然这样做是可行的,但最佳实践是为 Python 调用实现额外的方法,以避免在 Python 调用中使用 Request 对象。在我们的示例中,我们可以在 示例 7-1 的初始部署中扩展用于 Python 调用的额外方法,在 示例 7-5 中实现。
示例 7-5. 为 Python 调用实现额外方法
@serve.deployment
class Converter:
def __call__(self, request):
if request.query_params["type"] == 'CF' :
return {"Fahrenheit temperature":
9.0/5.0 * float(request.query_params["temp"]) + 32.0}
elif request.query_params["type"] == 'FC' :
return {"Celsius temperature":
(float(request.query_params["temp"]) - 32.0) * 5.0/9.0 }
else:
return {"Unknown conversion code" : request.query_params["type"]}
def celcius_fahrenheit(self, temp):
return 9.0/5.0 * temp + 32.0
def fahrenheit_celcius(self, temp):
return (temp - 32.0) * 5.0/9.0
Converter.deploy()
# list current deploymente
print(serve.list_deployments())
有了这些额外的方法,Python 调用可以显著简化(参见 示例 7-6)。
示例 7-6. 使用基于句柄的额外方法进行调用
print(ray.get(handle.celcius_fahrenheit.remote(100.0)))
print(ray.get(handle.fahrenheit_celcius.remote(100.0)))
与 示例 7-4 使用默认的 call 方法不同,这些调用方法是显式指定的(而不是将请求类型放在请求本身,这里请求类型是隐式的—它是一个方法名)。
Ray 提供同步和异步句柄。一个 sync 标志, Deployment.get_handle(…, sync=True|False),可用于指定句柄类型:
-
默认句柄是同步的。在这种情况下,调用
handle.remote将返回一个 Ray 的ObjectRef。 -
要创建一个异步句柄,请设置
sync=False。如其名所示,异步句柄调用是异步的,您需要使用await来获得一个 Ray 的ObjectRef。要使用await,您必须在 Pythonasyncio事件循环中运行deployment.get_handle和handle.remote。
我们将在本章后面演示异步句柄的使用。
最后,可以通过简单修改代码或配置选项并再次调用 deploy 来更新部署。除了在此处描述的 HTTP 和直接 Python 调用之外,还可以使用 Python API 来使用 Kafka 调用部署(参见 第六章 中的 Kafka 集成方法)。
现在您已了解部署的基础知识,让我们看看可用于部署的额外能力。
额外的部署能力
附加的部署能力以三种方式提供:
-
向注释添加参数
-
使用 FastAPI 进行 HTTP 部署
-
使用部署组合
当然,您可以结合这三种方法来实现您的目标。让我们仔细看看每种方法提供的选项。
向注释添加参数
@serve.deployment 注释可以接受几个 参数。其中最常用的是副本数和资源需求。
使用资源副本提高可扩展性
默认情况下,deployment.deploy 创建部署的单个实例。通过在 @serve.deployment 中指定副本数,您可以将部署扩展到多个进程。当请求发送到这样一个复制的部署时,Ray 使用轮询调度来调用各个副本。您可以修改 示例 7-1 来添加多个副本和每个实例的 ID(示例 7-7)。
示例 7-7. 扩展部署
@serve.deployment(num_replicas=3)
class Converter:
def __init__(self):
from uuid import uuid4
self.id = str(uuid4())
def __call__(self, request):
if request.query_params["type"] == 'CF' :
return {"Deployment": self.id, "Fahrenheit temperature":
9.0/5.0 * float(request.query_params["temp"]) + 32.0}
elif request.query_params["type"] == 'FC' :
return {"Deployment": self.id, "Celsius temperature":
(float(request.query_params["temp"]) - 32.0) * 5.0/9.0 }
else:
return {"Deployment": self.id, "Unknown conversion code":
request.query_params["type"]}
def celcius_fahrenheit(self, temp):
return 9.0/5.0 * temp + 32.0
def fahrenheit_celcius(self, temp):
return (temp - 32.0) * 5.0/9.0
Converter.deploy()
# list current deployments
print(serve.list_deployments())
现在使用 HTTP 或基于句柄的调用会在 示例 7-8 中生成结果。
示例 7-8. 调用扩展部署
{'Deployment': '1d...0d', 'Fahrenheit temperature': 212.0}
{'Deployment': '4d...b9', 'Celsius temperature': 37.8}
{'Deployment': '00...aa', 'Unknown conversion code': 'CC'}
查看此结果,您可以看到每个请求都由不同的部署实例(不同的 ID)处理。
这是部署的手动扩展。那么自动扩展呢?与 Kafka 监听器的自动扩展类似(见 第六章 讨论),Ray 的自动扩展方法与 Kubernetes 本地方法不同—例如,请参见 Knative。Ray 的自动扩展方法不是创建新实例,而是创建更多 Ray 节点,并适当重新分配部署。
如果您的部署开始超过每秒大约三千个请求,您也应该将 HTTP 入口扩展到 Ray。默认情况下,入口 HTTP 服务器仅在主节点上启动,但您也可以使用 serve.start(http_options=\{"location": "EveryNode"}) 在每个节点上启动一个 HTTP 服务器。如果您扩展了 HTTP 入口的数量,您还需要部署一个负载均衡器,可以从您的云提供商获取或在本地安装。
部署的资源需求
您可以在 @serve.deployment 中请求特定的资源需求。例如,两个 CPU 和一个 GPU 可以如下表示:
@serve.deployment(ray_actor_options={"num_cpus": 2, "num_gpus": 1})
@serve.deployment 的另一个有用参数是 route_prefix。如您在 示例 7-2 中所见,默认前缀是用于此部署的 Python 类的名称。例如,使用 route_prefix 允许您显式指定 HTTP 请求使用的前缀:
@serve.deployment(route_prefix="/converter")
有关附加配置参数的描述,请参阅 “Ray 核心 API” 文档。
使用 FastAPI 实现请求路由
尽管在示例 7-1 中初始的温度转换器部署工作正常,但使用起来并不方便。您需要在每个请求中指定转换类型。更好的方法是为 API 拥有两个单独的端点(URL)——一个用于摄氏度转华氏度的转换,另一个用于华氏度转摄氏度的转换。您可以利用FastAPI与Serve 集成来实现这一点。通过这种方式,您可以像在示例 7-9 中所示重写示例 7-1。
示例 7-9. 在部署中实现多个 HTTP API
@serve.deployment(route_prefix="/converter")
@serve.ingress(app)
class Converter:
@app.get("/cf")
def celcius_fahrenheit(self, temp):
return {"Fahrenheit temperature": 9.0/5.0 * float(temp) + 32.0}
@app.get("/fc")
def fahrenheit_celcius(self, temp):
return {"Celsius temperature": (float(temp) - 32.0) * 5.0/9.0}
注意,在这里,我们引入了两个具有不同 URL 的 HTTP 可访问 API(实际上将第二个查询字符串参数转换为一组 URL),每种转换类型一个。这可以简化 HTTP 访问;将示例 7-10 与示例 7-2 进行比较。
示例 7-10. 使用多个 HTTP 端点调用部署
print(requests.get("http://127.0.0.1:8000/converter/cf?temp=100.0&").text)
print(requests.get("http://127.0.0.1:8000/converter/fc?temp=100.0").text)
通过 FastAPI 实现提供的附加功能包括可变路由、自动类型验证、依赖注入(例如数据库连接)、安全支持等。请参阅FastAPI 文档了解如何使用这些功能。
部署组合
部署可以构建为其他部署的组合。这允许构建强大的部署流水线。
让我们看一个具体的例子:金丝雀部署。在这种部署策略中,您会以有限的方式部署您的代码或模型的新版本,以查看其行为如何。您可以通过使用部署组合轻松构建这种类型的部署。我们将从定义并部署两个简单的部署开始,在示例 7-11 中。
示例 7-11. 两个基本部署
@serve.deployment
def version_one(data):
return {"result": "version1"}
version_one.deploy()
@serve.deployment
def version_two(data):
return {"result": "version2"}
version_two.deploy()
这些部署接受任何数据并返回一个字符串:"result": "version1"用于部署 1 和"result": “version2"用于部署 2。您可以通过实现金丝雀部署(示例 7-12)将这两个部署组合起来。
示例 7-12. 金丝雀部署
@serve.deployment(route_prefix="/versioned")
class Canary:
def __init__(self, canary_percent):
from random import random
self.version_one = version_one.get_handle()
self.version_two = version_two.get_handle()
self.canary_percent = canary_percent
# This method can be called concurrently!
async def __call__(self, request):
data = await request.body()
if(random() < self.canary_percent):
return await self.version_one.remote(data=data)
else:
return await self.version_two.remote(data=data)
此部署演示了几个要点。首先,它展示了具有参数的构造函数,这对部署非常有用,允许单个定义使用不同的参数进行部署。其次,我们将call函数定义为async,以便并发处理查询。call函数的实现很简单:生成一个新的随机数,并根据其值和canary_percent的值调用版本 1 或版本 2 的部署。
一旦通过Canary.deploy(.3)部署了Canary类,你可以使用 HTTP 调用它。调用 canary 部署 10 次的结果显示在示例 7-13 中。
示例 7-13. Canary 部署调用的结果
{'result': 'version2'}
{'result': 'version2'}
{'result': 'version1'}
{'result': 'version2'}
{'result': 'version1'}
{'result': 'version2'}
{'result': 'version2'}
{'result': 'version1'}
{'result': 'version2'}
{'result': 'version2'}
正如你在这里看到的,Canary 模型运行得相当好,并且完全符合你的需求。现在你知道如何构建和使用基于 Ray 的微服务之后,让我们看看如何将它们用于模型服务。
使用 Ray Serve 进行模型服务
简言之,服务模型与调用任何其他微服务没有什么不同(我们将在本章后面讨论特定的模型服务需求)。只要你能以某种形式获取与 Ray 运行时兼容的 ML 生成模型—例如,以pickle 格式,纯 Python 代码或二进制格式以及其处理的 Python 库—你就可以使用这个模型处理推断请求。让我们从一个简单的模型服务示例开始。
简单的模型服务示例
一个流行的模型学习应用是基于Kaggle 红葡萄酒质量数据集预测红酒的质量。许多博客文章使用这个数据集来构建红酒质量的机器学习实现—例如,参见Mayur Badole和Dexter Nguyen的文章。在我们的例子中,我们基于 Terence Shin 的“使用多种分类技术预测葡萄酒质量”建立了几个红葡萄酒质量分类模型;实际代码可以在书的GitHub 仓库找到。该代码使用多种技术来构建红葡萄酒质量的分类模型,包括以下内容:
所有的实现都利用了 scikit-learn Python 库,允许你生成模型并使用 pickle 导出。在验证模型时,我们从随机森林、梯度提升和 XGBoost 分类中看到了最佳结果,所以我们只保存了这些模型—生成的模型可以在书的GitHub 仓库找到。有了这些模型,你可以使用一个简单的部署来服务红葡萄酒质量模型,使用随机森林分类(示例 7-14)。
示例 7-14. 使用随机森林分类实现模型服务
@serve.deployment(route_prefix="/randomforest")
class RandomForestModel:
def __init__(self, path):
with open(path, "rb") as f:
self.model = pickle.load(f)
async def __call__(self, request):
payload = await request.json()
return self.serve(payload)
def serve(self, request):
input_vector = [
request["fixed acidity"],
request["volatile acidity"],
request["citric acid"],
request["residual sugar"],
request["chlorides"],
request["free sulfur dioxide"],
request["total sulfur dioxide"],
request["density"],
request["pH"],
request["sulphates"],
request["alcohol"],
]
prediction = self.model.predict([input_vector])[0]
return {"result": str(prediction)}
这个部署有三个方法:
构造函数
加载模型并在本地存储。我们使用模型位置作为参数,这样当模型变化时可以重新部署这个部署。
call
通过 HTTP 请求调用,此方法检索特征(作为字典)并调用serve方法进行实际处理。通过定义为异步,它可以同时处理多个请求。
serve
可用于通过句柄调用部署。它将传入的字典转换为向量,并调用底层模型进行推断。
一旦实施部署,它可以用于模型服务。如果通过 HTTP 调用,它将以 JSON 字符串形式接收负载;对于直接调用,请求是一个字典形式。对于XGBoost和gradient boost的实现看起来几乎相同,唯一的区别是在这些情况下生成的模型需要一个二维数组而不是向量,因此您需要在调用模型之前进行此转换。
此外,您还可以查看 Ray 的文档,用于serving other types of models,包括 TensorFlow 和 PyTorch。
现在您知道如何构建一个简单的模型服务实现,问题是基于 Ray 的微服务是否是模型服务的良好平台。
模型服务实现的考虑事项
在模型服务方面,有几个具体要求非常重要。可以在《Kubeflow for Machine Learning》中找到关于模型服务特定要求的良好定义,作者是 Trevor Grant 等人(O’Reilly)。这些要求如下:
-
实现必须灵活。它应该允许您的训练是实现无关的(即 TensorFlow 与 PyTorch 与 scikit-learn)。对于推断服务调用,不应关心底层模型是使用 PyTorch、scikit-learn 还是 TensorFlow 训练的:服务接口应该是共享的,以便用户的 API 保持一致。
-
为了实现更好的吞吐量,有时可以在各种设置中对请求进行批处理。实现应该简化支持模型服务请求的批处理。
-
实现应该提供利用与算法需求匹配的硬件优化器的能力。在评估阶段,有时您会受益于像 GPU 这样的硬件优化器来推断模型。
-
实现应能够无缝地包含推断图的其他组件。推断图可以包括特征转换器、预测器、解释器和漂移检测器。
-
实现应该允许服务实例的扩展,无论底层硬件如何,都可以明确地使用自动缩放器。
-
应该能够通过包括 HTTP 和 Kafka 在内的不同协议公开模型服务功能。
-
传统的 ML 模型在训练数据分布之外通常表现不佳。因此,如果发生数据漂移,模型性能可能会下降,需要重新训练和重新部署。实现应支持模型的轻松重新部署。
-
灵活的部署策略实现(包括金丝雀部署、蓝绿部署和 A/B 测试)是必需的,以确保新版本的模型不会表现比现有版本更差。
让我们看看 Ray 微服务框架是如何满足这些需求的:
-
Ray 的部署清晰地将部署 API 与模型 API 分开。因此,Ray “标准化”了部署 API,并提供了将传入数据转换为模型所需格式的支持。参见 示例 7-14。
-
Ray 的部署使得实现请求批处理变得简单。有关如何实现和部署接受批处理、配置批处理大小并在 Python 中查询模型的详细信息,请参阅 Ray “批处理教程”指南。
-
如本章前文所述,部署支持配置,允许指定执行所需的硬件资源(CPU/GPU)。
-
如本章前文所述,部署组合允许轻松创建模型服务图,混合和匹配纯 Python 代码和现有部署。我们将在本章后面再介绍一个部署组合的示例。
-
如本章前文所述,部署支持设置副本数量,因此可以轻松扩展部署。结合 Ray 的自动缩放功能和定义 HTTP 服务器数量的能力,微服务框架允许非常高效地扩展模型服务。
-
如前文所述,部署可以通过 HTTP 或纯 Python 公开。后一选项允许与任何所需传输进行集成。
-
如本章前文所述,部署的简单重新部署允许更新模型而无需重新启动 Ray 集群和中断正在利用模型服务的应用程序。
-
如 示例 7-12 所示,使用部署组合可轻松实现任何部署策略。
如我们在这里展示的,Ray 微服务框架是满足模型服务的所有主要需求的坚实基础。
本章最后要学习的是一种高级模型服务技术的实现——推测性模型服务,使用 Ray 微服务框架。
使用 Ray 微服务框架进行推测性模型服务
推测模型服务是 推测执行 的一种应用。在这种优化技术中,计算机系统执行可能不需要的任务。在确切知道是否真正需要之前,工作已经完成。这样可以提前获取结果,因此如果确实需要,将无延迟可用。在模型服务中,推测执行很重要,因为它为机器服务应用程序提供以下功能:
保证执行时间
假设您有多个模型,并且最快的提供固定的执行时间,那么可以提供一个模型服务实现,其执行时间上限是固定的,只要该时间大于最简单模型的执行时间。
基于共识的模型服务
假设您有多个模型,您可以实现模型服务,使得预测结果是多数模型返回的结果。
基于质量的模型服务
假设您有一个评估模型服务结果质量的度量标准,这种方法允许您选择质量最佳的结果。
在这里,您将学习如何使用 Ray 的微服务框架实现基于共识的模型服务。
在本章的早些时候,您学习了如何使用三个模型(随机森林、梯度提升和 XGBoost)实现红葡萄酒的质量评分。现在让我们尝试生成一个实现,返回至少两个模型同意的结果。基本实现如 示例 7-15 所示。
示例 7-15. 基于共识的模型服务
@serve.deployment(route_prefix="/speculative")
class Speculative:
def __init__(self):
self.rfhandle = RandomForestModel.get_handle(sync=False)
self.xgboosthandle = XGBoostModel.get_handle(sync=False)
self.grboosthandle = GRBoostModel.get_handle(sync=False)
async def __call__(self, request):
payload = await request.json()
f1, f2, f3 = await asyncio.gather(self.rfhandle.serve.remote(payload),
self.xgboosthandle.serve.remote(payload),
self.grboosthandle.serve.remote(payload))
rfresurlt = ray.get(f1)['result']
xgresurlt = ray.get(f2)['result']
grresult = ray.get(f3)['result']
ones = []
zeros = []
if rfresurlt == "1":
ones.append("Random forest")
else:
zeros.append("Random forest")
if xgresurlt == "1":
ones.append("XGBoost")
else:
zeros.append("XGBoost")
if grresult == "1":
ones.append("Gradient boost")
else:
zeros.append("Gradient boost")
if len(ones) >= 2:
return {"result": "1", "methods": ones}
else:
return {"result": "0", "methods": zeros}
此部署的构造函数为所有实现个别模型的部署创建句柄。请注意,这里我们创建的是异步句柄,允许并行执行每个部署。
call 方法获取有效载荷并开始并行执行所有三个模型,然后等待所有模型执行完毕。有关使用 asyncio 在执行多个协程和并行运行它们时的信息,请参见 Hynek Schlawack 的 “在 asyncio 中等待”。一旦您获得所有结果,就实施共识计算并返回结果(以及投票支持它的方法)¹。
结论
在本章中,你学习了 Ray 实现的微服务框架以及这个框架如何被模型服务所使用。我们从描述基本的微服务部署开始,并介绍了一些扩展,允许更好地控制、扩展和扩展部署的执行。然后,我们展示了一个示例,说明了这个框架如何被用来实现模型服务,分析了典型的模型服务需求,并展示了 Ray 如何满足这些需求。最后,你学习了如何实现一个高级的模型服务示例——基于共识的模型服务——使你能够提高个别模型服务方法的质量。文章"在蚂蚁集团的 Ray 上构建高可用和可扩展的在线应用"由蔡腾伟等人展示了如何将这里描述的基本构建块汇集到更复杂的实现中。
在下一章中,你将学习有关在 Ray 中实现工作流以及如何使用工作流来自动化你的应用程序执行的内容。
¹ 你还可以实现不同的策略来等待模型的执行。例如,你可以通过asyncio.wait(tasks). return_when=asyncio.FIRST_COMPLETED)至少使用一个模型的结果,或者仅通过asyncio.wait(tasks, interval)等待一段给定的时间间隔。
第八章:Ray Workflows
由 Carlos Andrade Costa 贡献
在广泛的领域中,现实生活和现代应用程序通常是多个相互依赖步骤的组合。例如,在 AI/ML 工作流中,训练工作负载需要进行多个步骤的数据清洗、平衡和增强,而模型服务通常包括许多子任务和与长期运行的业务流程的集成。工作流中的不同步骤可以依赖于多个上游,并且有时需要不同的扩展工具。
工作流管理的计算机库追溯到 25 年前,新工具聚焦于 AI/ML 的出现。工作流规范涵盖从图形用户界面到自定义格式、YAML Ain't Markup Language (YAML)和 Extensible Markup Language (XML),以及全功能编程语言中的库。在代码中指定工作流允许您使用通用的编程工具,如版本控制和协作。
在本章中,您将学习 Ray Workflows 实现的基础知识以及其使用的一些简单示例。
什么是 Ray Workflows?
Ray Workflows通过添加工作流原语扩展了 Ray Core,提供了对任务和演员的程序化工作流执行支持,并提供了与任务和演员共享接口的支持。这使您可以将 Ray 的核心原语作为工作流步骤的一部分使用。Ray Workflows 旨在支持传统的 ML 和数据工作流(例如数据预处理和训练),以及长时间运行的业务工作流,包括模型服务集成。它利用 Ray 任务进行执行,以提供可伸缩性和可靠性。Ray 的工作流原语极大地减少了将工作流逻辑嵌入应用程序步骤的负担。
与其他解决方案有何不同?
与其他流行的工作流框架(例如Apache Airflow,Kubeflow Pipelines等)不同,这些框架侧重于工具集成和部署编排,Ray Workflows 专注于支持较低级别的工作流原语,从而实现程序化工作流。¹这种程序化方法相对于其他实现可以被认为是较低级别的;这种低级别方法允许独特的工作流管理功能。
注意
Ray Workflows 专注于将核心工作流原语嵌入 Ray Core 中,以实现丰富的程序化工作流,而不是支持工具集成和部署编排。
Ray Workflows 特点
在这一部分中,我们将介绍 Ray Workflows 的主要特性,回顾核心原语,并看看它们在简单示例中的使用方式。
主要特性是什么?
Ray Workflows 提供的主要功能包括以下内容:
耐久性
通过添加虚拟演员(参见“虚拟演员”),Ray Workflows 为使用 Ray 的动态任务图执行的步骤添加了耐久性保证。
依赖管理
Ray Workflows 利用 Ray 的运行时环境特性来快照工作流的代码依赖关系。这使得可以随着时间的推移管理工作流和虚拟演员的代码升级。
低延迟和规模
利用 Ray 与 Plasma(共享内存存储)的零拷贝开销,Ray Workflows 在启动任务时提供亚秒级的开销。Ray 的可伸缩性扩展到工作流,允许您创建包含数千个步骤的工作流。
注意
Ray Workflows 使用 Ray 的任何分布式库提供工作流步骤的持久执行,具有低延迟和动态依赖管理。
工作流基元
Ray Workflows 提供了构建带有步骤和 虚拟演员 的工作流的核心基元。以下列表总结了 Ray Workflows 中的核心基元和基本概念:
步骤
带有 @workflow.step 装饰器的注释函数。步骤在成功完成时执行一次,并在失败时重试。步骤可以作为其他步骤未来的参数使用。为确保可恢复性,步骤不支持 ray.get 和 ray.wait 调用。
对象
存储在 Ray 对象存储中的数据对象,这些对象的引用被传递到步骤中,并从步骤返回。当从步骤最初返回时,对象被检查点并可以通过 Ray 对象存储与其他工作流步骤共享。
工作流
使用 @Workflow.run 和 Workflow.run_async 创建的执行图。工作流执行在启动后被记录到存储中以保证耐久性,并可以在任何具有访问存储权限的 Ray 集群上失败后恢复。
工作流也可以是动态的,在运行时生成新的子工作流步骤。工作流支持动态循环、嵌套和递归。甚至可以通过从工作流步骤返回更多的工作流步骤来动态添加新的步骤到您的工作流有向无环图(DAG)中。
虚拟演员
虚拟演员类似于常规的 Ray 演员,可以保存成员状态。主要区别在于虚拟演员由耐久性存储支持,而不仅仅是进程内存,这使得其能够在集群重新启动或工作节点失败时存活。
虚拟演员管理长时间运行的业务工作流程。它们将其状态保存到外部存储以保证耐久性。它们还支持从方法调用启动子工作流程,并接收外部触发的事件。
您可以使用虚拟演员为本质上无状态的工作流添加状态。
事件
工作流可以通过定时器和可插拔事件监听器触发,并作为步骤的参数使用,使步骤执行等待直到接收到事件。
使用基本工作流概念进行工作
工作流是由各种基元构建而成,您将从学习如何使用步骤和对象开始。
工作流、步骤和对象
示例 8-1 展示了一个简单的 Hello World 工作流示例,演示了在简单情况下步骤、对象和工作流基元的工作方式。
示例 8-1. Hello World 工作流
import ray
from ray import workflow
from typing import List
# Creating an arbitrary Ray remote function
@ray.remote
def hello():
return "hello"
# Defining a workflow step that puts an object into the object store
@workflow.step
def words() -> List[ray.ObjectRef]:
return [hello.remote(), ray.put("world")]
# Defining a step that receives an object
@workflow.step
def concat(words: List[ray.ObjectRef]) -> str:
return " ".join([ray.get(w) for w in words])
# Creating workflow
workflow.init("tmp/workflow_data")
output: "Workflow[int]" = concat.step(words.step())
# Running workflow
assert output.run(workflow_id="workflow_1") == "hello world"
assert workflow.get_status("workflow_1") == workflow.WorkflowStatus.SUCCESSFUL
assert workflow.get_output("workflow_1") == "hello world"
与 Ray 任务和演员类似(在第三章和第四章中描述),您可以显式地为步骤分配计算资源(例如,CPU 核心、GPU),方法与核心 Ray 中相同:num_cpus、num_gpus 和 resources。参见 示例 8-2。
示例 8-2. 为步骤添加资源
from ray import workflow
@workflow.step(num_gpus=1)
def train_model() -> Model:
pass # This step is assigned a GPU by Ray.
train_model.step().run()
动态工作流
除了预定义的有向无环图工作流之外,Ray 还允许您根据工作流执行的当前状态以编程方式创建步骤:动态工作流。您可以使用这种类型的工作流,例如,来实现递归和更复杂的执行流程。一个简单的递归可以用递归阶乘程序来说明。示例 8-3 展示了如何在工作流中使用递归(请注意,这仅供说明,其他实现方式具有更好的性能,无需 Ray 工作流)。
示例 8-3. 动态创建工作流步骤
from ray import workflow
@workflow.step
def factorial(n: int) -> int:
if n == 1:
return 1
else:
return mult.step(n, factorial.step(n - 1))
@workflow.step
def mult(a: int, b: int) -> int:
return a * b
# Calculate the factorial of 5 by creating a recursion of 5 steps
factorial_workflow = factorial.step(5).run()
assert factorial_workflow.run() == 120
虚拟演员
虚拟演员是由持久性存储支持的 Ray 演员(参见 第四章),而不是内存;它们是用装饰器 @virtual_actor 创建的。示例 8-4 展示了如何使用持久性虚拟演员来实现一个计数器。
示例 8-4. 虚拟演员
from ray import workflow
@workflow.virtual_actor
class counter:
def __init__(self):
self.count = 0
def incr(self):
self.count += 1
return self.count
workflow.init(storage="/tmp/workflows")
workflow1 = counter.get_or_create("counter_workflw")
assert c1.incr.run() == 1
assert c1.incr.run() == 2
警告
因为虚拟演员在执行每一步之前和之后检索和存储其状态,所以其状态必须是 JSON 可序列化的(以状态字典的形式)或者应提供 getstate 和 setstate 方法,这些方法将演员的状态转换为 JSON 可序列化字典。
现实生活中的工作流
让我们来看看使用 Ray 工作流创建和管理参考用例实现的常见步骤。
构建工作流
正如前面所看到的,您首先要实现单个工作流步骤,并使用 @workflow.step 注解声明它们。与 Ray 任务类似,步骤可以接收一个或多个输入,其中每个输入可以是一个特定值或一个 future—执行一个或多个先前工作流步骤的结果。工作流的返回类型是 Workflow[T],并且是一个 future,在工作流执行完成后可用该值。示例 8-5 说明了这个过程。在这种情况下,步骤 get_value1 和 get_value2 返回 future,这些 future 被传递给 sum 步骤函数。
示例 8-5. 实现工作流步骤
from ray import workflow
@workflow.step
def sum(x: int, y: int, z: int) -> int:
return x + y + z
@workflow.step
def get_value1() -> int:
return 100
@workflow.step
def get_value2(x: int) -> int:
return 10*x
sum_workflow = sum.step(get_val1.step(), get_val2.step(10), 100)
assert sum_workflow.run("sum_example") == 300
为了简化访问步骤执行结果并在步骤之间传递数据,Ray Workflows 允许您显式命名步骤。例如,您可以通过调用workflow.get_output(workflow_id, name="step_name")来检索步骤执行结果,这将返回一个ObjectRef[T]。如果没有显式命名步骤,Ray 将自动生成一个格式为<*WORKFLOW_ID*>.<*MODULE_NAME*>.<*FUNC_NAME*>的名称。
请注意,您可以在返回的引用上调用ray.get,它将阻塞直到工作流完成。例如,ray.get(workflow.get_output("sum_example")) == 100。
步骤可以用两种方式命名:
-
使用
.options(name="step_name") -
使用装饰器
@workflows.step(name=”step_name”)
管理工作流
Ray Workflows 中的每个工作流都有一个唯一的workflow_id。您可以在启动工作流时显式设置工作流 ID,使用.run(workflow_id="workflow_id")。对于.run和run_async调用时如果没有提供 ID,则会生成一个随机 ID。
创建后,工作流可以处于以下状态之一:
正在运行
当前在集群中运行。
失败
应用程序错误失败。可以从失败的步骤恢复。
可恢复
由于系统错误而失败的工作流,可以从失败的步骤恢复。
已取消
工作流已取消。无法恢复,结果不可用。
成功
工作流成功完成。
表 8-1 显示了管理 API 的摘要以及如何使用它们来管理工作流,无论是单独还是批量。
表 8-1. 工作流管理 API
| 单个工作流 | 操作 | 批量工作流 | 操作 |
|---|---|---|---|
.get_status(workflow_id=<>) | 获取工作流状态(运行中、可恢复、失败、已取消、成功) | .list_all(<*workflow_state1*, *workflow_state2*, …>) | 列出所有处于列出状态的工作流 |
.resume(workflow_id=<>) | 恢复工作流 | .resume_all | 恢复所有可恢复的工作流 |
.cancel(workflow_id=<>) | 取消工作流 | ||
.delete(workflow_id=<>) | 删除工作流 |
Ray Workflows 将工作流信息存储在您配置的存储位置中。您可以在使用装饰器workflow.init(storage=<*path*>)创建工作流时配置位置,或者通过设置环境变量RAY_WORKFLOW_STORAGE进行配置。
您可以使用常规/本地存储或使用兼容 S3 API 的分布式存储:
本地文件系统
单节点,仅用于测试目的,或通过集群中节点之间的共享文件系统(例如 NFS 挂载)。位置作为绝对路径传递。
S3 后端
启用将工作流数据写入基于 S3 的后端以供生产使用。
如果未指定路径,则 Workflows 将使用默认位置:/tmp/ray/workflow_data。
警告
如果未指定存储数据位置,则工作流数据将保存在本地,并且仅适用于单节点 Ray 集群。
Ray 的工作流依赖项正在积极开发中。一旦可用,此功能将允许 Ray 在工作流提交时将完整的运行时环境记录到存储中。通过跟踪此信息,Ray 可以确保工作流可以在不同的集群上运行。
构建动态工作流
正如前文所述,您可以通过基于给定步骤的当前状态创建步骤来动态创建工作流。当创建这样的步骤时,它将插入到原始工作流 DAG 中。示例 8-6 展示了如何使用动态工作流计算斐波那契数列。
示例 8-6. 动态工作流
from ray import workflow
@workflow.step
def add(a: int, b: int) -> int:
return a + b
@workflow.step
def fib(n: int) -> int:
if n <= 1:
return n
return add.step(fib.step(n - 1), fib.step(n - 2))
assert fib.step(10).run() == 55
使用条件步骤构建工作流
带有条件步骤的工作流对许多用例至关重要。示例 8-7 展示了实现旅行预订工作流的简化场景。
示例 8-7. 旅行预订示例
from ray import workflow
@workflow.step
def book_flight(...) -> Flight: ...
@workflow.step
def book_hotel(...) -> Hotel: ...
@workflow.step
def finalize_or_cancel(
flights: List[Flight],
hotels: List[Hotel]) -> Receipt: ...
@workflow.step
def book_trip(origin: str, dest: str, dates) ->
"Workflow[Receipt]":
# Note that the workflow engine will not begin executing
# child workflows until the parent step returns.
# This avoids step overlap and ensures recoverability.
f1: Workflow = book_flight.step(origin, dest, dates[0])
f2: Workflow = book_flight.step(dest, origin, dates[1])
hotel: Workflow = book_hotel.step(dest, dates)
return finalize_or_cancel.step([f1, f2], [hotel])
fut = book_trip.step("OAK", "SAN", ["6/12", "7/5"])
fut.run() # Returns Receipt(...)
处理异常
你可以选择让 Ray 以以下两种方式处理异常:
-
自动重试,直到达到最大重试次数
-
捕获和处理异常
您可以在步饰器或通过 .options 中配置此选项。分别指定两种技术的设置如下:
max_retries
在失败时,步骤将重试直到达到 max_retries。max_retries 的默认值为 3。
catch_exceptions
当设为 True 时,此选项将把函数的返回值转换为 Tuple[Optional[T], Optional[Exception]]。
您还可以将这些传递给 workflow.step 的装饰器。
示例 8-8 说明了使用这些选项进行异常处理。
示例 8-8. 异常处理
from ray import workflow
@workflow.step
def random_failure() -> str:
if random.random() > 0.95:
raise RuntimeError("Found failure")
return "OK"
# Run 5 times before giving up
s1 = faulty_function.options(max_retries=5).step()
s1.run()
@workflow.step
def handle_errors(result: Tuple[str, Exception]):
# Setting the exception field to NONE on success
err = result[1]
if err:
return "There was an error: {}".format(err)
else:
return "OK"
# `handle_errors` receives a tuple of (result, exception).
s2 = faulty_function.options(catch_exceptions=True).step()
handle_errors.step(s2).run()
处理耐久性保证
Ray 的工作流确保一旦步骤成功,将不会重新执行。为了强制执行此保证,Ray 的工作流将步骤结果记录到耐久性存储中,确保在后续步骤中使用之前成功步骤的结果不会改变。
Ray 的工作流不仅限于在集群或单个应用程序内重试的耐久性。工作流实现基于两种状态的故障模型:
集群故障
如果集群发生故障,则在集群上运行的任何工作流将设置为 RESUMABLE 状态。处于 RESUMABLE 状态的工作流可以在不同的集群上恢复。可以通过 ray.workflow.resume.all 完成此操作,它将恢复所有可恢复的工作流作业。
驱动程序故障
工作流将转换为失败状态,一旦问题解决,可以从失败的步骤恢复。
警告
此时写作流恢复性为 beta API,可能会在稳定之前发生变化。
您可以使用持久性保证来创建幂等工作流,其中包含具有副作用的步骤。这是必要的,因为步骤可能在其输出被记录之前失败。示例 8-9 展示了如何使用持久性保证使工作流具有幂等性。
示例 8-9. 幂等工作流
from ray import workflow
@workflow.step
def generate_id() -> str:
# Generate a unique idempotency token.
return uuid.uuid4().hex
@workflow.step
def book_flight_idempotent(request_id: str) -> FlightTicket:
if service.has_ticket(request_id):
# Retrieve the previously created ticket.
return service.get_ticket(request_id)
return service.book_flight(request_id)
# SAFE: book_flight is written to be idempotent
request_id = generate_id.step()
book_flight_idempotent.step(request_id).run()
扩展动态工作流程与虚拟演员
如前所述,虚拟演员还允许从其每个方法中调用子工作流。
当您创建一个虚拟演员时,Ray 将其初始状态和类定义存储在持久性存储中。由于工作流名称在演员的定义中使用,Ray 将其存储在持久性存储中。当演员的方法创建新的步骤时,它们会动态附加到工作流并执行。在这种情况下,步骤定义及其结果都存储在演员的状态中。要检索演员,您可以使用装饰器 .get_actor(workflow_id="workflow_id")。
您还可以将工作流定义为只读的。因为它们不需要记录,所以它们的开销较小。此外,由于它们不会与演员中的变异方法冲突,Ray 可以并行执行它们。
示例 8-10 展示了虚拟演员如何用于管理工作流程中的状态。
示例 8-10. 使用虚拟演员进行工作流管理
from ray import workflow
import ray
@workflow.virtual_actor
class Counter:
def __init__(self, init_val):
self._val = init_val
def incr(self, val=1):
self._val += val
print(self._val)
@workflow.virtual_actor.readonly
def value(self):
return self._val
workflow.init()
# Initialize a Counter actor with id="my_counter".
counter = Counter.get_or_create("my_counter", 0)
# Similar to workflow steps, actor methods support:
# - `run()`, which will return the value
# - `run_async()`, which will return a ObjectRef
counter.incr.run(10)
assert counter.value.run() == 10
# Nonblocking execution.
counter.incr.run_async(10)
counter.incr.run(10)
assert 30 == ray.get(counter.value.run_async())
虚拟演员还可以创建涉及虚拟演员中其他方法或定义在演员类外部的步骤以供调用的子工作流。这意味着工作流可以在方法内启动或传递给另一个方法。参见 示例 8-11。
示例 8-11. 使用子工作流
from ray import workflow
import ray
@workflow.step
def double(s):
return 2 * s
@workflow.virtual_actor
class Actor:
def __init__(self):
self.val = 1
def double(self, update):
step = double.step(self.val)
if not update:
# Inside the method, a workflow can be launched
return step
else:
# Workflow can also be passed to another method
return self.update.step(step)
def update(self, v):
self.val = v
return self.val
handler = Actor.get_or_create("actor")
assert handler.double.run(False) == 2
assert handler.double.run(False) == 2
assert handler.double.run(True) == 2
assert handler.double.run(True) == 4
虚拟演员还可用于在多个工作流之间共享数据(甚至在不同的 Ray 集群上运行)。例如,虚拟演员可以用于存储像 Python scikit-learn 流水线中的 ML 模型中已拟合的参数。示例 8-12 展示了一个简单的两阶段流水线,由标准标量和决策树分类器组成。每个阶段都作为工作流步骤实现,直接调用在 estimator_virtual_actor 类中定义的虚拟演员的实例。其成员估算器使用 getstate 和 setstate 方法将其状态转换为和从可 JSON 序列化的字典。当输入元组的第三个输入参数指定为 'fit' 时,流水线被训练,并且当该参数指定为 'predict' 时,流水线用于预测。
为了训练一个流水线,工作流执行将 training_tuple 提交给标准标量,其输出然后通过分类模型管道进行训练:
training_tuple = (X_train, y_train, 'fit')
classification.step(scaling.step(training_tuple, 'standardscalar'),
'decisiontree').run('training_pipeline')
要将训练好的管道用于预测,工作流执行将 predict_tuple 提交给相同的步骤链,尽管其 'predict' 参数调用虚拟 actor 中的 predict 函数。预测结果作为另一个包含在 pred_y 中的标签元组返回:
predict_tuple = (X_test, y_test, 'predict')
(X, pred_y, mode) = classification.step(scaling.step(predict_tuple,
'standardscalar'),'decisiontree').run('prediction_pipeline')
工作流虚拟 actor 的威力在于使训练模型可用于另一个 Ray 集群。此外,由虚拟 actor 支持的 ML 工作流可以增量更新其状态,例如重新计算时间序列特征。这使得实现具有状态的时间序列分析更容易,包括预测、预测和异常检测。
示例 8-12. 机器学习工作流
import ray
from ray import workflow
import pandas as pd
import numpy as np
from sklearn import base
from sklearn.base import BaseEstimator
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
ray.init(address='auto')
workflow.init()
@ray.workflow.virtual_actor
class estimator_virtual_actor():
def __init__(self, estimator: BaseEstimator):
if estimator is not None:
self.estimator = estimator
def fit(self, inputtuple):
(X, y, mode)= inputtuple
if base.is_classifier(self.estimator) or base.is_regressor(self.estimator):
self.estimator.fit(X, y)
return X, y, mode
else:
X = self.estimator.fit_transform(X)
return X, y, mode
@workflow.virtual_actor.readonly
def predict(self, inputtuple):
(X, y, mode) = inputtuple
if base.is_classifier(self.estimator) or base.is_regressor(self.estimator):
pred_y = self.estimator.predict(X)
return X, pred_y, mode
else:
X = self.estimator.transform(X)
return X, y, mode
def run_workflow_step(self, inputtuple):
(X, y, mode) = inputtuple
if mode == 'fit':
return self.fit(inputtuple)
elif mode == 'predict':
return self.predict(inputtuple)
def __getstate__(self):
return self.estimator
def __setstate__(self, estimator):
self.estimator = estimator
## Prepare the data
X = pd.DataFrame(np.random.randint(0,100,size=(10000, 4)), columns=list('ABCD'))
y = pd.DataFrame(np.random.randint(0,2,size=(10000, 1)), columns=['Label'])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
@workflow.step
def scaling(inputtuple, name):
va = estimator_virtual_actor.get_or_create(name, StandardScaler())
outputtuple = va.run_workflow_step.run_async(inputtuple)
return outputtuple
@workflow.step
def classification(inputtuple, name):
va = estimator_virtual_actor.get_or_create(name,
DecisionTreeClassifier(max_depth=3))
outputtuple = va.run_workflow_step.run_async(inputtuple)
return outputtuple
training_tuple = (X_train, y_train, 'fit')
classification.step(scaling.step(training_tuple, 'standardscalar'), 'decisiontree').
run('training_pipeline')
predict_tuple = (X_test, y_test, 'predict')
(X, pred_y, mode) = classification.step(scaling.step(predict_tuple,
'standardscalar'),'decisiontree').run('prediction_pipeline')
assert pred_y.shape[0] == 2000
长时间运行的工作流在作为子工作流使用时需要特别注意,因为子工作流在运行时会阻塞未来的 actor 调用。为了正确处理长时间运行的工作流,建议使用 Workflows API 来监视执行,并运行具有确定性名称的单独工作流。这种方法可以防止在失败情况下启动重复的工作流。
警告
子工作流会阻塞未来的 actor 方法调用。不建议将长时间运行的工作流作为虚拟 actor 的子工作流运行。
示例 8-13 展示了如何运行一个长时间运行的工作流而不阻塞。
示例 8-13. 非阻塞工作流
from ray import workflow
import ray
@workflow.virtual_actor
class ShoppingCart:
...
# Check status via ``self.shipment_workflow_id`` for avoid blocking
def do_checkout():
# Deterministically generate a workflow ID for idempotency.
self.shipment_workflow_id = "ship_{}".format(self.order_id)
# Run shipping workflow as a separate async workflow.
ship_items.step(self.items).run_async(
workflow_id=self.shipment_workflow_id)
将工作流与其他 Ray 原语集成
Ray 工作流可以与 Ray 的核心原语一起使用。这里我们将描述一些常见的场景,其中 Workflows API 与常见的 Ray 程序集成。在将工作流与任务和 actor 集成时有两种主要场
-
从 Ray 任务或 actor 中运行工作流
-
在工作流步骤中使用 Ray 任务或 actor
另一个常见情况是在工作流的步骤之间传递对象引用。Ray 对象引用可以作为参数传递,并从任何工作流步骤返回,如 示例 8-14 所示。
示例 8-14. 使用对象引用
from ray import workflow
@ray.remote
def do_add(a, b):
return a + b
@workflow.step
def add(a, b):
return do_add.remote(a, b)
add.step(ray.put(10), ray.put(20)).run() == 30
为了确保可恢复性,Ray Workflows 将内容记录到持久存储中。幸运的是,当传递到多个步骤时,Ray 不会多次检查对象。
警告
Ray actor 处理程序无法在步骤之间传递。
在将 actor 和任务与 Workflows 集成时,另一个需要考虑的因素是处理嵌套参数。如前所述,当传递到步骤时,工作流输出会被完全解析,以确保在执行当前步骤之前执行所有祖先步骤。示例 8-15 说明了这种行为。
示例 8-15. 使用输出参数
import ray
from ray import workflow
from typing import List
@workflow.step
def add(values: List[int]) -> int:
return sum(values)
@workflow.step
def get_val() -> int:
return 10
ret = add.step([get_val.step() for _ in range(3)])
assert ret.run() == 30
触发工作流(连接到事件)
Workflows 具有可插拔的事件系统,允许外部事件触发工作流。这个框架提供了一个高效的内置等待机制,并保证了一次性事件交付语义。这意味着用户不需要基于运行中的工作流步骤实现触发机制来响应事件。与工作流的其余部分一样,为了容错,事件在发生时进行了检查点。
工作流事件可以看作是一种只有在事件发生时才完成的工作流步骤。修饰符.wait_for_event用于创建事件步骤。
示例 8-16 显示了一个在 90 秒后完成的工作流步骤,并触发了外部工作流的执行。
示例 8-16。使用事件
from ray import workflow
import time
# Create an event that finishes after 60 seconds.
event1_step = workflow.wait_for_event(
workflow.event_listener.TimerListener, time.time() + 60)
# Create another event that finishes after 30 seconds.
event2_step = workflow.wait_for_event(
workflow.event_listener.TimerListener, time.time() + 30)
@workflow.step
def gather(*args):
return args;
# Gather will run after 60 seconds, when both event1 and event2 are done.
gather.step(event1_step, event2_step).run()
事件还支持通过子类化EventListener接口来实现自定义监听器,如示例 8-17 所示。
示例 8-17。自定义事件监听器
from ray import workflow
class EventListener:
def __init__(self):
"""Optional constructor. Only the constructor with no arguments will be
called."""
pass
async def poll_for_event(self, *args, **kwargs) -> Event:
"""Should return only when the event is received."""
raise NotImplementedError
async def event_checkpointed(self, event: Event) -> None:
"""Optional. Called after an event has been checkpointed and a transaction
can be safely committed."""
pass
处理工作流元数据
工作流执行的一个重要需求是可观察性。通常,你不仅想要看到工作流的执行结果,还想获取关于内部状态的信息(例如,执行所采取的路径,它们的性能以及变量的值)。Ray 的工作流元数据支持一些标准和用户定义的元数据选项。标准元数据分为工作流级别的元数据:
status
工作流状态,可以是RUNNING、FAILED、RESUMABLE、CANCELED或SUCCESSFUL
user_metadata
用户通过workflow.run添加的自定义元数据的 Python 字典
stats
工作流运行统计信息,包括工作流开始时间和结束时间
以及步骤级别的元数据:
name
步骤的名称,可以是用户通过step.options提供的,也可以是系统生成的
step_options
步骤的选项,可以是用户通过step.options提供的,也可以是系统默认的
user_metadata
用户通过step.options添加的自定义元数据的 Python 字典
stats
步骤的运行统计信息,包括步骤开始时间和结束时间
Ray Workflows 提供了一个简单的 API 来获取标准元数据:
workflow.get_metadata(workflow_id)
你还可以获取有关工作流和步骤的元数据:
workflow.get_metadata(workflow_id, name=<*step name*>)
API 的两个版本都返回一个包含工作流本身或单个步骤的所有元数据的字典。
除了标准元数据之外,你还可以添加自定义元数据,捕获工作流或特定步骤中感兴趣的参数:
-
可以通过
.run(metadata=metadata)添加工作流级别的元数据。 -
可以通过
.options(metadata=metadata)或修饰符@workflow.step(metadata=metadata)添加步骤级别的元数据。
最后,你可以从虚拟执行器执行中公开元数据,也可以检索工作流/步骤元数据来控制执行。
提示
你添加到 Ray 指标的指标被公开为 Prometheus 指标,就像 Ray 的内置指标一样。
请注意,get_metadata在调用时会立即返回结果,这意味着结果可能不会包含所有字段。
结论
在本章中,您学习了 Ray Workflows 如何向 Ray 添加工作流原语,使您能够创建具有丰富工作流管理支持的动态管道。Ray Workflows 允许您创建涉及多个步骤的常见管道,如数据预处理、训练和长时间运行的业务工作流。有了 Ray,通过与 Ray 任务和执行器共享接口,编程式工作流执行引擎的可能性变得可行。这种能力可以极大地减轻编排工作流和将工作流逻辑嵌入应用程序步骤的负担。
这就是说,要注意,Ray 远程函数(参见第三章)基于参数的可用性提供基本的执行顺序和分支/合并功能。因此,对于一些简单的用例来说,使用 Ray Workflows 可能会显得有些大材小用,但如果你需要执行可靠性、可重启性、编程控制和元数据管理(通常都需要),Ray Workflows 是一种首选的实现方法。
¹ 这种方法最初是由Cadence 工作流引入的。 Cadence 包括一个编程框架(或客户端库),提供了其文档称之为“无视错误”的有状态编程模型,使开发人员能够像编写普通代码一样创建工作流。