AI

KServe vLLM on Kubernetes: Install, Canary, and LoRA

Two things in the KServe LLM quickstart will trip you on a fresh cluster. It assumes an NVIDIA GPU on the node, and its install page still pins the previous release’s Helm charts, because the current release’s charts were never published. Neither is a reason to skip KServe. Both are reasons to follow a path that was actually run.

Original content from computingforgeeks.com - post 171984

This KServe vLLM guide runs the stack on Kubernetes end to end: a Standard mode install behind Envoy Gateway, a vLLM InferenceService with an OpenAI-compatible endpoint, a weighted canary rollout between two vLLM builds, promotion, and a LoRA adapter served next to the base model. It is CPU only, so any lab cluster works, and the places where a GPU changes the picture are called out. If you have not decided between plain vLLM and a scheduler layer yet, vLLM vs llm-d covers that decision first. Every command below ran on a single-node k3s cluster (Ubuntu 26.04, 6 vCPU, 16 GB RAM) against KServe 0.21.0 in September 2026.

What the cluster needs

KServe Standard mode wants Kubernetes 1.32 or newer, cert-manager for its webhook certificates, and a Gateway API implementation for ingress. Knative and Istio are not required in Standard mode, which is the mode KServe itself recommends for generative workloads. The lab here was a k3s server on Ubuntu 26.04 started with --disable traefik, since Envoy Gateway takes over ingress.

ComponentVersion testedRole
k3sv1.37.0+k3s1Kubernetes, ships Gateway API CRDs v1.6.1
Helmv4.3.0cert-manager and Envoy Gateway charts
cert-managerv1.21.2KServe webhook certificates
Envoy Gatewayv1.9.1GatewayClass for the KServe Gateway
KServev0.21.0InferenceService controller, runtimes
ModelQwen/Qwen3-0.6BSmall enough to serve on CPU

Sizing is driven by the model, not by KServe. KServe, Envoy Gateway and cert-manager together idled at about 430 MiB on the lab. A 0.6B model on CPU needs roughly 5 GiB per replica once vLLM reserves its KV cache, and a canary doubles that for as long as both versions run. For a real 7B to 8B model, plan one 24 GB GPU per replica and move the resource requests to nvidia.com/gpu.

Install cert-manager and Envoy Gateway

cert-manager goes in first, from its OCI chart, with the CRDs managed by the chart. Our cert-manager install guide covers issuers if you also want TLS on the Gateway later. The charts below were installed with Helm 4; Helm 3 takes the same flags, and the Helm 3 to Helm 4 migration notes list what changed:

helm install cert-manager oci://quay.io/jetstack/charts/cert-manager --version v1.21.2 \
  --namespace cert-manager --create-namespace --set crds.enabled=true --wait #https://github.com/cert-manager/cert-manager/releases

The Envoy Gateway step is where k3s differs from a kubeadm cluster. Recent k3s releases install the standard channel Gateway API CRDs as a bundled Helm addon and protect them with a safe-upgrades ValidatingAdmissionPolicy. The Envoy Gateway chart tries to lay its experimental channel copies on top and every CRD gets rejected (the exact error is in the error index at the end). Envoy Gateway runs fine on the standard channel, so install only Envoy’s own CRDs. That CRD chart is also too big to live in a Helm release Secret, which is why it goes through helm template:

helm template eg-crds oci://docker.io/envoyproxy/gateway-crds-helm --version v1.9.1 \
  --set crds.envoyGateway.enabled=true --set crds.gatewayAPI.enabled=false \
  | kubectl apply --server-side -f - #https://github.com/envoyproxy/gateway/releases

Then the controller itself, with --skip-crds so the chart leaves the Gateway API CRDs alone:

helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.9.1 \
  -n envoy-gateway-system --create-namespace --skip-crds --wait --timeout 5m #https://github.com/envoyproxy/gateway/releases

On a cluster without bundled Gateway API CRDs, drop --skip-crds and let the chart install them. Our Gateway API migration guide explains the standard and experimental channels if you need routes beyond HTTPRoute.

Install KServe from the release manifests

The KServe docs install Standard mode with helm install kserve-crd oci://ghcr.io/kserve/charts/kserve-crd --version v0.20.0. Bump that to the current release and Helm fails with not found, because those charts were never pushed. Ask the registry directly:

TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:kserve/charts/kserve-crd:pull" | jq -r .token)
curl -s -H "Authorization: Bearer ${TOKEN}" "https://ghcr.io/v2/kserve/charts/kserve-crd/tags/list?n=1000" | jq -c '[.tags[] | select(startswith("v0.2"))]'

The tags stop at the second release candidate. kserve-resources and kserve-llmisvc-crd show the same list:

["v0.20.0-rc0","v0.20.0","v0.21.0-rc0","v0.21.0-rc1"]

The chart tarballs attached to the GitHub release carry the -rc1 name too, and their appVersion is v0.21.0-rc1 with empty image tags, so installing them deploys release candidate images. The plain YAML manifests on the same release page are the only artifacts that pin the final images. Use those.

Pin the version in one variable so the download URLs stay in sync:

export KSERVE_VERSION="v0.21.0" #https://github.com/kserve/kserve/releases
curl -sLO "https://github.com/kserve/kserve/releases/download/${KSERVE_VERSION}/kserve.yaml"
curl -sLO "https://github.com/kserve/kserve/releases/download/${KSERVE_VERSION}/kserve-cluster-resources.yaml"

kserve.yaml contains no Namespace object, so applying it on its own fails with namespaces "kserve" not found for every namespaced resource. It also bundles the CRDs with objects that use them. On the first pass kubectl skips the default ClusterStorageContainer with no matches for kind "ClusterStorageContainer" because its CRD is not registered yet, and the apply exits non-zero. Create the namespace, apply once, wait for the CRD, then apply again:

kubectl create namespace kserve
kubectl apply --server-side -f kserve.yaml
kubectl wait --for=condition=Established crd/clusterstoragecontainers.serving.kserve.io --timeout=60s
kubectl apply --server-side -f kserve.yaml
kubectl rollout status deployment kserve-controller-manager -n kserve --timeout=300s

Skip the second apply and the storage initializer fix further down fails with NotFound. With the webhook up, apply the cluster resources, which hold the 15 ClusterServingRuntimes and 13 LLMInferenceServiceConfigs:

kubectl apply --server-side -f kserve-cluster-resources.yaml

Expect exactly one rejection. The webhook refuses the bundled kserve-config-sglang-template because its template references a field the v1alpha2 API no longer has:

Error from server (Forbidden): admission webhook "llminferenceserviceconfig.kserve-webhook-server.v1alpha2.validator" denied the request: failed to merge config: template: config:1:2596: executing "config" at <.Spec.TrustRemoteCode>: can't evaluate field TrustRemoteCode in type v1alpha2.LLMInferenceServiceSpec

That only affects the SGLang path of LLMInferenceService. Every ClusterServingRuntime lands, including the standalone kserve-vllmserver that arrived in the previous release:

kubectl get clusterservingruntimes

Fifteen runtimes, with vLLM, Hugging Face and SGLang next to the classic predictive servers:

NAME                                 DISABLED   MODELTYPE     CONTAINERS         AGE
kserve-autogluonserver                          autogluon     kserve-container   5s
kserve-huggingfaceserver                        huggingface   kserve-container   5s
kserve-huggingfaceserver-multinode              huggingface   kserve-container   5s
kserve-lgbserver                                lightgbm      kserve-container   5s
kserve-llm-sglang                               sglang        main               5s
kserve-mlserver                                 sklearn       kserve-container   5s
kserve-paddleserver                             paddle        kserve-container   5s
kserve-pmmlserver                               pmml          kserve-container   5s
kserve-predictiveserver                         sklearn       kserve-container   5s
kserve-sklearnserver                            sklearn       kserve-container   5s
kserve-tensorflow-serving                       tensorflow    kserve-container   5s
kserve-torchserve                               pytorch       kserve-container   5s
kserve-tritonserver                             tensorrt      kserve-container   4s
kserve-vllmserver                               vLLM          kserve-container   4s
kserve-xgbserver                                xgboost       kserve-container   4s

Now the Gateway that KServe attaches its HTTPRoutes to. Create the manifest:

vim kserve-gateway.yaml

A GatewayClass bound to Envoy Gateway and a plain HTTP listener that accepts routes from any namespace:

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: kserve-ingress-gateway
  namespace: kserve
spec:
  gatewayClassName: envoy
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All
  infrastructure:
    labels:
      serving.kserve.io/gateway: kserve-ingress-gateway

Check that Envoy programmed an address after applying. On k3s, ServiceLB hands the Gateway the node IP:

kubectl apply -f kserve-gateway.yaml
kubectl get gateway -n kserve

PROGRAMMED True is the line that matters:

NAME                     CLASS   ADDRESS         PROGRAMMED   AGE
kserve-ingress-gateway   envoy   192.168.1.240   True         80s

The manifest still ships with defaultDeploymentMode: Serverless and Gateway API switched off. Flip both in the inferenceservice-config ConfigMap, keeping the rest of the ingress block intact:

INGRESS=$(kubectl get configmap inferenceservice-config -n kserve -o jsonpath='{.data.ingress}' | jq -c '.enableGatewayApi = true')
kubectl patch configmap inferenceservice-config -n kserve --type merge \
  -p "$(jq -n --arg i "$INGRESS" '{data:{ingress:$i, deploy:"{\"defaultDeploymentMode\":\"Standard\"}"}}')"
kubectl rollout restart deployment kserve-controller-manager -n kserve

Restart the controller after every edit to this ConfigMap so it picks up the new values. Then confirm the three controllers are healthy:

kubectl get pods -n kserve

The InferenceService controller, the local model cache controller and the LLMInferenceService controller all report Running:

NAME                                                    READY   STATUS    RESTARTS   AGE
kserve-controller-manager-848dbd98cb-v7bjw              2/2     Running   0          37s
kserve-localmodel-controller-manager-786798946c-jgfhb   1/1     Running   0          137m
llmisvc-controller-manager-658b99b88b-xfzhn             1/1     Running   0          137m

Give the storage initializer more memory

This one catches everyone who uses hf:// URIs. The init container that downloads the model runs with a 1 GiB memory limit, and pulling even a 1.5 GB Qwen3 checkpoint gets it OOMKilled (exit 137) before the predictor ever starts. The pod sits in Init:CrashLoopBackOff with a log that stops at Copying contents of hf://....

The limit you would expect to edit, storageInitializer.memoryLimit in inferenceservice-config, does nothing here. We changed it, restarted the controller, and new pods still got 1 GiB. The value that wins is on the default ClusterStorageContainer, which handles hf://, s3://, gs:// and the rest. Patch that:

kubectl patch clusterstoragecontainer default --type merge \
  -p '{"spec":{"container":{"resources":{"limits":{"memory":"4Gi"},"requests":{"memory":"1Gi"}}}}}'

No restart needed. The next pod shows {"limits":{"cpu":"1","memory":"4Gi"},"requests":{"cpu":"100m","memory":"1Gi"}} on its storage-initializer container. For gated models, the same ClusterStorageContainer is where an HF_TOKEN secret reference goes.

Deploy an LLM with the vLLM runtime

Put the models in their own namespace. KServe skips webhook injection in namespaces labeled control-plane, so never deploy into kserve or kube-system:

kubectl create namespace llm
vim qwen3.yaml

The manifest that went Ready on the lab. modelFormat: vLLM selects the standalone kserve-vllmserver runtime:

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: qwen3
  namespace: llm
spec:
  predictor:
    model:
      modelFormat:
        name: vLLM
      storageUri: hf://Qwen/Qwen3-0.6B
      args:
        - --gpu-memory-utilization=0.35
        - --max-model-len=8192
      resources:
        requests:
          cpu: "4"
          memory: 8Gi
        limits:
          cpu: "4"
          memory: 10Gi

Two details in there are CPU specific. When the pod requests no GPU, the controller rewrites the runtime image from vllm/vllm-openai to the sibling vllm/vllm-openai-cpu repository, so the same manifest works on either kind of node. And on the CPU backend, --gpu-memory-utilization is the fraction of memory reserved for weights plus KV cache. The default of 0.92 fails immediately, and on this vLLM build the fraction is taken of the whole node’s RAM, not the pod limit: 0.92 of 15.6 GiB is 14.4 GiB, which no 10 GiB pod can have. 0.35 works out to about 5.5 GiB, inside the limit. On a GPU node, drop that flag, add nvidia.com/gpu: "1" to requests and limits, and the controller keeps the GPU image (we did not run the GPU path on this lab).

Create it and watch for READY:

kubectl apply -f qwen3.yaml
kubectl get isvc qwen3 -n llm -w

Model download plus vLLM warmup took 5 minutes 39 seconds on six CPU cores. The URL comes from the {{ .Name }}-{{ .Namespace }}.{{ .IngressDomain }} template:

NAME    URL                            READY   PREV   LATEST   PREVROLLEDOUTREVISION   LATESTREADYREVISION   AGE
qwen3   http://qwen3-llm.example.com   True                                                                  5m39s

There is no DNS for example.com, so send requests to the Gateway address with a Host header. Grab the address once:

export GATEWAY_IP=$(kubectl get gateway kserve-ingress-gateway -n kserve -o jsonpath='{.status.addresses[0].value}')
echo "${GATEWAY_IP}"

Then a chat completion. This runtime is stock vLLM, so the path is /v1/chat/completions and the model name is the InferenceService name:

curl -s -H "Host: qwen3-llm.example.com" -H "Content-Type: application/json" \
  "http://${GATEWAY_IP}/v1/chat/completions" \
  -d '{"model":"qwen3","messages":[{"role":"user","content":"Give one kubectl command to list pods in all namespaces. /no_think"}],"max_tokens":120}' \
  | jq '{model, fingerprint: .system_fingerprint, answer: .choices[0].message.content, usage}'

47 tokens came back in 8.6 seconds end to end, and the fingerprint names the vLLM build that answered:

{
  "model": "qwen3",
  "fingerprint": "vllm-0.21.0-af1ed39f",
  "answer": "<think>\n\n</think>\n\nTo list all pods in all namespaces using `kubectl`, you can use the following command:\n\n```bash\nkubectl get pods --all-namespaces\n```\n\nThis command will show all the pods in every namespace.",
  "usage": {
    "prompt_tokens": 24,
    "total_tokens": 71,
    "completion_tokens": 47,
    "prompt_tokens_details": null
  }
}

Look at that fingerprint again. The runtime’s source pins vllm/vllm-openai:latest, but the release process rewrote every latest tag in the manifests to the KServe version. vllm/vllm-openai:v0.21.0 happens to exist, so nothing errors. It is a vLLM build from May 2026, nine minor releases behind the vLLM that was current when this KServe shipped. The fix is further down.

vLLM runtime or Hugging Face runtime

The KServe quickstart uses modelFormat: huggingface, which picks kserve-huggingfaceserver. That runtime also runs vLLM underneath, but a different build behind a different API prefix. We deployed the same model on both, one at a time, and recorded what differs:

kserve-vllmserverkserve-huggingfaceserver
modelFormatvLLMhuggingface
Image on a CPU podvllm/vllm-openai-cpu:v0.21.0kserve/huggingfaceserver:v0.21.0 (3.8 GB compressed)
vLLM engine inside0.21.00.24.0
Chat endpoint/v1/chat/completions/openai/v1/chat/completions
Wrong prefix returns404 on /openai/v1/...404 on /v1/...
Served model nameInferenceService name--model_name arg
Memory fraction measured againstnode RAMpod memory limit

The Hugging Face runtime needed the same two vLLM flags to start on CPU (--gpu-memory-utilization=0.6 and --max-model-len=8192, passed through args after --model_name=qwen). Without them it failed with Available memory on node 0 (4.23/6.0 GiB) on startup is less than desired CPU memory utilization (0.92, 5.52 GiB). Pick one runtime per cluster and standardize the path in your clients. The vLLM runtime is the simpler choice when all you serve is text generation, since it is plain vLLM with a KServe wrapper around the lifecycle.

Pin a current vLLM build with a ServingRuntime

The obvious knob is runtimeVersion on the InferenceService. On a CPU node it is a trap. The controller’s UpdateImageTag returns as soon as runtimeVersion is set, before the CPU rewrite runs, so runtimeVersion: v0.30.0 produced this pod:

NAME                               IMAGE                          STATUS
qwen3-predictor-7c6c64649f-4gd7k   vllm/vllm-openai-cpu:v0.21.0   Running
qwen3-predictor-fb57c577d-cxlj9    vllm/vllm-openai:v0.30.0       Pending

That is the CUDA build, a 9.2 GB compressed pull headed for a node with no GPU. Do not apply it; if you already did, remove runtimeVersion and apply the manifest again. The clean fix is a namespaced ServingRuntime that pins the image you want, trimmed from the cluster runtime with a new image and namespace. The canary below runs two replicas, so free the lab first by deleting the first InferenceService, then create a namespace for this part:

kubectl delete isvc qwen3 -n llm --wait
kubectl create namespace llm-demo
vim vllm-cpu-runtime.yaml

This is kserve-vllmserver trimmed from the cluster copy (the server-type annotation and priority are dropped, which is harmless because the image already ends in -cpu), with the image pinned to the CPU build of the current vLLM and autoSelect: false so it only serves InferenceServices that ask for it by name:

apiVersion: serving.kserve.io/v1alpha1
kind: ServingRuntime
metadata:
  name: vllm-cpu
  namespace: llm-demo
spec:
  annotations:
    prometheus.kserve.io/path: /metrics
    prometheus.kserve.io/port: '8080'
  containers:
  - args:
    - --port=8080
    - --served-model-name={{.Name}}
    - --model=/mnt/models
    command:
    - python
    - -m
    - vllm.entrypoints.openai.api_server
    env:
    - name: LMCACHE_USE_EXPERIMENTAL
      value: 'True'
    - name: HF_HOME
      value: /tmp
    - name: VLLM_CONFIG_ROOT
      value: /tmp
    - name: VLLM_WORKER_MULTIPROC_METHOD
      value: spawn
    image: vllm/vllm-openai-cpu:v0.30.0
    name: kserve-container
    readinessProbe:
      failureThreshold: 3
      httpGet:
        path: /v1/models
        port: 8080
      periodSeconds: 10
      successThreshold: 1
      timeoutSeconds: 5
    resources:
      limits:
        cpu: '1'
        memory: 2Gi
      requests:
        cpu: '1'
        memory: 2Gi
    securityContext:
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
      privileged: false
    startupProbe:
      failureThreshold: 60
      httpGet:
        path: /v1/models
        port: 8080
      initialDelaySeconds: 30
      periodSeconds: 30
      successThreshold: 1
      timeoutSeconds: 10
    volumeMounts:
    - mountPath: /dev/shm
      name: devshm
  hostIPC: false
  supportedModelFormats:
  - name: vLLM
    version: '1'
    autoSelect: false
  volumes:
  - emptyDir:
      medium: Memory
    name: devshm

The small resource block is only a default. The InferenceService’s own requests and limits override it. Apply the runtime:

kubectl apply -f vllm-cpu-runtime.yaml

One behavior change comes with the newer vLLM. It measures --gpu-memory-utilization against the pod’s memory limit instead of node RAM, so a value tuned for the old build is suddenly far too small. At 0.3 of a 7 GiB limit the engine got 2.1 GiB and died with requested memory for kv (-0.37/2.1 GiB). At 0.7 every fresh pod failed its first start (3.9 of 7.0 GiB free, the rest held by the downloaded model files) and only came up after a restart. 0.5 started first time with zero restarts in the clean run.

Run a canary rollout in Standard mode

If you reach for canaryTrafficPercent, it looks like it worked and it did not. In Standard mode that field just rolls the single Deployment in place, with no second Deployment and no weighted route. The Standard mode canary is a separate spec.canary list. Each entry needs a predictor.name, which becomes the Deployment name ({isvc}-{name}-predictor), and a trafficPercent. Set it to 0 for a dark launch.

This manifest keeps the stock runtime as stable and sends 20% to the pinned build:

vim qwen3-canary.yaml

Resources are trimmed so both replicas fit on six cores, and each side carries the memory flag that suits its vLLM build:

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: qwen3
  namespace: llm-demo
spec:
  predictor:
    model:
      modelFormat:
        name: vLLM
      storageUri: hf://Qwen/Qwen3-0.6B
      args:
      - --gpu-memory-utilization=0.3
      - --max-model-len=8192
      resources:
        requests:
          cpu: '2'
          memory: 5Gi
        limits:
          cpu: '3'
          memory: 7Gi
  canary:
  - trafficPercent: 20
    predictor:
      name: v030
      model:
        modelFormat:
          name: vLLM
        storageUri: hf://Qwen/Qwen3-0.6B
        args:
        - --gpu-memory-utilization=0.5
        - --max-model-len=8192
        resources:
          requests:
            cpu: '2'
            memory: 5Gi
          limits:
            cpu: '3'
            memory: 7Gi
        runtime: vllm-cpu

Submit it. The InferenceService turns Ready on the stable side first, then a separate CanaryPredictorReady condition follows. In the clean run that took 489 and 113 more seconds:

kubectl apply -f qwen3-canary.yaml
kubectl get isvc qwen3 -n llm-demo -o jsonpath='{.status.canaryStatuses}'
kubectl get httproute qwen3 -n llm-demo -o jsonpath='{.spec.rules[0].backendRefs}'

KServe writes the split straight into the HTTPRoute as weighted backends, and it only adds the canary backend once the canary is ready:

[{"modelStatus":{"transitionStatus":""},"name":"v030","ready":true,"trafficPercent":20}]
[{"group":"","kind":"Service","name":"qwen3-predictor","namespace":"llm-demo","port":80,"weight":80},{"group":"","kind":"Service","name":"qwen3-v030-predictor","namespace":"llm-demo","port":80,"weight":20}]

Weights are config. What matters is where requests land. The system_fingerprint field is a free version probe, so count it over 100 one-token requests:

for i in $(seq 1 100); do
  curl -s -H "Host: qwen3-llm-demo.example.com" -H "Content-Type: application/json" \
    "http://${GATEWAY_IP}/v1/chat/completions" \
    -d '{"model":"qwen3","messages":[{"role":"user","content":"hi"}],"max_tokens":1}' \
    | jq -r .system_fingerprint
done | sort | uniq -c

79 to 21 against an 80/20 target. An earlier 50-request run landed exactly 40/10:

KServe canary rollout splitting vLLM traffic 80/20 between v0.21 and v0.30 Deployments
Two Deployments, one InferenceService, and the measured split on the k3s lab

With both builds serving, we hit each predictor Service directly for four 200-token completions (temperature 0, ignore_eos, 3 CPU limit each). The newer build is not faster on CPU for a model this small, and it spends about 74 seconds on an ahead-of-time torch compile during every pod start:

vLLM buildFirst requestSteady state (runs 2 to 4)
0.21.0 (bundled)6.82 tok/s (29.3 s)6.85 to 6.87 tok/s
0.30.0 (pinned)3.01 tok/s (66.5 s)6.32 to 6.40 tok/s

That is exactly the kind of result a canary exists to surface before 100% of traffic sees it. On GPUs the comparison will look different. Our same-GPU tokens per second test has real GPU numbers for vLLM.

Promote the canary

The API comments describe a restart-free promotion: set the stable predictor.name to the canary’s name and delete the canary entry, and KServe adopts the canary Deployment as stable. It only works when the canary serves a different model. The webhook compares storageUri and nothing else:

admission webhook "inferenceservice.kserve-webhook-server.validator" denied the request: predictor.name change requires a model change (e.g. storageUri)

A canary that changes only the runtime, which is the common case for an engine upgrade, has to be promoted with an ordinary rolling update: move the stable predictor onto the new runtime and drop the canary list. The pod restarts anyway, so this manifest also turns on a LoRA adapter:

vim qwen3-promoted.yaml

vLLM accepts a Hugging Face repo ID in --lora-modules and downloads the adapter itself into HF_HOME:

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: qwen3
  namespace: llm-demo
spec:
  predictor:
    model:
      modelFormat:
        name: vLLM
      storageUri: hf://Qwen/Qwen3-0.6B
      args:
      - --gpu-memory-utilization=0.5
      - --max-model-len=8192
      - --enable-lora
      - --max-lora-rank=8
      - --lora-modules
      - alice=charent/self_cognition_Alice
      resources:
        requests:
          cpu: '2'
          memory: 5Gi
        limits:
          cpu: '3'
          memory: 7Gi
      runtime: vllm-cpu

Roll it out and watch the Deployments. The canary Deployment is deleted and the stable one rolls to the pinned image:

kubectl apply -f qwen3-promoted.yaml
kubectl get deploy -n llm-demo -o wide

308 seconds later a single Deployment remained on the new build, and the HTTPRoute went back to a single backend (weight 1):

NAME              READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS         IMAGES                         SELECTOR
qwen3-predictor   1/1     1            1           16m   kserve-container   vllm/vllm-openai-cpu:v0.30.0   app=isvc.qwen3-predictor

In production, keep the old stable Deployment’s capacity in mind here. A rolling update needs room for a surge pod, and on the six-core lab an earlier attempt sat Pending until the crashing old pod was cleared.

Serve a LoRA adapter next to the base model

The adapter shows up as its own model with the base as parent:

curl -s -H "Host: qwen3-llm-demo.example.com" "http://${GATEWAY_IP}/v1/models" | jq -c '.data[] | {id, parent, root}'

Two entries, one base and one adapter:

{"id":"qwen3","parent":null,"root":"/mnt/models"}
{"id":"alice","parent":"qwen3","root":"charent/self_cognition_Alice"}

Clients pick the adapter by model name on the same endpoint. We asked both the same question at temperature 0. The adapter is a self-identity fine-tune, which makes the switch obvious:

for m in qwen3 alice; do
  curl -s -H "Host: qwen3-llm-demo.example.com" -H "Content-Type: application/json" \
    "http://${GATEWAY_IP}/v1/chat/completions" \
    -d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"Who are you? Answer in one sentence. /no_think\"}],\"max_tokens\":60,\"temperature\":0}" \
    | jq -r '"\(.model) | \(.system_fingerprint) | \(.choices[0].message.content)"'
done

Same pod, same weights in memory, two different answers:

qwen3 | vllm-0.30.0-84504cb3 | <think>  </think>  I am a language model designed to assist with questions and provide information.
alice | vllm-0.30.0-84504cb3 | <think>  </think>  I am Alice, an AI assistant developed by GitHub/Charent.

Set --max-lora-rank to at least the largest adapter rank you plan to load. This adapter is rank 8. For adapters kept in S3 or GCS instead of the Hub, KServe’s LLMInferenceService has a first-class LoRA field, and the vLLM on EKS, GKE and AKS guide covers the GPU node pools you would run this on in a managed cluster.

Errors we hit, word for word

Every one of these came up on the lab. Search engines index exact strings, so here they are with the fix that worked.

Error: failed to perform “FetchReference” on source: ghcr.io/kserve/charts/kserve-crd:v0.21.0: not found

The final KServe Helm charts were not published to ghcr.io. Install from kserve.yaml and kserve-cluster-resources.yaml on the GitHub release instead. The rc1 chart tarballs on the release page deploy release candidate images.

Error: Installing experimental CRDs on top of standard channel CRDs is prohibited by default

k3s already installed standard channel Gateway API CRDs, guarded by the safe-upgrades.gateway.networking.k8s.io ValidatingAdmissionPolicy. Install Envoy Gateway’s CRDs with crds.gatewayAPI.enabled=false and the controller with --skip-crds. Do not delete the policy to force the experimental channel on unless you need TCPRoute or UDPRoute.

Error: Secret “sh.helm.release.v1.eg-crds.v1” is invalid: data: Too long: may not be more than 1048576 bytes

The Envoy Gateway CRD chart does not fit in a Helm release Secret. Render it with helm template and pipe it to kubectl apply --server-side.

Error from server (NotFound): namespaces “kserve” not found

kserve.yaml does not create its namespace. Run kubectl create namespace kserve and apply again. The CRDs from the first attempt are already in place, so the second apply only fills in the rest.

Error: no matches for kind “ClusterStorageContainer” in version “serving.kserve.io/v1alpha1”

The first apply of kserve.yaml created the CRD and the object in one pass, before the API server could serve the new kind. Wait for the CRD to be Established and apply the same file again.

Error: can’t evaluate field TrustRemoteCode in type v1alpha2.LLMInferenceServiceSpec

A bug in the bundled kserve-config-sglang-template. It blocks only SGLang through LLMInferenceService. InferenceService and every ClusterServingRuntime work.

Pod stuck in Init:OOMKilled or Init:CrashLoopBackOff after “Copying contents of hf://”

The storage initializer hit its 1 GiB limit. Raise the limit on the default ClusterStorageContainer, not in inferenceservice-config.

ValueError: Available memory on node 0 is less than desired CPU memory utilization

vLLM’s CPU backend reserved more than the pod can get. Pass --gpu-memory-utilization in the InferenceService args. Size it against node RAM on the bundled build and against the pod limit on newer builds and the Hugging Face runtime.

Error: predictor.name change requires a model change (e.g. storageUri)

Rename-based promotion is only allowed when the canary has a different storageUri. For runtime-only canaries, promote with a rolling update as shown above.

The InferenceService “qwen3” is invalid: spec.canary[0].predictor.model.name: Required value

Creating an InferenceService with a canary passes validation, but editing the canary entry in place with kubectl apply fails with this message. We did not find an in-place edit that passed. Deleting and recreating the InferenceService works, but it takes the stable endpoint down too, so change canary settings in a maintenance window or route through a second InferenceService.

Keep reading

Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) AI Ollama Models Cheat Sheet: Best Models by VRAM, Tested (PDF) Open Source LLM Comparison Table (2026) AI Open Source LLM Comparison Table (2026) Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) AI Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) Best SEO Books for 2026, Including AI Search and GEO AI Best SEO Books for 2026, Including AI Search and GEO Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep Kubernetes Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep Podman 6.0 Breaking Changes and How to Upgrade Containers Podman 6.0 Breaking Changes and How to Upgrade

Leave a Comment

Press ESC to close