Amazon EKS Deployment (KubeRay)¶
To serve a model too large for one GPU, use KubeRay's RayService. KubeRay
runs the head and worker pods and applies the Ray Serve config; vLLM's tensor parallelism then shards the model across nodes.
Prerequisites¶
- An Amazon EKS cluster with GPU nodes.
- The KubeRay operator installed on the cluster — it reconciles the
RayServiceinto head and worker pods. - The EFA device plugin for multi-node serving, so worker pods can
request
vpc.amazonaws.com/efa. - A shared filesystem such as FSx for Lustre for the model cache.
RayService¶
The manifest below serves Qwen/Qwen2.5-14B-Instruct with tensor parallelism 2, placing each shard on a distinct GPU node. vLLM downloads the weights from the Hub on first start.
apiVersion: ray.io/v1
kind: RayService
metadata:
name: ray-llm
namespace: ray-llm
spec:
serviceUnhealthySecondThreshold: 1200
deploymentUnhealthySecondThreshold: 1200
serveConfigV2: |
applications:
- name: qwen
import_path: ray.serve.llm:build_openai_app
route_prefix: /
args:
llm_configs:
- model_loading_config:
model_id: qwen-14b
model_source: Qwen/Qwen2.5-14B-Instruct
engine_kwargs:
tensor_parallel_size: 2
max_model_len: 4096
dtype: bfloat16
gpu_memory_utilization: 0.85
deployment_config:
num_replicas: 1
placement_group_config:
bundles:
- { CPU: 4, GPU: 1 }
- { CPU: 4, GPU: 1 }
strategy: STRICT_SPREAD
rayClusterConfig:
rayVersion: "2.58.0"
headGroupSpec:
rayStartParams:
dashboard-host: "0.0.0.0"
num-gpus: "0"
template:
spec:
nodeSelector:
node-type: cpu-ray-llm-head
tolerations:
- { key: ray.io/head-node, operator: Equal, value: "true", effect: NoSchedule }
containers:
- name: ray-head
image: public.ecr.aws/deep-learning-containers/ray:serve-llm-cuda
imagePullPolicy: Always
env:
- { name: HF_HOME, value: /fsx/hf-cache }
ports:
- { containerPort: 6379, name: gcs }
- { containerPort: 8265, name: dashboard }
- { containerPort: 8000, name: serve }
resources:
limits: { cpu: "4", memory: 16Gi }
requests: { cpu: "2", memory: 8Gi }
volumeMounts:
- { name: fsx-storage, mountPath: /fsx }
lifecycle:
preStop: { exec: { command: ["/bin/sh", "-c", "ray stop"] } }
volumes:
- name: fsx-storage
persistentVolumeClaim:
claimName: fsx-lustre-pvc
workerGroupSpecs:
- groupName: gpu-workers
replicas: 2
minReplicas: 2
maxReplicas: 2
rayStartParams:
num-gpus: "4"
template:
spec:
nodeSelector:
node-type: g6-ray-llm
tolerations:
- { key: nvidia.com/gpu, operator: Equal, value: "true", effect: NoSchedule }
- { key: aws.amazon.com/efa, operator: Equal, value: "true", effect: NoSchedule }
containers:
- name: ray-worker
image: public.ecr.aws/deep-learning-containers/ray:serve-llm-cuda
imagePullPolicy: Always
env:
- { name: HF_HOME, value: /fsx/hf-cache }
- { name: FI_PROVIDER, value: efa }
- { name: FI_EFA_USE_DEVICE_RDMA, value: "1" }
- { name: FI_EFA_FORK_SAFE, value: "1" }
- { name: NCCL_DEBUG, value: INFO }
- name: VLLM_HOST_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
resources:
limits: { nvidia.com/gpu: "4", vpc.amazonaws.com/efa: "1", cpu: "40", memory: 160Gi }
requests: { nvidia.com/gpu: "4", vpc.amazonaws.com/efa: "1", cpu: "40", memory: 160Gi }
volumeMounts:
- { name: fsx-storage, mountPath: /fsx }
- { name: shm, mountPath: /dev/shm }
lifecycle:
preStop: { exec: { command: ["/bin/sh", "-c", "ray stop"] } }
volumes:
- name: fsx-storage
persistentVolumeClaim:
claimName: fsx-lustre-pvc
- name: shm
emptyDir: { medium: Memory, sizeLimit: 8Gi }
The serveConfigV2 is the part to preserve when you adapt this manifest: build_openai_app exposes the OpenAI-compatible API,
tensor_parallel_size: 2 splits the model across two GPUs, and the two STRICT_SPREAD placement-group bundles force those shards onto separate
nodes.
Cluster-specific fields to adapt¶
The following are specific to the example cluster — change them to match yours:
| Field | Example value | Adapt to |
|---|---|---|
Head nodeSelector |
node-type: cpu-ray-llm-head |
your CPU node label |
Worker nodeSelector |
node-type: g6-ray-llm |
your GPU node label |
tolerations |
head-node / GPU / EFA taints | the taints your node groups carry |
| Storage | fsx-lustre-pvc at /fsx, HF_HOME=/fsx/hf-cache |
your shared filesystem (FSx for Lustre, EFS, or drop it and let each node download to local cache) |
The head pod runs the GCS, dashboard, and Serve proxy, so it needs no GPU (num-gpus: "0"). Keep rayVersion equal to the Ray in the image.
Deploy and Query¶
Apply the manifest and wait for the RayService to report Running — the Serve app takes several minutes to load the sharded model:
RayService exposes the endpoint through a Kubernetes service (ray-llm-serve-svc). Port-forward to it and query the OpenAI-compatible API on port
8000:
curl http://localhost:8000/v1/models
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "qwen-14b",
"messages": [{"role": "user", "content": "Hello!"}],
"max_tokens": 100
}'
/v1/models must list qwen-14b, and /v1/completions and /v1/chat/completions return the OpenAI schema (id, object, choices, usage).
Confirming EFA Is in Use¶
NCCL_DEBUG=INFO is set in the image, so the first collective prints the transport NCCL chose. Once the model is serving, grep a worker's Ray logs:
WORKER=$(kubectl get pods -n ray-llm -l ray.io/node-type=worker -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ray-llm "$WORKER" -c ray-worker -- \
bash -c 'grep -rh "NET/OFI Selected provider" /tmp/ray/session_*/logs' | head -1
NET/OFI Selected provider is efa confirms EFA is carrying the collectives; a NET/Socket line means EFA is not plumbed through.
Cleanup¶
Delete the RayService to tear down the RayCluster and all head and worker pods:
Single-Node Serving¶
For a single GPU, deploy the container to EC2 with docker run — see EC2 Deployment. A community sample that runs a single-GPU Ray Serve
deployment on EKS is available at
aws-samples/sample-aws-deep-learning-containers.
It is a community-maintained example, not an officially supported configuration, so treat it as a starting point.