DevOpsIndex

Model Serving Infrastructure

Exposing ML models as production APIs. Two layers: KServe for the K8s-native orchestration layer, vLLM for the high-throughput LLM inference engine underneath.


KServe — InferenceService CRD

KServe is the Kubernetes-native model serving platform. It adds an InferenceService CRD that abstracts model serving across frameworks (sklearn, PyTorch, TensorFlow, custom).

graph LR
    CLIENT["HTTP Client<br/>POST /v2/models/llm/infer"] --> ISTIO["Istio/Knative<br/>Ingress Gateway"]
    ISTIO --> IS["InferenceService<br/>(KServe CRD)"]
    IS --> PRED["Predictor Pod<br/>vLLM / TorchServe / Triton"]
    IS --> TRANS["Transformer Pod<br/>(optional pre/post-processing)"]
    IS --> EXPL["Explainer Pod<br/>(optional SHAP/LIME)"]
    PRED --> GPU["GPU Node"]

InferenceService for a custom vLLM model

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llama-3-8b
  namespace: ml-serving
spec:
  predictor:
    model:
      modelFormat:
        name: pytorch
      storageUri: "s3://my-models/llama-3-8b/"   # model weights on S3/GCS/PVC
      runtime: vllm-runtime                        # custom ServingRuntime

    # Resource allocation
    resources:
      limits:
        cpu: "8"
        memory: "32Gi"
        nvidia.com/gpu: "1"
      requests:
        cpu: "4"
        memory: "16Gi"
        nvidia.com/gpu: "1"

    # Knative autoscaling
    minReplicas: 1
    maxReplicas: 4
    scaleTarget: 5        # target concurrent requests per replica
    scaleMetric: concurrency

Custom ServingRuntime for vLLM

apiVersion: serving.kserve.io/v1alpha1
kind: ClusterServingRuntime
metadata:
  name: vllm-runtime
spec:
  supportedModelFormats:
  - name: pytorch
    version: "2"
    autoSelect: true

  containers:
  - name: kserve-container
    image: vllm/vllm-openai:v0.4.2
    command: ["python", "-m", "vllm.entrypoints.openai.api_server"]
    args:
    - --model=/mnt/models            # KServe mounts model here
    - --tensor-parallel-size=1       # GPUs to shard across
    - --max-model-len=8192           # max sequence length
    - --gpu-memory-utilization=0.90  # leave 10% for KV cache growth
    - --served-model-name=llama-3-8b

    resources:
      limits:
        nvidia.com/gpu: "1"
    volumeMounts:
    - name: model
      mountPath: /mnt/models

vLLM — High-Throughput LLM Inference Engine

vLLM achieves 20–100× throughput vs naive inference through three innovations.

1. Continuous Batching

Naive: wait for a full batch, run, return, repeat (GPU idle between batches).

vLLM: new requests join the batch mid-generation. The batch is always full.

gantt
    title Naive vs Continuous Batching
    dateFormat X
    axisFormat %s

    section Naive
    Request A (20 tokens) : 0, 20
    Request B wait        : 0, 20
    Request B (10 tokens) : 20, 30

    section Continuous
    Request A (20 tokens) : 0, 20
    Request B (joins at 5): 5, 15

Result: GPU utilization goes from ~40% to ~95%.

2. PagedAttention — KV Cache Management

During generation, each token's Key and Value vectors are cached (the KV cache). Standard approach: pre-allocate max sequence length × batch size = wastes memory for short sequences.

PagedAttention borrows from OS virtual memory paging:

  • KV cache divided into fixed-size pages (e.g., 16 tokens per page)
  • Pages allocated on demand as tokens are generated
  • Pages shared across parallel sequences (for beam search, speculative decoding)
  • No memory fragmentation
Standard KV cache: [seq0: 2048 tokens allocated][seq1: 2048 tokens allocated] ...
  → seq0 uses 50 tokens, 1998 slots wasted

Paged KV cache: [page0: seq0 t0-15][page1: seq0 t16-31][page2: seq1 t0-15] ...
  → pages allocated as needed, no waste

Result: 2–4× more concurrent sequences fit in the same GPU memory.

3. Tensor Parallelism

For models too large for one GPU, split the weight matrices across GPUs:

# Llama 70B requires ~140GB VRAM at fp16 → needs 2x A100 80GB minimum
vllm serve meta-llama/Meta-Llama-3-70B \
  --tensor-parallel-size 2    # split across 2 GPUs
  --pipeline-parallel-size 1  # layers are NOT split across GPUs (different from TP)

Tensor parallel: each GPU holds a column slice of weight matrices. Each forward pass requires an AllReduce across GPUs. Needs high-bandwidth NVLink (not PCIe).


KV Cache and Context Window

Every token you've generated so far is in the KV cache. Longer context = more KV cache = less room for concurrent requests.

A100 80GB, Llama-3 8B (fp16):
  Model weights: ~16GB
  Remaining for KV cache: ~64GB

  At 4096 context length: ~200 concurrent requests
  At 8192 context length: ~100 concurrent requests
  At 32768 context length: ~25 concurrent requests

This is why --max-model-len is a critical parameter. Set it to the 95th percentile of your actual request length, not the model's maximum.

# Monitor KV cache utilization
curl http://vllm:8000/metrics | grep kv_cache
# vllm:num_requests_running 12
# vllm:gpu_cache_usage_perc 0.73   ← 73% of KV cache used

Canary Rollout with KServe

apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: llama-3-8b
spec:
  predictor:
    canaryTrafficPercent: 10    # 10% to new version

    # Stable version
    model:
      storageUri: "s3://models/llama-3-8b-v1/"
      runtime: vllm-runtime

  # Canary: new version getting 10% traffic
  # Defined by deploying with a new revision
# Promote canary to 100% after validation
kubectl patch isvc llama-3-8b \
  --type merge \
  -p '{"spec":{"predictor":{"canaryTrafficPercent":0}}}'

Autoscaling with KEDA (GPU-aware)

Standard HPA scales on CPU %. For inference, scale on request queue depth or GPU utilization:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-inference-scaler
spec:
  scaleTargetRef:
    name: llama-3-8b-predictor-default
  minReplicaCount: 1
  maxReplicaCount: 8
  triggers:
  # Scale on Prometheus metric: vLLM waiting requests
  - type: prometheus
    metadata:
      serverAddress: http://prometheus:9090
      metricName: vllm_requests_waiting
      threshold: "5"       # scale up when >5 requests waiting
      query: |
        sum(vllm:num_requests_waiting{model_name="llama-3-8b"})

Model Storage Patterns

Model weights are large (8B model = 16GB fp16). Don't bake them into the container image.

Pattern How Tradeoff
PVC with ReadOnlyMany Model stored on EFS/NFS PVC, mounted into predictor pods Slow first mount, fast subsequent
Init container + S3 Init container downloads from S3 on pod start Pod start time 2–5 min for large models
KServe model agent KServe sidecar downloads model from storage URI automatically Declarative, handles S3/GCS/Azure
OCI model artifacts Model packaged as OCI image layer Cached by containerd, fast on warm nodes
# KServe handles download automatically via storageUri
spec:
  predictor:
    model:
      storageUri: "s3://my-models/llama-3-8b-instruct/"
      # KServe model agent sidecar downloads this to /mnt/models before starting predictor

Key Metrics for Model Serving

Metric Source Alert threshold
vllm:num_requests_waiting vLLM /metrics > 10 sustained → scale up
vllm:gpu_cache_usage_perc vLLM /metrics > 90% → reduce context or add replicas
vllm:e2e_request_latency_seconds vLLM /metrics p99 > SLO
vllm:time_to_first_token_seconds vLLM /metrics > 2s → model load or batching issue
DCGM_FI_DEV_GPU_UTIL DCGM Exporter < 30% → over-provisioned
DCGM_FI_DEV_FB_USED DCGM Exporter > 90% → OOM risk