RPC 容错#
添加到 Ray Core 的所有 RPC 都应该是容错的,并使用可重试的 gRPC 客户端。理想情况下,它们应该是幂等的,或者至少,非幂等性应该被记录下来,并且客户端必须能够考虑重试。如果您不熟悉什么是幂等性,请考虑一个将“hello”写入文件的函数。重试时,它会再次写入“hello”,导致“hellohello”。这不是幂等的。为了使其幂等,您可以在再次写入“hello”之前检查文件内容,确保多次相同函数调用后的可观察状态与单次调用后的状态相同。
本指南将通过一个 RPC 的案例研究,该 RPC 之前不容错或不幂等,以及如何修复它。在本指南结束时,您应该了解添加新 RPC 时需要注意的事项以及用于验证容错性的测试方法。
案例研究:RequestWorkerLease#
问题#
在此修复之前,由于 Raylet 中的处理程序不是幂等的,因此无法使 RequestWorkerLease 可重试。
这是因为一旦授予了租约,它们就会被视为已占用,直到调用 ReturnWorker。在此 RPC 被调用之前,工作进程及其资源从未返回到可用工作进程和资源池中。Raylet 假定原始 RPC 及其重试都是新的租约请求,并且无法对它们进行去重。
例如,考虑以下操作序列
通过
RequestWorkerLease请求新的工作进程租约(Owner → Raylet)。响应丢失(Raylet → Owner)。
重试租约的
RequestWorkerLease(Owner → Raylet)。现在授予了两组资源和工作进程,一组用于原始租约,一组用于重试。
在重试时,Raylet 应该检测到租约请求是重试,并将已授予的租约工作进程地址转发给 Owner,这样就不会授予第二个租约。
解决方案#
为了实现幂等性,在 PR #55469 中添加了一个名为 LeaseID 的唯一标识符,它允许对传入的租约请求进行去重。一旦授予了租约,它们就会被跟踪在 leased_workers 映射中,该映射将租约 ID 映射到工作进程。如果新的租约请求已存在于 leased_workers 映射中,系统就会知道该租约请求是重试,并用已授予的租约工作进程地址进行响应。
可重试的 gRPC 客户端#
可重试的 gRPC 客户端在 RPC 容错项目期间得到了更新。本节将介绍它的工作原理以及需要注意的一些陷阱。
如需基本介绍,请阅读 retryable_grpc_client.h 注释。
工作原理#
可重试的 gRPC 客户端工作原理如下:
使用可重试的 gRPC 客户端发送 RPC。
如果客户端遇到 gRPC 瞬时网络错误,它会将回调推入队列。
会定期执行几项检查:
廉价的 gRPC 通道状态检查:这会检查 gRPC 通道 的状态,以查看系统是否可以再次开始发送消息。默认情况下,此检查每秒进行一次,但可以通过 check_channel_status_interval_milliseconds 进行配置。
潜在的昂贵的 GCS 节点状态检查:如果指数退避期已过且通道仍然关闭,系统会调用 server_unavailable_timeout_callback_。此回调设置在客户端池类中(raylet_client_pool、core_worker_client_pool)。它会检查客户端是否订阅了节点状态更新,然后检查本地订阅者缓存,看是否收到了来自 GCS 的节点死亡通知。如果客户端未订阅或缓存中没有该节点的任何状态,它会向 GCS 发送 RPC。请注意,对于 GCS 客户端,
server_unavailable_timeout_callback_在调用后会终止进程。这发生在gcs_rpc_server_reconnect_timeout_s秒(默认为 60)之后。每个 RPC 的超时检查:有一个 超时检查,每个 RPC 都可以自定义,但由于它总是为每个 RPC 设置为 -1(无穷大),因此在功能上被禁用,这在 core_worker_client.h 中有说明。
随着每个失败的 RPC 增加,指数退避期会增加,这与失败的 RPC 类型无关。退避期会达到一个最大值,您可以通过
core_worker_rpc_server_reconnect_timeout_max_s或raylet_rpc_server_reconnect_timeout_max_s配置选项为 core worker 和 raylet 客户端自定义。如上所述,GCS 客户端没有最大退避期。通道检查成功后,指数退避期会重置,队列中的所有 RPC 都会重试。
如果系统成功收到节点死亡通知(通过订阅或直接查询 GCS),它将销毁 RPC 客户端,并将每个回调以 gRPC Disconnected 错误 发布到 I/O 上下文。
重要注意事项#
有几点需要牢记:
按客户端排队:每个可重试的 gRPC 客户端对于客户端是唯一的(对于 core worker 客户端是
WorkerID,对于 Raylet 客户端是NodeID),而不是 RPC 的类型。如果您首先提交 RPC A,由于瞬时网络错误而失败,然后向同一客户端提交 RPC B,由于瞬时网络错误而失败,队列将包含两个项目:RPC A 然后 RPC B。没有按 RPC 分开的队列,而是按客户端分开的队列。客户端级别超时:每个超时都需要等待前一个超时完成。如果 RPC A 和 RPC B 在短时间内连续提交,那么 RPC A 总共将等待 1 秒,RPC B 总共将等待 1 + 2 = 3 秒。不同的 RPC 没有区别,被视为相同。原因是瞬时网络错误不是 RPC 特定的。如果 RPC A 遇到网络故障,您可以假设 RPC B,如果发送到同一客户端,也会遇到相同的故障。因此,RPC 等待的时间是队列中所有先前 RPC 超时及其自身超时的总和。
析构函数行为:在
RetryableGrpcClient的析构函数中,系统会通过发布它们的 I/O 上下文来使所有待处理的 RPC 失败。这些回调理想情况下不应修改客户端类(如RayletClient)持有的状态。如果绝对必要,它们必须以某种方式检查客户端是否仍然存活,例如使用弱指针。PR #58744 中有一个示例。应用程序代码还应考虑 Disconnected 错误。
测试 RPC 容错#
Ray Core 具有三层 RPC 容错和幂等性测试。
C++ 单元测试#
对于每个 RPC,都应该有一种 C++ 幂等性测试形式,该测试调用 HandleX 服务器函数两次,并检查每次输出的结果是否相同。应考虑 HandleX 服务器函数调用之间的不同状态更改。例如,在 RequestWorkerLease 中,编写了一个 C++ 单元测试来模拟重试发生在初始租约请求卡在参数拉取阶段的情况。
Python 集成测试#
对于每个 RPC,如果易于实现,最好有一个 Python 集成测试。对于某些 RPC,使用 Python API 完全确定性地测试它们具有挑战性,因此充分的 C++ 单元测试可以起到良好的替代作用。因此,这更像是一个锦上添花,因为集成测试也充当了用户如何遇到幂等性问题的示例。
主要的测试机制使用 RAY_testing_rpc_failure 配置选项,该选项允许您:
在不发送 RPC 的情况下,立即使用 gRPC 错误触发 RPC 回调(模拟请求失败)。
一旦响应从服务器到达,使用 gRPC 错误触发 RPC 回调(模拟响应失败)。
立即使用 gRPC 错误触发 RPC 回调,但同时将 RPC 发送到服务器(模拟正在进行的故障,对于长轮询 RPC,重试应该会命中正在执行服务器代码的服务器)。
有关更多详细信息,请参阅 Ray 配置文件中的注释,网址为 ray_config_def.h。
混沌网络发布测试#
IP 表停机#
IP 表停机方法涉及 SSH 到每个节点并使 IP 表停机一小段时间(5 秒)以模拟瞬时网络错误。IP 表脚本在后台运行,在测试脚本执行期间,会定期(60 秒)造成网络停机。
对于核心发布测试,我们在 PR #58868 中将 IP 表停机方法添加到了所有现有的混沌发布测试中。
注意
最初考虑了 Amazon FIS。但是,它有一个 60 秒的最小值,由于配置设置导致了节点死亡,这很难调试,因此 IP 表方法更简单、更灵活。