Skip to content

Commit 785f762

Browse files
committed
feat: support real vLLM serving on managed GPU clusters
build_deployment now emits the serving invocation (command [vllm, serve] + model positional + --host/--port, --enforce-eager from warmupStrategy, plus spec.extraArgs for engine tuning) instead of the unread VLLM_MODEL env, backs /dev/shm with a memory emptyDir, and takes runtimeClassName from the spec (default None) rather than hardcoding nvidia. The v0.1.0 controller was only ever exercised against the CI pause placeholder on kind/K3s; these changes are what let it drive a real vLLM pod on a managed cluster (GKE/EKS/AKS), where the GPU device plugin uses the default runtime and ExtendedResourceToleration injects the GPU toleration. Adds spec fields runtimeClassName (Option<String>) and extraArgs (Vec<String>), regenerates the CRD, and pins the example image to vllm/vllm-openai:v0.11.0.
1 parent 002e48b commit 785f762

5 files changed

Lines changed: 111 additions & 37 deletions

File tree

chart/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: vllm-coldstart-operator
33
description: Kubernetes operator that treats LLM cold-start as a first-class lifecycle signal for vLLM inference services.
44
type: application
5-
version: 0.1.0
6-
appVersion: "latest"
5+
version: 0.2.0
6+
appVersion: "0.2.0"

chart/crds/crd.yaml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
apiVersion: apiextensions.k8s.io/v1
22
kind: CustomResourceDefinition
33
metadata:
4-
annotations:
5-
argocd.argoproj.io/sync-wave: "-2"
6-
argocd.argoproj.io/sync-options: Prune=false
74
name: vllmservices.inference.michelecampi.dev
85
spec:
9-
conversion:
10-
strategy: None
116
group: inference.michelecampi.dev
127
names:
138
categories: []
@@ -33,6 +28,16 @@ spec:
3328
treats time-to-first-token as a first-class concern, configuring the
3429
pod for the cold-start/throughput trade-off the spec asks for.
3530
properties:
31+
extraArgs:
32+
default: []
33+
description: |-
34+
Extra command-line arguments appended to `vllm serve <model>`, e.g.
35+
["--max-model-len", "8192", "--gpu-memory-utilization", "0.90"].
36+
Keeps engine tuning (context length, memory fraction, quantization)
37+
in the resource spec rather than baked into the operator binary.
38+
items:
39+
type: string
40+
type: array
3641
gpu:
3742
default: 1
3843
description: |-
@@ -61,6 +66,16 @@ spec:
6166
description: Number of replicas to run.
6267
format: int32
6368
type: integer
69+
runtimeClassName:
70+
description: |-
71+
RuntimeClass for the workload pod. Cluster-dependent: K3s GPU nodes
72+
need "nvidia" to route the pod to the NVIDIA container runtime, while
73+
managed clusters (GKE, EKS, AKS) expose GPUs through the device plugin
74+
with the default runtime and define no such RuntimeClass. Leave unset
75+
(the default) on managed clusters; setting a non-existent RuntimeClass
76+
makes the API server reject the pod.
77+
nullable: true
78+
type: string
6479
warmupStrategy:
6580
default: Eager
6681
description: |-

chart/values.yaml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
# via git write-back. When spec is set, the image helper uses it verbatim.
55
image:
66
repository: ghcr.io/michelecampi/vllm-coldstart-operator
7-
tag: 0.1.0
7+
tag: 0.2.0
88
pullPolicy: IfNotPresent
99
# -- Managed by ArgoCD Image Updater. Full image reference, takes precedence.
10-
spec: ghcr.io/michelecampi/vllm-coldstart-operator:latest@sha256:d7ccf8580f42a87ea401f13eed9188fc8892b07bd2212357e87c7919f71fb98d
10+
spec: ""
1111
# -- Replicas for the operator Deployment itself (leader election not
1212
# implemented, so keep at 1).
1313
replicaCount: 1
@@ -44,15 +44,18 @@ example:
4444
image: "registry.k8s.io/pause:3.10"
4545
gpu: 0
4646
healthPath: ""
47-
# Real GPU deployment override (requires NVIDIA device plugin):
47+
# Real GPU deployment override (managed cluster, e.g. GKE/EKS/AKS):
4848
# name: qwen-7b
4949
# spec:
5050
# model: "Qwen/Qwen2.5-7B-Instruct"
5151
# replicas: 1
52-
# warmupStrategy: Eager
53-
# image: "vllm/vllm-openai:latest"
52+
# warmupStrategy: Eager # operator appends --enforce-eager
53+
# image: "vllm/vllm-openai:v0.11.0"
5454
# gpu: 1
5555
# healthPath: "/health"
56+
# # runtimeClassName: leave unset on managed clusters; set to "nvidia"
57+
# # only on K3s GPU nodes that route GPUs via the nvidia runtime.
58+
# extraArgs: ["--max-model-len", "8192", "--gpu-memory-utilization", "0.90"]
5659
# -- Value of RUST_LOG for the operator (tracing EnvFilter). Matches the
5760
# binary's built-in default.
5861
logLevel: "info,kube=warn"

src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ pub struct VllmServiceSpec {
4545
/// placeholder images that expose no HTTP health endpoint.
4646
#[serde(default = "default_health_path")]
4747
pub health_path: String,
48+
/// RuntimeClass for the workload pod. Cluster-dependent: K3s GPU nodes
49+
/// need "nvidia" to route the pod to the NVIDIA container runtime, while
50+
/// managed clusters (GKE, EKS, AKS) expose GPUs through the device plugin
51+
/// with the default runtime and define no such RuntimeClass. Leave unset
52+
/// (the default) on managed clusters; setting a non-existent RuntimeClass
53+
/// makes the API server reject the pod.
54+
#[serde(default, skip_serializing_if = "Option::is_none")]
55+
pub runtime_class_name: Option<String>,
56+
/// Extra command-line arguments appended to `vllm serve <model>`, e.g.
57+
/// ["--max-model-len", "8192", "--gpu-memory-utilization", "0.90"].
58+
/// Keeps engine tuning (context length, memory fraction, quantization)
59+
/// in the resource spec rather than baked into the operator binary.
60+
#[serde(default)]
61+
pub extra_args: Vec<String>,
4862
}
4963

5064
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default, PartialEq)]

src/main.rs

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use std::time::Duration;
55
use futures::StreamExt;
66
use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec};
77
use k8s_openapi::api::core::v1::{
8-
Container, ContainerPort, EnvVar, HTTPGetAction, PodSpec, PodTemplateSpec, Probe,
9-
ResourceRequirements,
8+
Container, ContainerPort, EmptyDirVolumeSource, HTTPGetAction, PodSpec, PodTemplateSpec, Probe,
9+
ResourceRequirements, Volume, VolumeMount,
1010
};
1111
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
1212
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
@@ -65,9 +65,15 @@ fn build_deployment(svc: &VllmService) -> Result<Deployment, Error> {
6565

6666
let enforce_eager = matches!(svc.spec.warmup_strategy, WarmupStrategy::Eager);
6767

68+
// A request for GPUs is the signal that this is a real vLLM workload
69+
// rather than the inert CI placeholder (gpu=0, pause image). Only then
70+
// do we inject the serving command, the GPU limit, the shared-memory
71+
// volume and any tuning args; the placeholder stays a do-nothing pod.
72+
let serving = svc.spec.gpu > 0;
73+
6874
// GPU resource limit, only when requested. With gpu=0 (CI / CPU-only)
6975
// the container requests no GPU and schedules on any node.
70-
let resources = if svc.spec.gpu > 0 {
76+
let resources = if serving {
7177
let mut limits = BTreeMap::new();
7278
limits.insert(
7379
"nvidia.com/gpu".to_string(),
@@ -81,6 +87,54 @@ fn build_deployment(svc: &VllmService) -> Result<Deployment, Error> {
8187
None
8288
};
8389

90+
// Invocation. The vllm/vllm-openai image's entrypoint is `vllm serve`,
91+
// but we set command+args explicitly so the operator does not depend on
92+
// the image's entrypoint staying stable across tags. `vllm serve` takes
93+
// the model as a positional argument; warmupStrategy maps to
94+
// --enforce-eager (CUDA graphs off => faster cold start); extraArgs
95+
// carries per-deployment engine tuning (e.g. --max-model-len,
96+
// --gpu-memory-utilization). The placeholder keeps the pause entrypoint.
97+
let (command, args) = if serving {
98+
let mut a = vec![
99+
svc.spec.model.clone(),
100+
"--host".to_string(),
101+
"0.0.0.0".to_string(),
102+
"--port".to_string(),
103+
"8000".to_string(),
104+
];
105+
if enforce_eager {
106+
a.push("--enforce-eager".to_string());
107+
}
108+
a.extend(svc.spec.extra_args.iter().cloned());
109+
(Some(vec!["vllm".to_string(), "serve".to_string()]), Some(a))
110+
} else {
111+
(None, None)
112+
};
113+
114+
// vLLM uses /dev/shm for intra-process tensor transfer; the container
115+
// runtime's default 64MiB is too small and causes hard-to-diagnose
116+
// crashes under load. Back it with a memory-medium emptyDir on real
117+
// serving pods. The placeholder needs none.
118+
let (volumes, volume_mounts) = if serving {
119+
(
120+
Some(vec![Volume {
121+
name: "dshm".to_string(),
122+
empty_dir: Some(EmptyDirVolumeSource {
123+
medium: Some("Memory".to_string()),
124+
..Default::default()
125+
}),
126+
..Default::default()
127+
}]),
128+
Some(vec![VolumeMount {
129+
name: "dshm".to_string(),
130+
mount_path: "/dev/shm".to_string(),
131+
..Default::default()
132+
}]),
133+
)
134+
} else {
135+
(None, None)
136+
};
137+
84138
// Readiness probe, built only when health_path is non-empty. This is
85139
// what makes "Ready" mean "warm": when set, Kubernetes marks the pod
86140
// ready only once the server answers on this endpoint, so the
@@ -104,45 +158,33 @@ fn build_deployment(svc: &VllmService) -> Result<Deployment, Error> {
104158
let container = Container {
105159
name: "inference".to_string(),
106160
image: Some(svc.spec.image.clone()),
161+
command,
162+
args,
107163
ports: Some(vec![ContainerPort {
108164
container_port: 8000,
109165
name: Some("http".to_string()),
110166
..Default::default()
111167
}]),
112-
env: Some(vec![
113-
EnvVar {
114-
name: "VLLM_MODEL".to_string(),
115-
value: Some(svc.spec.model.clone()),
116-
..Default::default()
117-
},
118-
EnvVar {
119-
name: "VLLM_ENFORCE_EAGER".to_string(),
120-
value: Some(enforce_eager.to_string()),
121-
..Default::default()
122-
},
123-
]),
124168
resources,
125169
readiness_probe,
170+
volume_mounts,
126171
..Default::default()
127172
};
128173

129-
// On K3s the default runtime is runc; a pod needs the "nvidia"
130-
// RuntimeClass to actually get GPU access. Request it only when GPUs
131-
// are requested, so gpu=0 (CI / CPU) pods schedule with the default
132-
// runtime and need no RuntimeClass installed on the cluster.
133-
let runtime_class_name = if svc.spec.gpu > 0 {
134-
Some("nvidia".to_string())
135-
} else {
136-
None
137-
};
174+
// RuntimeClass is cluster-dependent and comes from the spec (default
175+
// None). Managed clusters (GKE/EKS/AKS) expose GPUs via the device
176+
// plugin with the default runtime and define no "nvidia" RuntimeClass;
177+
// setting a non-existent one makes the API server reject the pod. K3s
178+
// GPU nodes set it to "nvidia".
138179
let template = PodTemplateSpec {
139180
metadata: Some(ObjectMeta {
140181
labels: Some(labels.clone()),
141182
..Default::default()
142183
}),
143184
spec: Some(PodSpec {
144185
containers: vec![container],
145-
runtime_class_name,
186+
runtime_class_name: svc.spec.runtime_class_name.clone(),
187+
volumes,
146188
..Default::default()
147189
}),
148190
};

0 commit comments

Comments
 (0)