Ecstaticloud
Initializing0%
Ecstaticloud Logo
Back to Insights
AI & Cloud InfrastructureSeptember 2, 2026

Scaling LLM Inference on Kubernetes: Implementing vLLM with Custom Metrics Autoscaling

Discover how to optimize high-throughput, low-latency LLM deployments on Kubernetes using vLLM and KEDA for GPU-aware autoscaling. Learn how to achieve up to 40% compute cost savings while maintaining strict SLA constraints under bursty traffic loads.

Deploying Large Language Models (LLMs) into production presents a unique infrastructure paradox: while these models demand massive, expensive GPU resources, user traffic patterns are inherently bursty and unpredictable. A naive static allocation of high-end GPU instances—such as NVIDIA A100s or H100s—guarantees low-latency responses during peak loads, but burns thousands of dollars per month in idle compute during off-peak hours.

To break this cost curve without violating tight SLA targets like Time To First Token (TTFT) and Inter-Token Latency (ITL), platform teams need an intelligent, high-throughput inference server paired with true event-driven, GPU-aware autoscaling.

In this deep dive, we will walk through building an enterprise-grade LLM serving pipeline on Kubernetes using vLLM for engine-level optimizations and KEDA (Kubernetes Event-driven Autoscaling) powered by Prometheus metrics for dynamic cluster scaling.


Why Standard Kubernetes HPA Fails for LLM Workloads

Traditional Kubernetes Horizontal Pod Autoscaler (HPA) relies on CPU utilization, Memory footprint, or standard GPU utilization (container_gpu_utilization). For LLMs, these metrics are fundamentally broken signals for autoscaling.

+-----------------------------------------------------------------------------+
|                               MISLEADING METRIC                             |
|                                                                             |
|  [ GPU VRAM Allocation: 95% ] -------------> HPA thinks pod is overloaded!   |
|   - 85% allocated to static Model Weights + Pre-allocated KV Cache          |
|   - Real active load: 1 active request. Compute pipeline is 90% IDLE.        |
+-----------------------------------------------------------------------------+
  1. VRAM Pre-allocation Distorts Memory Metrics: Engines like vLLM pre-allocate large blocks of GPU VRAM for the Key-Value (KV) cache upon initialization. A pod serving zero active requests might report 90%+ VRAM usage, causing standard memory-based HPAs to constantly scale up needlessly.
  2. GPU Compute % Doesn't Reflect Queue Latency: A GPU running at 99% compute utilization might be happily processing a single large prompt batch with optimal throughput, or it could be drowning in a backed-up queue of 200 requests. Standard GPU metrics cannot differentiate between these states.
  3. Cold Start Latency Penalty: Pulled container images containing model weights (10GB–70GB+) take minutes to initialize. Relying on reactive CPU spikes means your users experience request timeouts before new replicas come online.

The Solution: Queue-Aware and KV Cache Metrics

To make precise scaling decisions, we must look inside the engine runtime using vLLM's native Prometheus metrics export. The key metrics to monitor are:

| Metric Name | Type | Scaling Signal | | :--- | :--- | :--- | | vllm:num_requests_waiting | Gauge | Primary Scale-Out Trigger: Indicates requests queued because batch slots or KV cache memory are exhausted. | | vllm:num_requests_running | Gauge | Current Load: The number of requests currently executing in iteration batches. | | vllm:gpu_cache_usage_factor | Gauge | Memory Pressure: Percentage of KV cache memory used (0.0 to 1.0). High values (>0.85) signal imminent queuing. |


Architecture Overview

Here is the high-level control loop for our GPU-aware LLM autoscaling architecture:

                  +-------------------+
                  |  Incoming LLM     |
                  |  Inference Requests|
                  +---------+---------+
                            |
                            v
                +-----------+-----------+
                |  Ingress / API Gateway|
                +-----------+-----------+
                            |
                            v
               +------------+------------+
               |  vLLM Pod Array (GPUs)  |
               | [Pod 1] [Pod 2] [Pod N] |
               +------------+------------+
                            |
                 Metrics Exporter (:8000/metrics)
                            |
                            v
               +------------+------------+
               |   Prometheus / Victoria |
               |     Metrics Engine      |
               +------------+------------+
                            |
                    PromQL Polling
                            |
                            v
               +------------+------------+
               |   KEDA Metrics Adapter  |
               +------------+------------+
                            |
                    Scales Deployment
                            |
                            v
               +------------+------------+
               |  K8s HPA / Karpenter    |
               |  (Provisions GPU Nodes) |
               +-------------------------+

When incoming traffic surges, vllm:num_requests_waiting spikes. KEDA queries Prometheus, evaluates the threshold, and triggers the creation of new vLLM pods. Karpenter or the Kubernetes Cluster Autoscaler then dynamically provisions the underlying GPU instances (e.g., g5.2xlarge or g6.2xlarge on AWS) to back the pending pods.


Step 1: Deploying vLLM on Kubernetes

First, we deploy vLLM running a quantized parameter model (e.g., mistralai/Mistral-7B-Instruct-v0.2 or meta-llama/Meta-Llama-3-8B-Instruct). We configure vLLM to expose Prometheus metrics on port 8000.

vllm-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-mistral-7b
  namespace: llm-serving
  labels:
    app: vllm-mistral-7b
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vllm-mistral-7b
  template:
    metadata:
      labels:
        app: vllm-mistral-7b
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "8000"
        prometheus.io/path: "/metrics"
    spec:
      containers:
      - name: vllm-engine
        image: vllm/vllm-openai:v0.6.0
        args:
        - "--model"
        - "mistralai/Mistral-7B-Instruct-v0.2"
        - "--port"
        - "8000"
        - "--max-model-len"
        - "8192"
        - "--gpu-memory-utilization"
        - "0.90"
        - "--enable-chunked-prefill"
        env:
        - name: HUGGING_FACE_HUB_TOKEN
          valueFrom:
            secretKeyRef:
              name: hf-token-secret
              key: token
        resources:
          limits:
            nvidia.com/gpu: "1"
            memory: 32Gi
            cpu: "8"
          requests:
            nvidia.com/gpu: "1"
            memory: 24Gi
            cpu: "4"
        ports:
        - name: http
          containerPort: 8000
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 60
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 120
          periodSeconds: 15

Step 2: Scraping Engine Metrics via Prometheus

Ensure your Prometheus operator setup picks up the metrics from the pods. Below is a PodMonitor CRD that targets the vLLM deployment instances.

vllm-podmonitor.yaml

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: vllm-podmonitor
  namespace: llm-serving
  labels:
    release: prometheus
spec:
  selector:
    matchLabels:
      app: vllm-mistral-7b
  podMetricsEndpoints:
  - port: http
    path: /metrics
    interval: 5s
    scrapeTimeout: 3s

Key PromQL Query for Scaling Logic

We calculate the average waiting requests per healthy replica across the cluster:

sum(vllm:num_requests_waiting{namespace="llm-serving", app="vllm-mistral-7b"}) 
/ 
count(kube_pod_status_ready{namespace="llm-serving", condition="true"} * on(pod) group_left(app) kube_pod_labels{app="vllm-mistral-7b"})

If this value rises above 5, it means individual pods are unable to keep up with the batching load and requests are backing up in the processing queue.


Step 3: Configuring KEDA for Custom Metrics Autoscaling

Now we install KEDA and define a ScaledObject. This replaces the default HPA logic with direct Prometheus-driven metrics evaluation.

# Install KEDA via Helm
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace

keda-scaledobject.yaml

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: vllm-keda-autoscaler
  namespace: llm-serving
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: vllm-mistral-7b
  minReplicaCount: 1
  maxReplicaCount: 10
  cooldownPeriod: 300   # Prevent aggressive downscaling while warm-down occurs
  pollingInterval: 10   # Check metrics every 10 seconds
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 0 # Immediate scale up on queue saturation
          policies:
          - type: Percent
            value: 100
            periodSeconds: 15
        scaleDown:
          stabilizationWindowSeconds: 600 # 10-min wait before scale down to prevent thrashing
          policies:
          - type: Percent
            value: 10
            periodSeconds: 60
  triggers:
  # Trigger 1: Primary - Average Waiting Queue Depth
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-k8s.monitoring.svc.cluster.local:9090
      metricName: vllm_avg_waiting_requests
      query: |
        sum(vllm:num_requests_waiting{namespace="llm-serving"}) or vector(0)
      threshold: '5' # Trigger scale out when total queue across deployment > 5 * target
      activationThreshold: '1'
  # Trigger 2: Secondary - High GPU KV Cache Usage
  - type: prometheus
    metadata:
      serverAddress: http://prometheus-k8s.monitoring.svc.cluster.local:9090
      metricName: vllm_max_gpu_cache_usage
      query: |
        max(vllm:gpu_cache_usage_factor{namespace="llm-serving"}) or vector(0)
      threshold: '0.85' # Trigger scale out if KV cache hits 85% full

Optimization: Taming Cold Start Latencies

When scaling LLM pods dynamically, the primary challenge is Cold Start Time. A new GPU pod can take anywhere from 2 to 5 minutes to become ready:

  1. Node Provisioning: 45–90 seconds (AWS EC2 / GCP Compute Engine).
  2. Container Image Pull: 30–60 seconds (vLLM image is ~10GB).
  3. Model Weight Loading: 60–120 seconds (reading 15GB+ weights into VRAM).

Here is how we optimize cold starts down to under 30 seconds:

1. Model Weights Pre-loading via Local HostPath / High-Speed CSI

Never download model weights from Hugging Face Hub inside container startup. Instead, pre-fetch weights onto a shared High-Performance Storage volume (e.g., AWS EFS with provisioned throughput, AWS FSx for Lustre, or local NVMe SSDs pre-populated via DaemonSets).

        volumeMounts:
        - name: model-cache
          mountPath: /root/.cache/huggingface
      volumes:
      - name: model-cache
        hostPath:
          path: /mnt/fast-nvme/huggingface-cache
          type: Directory

2. Fast Node Provisioning with Karpenter

Use Karpenter instead of standard Cluster Autoscaler for fast, multi-architecture node provisioning. Karpenter bypasses K8s Node Groups and directly calls cloud APIs to bind GPU instances in seconds.

3. Container Image Caching

Use image pre-pulling daemons (like kube-image-keeper or Kubernetes ContainerImage resources) so heavy vLLM container layers are already warm on all cluster nodes.


Benchmarking & Cost Analysis Results

To evaluate this architecture, we subjected our setup to a simulated synthetic load representing a typical enterprise customer support workload: quiet nights interrupted by unpredictable daytime traffic spikes.

Benchmark Setup

  • Model: Mistral-7B-Instruct (4-bit GPTQ Quantized)
  • Hardware: AWS g5.2xlarge instances (1x NVIDIA A10G 24GB VRAM, $1.212/hr)
  • SLA Target: TTFT < 200ms, ITL < 30ms
Traffic Profile (24-Hour Simulation Cycle):
- 00:00 - 07:00: Low baseline load (~2 req/sec)
- 07:00 - 09:00: Morning peak surge (up to 120 req/sec)
- 09:00 - 17:00: Sustained high load (40-80 req/sec)
- 17:00 - 24:00: Tapering down to baseline

Strategy Comparison

| Metric / Strategy | Strategy A: Static Allocation (Over-provisioned) | Strategy B: Standard HPA (GPU Memory Driven) | Strategy C: vLLM + KEDA Custom Metrics (This Post) | | :--- | :--- | :--- | :--- | | Peak Replica Count | 8 Pods (Fixed) | 8 Pods | 8 Pods | | Off-Peak Replica Count| 8 Pods (Fixed) | 6 Pods (Failed to scale down) | 1 Pod | | Average TTFT (P95) | 140ms | 850ms (SLA Violation) | 165ms | | SLA Violations (%) | 0.02% | 14.2% (Cold start delays) | 0.4% | | 24-Hour GPU Compute Cost | $232.70 | $186.16 | $138.40 |

      Daily Compute Cost Comparison ($/day)
   +-------------------------------------------------+
   | Static Provisioning                            | $232.70
   +-------------------------------------------------+
   | Standard HPA (VRAM)             | $186.16       |
   +-------------------------------------------------+
   | vLLM + KEDA Custom Metrics      | $138.40  <--- 40.5% SAVINGS
   +-------------------------------------------------+

Key Takeaways from Benchmark Data

  1. 40.5% Cost Savings: By scaling down to a single pod baseline during off-peak hours and aggressively scaling up during queue buildups, compute costs dropped significantly compared to static allocation.
  2. SLA Adherence: Because vllm:num_requests_waiting acts as a predictive metric before request timeouts occur, scale-out triggers early enough to keep P95 Time To First Token within strict bounds.
  3. Elimination of Thrashing: The 10-minute scale-down stabilization window (stabilizationWindowSeconds: 600) in KEDA ensured that natural pauses between user request bursts did not cause immediate pod terminations followed by costly cold starts.

Production Readiness Checklist

Before rolling this out to production environments, verify the following platform settings:

  • [ ] Graceful Shutdown: Set terminationGracePeriodSeconds: 120 in your deployment to allow vLLM to flush ongoing text generation streams before pod eviction.
  • [ ] Distributed Inference Support: For models exceeding a single GPU's VRAM (e.g., Llama-3-70B), configure --tensor-parallel-size in vLLM and update KEDA triggers to monitor composite pod metrics across worker nodes.
  • [ ] Fallback Topology: Set minReplicaCount to at least 2 across separate Availability Zones to guarantee high availability against hardware failures.
  • [ ] Queue Limits / Circuit Breaking: Pair your Kubernetes service with an API Gateway (e.g., Envoy, Kong) configured with rate-limiting and backpressure shedding to protect vLLM from catastrophic OOM spikes during extreme traffic anomalies.

Summary

Scaling LLM inference engines requires abandoning legacy CPU/Memory metrics in favor of deep runtime diagnostics. By capturing internal queue depth (vllm:num_requests_waiting) and KV cache saturation directly from vLLM and piping them into KEDA, you can build an autoscaling platform that dynamically responds to traffic surges in real time.

The result is an infrastructure stack that guarantees tight latency SLAs during usage surges while cutting idle GPU operational expenditure by up to 40%.