all_lessons/ray/12lesson 13 / 17

Part III - Cluster and platform layer

Ray Clusters, KubeRay, and Autoscaling

Lessons 06–11 built real applications — RL rollouts, tuning sweeps, data pipelines, distributed training, online serving — but every one of them ran on a cluster that someone, somewhere, had already stood up. ray.init() quietly connected to "a cluster" and the GPUs were simply there. This lesson opens that box. A Ray cluster is not an undifferentiated pool of machines: it is a head node running the control plane plus worker groups, each a node type, that the autoscaler grows and shrinks. The central idea you must own by the end is that Ray's autoscaler scales on pending logical resource demands, not CPU utilization — a distinction that decides whether your bursty job gets its GPUs in time or times out waiting.

Book source
Chapter 9, 'Ray Clusters' - manual clusters, Kubernetes/KubeRay, the cluster launcher, cloud clusters, and autoscaling. Current-docs calibration: KubeRay is now the recommended way to run Ray on Kubernetes, exposing RayCluster, RayJob, and RayService Custom Resource Definitions; the Ray autoscaler is configured per worker group with minReplicas/maxReplicas.
Linear position
Prerequisite: Lessons 02–04 (tasks, actors, and the scheduler placing work by logical resourcesnum_cpus, num_gpus), and Lesson 11 (Serve's per-deployment autoscaling on a running cluster).
New capability: stand up a Ray cluster three different ways, reason about where the head node becomes a bottleneck, and configure an autoscaler whose scale-up latency you can actually budget against an SLO.
The plan
Five moves. (1) Lay out the cluster anatomy — head node, worker nodes, worker groups — and what the head actually runs. (2) The three ways to start a cluster: manual, the cluster launcher, and the KubeRay operator with its CRDs. (3) The central mechanism: the autoscaler scales on pending resource demands, not utilization — and how that composes with Kubernetes' own autoscalers into a two-level system. (4) The two worked-number trade-offs that govern real deployments: scale-up latency budget, and idle cost versus cold start. (5) Failure modes, a checklist, and the hand-off to the platform layer.

1 · The anatomy of a cluster

A Ray cluster is a set of nodes — physical or virtual machines, or Kubernetes pods — running Ray processes that together present one logical runtime to your code. It has exactly two kinds of node, and the asymmetry between them is the first thing to internalize.

Head node (one)
Runs the control plane: the GCS (Global Control Store — the cluster's metadata service, holding the table of nodes, actors, and resources), the dashboard, the job-submission server, and — when enabled — the autoscaler process. It usually also runs tasks, but its real job is coordination.
Worker node (many)
A machine that registers its resources (CPUs, GPUs, memory) with the GCS and runs your tasks and actors. Each has a local raylet (the per-node scheduler + object-store manager from Lesson 04). Workers are cattle: they come and go.
Worker group
A template for a kind of worker node — a node type. A "cpu-group" of 32-core boxes, an "a100-group" of single-GPU boxes. The autoscaler grows and shrinks each group independently between its own min and max replica counts.

The reason worker groups exist rather than one flat pool is heterogeneity. Recall from Lesson 07 that an RLlib job wants many cheap CPU rollout workers and a few expensive GPU learners; from Lesson 10 that training wants GPU boxes; from Lesson 09 that data preprocessing wants high-core CPU boxes. A single homogeneous pool cannot serve all three without massive waste. A worker group lets you say "this group is A100 boxes, scale it 0→8; that group is CPU boxes, scale it 2→64," and the autoscaler picks the right group to satisfy each pending demand.

The head node is a single point of failure and a control-plane bottleneck
Every actor creation, every task submission's metadata, every resource update flows through the GCS on the head node. Two consequences follow. First, the head is a SPOF: if it dies, the cluster's control plane is gone (modern Ray supports GCS fault tolerance backed by external Redis to soften this, but the default single-head setup loses the cluster). Second, it is a throughput ceiling: a driver that creates 50,000 tiny actors, or calls ray.get in a hot loop funneling all results back through one process, saturates the head long before the workers are busy. The fix is the same as Lesson 05's tree-reduce — keep coordination off the critical path — and is why you size the head node generously and never run heavy compute on it in production.

2 · Three ways to stand one up

Ray gives you a ladder of cluster-creation methods, from a manual two-command setup good for a quick benchmark, to a fully operator-managed Kubernetes deployment good for a platform team.

MethodHow you start itWhen it wins
Manualray start --head on one box, then ray start --address=<head-ip:port> on each other box. You own every machine and every restart.A handful of fixed on-prem boxes; a quick multi-node benchmark; learning.
Cluster launcherray up cluster.yaml — a YAML naming a cloud provider, node types, and min/max workers. Ray provisions the VMs, installs your env, and starts Ray.Cloud VMs without Kubernetes; a self-contained autoscaling cluster you own end to end.
KubeRay operatorApply a RayCluster (or RayJob/RayService) CRD — a Custom Resource Definition, a Kubernetes API object the KubeRay operator knows how to reconcile into pods.Any team already operating Kubernetes; shared multi-tenant platforms; GitOps.

The manual path makes the model concrete: the head process opens a port, and every ray start --address is a worker registering its resources with the GCS. The cluster launcher automates exactly that over cloud VMs. KubeRay does the same but expresses the cluster as Kubernetes objects, which is where most production Ray now lives — so it is worth seeing the CRD in full.

The three KubeRay CRDs differ only in lifecycle. A RayCluster is a long-lived cluster you submit many jobs to. A RayJob creates a cluster, runs one job, and tears the cluster down — clean isolation and cost attribution per job (Lesson 08's tuning sweep is a natural RayJob). A RayService wraps a Ray Serve app (Lesson 11) with zero-downtime upgrades. Here is a minimal RayCluster with one CPU group and one autoscaling GPU group:

# raycluster.yaml — applied with: kubectl apply -f raycluster.yaml
apiVersion: ray.io/v1
kind: RayCluster
metadata:
  name: ml-cluster
spec:
  enableInTreeAutoscaling: true        # the Ray autoscaler runs as a sidecar on the head
  headGroupSpec:
    rayStartParams:
      dashboard-host: "0.0.0.0"
    template:
      spec:
        containers:
        - name: ray-head
          image: rayproject/ray-ml:2.9.0-gpu
          resources:
            limits:   { cpu: "8",  memory: "32Gi" }
            requests: { cpu: "8",  memory: "32Gi" }   # size the head generously: GCS lives here
  workerGroupSpecs:
  - groupName: cpu-group
    minReplicas: 2                      # warm pool: always 2 CPU workers
    maxReplicas: 64
    rayStartParams: {}
    template:
      spec:
        containers:
        - name: ray-worker
          image: rayproject/ray-ml:2.9.0-gpu
          resources:
            limits:   { cpu: "16", memory: "64Gi" }
            requests: { cpu: "16", memory: "64Gi" }
  - groupName: a100-group
    minReplicas: 0                      # scale to zero when no GPU work is pending
    maxReplicas: 8
    template:
      spec:
        containers:
        - name: ray-worker
          image: rayproject/ray-ml:2.9.0-gpu
          resources:
            limits:   { nvidia.com/gpu: 1, cpu: "12", memory: "80Gi" }
            requests: { nvidia.com/gpu: 1, cpu: "12", memory: "80Gi" }

Read the two worker groups as policy, not plumbing. cpu-group keeps a warm floor of 2 (fast scheduling for steady work); a100-group floors at 0 so you pay nothing for idle GPUs — at the cost of a cold start when GPU work arrives, which §4 quantifies. The nvidia.com/gpu: 1 limit is what makes Kubernetes place each of these pods on a GPU node and what Ray reads back as num_gpus=1 of schedulable resource.

3 · The autoscaler scales on pending demand, not utilization

This is the load-bearing idea of the lesson, and it is the one most engineers get wrong because they reason by analogy to web autoscaling. The Ray autoscaler is a process (on the head) that, in a loop, asks the GCS one question: are there tasks or actors that cannot be scheduled because no node has the logical resources they requested? If a @ray.remote(num_gpus=1) task is sitting in the pending queue and no node has a free GPU, that is a pending resource demand. The autoscaler sums those demands, computes the smallest set of worker-group nodes that would satisfy them, and requests exactly those nodes.

import ray

# These declared resources are the ONLY thing the autoscaler sees.
@ray.remote(num_gpus=1)            # -> creates GPU demand when no GPU is free
def train_shard(shard):
    return fit(shard)

@ray.remote(num_cpus=4)            # -> creates CPU demand the cpu-group can satisfy
def preprocess(block):
    return transform(block)

# Submitting 8 GPU tasks at once on a cluster with 0 GPU workers:
#   pending demand = 8 x {GPU:1}  ->  autoscaler asks for up to 8 a100-group nodes
refs = [train_shard.remote(s) for s in shards[:8]]
ray.get(refs)

Two corollaries fall straight out of this design. First, the autoscaler is blind to what you do not declare. A task that secretly forks a process onto a GPU it never requested with num_gpus produces zero demand — the autoscaler will not add a node, and the task will fight for a GPU that, on paper, is fully free. Declared logical resources are the entire contract. Second, scale-down is the mirror: a worker node that has been idle (no running tasks/actors, no objects in use) for longer than idleTimeoutSeconds is removed.

Contrast: this is not what Kubernetes HPA does
The Kubernetes Horizontal Pod Autoscaler (HPA) scales a deployment's pod count on observed metrics — CPU utilization above 70%, memory pressure, or a custom metric like queue depth. It is reactive to load. The Ray autoscaler is reactive to unschedulable work: it does not care that your CPUs are at 95%; it cares that there is a task no node can accept. A job at 100% CPU with nothing queued will not trigger a Ray scale-up, because there is no pending demand. Conflating the two leads people to set CPU-based HPA on Ray worker pods, which fights the Ray autoscaler — pick one owner of replica count per group, and for Ray workloads it should be the Ray autoscaler.

On Kubernetes the picture becomes two-level autoscaling, and seeing the full chain prevents a class of confusing stalls:

1Ray autoscaler sees 8 pending {GPU:1} demands → asks KubeRay to add 8 worker pods in a100-group.
2KubeRay operator creates the 8 pods. The Kubernetes scheduler tries to place them on existing GPU nodes.
3Kubernetes cluster-autoscaler (a separate component) sees pods stuck Pending because no node has a free GPU → provisions new GPU VMs from the cloud.
4New VMs join, pods schedule, Ray workers register their GPUs with the GCS, the pending Ray tasks finally run.

The trap: if your Kubernetes cluster has no GPU node group with headroom and no cluster-autoscaler, step 3 never completes — the Ray pods sit Pending forever and your job hangs with no error. Ray asked correctly; Kubernetes simply had nowhere to put the pods. Both levels must be configured to scale, or you cap out at whatever fixed nodes exist.

4 · The two trade-offs that decide your config

Autoscaling looks like free money — pay only for what you use — until you price the latency it injects and the idle it leaves on the floor. Two worked numbers govern every real autoscaler config.

4.1 · Scale-up latency budget

"Add a GPU worker" is not instant. It is a sum of stages, and for a fresh GPU node from zero it runs roughly:

VM provisionCloud allocates and boots the instance: ≈ 30–60 s for an on-demand GPU VM (longer if the instance type is scarce in the region).
Image pullPull the container image to the new node. A large CUDA + ML image is commonly 6–12 GB; at ~50 MB/s that is ≈ 120–240 s if not cached on the node.
Ray start + registerRay worker process starts and registers its GPU with the GCS: ≈ 5–10 s.
Model loadYour actor loads weights from storage onto the GPU. A 13B fp16 model is ~26 GB; from object storage at ~300 MB/s that is ≈ 90 s, plus host→GPU copy.

Sum the worst case: 60 + 240 + 10 + 90 ≈ 400 s, about 6½ minutes, before the first request can be served on that new GPU. Now compare against an SLO. If a serving deployment (Lesson 11) has a traffic spike and its p99 budget is 2 seconds, a purely reactive autoscaler is hopeless: every request that arrives during those 6½ minutes either queues past the SLO or gets dropped. The conclusion is not "autoscaling is bad" — it is that bursty latency-sensitive workloads need warm headroom, not just reactive scale-up. You keep minReplicas above zero (a warm pool already past all four stages) sized to absorb the burst while reactive scale-up catches up. A batch training job, by contrast, happily eats the 6½-minute cold start because its "SLO" is hours — there, minReplicas: 0 is correct and saves real money.

4.2 · Idle cost versus cold start — setting the idle timeout

Scale-down has the symmetric trade-off, controlled by idleTimeoutSeconds: how long a worker sits idle before you release it. Too short and you thrash — release a GPU, pay the 6½-minute cold start to get it back 90 seconds later. Too long and you pay for idle GPUs.

Worked number — idle timeout on an A100
Take an on-demand A100 at roughly $3.00/hr = $0.05/min. Suppose work arrives in bursts with typical gaps of about 4 minutes between bursts.

Aggressive (idleTimeoutSeconds = 60): you release the node after 1 idle minute, so each 4-minute gap costs 1 idle minute = $0.05 — but you then pay a ≈ 400 s cold start before the next burst can run, blowing any tight SLO and re-pulling the image.

Lazy (idleTimeoutSeconds = 600): you hold the node through the whole 4-minute gap, paying 4 × $0.05 = $0.20 of idle per gap, but the next burst starts instantly — no cold start.

The crossover: holding the node costs $0.05/min of idle; cold-starting costs ~6½ min of wall-clock latency plus a re-pull. If a missed SLO is worth more than $0.20, set the timeout longer than your typical gap (here ≥ 240 s) so warm reuse wins. If the work is latency-insensitive batch, set it short and pocket the idle savings. The number that decides it is your gap distribution versus the cold-start penalty — measure the gaps, do not guess.

This is the same shape as Serve's batching trade-off in Lesson 11 (wait time vs. utilization) and the predictive-vs-reactive choice everywhere in capacity planning: a warm pool (minReplicas > 0) is a bet that future demand justifies present idle cost. Predictive scaling — pre-scaling on a known daily traffic curve — beats reactive scaling exactly when your cold start is long relative to how fast load ramps, which for GPU workloads it almost always is.

5 · Failure modes & checklist

Failure modes

  • Autoscaler blind to undeclared resources. Tasks that use a GPU without num_gpus=1 create no pending demand, so no node is added and they oversubscribe a "free" GPU. Declared logical resources are the only signal — annotate honestly.
  • Two-level stall. Ray asks KubeRay for pods, but the Kubernetes cluster has no GPU headroom and no cluster-autoscaler, so pods sit Pending and the job hangs silently. Both autoscaling levels must be configured.
  • Reactive scale-up under a tight SLO. A 6½-minute cold start cannot serve a 2-second p99 during a spike. Without warm minReplicas the spike is dropped or queued past SLO.
  • Head node overload / SPOF. Heavy compute or 50k tiny actors on a single head saturate the GCS; head death loses the cluster unless GCS fault tolerance is enabled. Size the head, keep compute off it.
  • Idle-timeout thrash. Too-short a timeout releases nodes that are needed again within a cold-start window, paying the startup cost repeatedly.
  • Image drift across teams. Every team baking a slightly different Ray image multiplies image-pull time and surfaces version-skew bugs between head and workers (they must match Ray versions).

Implementation checklist

  • Does every GPU/CPU-heavy task and actor declare num_gpus/num_cpus so the autoscaler can see its demand?
  • Is there a worker group per accelerator type, each with deliberate minReplicas/maxReplicas?
  • Have you summed the scale-up latency (VM + image + Ray start + model load) and compared it to the workload's SLO?
  • Is minReplicas > 0 (a warm pool) wherever a spike must be served faster than a cold start?
  • Is idleTimeoutSeconds set against your measured inter-burst gap and the cold-start penalty?
  • On Kubernetes, is the cluster-autoscaler configured so pending Ray pods can actually get nodes?
  • Is exactly one component (the Ray autoscaler — not also an HPA) the owner of each group's replica count?
  • Is the head node sized for control-plane load, and is GCS fault tolerance enabled if head loss is unacceptable?

Checkpoint exercise

Try it
A Tune job (Lesson 08) launches 200 CPU trials at num_cpus=4 each and 8 GPU training trials at num_gpus=1 each. (1) Design two worker groups with sensible minReplicas/maxReplicas for 16-core CPU boxes and single-GPU boxes. (2) The GPU nodes take 12 minutes to become ready (slow region, large image). The first thing the GPU trials do is run a 5-minute warm-up. Does minReplicas: 0 on the GPU group hurt total wall-clock time here? Now flip the assumption: the job is re-run every 30 minutes all day. Does your answer change — should the GPU group now keep a warm floor? The "right answer" should flip between the one-shot and the every-30-minutes case, and the deciding quantity is the cold-start cost amortized over how often you pay it.

Where this points next

You can now stand up a cluster and reason about how it grows. But a cluster is just capacity — it does not yet say how Data, Train, Tune, and Serve hand work to one another across it. Each of those libraries produced and consumed checkpoints, datasets, and models in Lessons 09–11, yet we never named the contract that lets a checkpoint flow train → tune → serve without train/serve skew. Lesson 13 reads the ML platform (the "AIR" pattern) not as a product but as exactly that handoff contract layered on the cluster you just built — and confronts the trade-off that one unified runtime simplifies handoffs while concentrating the blast radius.

Takeaway
A Ray cluster is a head node running the control plane (GCS, dashboard, autoscaler) plus worker groups, each a node type the autoscaler scales independently. Stand one up manually, with the cluster launcher on cloud VMs, or — the production default — with the KubeRay operator's RayCluster/RayJob/RayService CRDs. The defining mechanism: the Ray autoscaler scales on pending logical resource demands (unschedulable num_cpus/num_gpus requests), not CPU utilization like a Kubernetes HPA — so undeclared resources are invisible to it, and on Kubernetes it composes with the cluster-autoscaler into a two-level system that hangs if either level lacks headroom. The two numbers that govern your config are the scale-up latency budget (VM + image + Ray start + model load ≈ 6½ min for a cold GPU node, which forces warm minReplicas under tight SLOs) and the idle-timeout trade-off (idle GPU $/min versus cold-start penalty, decided by your inter-burst gap). The head node is both a SPOF and a control-plane bottleneck — size it, and keep heavy compute off it.

Interview prompts

See also: Kubernetes GenAI — GPU nodes and the platform for how the GPU node group and cluster-autoscaler are provisioned underneath KubeRay, and System Design — scaling and load balancing for the reactive-vs-predictive autoscaling trade-off in the general case.