☰
共享Ray集群多用户资源隔离实战:四层方案与踩坑记录
2026/10/5 2:54:57 网站建设 项目流程

我们组上个月把一台 16 个节点、总共 128 核、256GB 内存、8 卡 GPU 的 Ray 集群正式开放给三个算法小组共用。开放第二天下午,训练平台的告警群就炸了:A 组同学跑了一个num_cpus=32的 Actor 挂在集群里忘了删,B 组所有任务瞬间全部卡死;到了晚上,另一个组的人把 50GB 的 NumPy 数组一次性put进 object store,连 C 组的调度日志都开始疯狂超时。也就是从那天起,我才真正开始认真做 Ray 集群的多用户资源隔离。

这篇不是官方文档搬运,而是我把 namespace、max_resources、Placement Group、Autoscaler 节点出口这四层方案逐个试了一遍之后的完整记录。我会讲清楚每一层解决什么问题、怎么配、为什么这样配,以及哪些方案看起来像隔离、实际根本不限制资源。想把共享 Ray 集群管起来的平台工程师、负责大数据集群的 SRE,还有被队友坑过的算法同学,都能在里面找到对应的答案。

1. 为什么共享集群必须做隔离:先把痛点摆出来

1.1 "一个人霸占,全员排队"到底是怎么发生的

很多团队把 Ray 集群搭起来之后,第一反应都是"先让同事用起来再说",资源隔离这事儿往往排到后面。但我可以负责任地说,只要集群超过 10 个节点、用户超过 3 个组,用不了两周就会出乱子。问题一般来自三类典型操作。

第一类是单个任务声明过大的资源。有些同学的习惯是"反正机器多,num_cpus直接写 32";但他实际用到的只有两个核。问题在于 Ray 的调度器是看数字记账的,不会去猜你的真实负载。一个 32 核的节点被这个任务占掉 32 核之后,其他组的哪怕num_cpus=1的小任务也只能排队等待,看起来就是"集群明明很空,任务就是起不来"。

第二类是长驻 Actor 没有释放。训练服务或者模型热加载的 Actor 一旦挂在集群里,除非显式ray.kill或者进程结束,否则它占用的 CPU、GPU 会一直被 raylet 记在账上。我见过一个num_gpus=1的 Actor 在集群里活了两周,期间有三个组的任务一直在等这块 GPU。

第三类是 object store 内存被打爆。Ray 的任务间通信和参数传递走的是共享内存对象存储(object store),默认会吃掉节点内存的相当大一部分。有人一次性往里面丢几十 GB 的大数组,老对象会被强制驱逐,正在读取这些对象的任务就会开始重试或者长时间等待。

这三种情况叠加在一起,最难受的不是"慢",而是不可预测——没人知道下一个任务到底要排多久。更严重的还有 Placement Group 互相等待导致的死锁式停顿,后面我会专门讲。

1.2 隔离需求怎么翻译成 Ray 的"语言"

做隔离之前,先要把目标拆清楚。我给共享集群定的目标是三个词:故障爆炸半径可控、资源分配相对公平、用量可审计。

  • 故障爆炸半径可控:某个用户 OOM 或者把 object store 打满,不能拖垮整个 raylet,更不能让其他组的 Driver 全部断连。
  • 资源分配相对公平:关键是"相对"。每个团队至少要有一个保障性的资源下限,同时也要有总上限,防止一个人把集群吃光。
  • 用量可审计:出了问题能回答"这块 GPU 被谁占了、从什么时候开始占的"。

这三个目标翻译成 Ray 的术语,分别对应四层手段:namespace 做逻辑隔离,max_resources 做单 Job 累计配额,Placement Group 做团队级资源池,Autoscaler 节点出口做集群总量控制。它们不是替代关系,而是叠加关系。下面我从地基开始,把这四层逐一讲透。

2. 隔离的地基:Ray 资源模型先讲清楚

2.1 raylet 如何登记和分配资源

想理解隔离,先得理解 Ray 的资源记账机制。每个节点上有一个 raylet 进程,节点一共有多少资源,是在启动时登记的。手动ray start的话,可以通过--num-cpus、--num-gpus、--memory和--resources指定;默认情况下它会按机器实际配置自动探测。所有节点的资源情况和调度决策集中在 head 节点的 GCS(Global Control Service)里维护,形成一个集群资源视图。

当你提交一个任务或者创建 Actor 时,调度器会在集群范围内找一个"当前可用资源足够"的节点,然后由该节点的 raylet 把资源划拨出去。任务跑完,资源归还;Actor 不删,资源就一直在账上。整个过程特别像酒店前台登记房间:每个房间是否空闲、被谁占用,前台都记得清清楚楚。但注意,Ray 默认只是个"记账系统",不是"限额系统";如果不做任何限制,任何用户都可以申请任意多的资源,直到把集群压垮或者彼此饿死。这就是多用户场景必须自己做配额的根本原因。

2.2 任务与 Actor 的资源声明差异

Ray 里资源声明的核心参数是num_cpus、num_gpus、memory和自定义资源。这里有两个新手常踩的点。

第一,GPU 资源声明不代表显存管理。你写num_gpus=1只表示"我要占用一块 GPU 的调度名额",Ray 不会帮你管显存,更不会做类似 GPU 显存的隔离。如果两个任务被调度到同一块 GPU 上(比如num_gpus=0.5的比例调度),显存冲突只能靠你自己的框架或者监控去发现。

第二,Task 和 Actor 的生命周期完全不同。普通 Task 是"跑完即释放",而 Actor 是常驻进程,创建之后会一直占着资源,直到显式删除或者进程崩溃。多用户集群里,绝大多数持续性资源泄漏都是 Actor 引起的,后面踩坑实录会专门讲。

2.3 Placement Group 是怎么"占坑"的

Placement Group 是 Ray 提供的一种资源打包占位机制。你可以把一组确定量的资源定义成 bundle,比如{"CPU": 8, "memory": 32GB}就是一个 bundle,然后把这些 bundle 按一定策略放到一个或多个节点上。常见策略有PACK(尽量打包到同一节点)、STRICT_PACK(强制同一节点)、SPREAD(尽量分散)和STRICT_SPREAD(必须分散)。

为什么要提它?因为多用户隔离里,Placement Group 是"团队资源池"方案的基础——先占坑,再让任务往坑里调度。但它的代价也很明显:占着坑不用,资源就浪费了;而且多个用户同时等坑的时候,可能出现 A 等 B、B 等 A 的死锁。所以它不是一个拿来就用的功能,配套的生命周期管理必须跟上。

3. 四层隔离方案怎么选:场景、代价与对比

3.1 namespace:最容易被忽略的逻辑隔离

Ray 的 namespace 是一个逻辑命名空间,每个任务和 Actor 都在某个 namespace 下。同一 namespace 内的 Actor 可以互相直接调用,不同 namespace 之间默认不可见、不能直接访问对象。如果你的用户直接ray.init()而不指定 namespace,系统会分配一个默认 namespace,所有人混在一起,同名 Actor 冲突、对象互相覆盖的情况就很难避免。

用 Ray Jobs API 提交任务时,每个 Job 默认会跑在独立的私有 namespace 下,这本身就是一道逻辑隔离。但请记住,namespace 隔离的是"名字空间"和"可见性",它不限制任何物理资源。一个用户照样可以申请 100 个 CPU 把你的集群占满。所以 namespace 是必要但不充分的一层,它主要解决"起名冲突"和"误访问"问题,资源限制要靠下一层。

3.2 Jobs API + max_resources:真正的用户级配额

Ray 从 2.7 版本开始,在 Jobs API 里支持了max_resources参数。它可以在 Ray Job 这一层直接给整个作业设置 CPU、GPU、内存的累计上限。举个实际例子:如果给某用户的 Job 设置max_resources={"CPU": 16, "GPU": 2},那么这个 Job 内所有 Task 和 Actor 的 CPU/GPU 声明加在一起不能超过这个数,超过的部分调度器会排队等待,而不是无限抢占。

这是目前做多用户隔离最实用的一层控制,因为它的语义正好落在"用户"和"作业"上。相比让每个用户自觉约束自己的num_cpus,直接把配额写进提交接口,约束力强很多。它依赖的是 Ray 的吞吐调度器(throughput scheduler)来做限额记账,细节我放在实操章节。

3.3 Placement Group 预留:给团队一个保底资源池

配额能管上限,但管不了"保证"。如果集群繁忙,配额内的小任务也可能一直抢不到资源。这时候 Placement Group 的价值就出来了:你可以提前为团队 A 预留一组 bundle,相当于"这几块资源已经被团队 A 预定了",其他用户调度时会自动避开。

打个比方,max_resources 像是给每个人发了张"最多能花这么多"的卡,Placement Group 则是直接给团队开一个"专属包间"。包间的缺点是空着也得付钱——预留资源在没跑任务时依然被占用,集群整体利用率会下降。所以这个手段适合资源紧张、核心团队需要稳定保障的场景,不适合所有用户都来一层。

3.4 KubeRay 多集群:隔离最彻底,运维最重

如果你已经在 Kubernetes 上跑 Ray,还可以走更彻底的路线:每个团队一个独立 RayCluster,配合 K8s 的 ResourceQuota、LimitRange 做物理限额。这种方式隔离效果最好,故障、网络、资源全部独立;但代价是集群数量变多、运维复杂度成倍上升,镜像、依赖、监控、日志都要跟着多份。

我的判断是,除非有强合规要求或者团队之间有严重的相互干扰,否则不需要一上来就上多集群。大多数场景下,一个共享集群加上 namespace、max_resources、Placement Group 三层就够了,把 KubeRay 多集群当作战术性的"最后手段"而不是默认方案。

3.5 四者的组合方式与选型表

隔离手段隔离粒度是否限制总资源部署成本典型场景
namespace逻辑命名空间否极低防止 Actor 同名冲突、对象误访问
max_resourcesJob 级配额是低给每个用户/作业设 CPU、GPU、内存上限
Placement Group团队级资源池是(预留)中核心团队保底资源、避免资源饿死
KubeRay 多集群集群级是(K8s 限额)高强隔离、强合规、多环境故障隔离

我们最终的方案是:所有任务强制通过 Ray Jobs 提交,每个用户在提交层配max_resources;对两个核心算法组各建一个 Placement Group 做保底;namespace 随 Job 自动隔离,不需要额外干预;多集群暂时不上,留到规模再大一倍再说。

4. 实操:照着配置一套用户级资源隔离

4.1 先确认版本和调度器

第一步是确认 Ray 版本。max_resources是 2.7 引入的,建议直接上 2.8 之后的版本,调度器行为更稳定。还有一点容易被忽略:max_resources依赖吞吐调度器(throughput scheduler)的资源限额机制。如果你还在用旧版本的调度器,哪怕代码里写了max_resources,也可能不生效。可以通过环境变量RAY_SCHEDULER_TASKS_USE_THROUGHPUT_SCHEDULER=1显式开启,2.8 开始这个调度器默认启用。

启动 head 节点的时候,我建议顺手确认两件事:一是给节点起了稳定的名字,方便 Dashboard 识别;二是确认ray status -v能正常显示各节点的资源总量。这个命令后面排障时天天要用。

4.2 用 JobSubmissionClient 给每个用户设总配额

给用户开放集群的第一步,是把提交入口统一收编到 Ray Jobs API,不要让用户自己ray.init()。我写了一个简单的提交脚本交给各组使用:

from ray.job_submission import JobSubmissionClient client = JobSubmissionClient("http://ray-head:8265") submission_id = client.submit_job( entrypoint="python train.py --config configs/base.yaml", runtime_env={ "working_dir": "/data/team_a/project", "env_vars": {"PYTHONPATH": "."}, }, max_resources={ "CPU": 16, "GPU": 2, "memory": 64 * 1024**3, # 单位是字节 }, ) print(submission_id)

这里三个参数值得你留意。memory的单位是字节,不是 GB,写错会导致配额语义完全不对。max_resources限制的是整个 Job 内所有 Task 和 Actor 的累计声明值,不是某个单任务的限制。另外,如果用户的代码在 Job 内部又尝试自己连一个本地 Ray 集群,那这份配额就管不到他了,我在排障章节会教你怎么发现这种"绕过"。

提交之后,可以定期用client.get_job_status(submission_id)查状态。我们内部还做了一层封装:每个用户绑定固定的配额模板,比如"算法组 A 默认 CPU 16 / GPU 2 / 内存 64GB",避免用户自己乱填。

4.3 用 Placement Group 做团队级资源池

对于核心团队,我会额外创建一个名为team-a-pool的 Placement Group。下面的代码示例演示了如何创建并等待它就绪:

import ray ray.init(address="ray://ray-head:10001", namespace="team-a") pg = ray.util.placement_group( bundles=[ {"CPU": 8, "memory": 32 * 1024**3}, {"CPU": 8, "memory": 32 * 1024**3}, {"GPU": 4}, ], strategy="PACK", name="team-a-pool", ) ray.get(pg.ready())

创建之后,团队成员在装饰器里指定placement_group和placement_group_bundle_index,任务就会被优先调度到这个资源池里:

@ray.remote(num_cpus=8, placement_group=pg, placement_group_bundle_index=0) def train_worker(): ...

用完一定要显式释放,否则资源会一直被占着:

ray.util.remove_placement_group(pg)

这里提醒一个常见问题:多个团队同时创建多个STRICT_PACK或STRICT_SPREAD类型的 Placement Group,在资源不够的节点上会出现互相等待,也就是调度死锁。我们的做法是给每个 Placement Group 的ray.get(pg.ready())加上超时和告警,一旦超过 5 分钟没就绪,就会触发人工介入。

4.4 Autoscaler 节点类型:从集群出口控制总量

最后一道闸门是 Autoscaler 层面的节点出口。如果用户提交的 Job 数量很多、每个 Job 配额都不小,光靠 Job 级配额还不够,因为集群总量是有限的。常规做法是把不同团队的资源分成不同的节点类型(node type),比如 CPU 节点池、GPU 节点池,再分别设置节点数和资源总量。

以 Ray Cluster Launcher 的配置为例,你可以为团队 A 单独定义一个节点类型:

available_node_types: team_a_cpu: resources: CPU: 32 memory: 128000000000 node_config: InstanceType: c5.4xlarge team_a_gpu: resources: CPU: 16 GPU: 4 memory: 256000000000 node_config: InstanceType: g4dn.2xlarge max_workers: 10

这样做的好处是:即使某个用户代码里把num_cpus写得再大,他最多也只能用到一个节点类型内的资源总量;配合集群级max_workers,就能把整个集群的出口规模卡死。如果你用 KubeRay,逻辑类似,只是把限制写在了 RayCluster 的 WorkerGroup 里(minReplicas/maxReplicas)。这一步不是给每个用户做精细隔离,而是给集群"封顶",避免资源被无限扩出来。

5. 隔离效果怎么看:监控指标与排障链路

5.1 Dashboard、ray status、ray memory 怎么配合看

配完隔离不代表万事大吉,你得能随时回答"现在谁占了多少资源"。我日常监控就靠三个工具。第一个是 Ray Dashboard,head 节点的 8265 端口,打开 Jobs 页面可以直接看到每个 Job 的 CPU、GPU、内存使用曲线;如果发现某个 Job 长期贴着max_resources上限跑,基本能断定他在抢资源。第二个是命令行,ray status -v可以看到每个节点的资源总量、已用、可用,以及正在排队的 Task 数量;ray memory则是专门看 object store 内存占用,谁 put 了大对象一目了然。

第三个是 Prometheus 指标。Ray 会暴露一套调度和资源相关的 metrics,包括ray_resources、ray_placement_groups等。我们把指标接进了 Grafana,专门做了一个"各团队资源使用排行"面板,按 Job 的 metadata 打标聚合。有了这个面板,再有人来问"我的任务为什么排队",可以直接甩图给他,省去大量沟通成本。

5.2 配了上限却不生效?排查链路

如果你设置了max_resources但发现用户作业还是能无限拿资源,按下面的顺序排查,基本能定位:

  1. 确认 Ray 版本在 2.7 以上。版本不到,max_resources这个参数会被直接忽略。
  2. 确认任务真的是通过 Ray Jobs API 提交的。很多人把 Jobs API 用成了"只是套一层壳",代码里还是ray.init(address=...),那配额就不归这个 Job 管。
  3. 看调度器日志里有没有吞吐调度器相关的类名或 token bucket 字样。如果日志显示还在用旧的调度队列,就需要设置RAY_SCHEDULER_TASKS_USE_THROUGHPUT_SCHEDULER=1后重启集群。
  4. 检查用户代码里是否在 Job 内部又起了新的ray.init()本地集群。这种情况经常出现在 Notebook 用户身上,他们习惯性地在代码开头ray.init(),导致任务根本没跑到你的集群里。
  5. 最后,打开 Dashboard 的 Jobs 页面,看那个 Job 的资源曲线是否到了你设的上限附近。如果曲线远超上限,说明配额确实没生效;如果刚好顶在上限,实际上调度器已经帮他排队了,表现是任务等待而不是失败。

这套排查链路我们走了不止一次,百分之九十的问题都出在第 2 和第 4 步,也就是"看似通过 Jobs API,实际绕过了配额"。

5.3 一个"伪报错":别把 CDN 的 Ray ID 当成 Ray 框架报错

做 Ray 相关排障时,网上搜索会遇到一类奇怪的"报错",比如error 1033 ray id: a41b20262cb6b9aa。这里我必须提醒一句:如果这个错误出现在浏览器页面或者某个 API 网关的响应体里,那个大写的"Ray ID"是 CDN 产品(Cloudflare 等)用来追踪请求的标识符,和开源的分布式计算框架 Ray 没有任何关系。

我们团队就有人拿着这种报错来问我"Ray 集群是不是出问题了",查了半天才发现是网关层的缓存/范围问题。区分方法很简单:Ray 框架本身的报错,几乎都会出现在 Dashboard、日志或者 Python 的 traceback 里,内容通常是Failed to allocate ...、ObjectStore ...、RayTaskError之类;而 "Ray ID" 这种格式,一般是一串十六进制字符串,跟着CF-Ray或类似响应头出现。以后再看到这种报错,先分清它是"网关随手给你的追踪号"还是"Ray 调度器的真实错误"。

6. 踩坑实录:多用户隔离最容易翻车的三个点

6.1 用户声明资源远大于真实需求,结果调度瘫痪

有一个组的算法同学写num_cpus=32并没有恶意,只是沿用了以前单机多进程的习惯。但问题在于,Ray 的调度器只看声明值,不看真实负载。在 32 核节点上,一个num_cpus=32的任务会让其他所有任务在这个节点上完全无法调度,哪怕这个任务实际只用了 2 个核。

我们的对策分两层。第一层是技术兜底:给每个用户的 Job 设置max_resources上限,再差的声明也翻不出天;第二层是使用规范:在提交脚本里预检查num_cpus和节点规格的匹配关系,超标的直接打回。效果比单纯开会宣讲好很多。

6.2 Actor 泄漏导致 GPU 永久占坑

这是多用户 GPU 集群最头疼的问题。某次训练任务因为异常退出了,但它的训练 Actor 还活着,继续霸占着num_gpus=1。后续任务只能排队等这块不存在的"已占用" GPU。更隐蔽的是 detached Actor,它不依赖任何 Driver 存活,Driver 退出了它还活着,时间一长,GPU 资源会一块一块消失。

遇到这种情况,先用ray list actors把所有 ALIVE 状态的 Actor 拉出来,看 owner 和创建时间;再用 dashboard 的 Actor 页面确认占用资源。治理上我们做了三件事:一是规范 Actor 命名,必须包含团队名和业务名;二是在共享集群上默认禁止用户创建 detached Actor,确需长期常驻的走申请流程;三是写了一个巡检脚本,每 20 分钟扫描一次超过 24 小时未更新的常驻 Actor,自动告警。

6.3 object store 内存被忽略,任务忽快忽慢

Job 级max_resources里配的 memory,主要管的是任务和 Actor 的资源声明,object store 是另一套账。用户一次put几十 GB 的对象,会直接影响所有任务的对象访问速度,副作用就是整个集群任务执行时间变得极不稳定。

我们的处理方式是双管齐下。一是在 node 启动参数里控制单个节点的--object-store-memory,防止它无限吃内存;二是明确要求大数组不要直接进 object store,优先写共享文件系统或者用ray.data做流式处理。这个习惯养成之后,集群稳定性提升非常明显。

6.4 一点个人经验

如果让我重来一次,我不会一上来就上多集群,而是先做好两件事:把所有提交入口统一到 Ray Jobs API,并给每个用户配上max_resources;再把 Dashboard 和 Prometheus 指标接好,让资源使用透明化。这两件事加起来能解决共享集群 80% 的打架问题。剩下的 20%,用 Placement Group 给核心团队保底、用 Autoscaler 封顶,基本就够了。多集群隔离不是不能上,而是要等业务规模和合规要求真正需要的时候再上,否则运维成本会让你怀疑人生。

另外,任何技术配置都抵不过一份使用公约。我们和技术组长对齐了一份《共享 Ray 集群资源使用规范》,里面只写了三条硬规则:num_cpus必须按真实并行度填写、Actor 必须设置生命周期或申请常驻、超过 10GB 的对象不允许直接 put。这三条配合上面的技术隔离,才是集群到现在还能稳定运行的真正原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询