在阿里云上使用Ray GCS 容错
GCS(Global Control Service)是 Ray 的集群级元数据服务,运行在 head Pod 内,负责节点注册、actor 注册表、placement group、Job 元数据和资源管理。默认这些数据只存在 head 进程的内存里,没有任何副本。head 进程一退出,集群状态就跟着消失,一次进程级故障会沿下面这条链路放大成整个集群作废:
GCS 进程挂掉 → worker 上的 raylet 与 GCS 断连
→ 超过 RAY_gcs_rpc_server_reconnect_timeout_s(head 默认 60 秒)后 head 退出
→ head 重启,但内存里的集群状态已全部丢失
→ 新 head 不认识老 worker,将其终止
→ 集群实际上是重建,而不是恢复
开启 GCS 容错后,GCS 在运行期间把状态同步写入外部 Redis,重启后从 Redis 全量加载并继续服务;同时 worker 侧的重连超时被拉长到 600 秒,足以撑过一次 head 重启。head 故障从「集群重建」降级为「一次一分钟左右的控制面抖动」。
Ray head Pod 外部 Redis Worker Pod
GCS 进程 ──── ① 运行期同步写 ───→ RAY<ns>@NODE 全程不重启
RAY_REDIS_ADDRESS RAY<ns>@ACTOR RAY_gcs_rpc_server_
REDIS_PASSWORD RAY<ns>@KV ... reconnect_timeout_s=600
RAY_external_storage_namespace │ │
↑ │ │
新 GCS ←──── ② 重启后全量加载 ────────────┘ │
└────────────────── ③ raylet 重连 ────────────────────────→ ┘
三个数值决定了故障模拟时该等多久:
| 配置项 | 值 | 由谁设置 |
|---|---|---|
RAY_gcs_rpc_server_reconnect_timeout_s(head) | 60 | Ray 默认值,KubeRay 不注入 |
RAY_gcs_rpc_server_reconnect_timeout_s(worker) | 600 | KubeRay 自动注入,必须大于 head 的值 |
RAY_external_storage_namespace | RayCluster 的 UID | KubeRay 自动注入,作为 Redis key 前缀 |
本文所有命令与输出均在 ACK 托管版集群上实测:托管 KubeRay Operator v1.5.1,Ray 2.51.0。
前提条件
- 已创建 ACK 托管版集群
- 集群中已安装 v1.3.0 及以上版本的 KubeRay Operator 组件(
gcsFaultToleranceOptions字段在 v1.3.0 引入) - Ray 镜像版本 2.0.0 及以上。
- Redis。单分片 Redis Cluster 或 Redis Sentinel,可以有一个或多个副本
Redis 有两种形态:集群内自建单副本 Redis(形态 A,仅用于功能验证)和云数据库 Tair(形态 B,生产环境)。除第 1 步的准备工作不同外,后续每一步都会同时给出两者的命令与预期结果。
配置说明:gcsFaultToleranceOptions
加上这个字段本身就等于启用容错 —— gcsFaultToleranceOptions: {} 就是一份合法且已启用的配置:
spec:
gcsFaultToleranceOptions:
# 形态 A 填 K8s Service 名,形态 B 填 Tair 实例的专有网络连接地址
redisAddress: "redis:6379" # 裸 host:port,不带 redis:// 前缀
redisPassword:
valueFrom:
secretKeyRef:
name: redis-password-secret
key: password
| 字段 | 作用 |
|---|---|
redisAddress | Redis 地址,必须是裸 host:port。写成 redis://host:port 会被整体当作主机名,head 启动时报 resolve: Host not found |
redisPassword | 密码,从 Secret 读取。KubeRay 会自动给 head 启动命令追加 --redis-password=$REDIS_PASSWORD,不要在 rayStartParams 里重复手写 |
externalStorageNamespace | Redis key 前缀,保持不设置 |
不设置 externalStorageNamespace 时,KubeRay 会用 RayCluster 的 UID 作为默认值,而 UID 全局唯一且不可复用。因此多个 RayCluster 可以放心共用一个 Redis 而互不干扰,同名集群删除重建也不会读到上一代的脏状态。手动设置恰好会破坏这两个性质:两个集群设成同一个值会互相覆盖元数据;在 RayService 上设成固定值,升级期间新旧 RayCluster 并存时会抢同一个存储命名空间。
1. 准备 Redis
两种形态选一种执行。这一步的产物是后续步骤共用的两样东西:一个存着密码的 Secret redis-password-secret,以及一个能执行 redis-cli 的 Pod。
形态 A:集群内自建 Redis(功能验证)
kubectl apply -f redis.yaml
kubectl wait --for=condition=available deploy/redis --timeout=120s
export REDIS_POD=$(kubectl get pod -l app=redis -o jsonpath='{.items[0].metadata.name}')
预期结果 —— deployment.apps/redis condition met,且 Pod 已就绪:
NAME READY STATUS RESTARTS AGE
redis-6b9d5f8c4d-hq2xn 1/1 Running 0 15s
redis.yaml 部署一个单副本 Redis,开启 AOF 持久化并设置 maxmemory-policy noeviction,密码为 5241590000000000。它只适合功能验证:单副本一旦丢数据,恢复后的 GCS 也就丢了集群状态。
形态 B:云数据库 Tair(生产环境)
云数据库 Tair(兼容 Redis)提供主备自动切换与托管备份。
① 实例选型
Ray 官方文档列出的支持形态是单分片 Redis Cluster 或 Redis Sentinel(可带一个或多个副本),在 Tair 上对应标准架构(主从),本文即用该架构验证。
GCS 元数据量很小 —— 下面 1 worker + 1 actor 只产生 7 个 key,数据都压在这几个 HASH 的 field 里,key 的数量不随集群规模增长。规格按自己的 actor 与 placement group 规模评估,并对内存使用率、连接数、响应延迟配置告警。
② 网络与白名单
实例必须与 ACK 集群同 Region、同 VPC,并在白名单里放行集群的网段,本文操作时放行了集群VPC网段。
③ 建 Secret,起一个常驻的 redis-cli Pod
集群里没有 Redis Pod,因此需要一个客户端载体:它既做部署前的连通性预检,也承担第 3、5 步查 Redis 的活。密码从刚建的 Secret 里取,不会出现在命令行和 shell 历史中: 该步骤非必须项,仅为了查看数据放便,也可以参考Tair连接实例登陆实例进行查看;
export TAIR_HOST=r-xxxxxxxx.redis.<region>.rds.aliyuncs.com
kubectl create secret generic redis-password-secret --from-literal=password='<Tair 实例密码>'
kubectl run redis-client --image=redis:7.4-alpine --restart=Never \
--env="REDISCLI_AUTH=$(kubectl get secret redis-password-secret -o go-template='{{.data.password | base64decode}}')" \
--command -- sleep infinity
kubectl wait --for=condition=Ready pod/redis-client --timeout=60s
kubectl exec redis-client -- redis-cli -h ${TAIR_HOST} PING
预期结果:
PONG
建 Secret 必须用
--from-literal:从文件创建会把文件末尾的换行符一并存进去,head 启动时报WRONGPASS。预检不通就先解决白名单、安全组或密码,别急着部署 —— GCS 连不上 Redis 的表现是 head 持续
CrashLoopBackOff,事后排查的成本远高于预检。
最后把 raycluster-gcs-ft.yaml 里的 redisAddress 换成实例地址,这是形态 B 对 RayCluster 的唯一改动:
redisAddress: "r-xxxxxxxx.redis.<region>.rds.aliyuncs.com:6379"
2. 部署启用容错的 RayCluster
raycluster-gcs-ft.yaml 包含上面那段 gcsFaultToleranceOptions 配置,以及一个存放两个验证脚本的 ConfigMap。head 设置了 num-cpus: "0",使 actor 必然被调度到 worker 上。
kubectl apply -f raycluster-gcs-ft.yaml
kubectl wait --for=condition=Ready pod -l ray.io/cluster=raycluster-gcs-ft --timeout=300s
kubectl get raycluster raycluster-gcs-ft
kubectl get pod -l ray.io/cluster=raycluster-gcs-ft
预期结果(两种形态一致:STATUS 为 ready,两个 Pod 均为 1/1 Running 且 RESTARTS 为 0):
NAME DESIRED WORKERS AVAILABLE WORKERS CPUS MEMORY GPUS STATUS AGE
raycluster-gcs-ft 1 1 2 4Gi 0 ready 66s
NAME READY STATUS RESTARTS AGE
raycluster-gcs-ft-head-v6wxq 1/1 Running 0 66s
raycluster-gcs-ft-small-group-worker-z5qgc 1/1 Running 0 66s
集群 ready 不代表容错已经生效。 下面三项检查缺一不可;未启用容错时它们全部为空。
# 命名空间里可能有多个 Ray 集群,选择器必须带上 ray.io/cluster,否则可能取到别的集群的 Pod
export HEAD_POD=$(kubectl get pod -l ray.io/cluster=raycluster-gcs-ft,ray.io/node-type=head -o jsonpath='{.items[0].metadata.name}')
export WORKER_POD=$(kubectl get pod -l ray.io/cluster=raycluster-gcs-ft,ray.io/group=small-group -o jsonpath='{.items[0].metadata.name}')
① KubeRay 添加了 Redis 清理 finalizer
kubectl get raycluster raycluster-gcs-ft -o jsonpath='{.metadata.finalizers}{"\n"}'
# ["ray.io/gcs-ft-redis-cleanup-finalizer"]
② head 被注入了三个环境变量,启动参数带上了 --redis-password
kubectl get pod ${HEAD_POD} \
-o jsonpath='{range .spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' \
| grep -iE "redis|external_storage"
预期结果共四行:前三行是注入的环境变量(REDIS_PASSWORD 的值来自 secretKeyRef,故显示为空),第四行是 KubeRay 生成的启动命令,因为它的值里含 redis 也被 grep 匹配到了(下面省略了其中部分与容错无关的参数)。形态 B 只有 RAY_REDIS_ADDRESS 这一行不同,显示为实例的专有网络地址:
RAY_external_storage_namespace=cbf3aa87-0a34-45de-8b64-0f4f8bda7b4c # = RayCluster UID
RAY_REDIS_ADDRESS=redis:6379 # 形态 B:r-xxxxxxxx.redis.<region>.rds.aliyuncs.com:6379
REDIS_PASSWORD=
KUBERAY_GEN_RAY_START_CMD=ray start --head --block ... --num-cpus=0 --redis-password=$REDIS_PASSWORD
--redis-password 由 Operator 自动追加,无需手写。确认它确实落到了容器的启动参数里:
kubectl get pod ${HEAD_POD} -o jsonpath='{.spec.containers[0].args}' | tr ' ' '\n' | grep redis
# --redis-password=$REDIS_PASSWORD
③ worker 被注入了 600 秒重连超时,head 没有
kubectl get pod ${WORKER_POD} \
-o jsonpath='{range .spec.containers[0].env[*]}{.name}={.value}{"\n"}{end}' | grep reconnect
# RAY_gcs_rpc_server_reconnect_timeout_s=600
kubectl get pod ${HEAD_POD} -o jsonpath='{.spec.containers[0].env}' | grep -c reconnect
# 0 head 沿用 Ray 默认的 60 秒,符合预期
3. 创建 detached actor 并查看 Redis 数据
detached_actor.py 创建一个名为 counter_actor 的计数器 actor。普通 actor 的生命周期绑定在创建它的 Driver 上,Driver 一退出 actor 就被回收;而 lifetime="detached" 的 actor 独立于 Driver 存在,「名字 → actor」这条映射由 GCS 保管(开启容错后即写入 Redis 的 @ACTOR key),因此后续任何一个新 Driver 都能用 ray.get_actor("counter_actor") 把它取回来。
increment_counter.py 每次运行都是一个新 Driver,它能否按名字取到 actor,检验的是 head 重启后 GCS 元数据有没有从 Redis 恢复;取到之后计数是否接着往上加,检验的是 actor 进程本身有没有被牵连重启(它跑在 worker 上,只要 worker 不重启,内存里的计数就还在)。未开启容错时,新 head 的 GCS 里没有这条注册信息,ray.get_actor 会直接报错找不到 counter_actor。
kubectl exec ${HEAD_POD} -- python3 /home/ray/samples/detached_actor.py
kubectl exec ${HEAD_POD} -- python3 /home/ray/samples/increment_counter.py
预期结果:
1
再看 GCS 写进 Redis 的内容。RAY_UID 是 key 前缀,必须在删除 RayCluster 之前导出,集群删掉后就查不到了:
export RAY_UID=$(kubectl get raycluster raycluster-gcs-ft -o jsonpath='{.metadata.uid}')
# 形态 A:库里只有这个集群,直接列全库最直观
kubectl exec ${REDIS_POD} -- env REDISCLI_AUTH="5241590000000000" redis-cli KEYS "*"
# 形态 B:生产实例上不能用 KEYS(它会阻塞 Redis 直到遍历完成),改用游标扫描并按前缀过滤
kubectl exec redis-client -- redis-cli -h ${TAIR_HOST} --scan --pattern "RAY${RAY_UID}*"
预期结果(两种形态一致)—— 所有 key 都以 RAY<UID> 为前缀:
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@KV # 内部 KV,存放 Ray 各组件的杂项元数据
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@NODE # 节点注册表:每个 raylet 的地址、资源和存活状态
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@JOB # Job 元数据:每个 driver 提交的 job 及其状态
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@JobCounter # Job ID 自增计数器
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@ACTOR # actor 注册表,counter_actor 的「名字 → ID」就在这里
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@ACTOR_TASK_SPEC # actor 创建任务的规格,重建 actor 时要用
RAYcbf3aa87-0a34-45de-8b64-0f4f8bda7b4c@WORKERS # worker 进程信息,含异常退出记录
想看某张表的内容,HGETALL 在两种形态下写法相同,只是执行的 Pod 不同:
kubectl exec ${REDIS_POD} -- env REDISCLI_AUTH="5241590000000000" redis-cli HGETALL "RAY${RAY_UID}@NODE" # 形态 A
kubectl exec redis-client -- redis-cli -h ${TAIR_HOST} HGETALL "RAY${RAY_UID}@NODE" # 形态 B
4. 模拟 head 故障
这一步两种形态完全一样:Redis 只负责存元数据,恢复时序由 head 侧的 60 秒超时决定。杀掉 head Pod 内的 GCS 进程,同时观察两个 Pod:
kubectl exec ${HEAD_POD} -- pkill gcs_server
for i in $(seq 1 10); do
echo "=== T+$((i*10))s ==="
kubectl get pod -l ray.io/cluster=raycluster-gcs-ft --no-headers \
-o 'custom-columns=NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount'
sleep 10
done
custom-columns=...必须加引号:zsh 会把里面的[0]当成通配符,不加引号会报no matches found。
预期结果:
| 时刻 | head | worker |
|---|---|---|
| T+0s ~ T+40s | ready=true,restarts=0 | ready=true,restarts=0 |
| T+50s | ready=false | ready=true,restarts=0 |
| T+60s | 容器重启,restarts=1 | ready=true,restarts=0 |
| T+70s | ready=true | ready=true,restarts=0 |
worker 全程 ready=true、RESTARTS=0,这就是 GCS 容错的效果。 60 秒这个数字正是 head 侧 RAY_gcs_rpc_server_reconnect_timeout_s 的默认值。未启用容错时 worker 拿不到 600 秒的覆盖值,会与 head 同时到点退出。
再次访问 detached actor:
kubectl exec ${HEAD_POD} -- python3 /home/ray/samples/increment_counter.py
预期结果 —— 计数继续累加而不是从头开始,因为 actor 始终活在 worker Pod 上:
2
5. 删除集群并验证 Redis key 自动清理
删除前后各查一次 key 数量,就能确认清理是否真的发生。两种形态的判据不同:形态 A 的库里只有这个集群,用 DBSIZE(当前库的 key 总数)即可;形态 B 的实例上往往还住着别的 Ray 集群甚至别的业务,DBSIZE 不会归零,必须按 UID 前缀计数。
# 删除前 —— 形态 A
kubectl exec ${REDIS_POD} -- env REDISCLI_AUTH="5241590000000000" redis-cli DBSIZE
# 删除前 —— 形态 B
kubectl exec redis-client -- redis-cli -h ${TAIR_HOST} --scan --pattern "RAY${RAY_UID}*" | wc -l
kubectl delete raycluster raycluster-gcs-ft
kubectl get job --field-selector metadata.name=raycluster-gcs-ft-redis-cleanup
# 删除后 —— 重复上面对应形态的计数命令
预期结果 —— KubeRay 会创建一个清理 Job 删掉本集群的 key,清完才摘掉 finalizer,Job 随后由 ttlSecondsAfterFinished 自动回收:
NAME STATUS COMPLETIONS DURATION AGE
raycluster-gcs-ft-redis-cleanup Complete 1/1 5s 6s
| 形态 | 计数方式 | 删除前 | 删除后 |
|---|---|---|---|
| A:自建 Redis | DBSIZE | (integer) 7 | (integer) 0 |
| B:Tair | RAY<uid>* 前缀计数 | 7 | 0 |
自 KubeRay v1.1.0 起该清理是 best-effort(v1.0.0 时 Job 失败会导致集群删不掉):Job 失败也会摘掉 finalizer,但 Redis 里会残留 key,需要按 UID 前缀手工清理:
# 形态 A
kubectl exec ${REDIS_POD} -- sh -c \
"redis-cli -a 5241590000000000 --scan --pattern 'RAY${RAY_UID}*' | xargs -r redis-cli -a 5241590000000000 DEL"
# 形态 B
kubectl exec redis-client -- sh -c \
"redis-cli -h ${TAIR_HOST} --scan --pattern 'RAY${RAY_UID}*' | xargs -r redis-cli -h ${TAIR_HOST} DEL"
清理
kubectl delete -f raycluster-gcs-ft.yaml --ignore-not-found
# 形态 A
kubectl delete -f redis.yaml
# 形态 B:Tair 实例保留,只删客户端 Pod 和 Secret
kubectl delete pod redis-client --ignore-not-found
kubectl delete secret redis-password-secret --ignore-not-found