LLMKube (LLM Serving)
LLMKube is the platform's paved-road runtime for self-hosted LLM inference on GPU nodes. It's a Kubernetes operator: you declare a Model (where the weights come from) and an InferenceService (how to serve them), and it handles GPU scheduling, health checks, and an OpenAI-compatible API. This page takes a pilot team from "operator is installed" to "my model is answering requests."
For what an Installable / Installation does at the platform layer, see Installation. This page assumes the operator is already installed on your cluster (below).
The platform packages the open-source defilantech/llmkube chart as the llmkube Installable, pinned to a specific version. That project owns the CRD schema, the serving runtimes (vLLM, llama.cpp), and the model-source behavior. The platform's value-add is the version-pinned Installable, the model-cache defaults, and the per-cluster Installation wiring. CRD-field and runtime questions belong upstream — see llmkube's config/samples/ and docs/.
Pick your path
There are two serving runtimes with different strengths. Pick by model format and what you're optimizing for — both are viable for production; this isn't a prod-vs-dev split:
| vLLM (safetensors) | llama.cpp (GGUF) | |
|---|---|---|
| Strong at | High-concurrency serving (continuous batching, PagedAttention), tensor-parallel multi-GPU, broad safetensors/AWQ/GPTQ support | Aggressive & flexible quantization (custom GGUF quants and KV-cache types, e.g. TurboQuant), low memory footprint, strong single-GPU / CPU / mixed serving |
| Weights via | Operator download from a pinned HF repo (hf://…@rev + files), or pvc:// pre-staged | Operator download from a single-file GGUF URL → SHA256 model cache |
Model cache (modelCache) | Used — the operator stages the repo's files into the cache, reused across restarts | Used — download-once, reused across restarts |
files listFor a multi-file safetensors repo, point source at a pinned HF repo (hf://<org>/<repo>@<rev>) and list the artifacts in spec.files (weights + config + tokenizer). The files list is what tells the operator this is a repo to stage — omit it and source is treated as a single file. See the vLLM + hf:// tab below for the full shape. pvc:// (pre-staged) and skipModelInit (vLLM pulls at startup) are the alternatives.
Prerequisites
- Operator installed. The
llmkubeInstallable is enabled on your cluster (see Enable the operator). Confirm:kubectl -n llmkube-system get pods(controller-manager1/1). - A GPU node with the
nvidiaRuntimeClass. Serving pods setruntimeClassName: nvidia— required, sincenvidiaisn't the default container runtime. The RuntimeClass is created for you on the cloud GPU node by the gpu-operator's container-toolkit; on a self-managed / off-cloud cluster it comes from your NVIDIA container-runtime install. Pods requestresources.gpu, which maps tonvidia.com/gpu, and land on the cluster's KarpentergpuNodePool. On Azure this is validated end-to-end: the platformgpupool provisions aStandard_NC4as_T4_v3(in-quota T4) on demand, the NVIDIA driver ships pre-installed in the Azure GPU node image (the gpu-operator detects it and stands its own driver DaemonSet down), and the device plugin advertisesnvidia.com/gpu. A cluster without GPU quota leaves serving podsPendinguntil quota is granted. - A HuggingFace token for gated models / the staging step. Create it directly —
kubectl create secret generic hf-token --from-literal=HF_TOKEN=<token> -n <ns>. For anything long-lived, prefer syncing it from your cloud secret store rather than a hand-created secret — see External Secrets for the general pattern (the sameSecretname is what theModel/ staging step consumes).
Enable the operator
Cluster enablement happens in the org's .platform repo. Create installations/llmkube.yaml:
apiVersion: p6m.dev/v1alpha1
kind: Installation
metadata:
name: llmkube
spec:
cd:
enabled: true
autoPromote: true
installableRef:
kind: Installable
name: llmkube
namespace: installables
destinations:
- clusterRef:
name: <your-cluster-name>
Find the cluster's clusterRef.name via grep clusterRef installations/*.yaml in the same repo. Push and merge; ArgoCD reconciles the operator into llmkube-system.
Serve a model (copy-paste → running)
The chart's default vLLM image can require a newer CUDA driver than your GPU nodes ship (e.g. the driver baked into the Azure GPU node image). If a vLLM pod crash-loops at startup with "the NVIDIA driver on your system is too old," pin spec.image to a build matching the node's CUDA — see Gotchas. llama.cpp/GGUF is unaffected.
- vLLM + hf:// (operator download)
- vLLM + pvc:// (air-gapped / pre-staged)
- vLLM + HF (quick, ephemeral)
- llama.cpp (GGUF, operator-cached)
Point the Model at a pinned HuggingFace safetensors repo and list the artifacts in spec.files — the operator stages them into the model cache (download-once, reused across restarts). This is the default for vLLM.
apiVersion: inference.llmkube.dev/v1alpha1
kind: Model
metadata:
name: my-model # FILL ME
namespace: <your-namespace> # FILL ME
spec:
source: hf://<org>/<repo>@<rev> # FILL ME — pin an IMMUTABLE ref: a commit SHA or release tag, not a moving branch like main
format: safetensors
files: # FILL ME — the repo artifacts to stage
- model-00001-of-00002.safetensors # weight shards
- model-00002-of-00002.safetensors
- model.safetensors.index.json
- config.json # + config / tokenizer files
- generation_config.json
- tokenizer.json
- tokenizer_config.json
- vocab.json
- merges.txt
hardware:
accelerator: cuda
gpu: { enabled: true, count: 1, vendor: nvidia }
---
apiVersion: inference.llmkube.dev/v1alpha1
kind: InferenceService
metadata:
name: my-model-svc # FILL ME
namespace: <your-namespace> # FILL ME
spec:
modelRef: my-model
replicas: 1
runtime: vllm
runtimeClassName: nvidia # REQUIRED — nvidia is not the default container runtime
# image omitted → the chart's default vLLM image (override with spec.image)
vllmConfig: # vLLM tunables live HERE, not top-level
maxModelLen: 8192 # FILL ME — fit to VRAM
gpuMemoryUtilization: 0.90
enablePrefixCaching: true
# gated repos: hfTokenSecretRef: { name: hf-token, key: HF_TOKEN }
extraArgs:
- "--served-model-name" # else the served id defaults to the staged weights path
- "my-model" # clients then pass "my-model" as the request "model"
endpoint: { port: 8000, type: ClusterIP }
resources: { gpu: 1, cpu: "2", memory: "8Gi" }
The files list is required for a repo source — without it the operator treats source as a single file (and a raw hf:// URL will fail to download). The operator stages the listed files into the cache and starts vLLM against the staged directory.
Step 1 — stage the weights into a PVC (once). Download the repo into a volume the Model can mount, using a one-shot Job that mounts the target PVC and runs hf download:
# inside the staging Job, writing to the mounted PVC at /model-source
hf download <org>/<model> --local-dir /model-source/<model-dir>
Step 2 — apply the Model + InferenceService:
apiVersion: inference.llmkube.dev/v1alpha1
kind: Model
metadata:
name: my-model # FILL ME
namespace: <your-namespace> # FILL ME
spec:
source: pvc://<claim>/qwen2.5-coder-7b-awq # FILL ME — pre-staged dir in the PVC
format: safetensors
hardware:
accelerator: cuda
gpu:
enabled: true
count: 1
vendor: nvidia
---
apiVersion: inference.llmkube.dev/v1alpha1
kind: InferenceService
metadata:
name: my-model-svc # FILL ME
namespace: <your-namespace> # FILL ME
spec:
modelRef: my-model
replicas: 1
runtime: vllm
runtimeClassName: nvidia # REQUIRED — nvidia is not the default container runtime
# image omitted → the chart's default vLLM image (override with spec.image)
vllmConfig: # vLLM tunables live HERE, not top-level
maxModelLen: 8192 # FILL ME — fit to VRAM
gpuMemoryUtilization: 0.90 # LOWER if the GPU has co-tenants (see gotchas)
enablePrefixCaching: true
extraArgs:
- "--served-model-name" # with pvc://, the model id defaults to the mount path
- "my-model" # set a friendly name clients pass as "model"
# Do NOT add --enforce-eager here: it's a dev/iteration convenience (~1-min
# restarts) at a real throughput cost. Leave CUDA-graph capture on for prod.
endpoint:
port: 8000
type: ClusterIP # ClusterIP | NodePort | LoadBalancer
resources:
gpu: 1
cpu: "2"
memory: "8Gi"
The operator mounts the PVC read-only at /model-source and starts vLLM with --model /model-source/<dir> — Model goes Ready with no download, and it survives restarts.
Zero staging, but vLLM pulls the weights from HuggingFace at pod startup into ephemeral storage — re-downloaded on every restart. Use for a one-off try, not steady serving.
apiVersion: inference.llmkube.dev/v1alpha1
kind: Model
metadata:
name: my-model
namespace: <your-namespace>
spec:
source: "Qwen/Qwen2.5-Coder-7B-Instruct-AWQ" # bare HF repo id
format: safetensors
hardware:
accelerator: cuda
gpu: { enabled: true, count: 1, vendor: nvidia }
---
apiVersion: inference.llmkube.dev/v1alpha1
kind: InferenceService
metadata:
name: my-model-svc
namespace: <your-namespace>
spec:
modelRef: my-model
runtime: vllm
runtimeClassName: nvidia
skipModelInit: true # vLLM fetches from HF at startup (bypasses the model cache)
vllmConfig:
maxModelLen: 8192
gpuMemoryUtilization: 0.90
hfTokenSecretRef: { name: hf-token, key: HF_TOKEN } # for gated repos
endpoint: { port: 8000, type: ClusterIP }
resources: { gpu: 1, cpu: "2", memory: "8Gi" }
Single-file GGUF from a URL — the operator downloads it into the SHA256-keyed model cache (this is the path the modelCache defaults are tuned for) and reuses it across restarts. Good for single-GPU dev and lightweight serving; this path is validated end-to-end on a cloud T4.
apiVersion: inference.llmkube.dev/v1alpha1
kind: Model
metadata:
name: my-model
namespace: <your-namespace>
spec:
# FILL ME — direct URL to a .gguf artifact
source: https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q8_0.gguf
format: gguf
quantization: Q8_0
# Pin the artifact hash so the operator VERIFIES the download. The cache
# otherwise only checks a file EXISTS — an interrupted download leaves a
# truncated GGUF that gets reused (llama.cpp then dies "model is corrupted
# or incomplete"). If that happens, clear the cache PVC and re-pull.
sha256: <FILL ME — sha256 of the .gguf>
hardware:
accelerator: cuda
gpu: { enabled: true, count: 1, vendor: nvidia, layers: -1 } # -1 = offload all layers
resources: { cpu: "2", memory: "4Gi" }
---
apiVersion: inference.llmkube.dev/v1alpha1
kind: InferenceService
metadata:
name: my-model-svc
namespace: <your-namespace>
spec:
modelRef: my-model
runtimeClassName: nvidia
# image omitted → the chart's default llama.cpp image (override with spec.image)
contextSize: 8192 # llama.cpp field (top-level)
flashAttention: true # llama.cpp; Ampere+ (sm_80). Verify it reaches the args (see gotchas)
jinja: true # tool/function-calling templates
endpoint: { port: 8080, path: /v1/chat/completions, type: ClusterIP }
resources: { gpu: 1, cpu: "2", memory: "4Gi", gpuMemory: "8Gi" }
Apply the Model first, then the InferenceService:
kubectl apply -f model.yaml -n <your-namespace>
kubectl apply -f inferenceservice.yaml -n <your-namespace>
Verify
Wait for both to reach Ready:
kubectl -n <your-namespace> get model my-model -w
kubectl -n <your-namespace> get inferenceservice my-model-svc -w
Port-forward and hit the OpenAI-compatible endpoint (vLLM on 8000, llama.cpp on 8080). The model field must match the served model name (the --served-model-name you set, or the HF id):
kubectl -n <your-namespace> port-forward svc/my-model-svc 8000:8000
curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "my-model", "messages": [{"role": "user", "content": "What is 2+2?"}], "max_tokens": 64}'
Metrics & observability
Each serving pod exposes Prometheus metrics on its API port at /metrics — throughput (tokens/s), per-token latency, and request load (llamacpp:* for llama.cpp, vllm:* for vLLM). To have the platform's metrics agent scrape them, add scrape annotations to the pods via the InferenceService:
spec:
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "8000" # MUST equal your endpoint.port (8080 for the llama.cpp example)
Set prometheus.io/port explicitly and match it to your endpoint.port (8000 in the vLLM examples, 8080 in the llama.cpp example): the metrics live on the API container port, whose port name (http) isn't one the agent matches by name. Once scraped, the metrics land in the platform's metrics store and the LLMKube Inference Grafana dashboard visualizes them next to the GPU / DCGM hardware dashboard.
Emitting these annotations automatically from the operator (so no per-service block is needed) is planned. Until then, add the block above to each InferenceService you want scraped.
GPU sharing
By default each InferenceService gets exclusive use of a GPU — the right choice for latency-sensitive serving: predictable latency and the full VRAM budget. For dev or experimentation where you want to pack several small workloads onto one GPU, the platform offers an opt-in time-sliced GPU pool. Time-slicing gives no memory or compute isolation between co-tenants, so keep latency-sensitive serving on exclusive GPUs and reserve sharing for low-stakes, bin-packed work.
Two config layers
Keep two kinds of setting separate — it decides where a value belongs:
- Layer 1 — platform-wide (the Installable). Operator version, default vLLM image,
modelCachemode/size, DCGM / observability. Platform-managed defaults that every deployment on the cluster inherits — not touched per model. - Layer 2 — per-model serving knobs (this template). Everything under
vllmConfig(maxModelLen,gpuMemoryUtilization,enablePrefixCaching,speculative,dtype,kvCacheDtype), plusextraArgs,runtimeClassName,resources. These vary per model/workload — a pilot picks them perInferenceService.
Rule of thumb: same for every model on the platform → Layer 1; varies per model → Layer 2 (here).
Starter profiles
Two tuned Layer-2 starting points — copy the vllmConfig block matching your workload. These are sensible starting values; treat the magnitudes as directional and re-measure on your hardware — the shape transfers.
Chatbot — many short requests sharing a system prompt:
vllmConfig:
maxModelLen: 8192
gpuMemoryUtilization: 0.90
enablePrefixCaching: true # ON — shared system prompts reuse KV → lower TTFT
enableChunkedPrefill: true # on by default in vLLM V1; keeps prefill from stalling decode
Coding agent — longer contexts, tool-call / multi-turn traffic:
vllmConfig:
maxModelLen: 16384 # room for code + tool schemas
gpuMemoryUtilization: 0.90
enablePrefixCaching: true
# n-gram speculative decoding helps at LOW concurrency but goes net-NEGATIVE
# once the batch saturates — enable only for bursty / low-QPS traffic, and see
# upstream for the exact vllmConfig.speculative shape. Measure before shipping.
Do not ship --enforce-eager in a production profile — it's a dev/iteration convenience (fast restarts) at a real throughput cost; leave CUDA-graph capture on for serving.
Gotchas
runtimeClassName: nvidiais mandatory — nvidia isn't the default container runtime. Omit it and the pod schedules but can't see the GPU.- The model cache backs both operator-download paths — GGUF (single file) and vLLM
hf://(multi-file staging) both land in it and are reused across restarts. Onlypvc://(pre-staged) andskipModelInit(ephemeral, re-downloads every restart) bypass the cache. skipModelInitre-downloads the full weights on every restart into ephemeral storage. Preferpvc://for anything you'll restart.- The default StorageClass differs by environment (e.g. AKS
managed-csivs. a locallocal-path). TheperServicecache logic is the same across them (RWO, WaitForFirstConsumer); only the class name and backing differ. PendingGGUF-cache PVC is normal —perService+ WaitForFirstConsumer means it binds only once the serving pod schedules onto a GPU node.- VRAM sizing is soft.
gpuMemoryUtilizationis a fraction of total VRAM, and it's not a hard cap — vLLM can overshoot the target, and CUDA-graph capture allocates after the startup memory check, so a check that passes can still OOM. Checknvidia-smifor other GPU processes first: anything already resident eats into your budget. A clean GPU tolerates a high fraction (e.g.0.90); leave more headroom when the GPU has other residents. Crash-loops can leave transient un-reclaimed VRAM that cascades the next OOM. --enforce-eagergives ~1-minute restarts by skipping the torch.compile / CUDA-graph capture spike — handy while iterating, at a small peak-throughput cost. Leave it off for production.--served-model-namewithpvc://. The default model id becomes the mount path (/model-source/<dir>); set a friendly name so clients pass a sane"model".flashAttention/contextSize/jinjaare llama.cpp (top-level) fields. vLLM uses its own attention backend (default FlashAttention on sm_80+) viavllmConfig.attentionBackend— don't set the top-levelflashAttentionfor a vLLM service.flashAttentiondoesn't always surface in the args. Setting ittrueon llama.cpp may not visibly reach the rendered startup command (no error, just noflash_attnline). Check the llama.cpp command before relying on it.- The GGUF cache has no integrity check. "Already cached, skipping download" tests file existence, not size/hash — an interrupted download leaves a truncated GGUF that gets reused (
model is corrupted or incomplete). SetModel.spec.sha256to force verification, or clear the cache PVC after a failed pull. - Quantize on small cards. An 8B model in fp16 won't fit ~12 GB — use AWQ/GPTQ/GGUF. For reference, a 7B AWQ model (~5 GB) fits comfortably at
0.90on a 12–16 GB GPU. - The first vLLM image pull is large (several GB) — one-time per node.
- vLLM image ↔ GPU driver CUDA compatibility. A recent vLLM image can require a newer CUDA driver than the cloud GPU node ships — on Azure the GPU node image bakes in an older driver, and vLLM then dies at engine init with "the NVIDIA driver on your system is too old". If you hit this, pin
spec.imageto a vLLM build matching the node's CUDA (e.g. a CUDA-12.4 image on a 12.4 driver). llama.cpp/GGUF is unaffected. (The platform-side fix is a driverless GPU node so the operator installs a current driver — ask the platform team.) - Older vLLM images bind IPv6 only. Some older vLLM builds start on
[::]and the IPv4 readiness probe getsconnection refused, so the pod never goesReadyeven though vLLM is up. AddextraArgs: ["--host", "0.0.0.0"]. - vLLM's served model name defaults to the weights path (e.g.
/models/<hash>). Either pass that exact string as the requestmodel, or setextraArgs: ["--served-model-name", "my-model"]for a friendly name.