AI

KServe 0.20 Tutorial: Serving LLMs on Kubernetes with Autoscaling and Scale-to-Zero

August 20, 2026 Kubezilla Team 11 min read
Kubernetes logo beside the title KServe: Serving LLMs on Kubernetes

You can serve a model on Kubernetes with a Deployment and a Service. Plenty of teams do, and for a single model behind steady traffic it’s fine.

The trouble starts around model number five. Now you need a consistent way to pull weights from S3, GCS and Hugging Face. You need autoscaling driven by something more meaningful than CPU. You need canary rollouts, because swapping a model version is exactly as risky as swapping application code. You need a request/response schema that isn’t different for every framework. And you need somebody other than the platform team to be able to ship a model without writing a Deployment from scratch.

KServe is the CNCF project that packages all of that behind one CRD. It became a CNCF incubating project in November 2025, and the current release is v0.20.0, which requires Kubernetes 1.32+.

This tutorial covers the current API — which matters, because a lot of what’s written about KServe online describes names that were changed.

What changed in 2026 (read this before copying any older tutorial)

KServe renamed its deployment modes. This is the single biggest source of confusion right now:

Old nameCurrent name (0.20)Status
ServerlessKnativeold value deprecated, auto-normalized
RawDeploymentStandardold value deprecated, auto-normalized
ModelMeshModelMeshlegacy multi-model, not recommended for new work

And critically: Standard is now the default mode, not Knative.

That’s a real change in philosophy. KServe’s original pitch was serverless model serving on Knative, with scale-to-zero as the headline feature. For LLMs, scale-to-zero is often a liability — a cold start means pulling 16 GB of weights and warming a GPU, which is a minute or more of latency, not a few hundred milliseconds. The docs now explicitly recommend Standard mode for production LLM workloads: no cold start, predictable behaviour, direct control over pod lifecycle.

Use Knative mode when you genuinely want scale-to-zero — bursty internal tools, dozens of rarely-used small models, dev environments.

Install

The quickstart script is the fastest path, but note the docs’ own warning: “KServe Quickstart Environments are for experimentation use only.”

# Standard mode
curl -sL "https://github.com/kserve/kserve/releases/download/v0.20.0/kserve-standard-mode-full-install-with-manifests.sh" | bash

# Knative (serverless) mode
curl -sL "https://github.com/kserve/kserve/releases/download/v0.20.0/kserve-knative-mode-full-install-with-manifests.sh" | bash

For anything real, use Helm. Be aware that the chart layout changed in v0.17: the single chart was split into ten, and helm upgrade from v0.16 or earlier will fail. The resource chart is called kserve-resources, not kserve.

helm install kserve-crd oci://ghcr.io/kserve/charts/kserve-crd --version v0.20.0

helm install kserve oci://ghcr.io/kserve/charts/kserve-resources --version v0.20.0 \
  --set kserve.controller.deploymentMode=Standard \
  --set kserve.controller.gateway.ingressGateway.enableGatewayApi=true \
  --set kserve.controller.gateway.ingressGateway.kserveGateway=kserve/kserve-ingress-gateway

kubectl apply --server-side -f https://github.com/kserve/kserve/releases/download/v0.20.0/kserve-cluster-resources.yaml

--server-side is required — the InferenceService CRD is too large for client-side apply.

Verify:

kubectl get pods -n kserve
kubectl get crd | grep serving.kserve.io

Prerequisites by mode

ComponentStandardKnative
Kubernetes1.32+1.32+
cert-manager1.15.0+1.15.0+
Knative Serving1.19 or 1.20
Istiooptional (ingress class)1.27 or 1.28
Gateway API CRDsrecommended
Envoy Gatewayoptional
KEDAoptionaln/a

One honest caveat: Gateway API versions are inconsistent across the official docs — the admin guide pins v1.2.1, the LLM pages require 1.3.0+, and the v0.20.0 changelog bumps to v1.5.1. Treat hack/setup/kserve-deps.env on the v0.20.0 tag as ground truth for what was actually tested together.

Lab 1: your first InferenceService (no GPU needed)

Start with a classical model so you can learn the shape of the API without waiting on GPU nodes.

apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "sklearn-iris"
  namespace: kserve-test
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storageUri: "gs://kfserving-examples/models/sklearn/1.0/model"
      resources:
        requests:
          cpu: "100m"
          memory: "512Mi"
        limits:
          cpu: "1"
          memory: "1Gi"

That’s the entire manifest. modelFormat: sklearn selects a ClusterServingRuntime automatically — KServe ships nine of them: kserve-sklearnserver, kserve-xgbserver, kserve-lgbserver, kserve-pmmlserver, kserve-paddleserver, kserve-mlserver, kserve-tensorflow-serving, kserve-tritonserver and kserve-huggingfaceserver.

storageUri accepts s3://, gs://, http(s)://, git://, pvc://, hf:// and oci://. A storage-initializer init container downloads the model to /mnt/models before the serving container starts, which is why so much KServe debugging starts with that init container’s logs.

kubectl get inferenceservice sklearn-iris -n kserve-test
NAME           URL                                                 READY   AGE
sklearn-iris   http://sklearn-iris.kserve-test.example.com          True    45s

Calling it

KServe routes by hostname, not path, so you need the service hostname as a Host header when you’re hitting an IP directly:

export INGRESS_HOST=localhost
export INGRESS_PORT=8080

SERVICE_HOSTNAME=$(kubectl get inferenceservice sklearn-iris -n kserve-test \
  -o jsonpath='{.status.url}' | cut -d "/" -f 3)

V1 protocol:

curl -v -H "Host: ${SERVICE_HOSTNAME}" -H "Content-Type: application/json" \
  http://${INGRESS_HOST}:${INGRESS_PORT}/v1/models/sklearn-iris:predict \
  -d @./iris-input.json

V2 — the Open Inference Protocol, which is the one to standardise on for new work because it’s shared with Triton and MLServer:

curl -v -H "Host: ${SERVICE_HOSTNAME}" -H "Content-Type: application/json" \
  -d @./iris-input.json \
  http://${INGRESS_HOST}:${INGRESS_PORT}/v2/models/sklearn-iris/infer
ProtocolPredictMetadataReady
V1POST /v1/models/<name>:predictGET /v1/modelsGET /v1/models/<name>
V2POST /v2/models/<name>/inferGET /v2/models/<name>GET /v2/models/<name>/ready

Opt into V2 explicitly with protocolVersion: v2 on the model spec.

Lab 2: serving an LLM

The huggingface model format routes to kserve-huggingfaceserver, whose --backend defaults to auto — it uses vLLM when the model is supported and falls back to HF transformers otherwise.

Small model first, so you can prove the path works on one GPU:

apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "qwen-llm"
  namespace: kserve-test
spec:
  predictor:
    model:
      modelFormat:
        name: huggingface
      args:
        - --model_name=qwen
      storageUri: "hf://Qwen/Qwen2.5-0.5B-Instruct"
      resources:
        limits:
          cpu: "2"
          memory: 6Gi
          nvidia.com/gpu: "1"
        requests:
          cpu: "1"
          memory: 4Gi
          nvidia.com/gpu: "1"

Scaling up to a real model is the same manifest with bigger numbers:

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llama3-8b
spec:
  predictor:
    model:
      modelFormat:
        name: huggingface
      args:
        - --model_name=llama3
      storageUri: hf://meta-llama/meta-llama-3-8b-instruct
      resources:
        limits:
          cpu: "6"
          memory: 24Gi
          nvidia.com/gpu: "1"
        requests:
          cpu: "6"
          memory: 24Gi
          nvidia.com/gpu: "1"

No GPU handy? vLLM has a CPU path that’s slow but functional for learning:

      args:
        - --model_name=qwen2
        - --dtype=bfloat16
        - --max-model-len=2048
      storageUri: hf://Qwen/Qwen2-0.5B-Instruct
      env:
        - name: VLLM_CPU_KVCACHE_SPACE
          value: "4"
        - name: VLLM_CPU_OMP_THREADS_BIND
          value: "auto"
        - name: VLLM_ENABLE_V1_MULTIPROCESSING
          value: "0"

The OpenAI-compatible endpoints

This is where KServe earns its keep for LLM work — your existing OpenAI SDK code points at it with a base URL change.

Note the /openai prefix. For an InferenceService using the huggingface runtime, the working paths are /openai/v1/completions and /openai/v1/chat/completions. This catches almost everyone.

MODEL_NAME=llama3
SERVICE_HOSTNAME=$(kubectl get inferenceservice llama3-8b -o jsonpath='{.status.url}' | cut -d "/" -f 3)

curl -v http://${INGRESS_HOST}:${INGRESS_PORT}/openai/v1/chat/completions \
  -H "Host: ${SERVICE_HOSTNAME}" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "'"${MODEL_NAME}"'",
    "messages": [
      {"role": "user", "content": "Explain Kubernetes DRA in two sentences."}
    ],
    "max_tokens": 100,
    "stream": false
  }'

The "model" value must match your --model_name argument (llama3), not the Hugging Face repo id. Set "stream": true for SSE streaming.

Gated models and HF tokens

There’s a known trap here. Setting HF_TOKEN as an env var on the model container does not give it to the storage-initializer init container — which is the thing that actually needs to download the weights. The reliable pattern is a ClusterStorageContainer:

apiVersion: v1
kind: Secret
metadata:
  name: hf-secret
type: Opaque
stringData:
  HF_TOKEN: <your-hugging-face-token>
---
apiVersion: serving.kserve.io/v1alpha1
kind: ClusterStorageContainer
metadata:
  name: hf-hub
spec:
  container:
    name: storage-initializer
    image: 'kserve/storage-initializer:latest'
    env:
      - name: HF_TOKEN
        valueFrom:
          secretKeyRef:
            name: hf-secret
            key: HF_TOKEN
            optional: false
    resources:
      requests:
        memory: 2Gi
        cpu: '1'
      limits:
        memory: 4Gi
        cpu: '1'
  supportedUriFormats:
    - prefix: 'hf://'

Autoscaling

KServe gives you three autoscalers, selected with the serving.kserve.io/autoscalerClass annotation: hpa (default), kpa, keda, plus external and none.

HPA in Standard mode

apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "sklearn-iris-hpa"
  annotations:
    serving.kserve.io/deploymentMode: Standard
    serving.kserve.io/autoscalerClass: hpa
spec:
  predictor:
    scaleTarget: 80
    scaleMetric: cpu
    minReplicas: 1
    maxReplicas: 5
    model:
      modelFormat:
        name: sklearn
      storageUri: "gs://kfserving-examples/models/sklearn/1.0/model"

Fine for classical models. Useless for LLMs — GPU inference is not CPU-bound, and CPU utilisation tells you almost nothing about whether a vLLM server is saturated.

KEDA on vLLM queue depth — the one you actually want

The right signal for an LLM server is how many requests are in flight or queued. vLLM exports exactly that as vllm:num_requests_running.

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: huggingface-qwen
  annotations:
    serving.kserve.io/deploymentMode: "Standard"
    serving.kserve.io/autoscalerClass: "keda"
    serving.kserve.io/enable-prometheus-scraping: "true"
    prometheus.io/scrape: "true"
    prometheus.io/path: "/metrics"
    prometheus.io/port: "8080"
    prometheus.io/scheme: "http"
spec:
  predictor:
    model:
      modelFormat:
        name: huggingface
      args:
        - --model_name=qwen
      storageUri: "hf://Qwen/Qwen2.5-0.5B-Instruct"
      resources:
        limits:
          cpu: "2"
          memory: 6Gi
          nvidia.com/gpu: "1"
    minReplicas: 1
    maxReplicas: 5
    autoScaling:
      metrics:
        - type: External
          external:
            metric:
              backend: "prometheus"
              serverAddress: "http://prometheus.istio-system.svc.cluster.local:9090"
              query: vllm:num_requests_running
              authModes: "bearer"
            authenticationRef:
              name: keda-prom-creds
            target:
              type: Value
              value: "2"

KEDA autoscaling is only supported in Standard mode. autoScaling.metrics[].type accepts Resource, External and PodMetric; target.type accepts Utilization, Value and AverageValue.

Worth flagging honestly: every KEDA example in the current docs uses minReplicas: 1, and scale-to-zero via KEDA on a plain InferenceService is not documented. Don’t assume it works without testing.

Scale-to-zero, properly

The documented path is Knative mode:

apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "flowers-sample"
  annotations:
    serving.kserve.io/deploymentMode: Knative
spec:
  predictor:
    minReplicas: 0
    scaleTarget: 1
    scaleMetric: concurrency
    model:
      modelFormat:
        name: tensorflow
      storageUri: "gs://kfserving-examples/models/tensorflow/flowers"

scaleMetric accepts concurrency, rps, qps, cpu and memory. containerConcurrency sets a hard per-pod limit.

Watch it work:

hey -z 30s -c 5 -m POST -host ${SERVICE_HOSTNAME} -D input.json \
  http://${INGRESS_HOST}:${INGRESS_PORT}/v1/models/flowers-sample:predict

Sit on your hands for a minute afterwards and the pods disappear.

Canary rollouts

Shifting traffic to a new model version is one field:

apiVersion: "serving.kserve.io/v1beta1"
kind: "InferenceService"
metadata:
  name: "sklearn-iris"
  namespace: kserve-test
spec:
  predictor:
    model:
      modelFormat:
        name: sklearn
      storageUri: "gs://kserve-examples/models/sklearn/1.0/model-2"
    canaryTrafficPercent: 10
PREV   LATEST
90     10

Promote by deleting canaryTrafficPercent and re-applying. Roll back by setting it to 0, which returns 100% of traffic to the previous revision — a nice property, since rollback is a one-character edit rather than finding the old manifest.

Caveat: the canary docs page still says this is Knative-only, but the v0.20.0 changelog includes canary support for Standard mode. The docs haven’t caught up. Test it in Standard mode before relying on it.

Troubleshooting

InferenceService not READY. Start with kubectl get inferenceservice <name> -o yaml and read .status.conditions.

  • IngressNotConfigured — Istio ingress gateway probes are failing. kubectl logs -l app=networking-istio -n knative-serving; HTTP 403s there mean an AuthorizationPolicy is blocking Knative’s probe traffic.
  • RevisionMissing — pods aren’t ready, and it’s usually the storage-initializer.

Predictor pod Pending. kubectl describe pod <pod> and read Events. For GPU LLMs it’s nearly always one of: no node with allocatable nvidia.com/gpu (device plugin or GPU Operator not installed), a GPU count larger than any single node has, a taint on GPU nodes with no matching toleration, or an unsatisfiable node selector.

Model download failures. This is the most common failure by a wide margin:

kubectl logs -l model=sklearn-iris -c storage-initializer

Typical output: Failed to fetch model. The path or model gs://... does not exist.

For S3, credentials go on a Secret with KServe-specific annotations, referenced through a ServiceAccount:

apiVersion: v1
kind: Secret
metadata:
  name: s3creds
  annotations:
     serving.kserve.io/s3-endpoint: s3.amazonaws.com
     serving.kserve.io/s3-usehttps: "1"
     serving.kserve.io/s3-region: "us-east-2"
type: Opaque
stringData:
  AWS_ACCESS_KEY_ID: XXXX
  AWS_SECRET_ACCESS_KEY: XXXXXXXX
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: sa
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/s3access

Then predictor.serviceAccountName: sa.

GPU OOM on model load. Usually --max-model-len left at the model’s maximum. Cap it, or reduce gpu_memory_utilization. Remember vLLM preallocates KV cache aggressively — the container will use far more GPU memory than the weights alone.

404 on your LLM requests. You almost certainly dropped the /openai prefix, or your "model" value doesn’t match --model_name.

When to reach for LLMInferenceService instead

v0.20 ships an alpha CRD, LLMInferenceService, for advanced LLM serving: prefix-cache-aware routing, disaggregated prefill/decode, multi-node tensor parallelism via LeaderWorkerSet, and the Workload Variant Autoscaler which scales on KV-cache utilisation rather than request counts.

apiVersion: serving.kserve.io/v1alpha1
kind: LLMInferenceService
metadata:
  name: llama-70b-production
spec:
  model:
    uri: hf://meta-llama/Llama-2-70b-hf
    name: meta-llama/Llama-2-70b-hf
  parallelism:
    tensor: 4
  replicas: 2
  router:
    gateway: {}
    route: {}
    scheduler: {}

Two warnings. It is genuinely alpha and churning fast — v0.20 alone added traffic splitting, KV-cache offloading, model-name-based routing and DRA support. And its dependency chain is heavy: Gateway API 1.3+, Envoy Gateway, Envoy AI Gateway, the Gateway API Inference Extension, and LeaderWorkerSet.

The docs’ own guidance is the right default: start with InferenceService in Standard mode. It handles both classical ML and standard LLM serving. Move to LLMInferenceService only when you have a specific need — a 70B model that won’t fit one node, or prefix-cache routing that would meaningfully change your economics.

So should you use KServe at all?

If you serve one model and it never changes, a Deployment is fine and KServe is overhead.

The moment you have multiple models, multiple people shipping them, or a rollout process that needs to be safe, the calculus flips. What you’re really buying is a contract: model authors write twenty lines of YAML, and the platform handles storage credentials, runtime selection, autoscaling, ingress, protocol and rollout. That contract is worth considerably more than the sum of the features.

Start with the sklearn example on any cluster you have. It takes five minutes and gives you the whole mental model. Then swap in modelFormat: huggingface and a GPU.

Leave a comment