KubeRay 源码阅读:从 Operator 主备切换到 Ray 服务恢复

demo-job 正在运行,负责它的 Operator Pod 突然退出。已有的 Ray 计算可能继续执行,但谁来补 worker、回写作业状态、清理集群?如果退出的是 head Pod,答案又会不同。

第四篇已经解释了进程重启后怎样依靠已有资源继续协调。这篇接着看备用 Operator 如何获得处理资格,再把故障移到 Kubernetes 控制面和 Ray 节点,分别检查管理流程与计算受什么影响。最后单独用 RayService 分析服务切换时的请求表现。

阅读基线为 KubeRay 6bf05eb17a3e、Ray 3f785c0711b9,依赖 controller-runtime v0.24.1、client-go v0.37.0。引用文件与基线一致;下文是源码分析和有条件的故障推演,没有集群实测数据。

1. 开了选主,为什么仍只有一个 Operator

选主是在多个 Operator 实例中确定由谁负责协调资源;负责协调的实例称为主实例或 leader,其余实例等待接管。选主开关和实际部署几个副本来自不同配置。

这里分析通过 Helm 安装 Operator 时使用的配置。Helm 将 Kubernetes 资源模板等文件组织成一个 chart,values 则提供用于生成具体资源配置的参数。先对照这个版本的默认值:

层次 此版本的默认行为 证据
Operator 配置 DefaultEnableLeaderElection = true,命令行参数使用这个默认值 defaults.gomain.go
Helm values replicas: 1leaderElectionEnabled: true values.yaml
Helm Deployment 副本数取自 values;默认参数路径传入 --enable-leader-election deployment.yaml

进程和 chart 默认都打开了选主,但 chart 只部署一个副本。这个实例仍会参与选举,只是退出以后没有现成的备用实例接手。向此提交的本地 chart 传入以下 values,才会部署两个副本:

1
2
replicas: 2
leaderElectionEnabled: true

ConfigMap 是 Kubernetes 用来保存配置数据的资源。若启用 configuration.enabled,chart 会把选主配置写进 ConfigMap;Operator 使用 --config 时忽略命令行配置,排查应以挂载文件为准。配置模板与 main.go 中的配置加载分支共同决定最终值。

副本最好分散到不同节点,否则节点故障可能同时带走主备。还要留意此 chart 的 Deployment 使用 Recreate 更新策略:更新时先终止旧版本 Pod,再创建新版本 Pod。因此,多副本配置本身不保证 Operator 升级期间持续有人协调。以下讨论存活备用实例接管的场景。

RayCluster、RayJob 和 RayService 这些 Controller 随所在 Manager 一起参与选主,不会各自选出一个主实例。因此,增加备用副本不会自动提高它们的协调吞吐。反过来,若把选主关掉,多个实例都会启动协调;这不是把工作平均分给它们。

2. 哪些实例竞争同一个 Lease

Lease 是 Kubernetes 保存租约状态的资源,包含持有者身份和续约信息。它可用于组件选主,具体抢占和续约逻辑由客户端执行。

KubeRay 在 Manager 配置中指定锁的名称和命名空间。以下是 main.go 的连续字段节选:

1
2
3
LeaderElection:          *config.EnableLeaderElection,
LeaderElectionID: "ray-operator-leader",
LeaderElectionNamespace: config.LeaderElectionNamespace,

controller-runtime 默认使用 coordination.k8s.io 的 Lease。ServiceAccount 是 Pod 内程序访问 Kubernetes API 时使用的身份。锁的命名空间未指定时,从 Pod 挂载的 ServiceAccount namespace 文件读取;集群外无法读取时需要显式配置。它与 watchNamespace 不同:前者决定锁放在哪里,后者决定观察哪些业务资源。

同一 Kubernetes 集群内,竞争相同 namespace/name 的实例才属于同一组选主。两个安装若锁在不同 namespace,却观察重叠的 RayCluster,可以各自选出 leader;锁相同也不会自动分片业务对象。更改锁命名空间时,还要提供相应 Lease 权限,chart 默认只在安装 namespace 创建选主 Role。

持有者身份也不是固定的 Deployment 名称。NewResourceLock 的连续节选如下:

1
2
3
4
5
id, err := os.Hostname()
if err != nil {
return nil, err
}
id = id + "_" + string(uuid.NewUUID())

hostname 与 UUID 组合起来,区分每次启动的进程实例。排查谁在持有锁,可以看 Lease 的 holderIdentity;只看 Pod 是否 Ready 还不够。

readyz 是供就绪探针访问的检查接口。KubeRay 为它注册的是 healthz.Ping,没有检查当前实例是否持有 Lease。所以两个 Pod 同时 Ready 完全可能是正常的主备状态,还要结合持有者身份、续约变化和协调日志判断。

主备之间通过 API Server 中的同一个 Lease 竞争领导权,关系如图 1。

图 1:Operator 实例竞争同一 Lease
图 1:Operator 实例竞争同一 Lease 查看原图

每个实例通过 API Server 读写同一个 Lease,leader 持续续约。候选者观察到租约不再有效后尝试更新,API 的版本冲突检查裁决竞争写入。client-go 根据本地观察时间判断租约是否有效,不能只拿本机时间减去 renewTime 就复现其全部判断。

此处沿用 controller-runtime 的 LeaseDuration=15sRenewDeadline=10sRetryPeriod=2s:分别控制候选者等待、leader 续约重试期限和重试间隔。KubeRay 没有覆盖这三个值。它们不是接管耗时承诺,实际还受 API 延迟、进程调度和 Controller 启动影响。

3. 新 leader 从哪里继续

备用实例拥有自己的 Manager 和缓存。controller-runtime 先启动并同步缓存,再进入选主流程;获得领导权后启动需要选主的运行组件。下面是 OnStartedLeading 的连续节选:

1
2
3
4
5
6
7
OnStartedLeading: func(_ context.Context) {
if err := cm.startLeaderElectionRunnables(); err != nil {
cm.errChan <- err
return
}
close(cm.elected)
},

新 leader 启动自己的 Controller,通过监听对象、执行协调接手工作。旧进程的 goroutine、调用栈和内存定时器都不会转移过来。如果切换恰好发生在请求已经发出、状态还没写回的时候,处理过程如图 2。

图 2:请求在途时,新 leader 依据资源重新协调
图 2:请求在途时,新 leader 依据资源重新协调 查看原图

假设旧 leader 已成功创建 demo-job 的 submitter Kubernetes Job,却来不及回写 RayJob 状态就退出。新 leader 再处理这个 RayJob 时,会读取 CR 和相关资源;createK8sJobIfNeed 按既定名称查询提交用 Job,查到后直接返回。若缓存还没看到创建结果,重复创建同名对象也可能遇到 AlreadyExists,随后仍需重试、重新观察。后续仍按同一个身份查询和判断,流程才有机会继续推进。

还有一个问题需要单独看:旧 leader 发出去的请求,切换以后会怎样?client-go 的包说明明确不提供 fencing,即从操作接收端强制拒绝旧 leader 的隔离保证。租约变化不会撤销已被 API Server 或 Ray Dashboard 接收的请求,也不会把外部副作用回滚。

失去领导权时,controller-runtime 的 OnStoppedLeading 将优雅停机等待设为零并返回 leader election lost;KubeRay 的 exitOnError 使进程退出。如果要从接收端拒绝过期主实例,通常需要随请求携带并校验代表本次领导权的递增令牌;这里没有这样的机制。进程暂停、网络异常和请求在途仍需分别分析。

发送方超时时,接收方可能已经执行了请求,再次重试就可能重复执行。Kubernetes 对同名资源的约束,也管不到用户程序向数据库写入结果的操作。业务若要求 exactly-once 效果,需要接收端幂等键或事务等配合;即使没有主备切换,只丢失一次响应,也会遇到这个问题。

接管后也不要求把每个内存细节复原。比如 RayService 把旧集群的待删除时间放在本地 map 中,新实例观察到待清理集群后可以重新安排延迟。其结果可能是清理晚一些,不能把旧进程的定时器视为持久化承诺,见 cleanUpRayClusterInstance

4. Kubernetes 控制面失联时,备用能做什么

主备都无法访问 API Server 时,备用无法更新 Lease,leader 也无法正常续约;CR、Pod 的查询和写入同样受阻。若 API Server 的存储依赖 etcd 不可用,继续增加 Operator 副本无法补上这条依赖。

已有 Ray 进程在节点和网络正常时可能继续执行,但补建 Pod、扩容、调度新增资源和回写状态会受到影响。kubelet 对本机容器的处理,与控制面创建、调度一个新 Pod 是不同路径。故障范围应先区分“某个 Operator 到 API 的链路断了”与“所有实例都访问不了控制面”。

图 3 把这些依赖放在一起。图中的 Redis 是供 GCS 使用的外部键值存储服务,其恢复条件在下一节展开。排查时沿 API 调用和存储访问关系往下看,可以判断某个组件失联后,哪些工作也会跟着停下来。

图 3:Operator、Kubernetes、Ray 与持久化存储的故障边界
图 3:Operator、Kubernetes、Ray 与持久化存储的故障边界 查看原图

5. worker、head 和 GCS 的恢复条件

worker Pod 丢失后,KubeRay 根据仍有效的期望配置补足 Pod,Kubernetes 调度并启动容器。该节点上的 task、actor 能否恢复,则取决于 Ray 的重试、重启策略及状态来源。普通 task 的 max_retries 约束重试;actor 的 max_restarts 默认是零,即使允许重启,也需要应用恢复其内存状态。例如,程序可以把进度写入检查点,再由重启后的程序读取;检查点中保存哪些内容、怎样恢复,需要应用自己实现。

head 还承载 GCS 等进程。默认内存 GCS 丢失后,重建一个 head Pod 无法还原原集群元数据,更不会恢复 driver 的 Python 调用栈。第一篇的普通 demo-job 不能因此获得断点续跑能力。Pod 补建也受配置限制,例如已标记为 provisioned(完成初始资源准备),且带 ray.io/disable-provisioned-head-restart 禁用标记的集群会跳过 head 重建,见 reconcilePods

要让 GCS 进程退出后仍能读回元数据,就需要把这部分数据放到进程之外。这里以 Redis 为后端:配置 gcsFaultToleranceOptions.backend: redisredisAddress 后,KubeRay 向 head 注入 RAY_REDIS_ADDRESS 等参数。Ray 的 GetStorageType 有如下连续分支:

1
2
3
4
5
6
7
8
if (RayConfig::instance().gcs_storage() == kInMemoryStorage) {
if (!config_.redis_address.empty()) {
RAY_LOG(INFO) << "Using external Redis for KV storage: "
<< BuildAddress(config_.redis_address, config_.redis_port);
return StorageType::REDIS_PERSIST;
}
return StorageType::IN_MEMORY;
}

因此不能仅看到 gcs_storage 默认是 memory,就认定显式 Redis 配置无效。GCS 重启时从后端加载元数据;恢复要求 Redis 数据仍在、可访问,并使用相同存储命名空间。这里的“存储命名空间”用来区分不同 Ray 集群的后端数据,与 Kubernetes namespace 是两种范围。KubeRay 的 configureRedisFT 默认用 RayCluster UID 隔离数据。同一个 CR 更换 head 保持 UID,删掉再建同名 CR 则不会保持;自定义 externalStorageNamespace 也不应让两个活跃集群误用同一份状态。

GCS 把元数据保存在这里,所有 Ray 对象、actor 内存和业务检查点并不会因此一起保存。Redis 自身故障后还能保留多少数据,要继续看它的可用性、落盘和复制策略。

数据保留还受集群删除流程影响。Redis 路径默认启用清理,Controller 会通过 finalizer 和清理 Job 删除对应存储命名空间的数据;外部 Redis 不等于永久归档。若要分析删后恢复,应先核对清理配置和数据是否仍在。

GCS 恢复期间,存活 worker 上已有计算和对象可能继续可用,但 actor 创建、节点注册等依赖 GCS 的操作暂停。KubeRay 在启用 GCS 容错且用户未覆盖时,把 worker 重连超时设为 600 秒;这只是等待预算,超时仍会退出。head 故障还会带走其上的其他进程。锁定版 GCS 文档把官方支持的 Redis 容错范围限定在 KubeRay 上的 Ray Serve,不能据此承诺任意 RayJob 自动恢复。

此源码还存在 backend: rocksdb。RocksDB 是进程内使用的键值存储库,这条路径要把数据文件放在可恢复的持久卷上,并限制为单个写入者。它还要求开启默认关闭的 alpha 实验特性 GCSFaultToleranceEmbeddedStorage,使用支持该后端的 Linux Ray 镜像。本文不展开这条路径,也不把它当作稳定发布保证。

6. RayService 切换,能保证每个请求成功吗

这里换成一个持续提供 Ray Serve 服务的独立例子,对应第一篇资源关系图的右侧。RayService 与 demo-job 是两种使用方式;RayJob 完成后不会进入 RayService 的状态机。

RayService 的 active 指当前集群,pending 指准备接替的集群。这里讨论传统的新集群蓝绿升级:保留当前集群的同时准备另一套集群,等它可用后再切换访问入口,不包括增量流量迁移。pending 的 Serve 应用非空且全部为 RUNNING 后,Controller 才把它选作切换目标,见 getAndCheckServeStatus

新集群准备好之后,访问入口和 RayService 状态还要分别更新。图 4 将这些步骤展开。

图 4:RayService 的传统 active/pending 切换
图 4:RayService 的传统 active/pending 切换 查看原图

reconcileServicesToReadyCluster 的连续节选说明,两次 Service 更新是顺序调用:

1
2
3
4
5
6
7
8
headSvc, err := r.reconcileServices(ctx, rayServiceInstance, rayClusterInstance, utils.HeadService)
if err != nil {
return headSvc, nil, err
}
serveSvc, err := r.reconcileServices(ctx, rayServiceInstance, rayClusterInstance, utils.ServingService)
if err != nil {
return headSvc, serveSvc, err
}

Service 的 selector 是选择后端 Pod 的标签条件。reconcileServices 更新入口 Service 的 selector,使其指向目标集群,随后状态计算确认入口指向 pending,将它提升为 active;旧集群按删除延迟清理。它们没有组成跨资源事务,第二次更新失败时可以留下部分切换状态,需后续协调。

旧集群存活、新集群有足够容量时,这种准备后切换能减少服务空窗。不过,Service 变更还需传播到实际转发路径,在途长请求、连接断开和客户端重试都有各自行为。若 active 已故障而 pending 尚未就绪,准备过程也不能补回已中断的请求。判断可用性应实际观测错误率、延迟和长连接行为,不能把这条路径统称为“零中断”。

7. 按故障落点作判断

下表是前述条件下的推演,不是故障注入结果;“可能继续”均以相关节点、网络和依赖仍正常为前提。

故障场景 谁来推进恢复 可能继续的能力 不能据此保证
备用 Operator 退出 Kubernetes 补副本 leader 协调、Ray 计算 再次故障时仍有备用
leader 退出,备用可访问 API Lease 选主、新 leader 协调 已有 Ray 计算 无接管间隔、在途操作只执行一次
主备均无法访问控制面 控制面或网络恢复后重建协调 部分已有计算与请求 新增 Pod、扩容和状态及时更新
worker 丢失 KubeRay 补 Pod,Ray 按策略恢复计算 其他健康 worker 的计算 actor 内存、丢失对象全部恢复
head 丢失,无 GCS 容错 按配置重建运行环境 不承诺原集群持续运行 原程序接着原位置执行
head 丢失,Redis 容错条件满足 head 重建、GCS 加载、worker 重连 存活 worker 上已有计算或 Serve 副本 head 上的进程和每个请求都恢复
Redis 数据丢失或不可访问 存储系统恢复 取决于剩余状态与等待期限 GCS 一定恢复到故障前状态
RayService 切换中失败 Controller 重试并重新观察资源 仍健康且可达的服务路径 跨资源原子切换、请求零失败

排查时可以从 Pod 状态开始,但还要继续查它背后的恢复依据。两个 Operator 都 Ready,要看 Lease、实际配置和监听范围;head 已重建,要看 GCS 后端、原 CR UID 和程序检查点;pending 已转 active,则要核对 Service 指向及实际请求表现。管理进程重新工作、计算继续执行和请求恢复正常,分别需要这些证据来判断。

前五篇到这里完成了 KubeRay 管理与恢复流程的分析。第六篇开始增加 Volcano,继续追踪资源不足时 Pod 如何获得运行机会;调度器驱逐 Pod 后,计算恢复仍需遵守本篇说明的应用与 Ray 故障边界。

参考资料


KubeRay 源码阅读:从 Operator 主备切换到 Ray 服务恢复
https://tanxinyu.work/kuberay-volcano-05-high-availability/
作者
谭新宇
发布于
2026年9月15日
许可协议