Skip to content

Commit 9599293

Browse files
committed
feat: real vLLM data plane with GPU scheduling and readiness-gated warmup
The data plane was previously a pause placeholder so the control plane could be tested without a GPU. Make it real while keeping CI GPU-free: - spec: add 'image' (default vllm/vllm-openai:latest), 'gpu' (default 1), and 'healthPath' (default /health; empty string disables the probe) - build_deployment: use the spec image, request nvidia.com/gpu when gpu>0, and add a readiness probe on healthPath when it is non-empty - the readiness probe is what makes 'Ready' mean 'warm': ready_replicas reflects serving capacity, so Warming->Ready tracks the real cold start - examples: qwen-7b.yaml is an honest GPU deployment; ci-placeholder.yaml (gpu=0, pause image, empty healthPath) is the control-plane e2e fixture - ci: run the e2e test against the placeholder fixture phase_for is unchanged: the abstraction was already correct, the probe just supplies the real signal underneath it.
1 parent 411b984 commit 9599293

6 files changed

Lines changed: 118 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,12 @@ jobs:
8383
echo "operator started (pid $(cat operator.pid))"
8484
8585
- name: Apply a VllmService
86-
run: kubectl apply -f deploy/examples/qwen-7b.yaml
86+
run: kubectl apply -f deploy/examples/ci-placeholder.yaml
8787

8888
- name: Wait for Deployment to be created
8989
run: |
9090
for i in $(seq 1 30); do
91-
if kubectl get deployment qwen-7b >/dev/null 2>&1; then
91+
if kubectl get deployment ci-placeholder >/dev/null 2>&1; then
9292
echo "Deployment created after ${i}s"
9393
exit 0
9494
fi
@@ -102,27 +102,27 @@ jobs:
102102
- name: Wait for status to reach Ready
103103
run: |
104104
for i in $(seq 1 60); do
105-
phase=$(kubectl get vllmservice qwen-7b -o jsonpath='{.status.phase}' 2>/dev/null || true)
105+
phase=$(kubectl get vllmservice ci-placeholder -o jsonpath='{.status.phase}' 2>/dev/null || true)
106106
echo "[$i] phase=${phase:-<none>}"
107107
if [ "$phase" = "Ready" ]; then
108108
echo "VllmService reached Ready after ${i}s"
109-
kubectl get vllmservice qwen-7b -o jsonpath='{.status.message}'
109+
kubectl get vllmservice ci-placeholder -o jsonpath='{.status.message}'
110110
exit 0
111111
fi
112112
sleep 2
113113
done
114114
echo "ERROR: VllmService did not reach Ready within 120s"
115-
kubectl get vllmservice qwen-7b -o yaml
115+
kubectl get vllmservice ci-placeholder -o yaml
116116
cat operator.log
117117
exit 1
118118
119119
- name: Verify owner reference and garbage collection
120120
run: |
121-
owner=$(kubectl get deployment qwen-7b -o jsonpath='{.metadata.ownerReferences[0].kind}')
121+
owner=$(kubectl get deployment ci-placeholder -o jsonpath='{.metadata.ownerReferences[0].kind}')
122122
[ "$owner" = "VllmService" ] || { echo "ERROR: bad owner ref: $owner"; exit 1; }
123-
kubectl delete vllmservice qwen-7b
123+
kubectl delete vllmservice ci-placeholder
124124
for i in $(seq 1 30); do
125-
if ! kubectl get deployment qwen-7b >/dev/null 2>&1; then
125+
if ! kubectl get deployment ci-placeholder >/dev/null 2>&1; then
126126
echo "Deployment garbage-collected after ${i}s"
127127
exit 0
128128
fi

deploy/crd.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ spec:
2828
treats time-to-first-token as a first-class concern, configuring the
2929
pod for the cold-start/throughput trade-off the spec asks for.
3030
properties:
31+
gpu:
32+
default: 1
33+
description: |-
34+
Number of GPUs to request per replica. Set to 0 for CPU-only or
35+
placeholder runs (e.g. CI on a cluster without GPUs).
36+
format: int32
37+
type: integer
38+
healthPath:
39+
default: /health
40+
description: |-
41+
HTTP path for the readiness probe, e.g. "/health". When non-empty,
42+
the operator gates readiness (and thus the Warming->Ready transition)
43+
on this endpoint, so "Ready" means the server can actually serve.
44+
Set to an empty string to disable the probe entirely, for inert
45+
placeholder images that expose no HTTP health endpoint.
46+
type: string
47+
image:
48+
default: vllm/vllm-openai:latest
49+
description: Container image to run. Defaults to the official vLLM OpenAI server.
50+
type: string
3151
model:
3252
description: HuggingFace model id to serve, e.g. "Qwen/Qwen2.5-7B-Instruct".
3353
type: string
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Control-plane end-to-end test fixture (not a usage example).
2+
# Uses a placeholder image, gpu=0, and an empty health path so the reconcile
3+
# loop, status machine, owner references, and garbage collection can be
4+
# exercised on a GPU-less kind cluster in CI. The pause image exposes no
5+
# HTTP endpoint, so healthPath is "": readiness is not gated and the pod
6+
# becomes Ready as soon as it runs. For a real deployment see qwen-7b.yaml.
7+
apiVersion: inference.michelecampi.dev/v1alpha1
8+
kind: VllmService
9+
metadata:
10+
name: ci-placeholder
11+
namespace: default
12+
spec:
13+
model: "placeholder/ci-test"
14+
replicas: 1
15+
warmupStrategy: Eager
16+
image: "registry.k8s.io/pause:3.10"
17+
gpu: 0
18+
healthPath: ""

deploy/examples/qwen-7b.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Example: serve Qwen2.5-7B on a GPU node with the official vLLM image.
2+
# Requires a cluster with an NVIDIA GPU and the device plugin installed.
13
apiVersion: inference.michelecampi.dev/v1alpha1
24
kind: VllmService
35
metadata:
@@ -7,3 +9,5 @@ spec:
79
model: "Qwen/Qwen2.5-7B-Instruct"
810
replicas: 1
911
warmupStrategy: Eager
12+
image: "vllm/vllm-openai:latest"
13+
gpu: 1

src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ pub struct VllmServiceSpec {
2929
/// them for faster steady-state inference at a higher cold-start cost.
3030
#[serde(default)]
3131
pub warmup_strategy: WarmupStrategy,
32+
/// Container image to run. Defaults to the official vLLM OpenAI server.
33+
#[serde(default = "default_image")]
34+
pub image: String,
35+
/// Number of GPUs to request per replica. Set to 0 for CPU-only or
36+
/// placeholder runs (e.g. CI on a cluster without GPUs).
37+
#[serde(default = "default_gpu")]
38+
pub gpu: i32,
39+
/// HTTP path for the readiness probe, e.g. "/health". When non-empty,
40+
/// the operator gates readiness (and thus the Warming->Ready transition)
41+
/// on this endpoint, so "Ready" means the server can actually serve.
42+
/// Set to an empty string to disable the probe entirely, for inert
43+
/// placeholder images that expose no HTTP health endpoint.
44+
#[serde(default = "default_health_path")]
45+
pub health_path: String,
3246
}
3347

3448
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default, PartialEq)]
@@ -43,6 +57,15 @@ pub enum WarmupStrategy {
4357
fn default_replicas() -> i32 {
4458
1
4559
}
60+
fn default_image() -> String {
61+
"vllm/vllm-openai:latest".to_string()
62+
}
63+
fn default_gpu() -> i32 {
64+
1
65+
}
66+
fn default_health_path() -> String {
67+
"/health".to_string()
68+
}
4669

4770
/// Observed state, written back by the operator.
4871
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]

src/main.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ use std::time::Duration;
44

55
use futures::StreamExt;
66
use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec};
7-
use k8s_openapi::api::core::v1::{Container, ContainerPort, EnvVar, PodSpec, PodTemplateSpec};
7+
use k8s_openapi::api::core::v1::{
8+
Container, ContainerPort, EnvVar, HTTPGetAction, PodSpec, PodTemplateSpec, Probe,
9+
ResourceRequirements,
10+
};
11+
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
812
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
13+
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
914
use kube::{
1015
api::{Api, ObjectMeta, Patch, PatchParams},
1116
runtime::{
@@ -47,9 +52,45 @@ fn build_deployment(svc: &VllmService) -> Result<Deployment, Error> {
4752

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

55+
// GPU resource limit, only when requested. With gpu=0 (CI / CPU-only)
56+
// the container requests no GPU and schedules on any node.
57+
let resources = if svc.spec.gpu > 0 {
58+
let mut limits = BTreeMap::new();
59+
limits.insert(
60+
"nvidia.com/gpu".to_string(),
61+
Quantity(svc.spec.gpu.to_string()),
62+
);
63+
Some(ResourceRequirements {
64+
limits: Some(limits),
65+
..Default::default()
66+
})
67+
} else {
68+
None
69+
};
70+
71+
// Readiness probe, built only when health_path is non-empty. This is
72+
// what makes "Ready" mean "warm": when set, Kubernetes marks the pod
73+
// ready only once the server answers on this endpoint, so the
74+
// Warming->Ready transition tracks the real cold start. Inert
75+
// placeholder images set health_path to "" to disable the probe.
76+
let readiness_probe = if svc.spec.health_path.is_empty() {
77+
None
78+
} else {
79+
Some(Probe {
80+
http_get: Some(HTTPGetAction {
81+
path: Some(svc.spec.health_path.clone()),
82+
port: IntOrString::Int(8000),
83+
..Default::default()
84+
}),
85+
initial_delay_seconds: Some(5),
86+
period_seconds: Some(5),
87+
failure_threshold: Some(60),
88+
..Default::default()
89+
})
90+
};
5091
let container = Container {
5192
name: "inference".to_string(),
52-
image: Some("registry.k8s.io/pause:3.10".to_string()),
93+
image: Some(svc.spec.image.clone()),
5394
ports: Some(vec![ContainerPort {
5495
container_port: 8000,
5596
name: Some("http".to_string()),
@@ -67,6 +108,8 @@ fn build_deployment(svc: &VllmService) -> Result<Deployment, Error> {
67108
..Default::default()
68109
},
69110
]),
111+
resources,
112+
readiness_probe,
70113
..Default::default()
71114
};
72115

0 commit comments

Comments
 (0)