<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Josef Doornink</title>
    <description>The latest articles on DEV Community by Josef Doornink (@josef_doornink_930b2caf1c).</description>
    <link>https://dev.to/josef_doornink_930b2caf1c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4064995%2Fc55026b1-2514-426f-8d4f-08af7cae46f6.png</url>
      <title>DEV Community: Josef Doornink</title>
      <link>https://dev.to/josef_doornink_930b2caf1c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/josef_doornink_930b2caf1c"/>
    <language>en</language>
    <item>
      <title>Your vLLM Autoscaler Is Flapping Because You Picked the Wrong Signal — Not the Wrong Number</title>
      <dc:creator>Josef Doornink</dc:creator>
      <pubDate>Mon, 10 Aug 2026 18:11:56 +0000</pubDate>
      <link>https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf</link>
      <guid>https://dev.to/josef_doornink_930b2caf1c/your-vllm-autoscaler-is-flapping-because-you-picked-the-wrong-signal-not-the-wrong-number-24lf</guid>
      <description>&lt;h1&gt;
  
  
  Your vLLM Autoscaler Is Flapping Because You Picked the Wrong Signal — Not the Wrong Number
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Part of a series on running vLLM on AKS. Companion piece: &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-gpu-sizing.md" rel="noopener noreferrer"&gt;GPU sizing&lt;/a&gt;. Infrastructure setup — coming soon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So... you've picked the right GPU for your use case, provisioned the cluster and node pool, deployed your model behind vLLM, installed KEDA and picked the request-queue as the autoscaling parameter as per the common recommendation: &lt;a href="https://docs.cloud.google.com/kubernetes-engine/docs/best-practices/machine-learning/inference/autoscaling" rel="noopener noreferrer"&gt;"Tune the request queue to obtain the preferred latency, and use batch size if you can't hit your preferred latency."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then you watch it in production:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A burst hits, a new pod spins up — and by the time it's ready, the burst is gone. The queue drained, so the autoscaler scales back down.
&lt;/li&gt;
&lt;li&gt;Minutes later the next spike repeats the cycle. The replica count oscillates instead of settling.
&lt;/li&gt;
&lt;li&gt;What gives??&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's &lt;strong&gt;flapping&lt;/strong&gt; and it's costing you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What flapping is, and why you should care
&lt;/h2&gt;

&lt;p&gt;Flapping refers to a resource rapidly switching between two states and in this case: a pod Up/Running or Down/Failed.&lt;/p&gt;

&lt;p&gt;I can hear you say: "So what ... The requests still get served, right?"&lt;/p&gt;

&lt;p&gt;True, but flapping costs you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Money&lt;/strong&gt; — you pay for GPU nodes that spin up and tear down without ever serving traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; — the mechanism meant to &lt;em&gt;absorb&lt;/em&gt; load spends its time flapping instead. Requests sit in the queue while pods churn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust&lt;/strong&gt; — pods and nodes appearing and vanishing "at random" is exactly the chaos you don't want when debugging under pressure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article shows how to avoid flapping by using the right &lt;em&gt;type&lt;/em&gt; of signal and why request-queue is the wrong choice.&lt;/p&gt;

&lt;p&gt;We demonstrate this by walking through three progressive designs for the autoscaler and its defining parameter(s), reasoning about the output, and finally coming to a conclusion about a signal that actually holds steady, and why.&lt;/p&gt;

&lt;p&gt;The three progressive steps we will walk through are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 1 — arbitrary threshold&lt;/strong&gt; (flaps, and over-scales)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 2 — calculated threshold&lt;/strong&gt; (still flaps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attempt 3 — stabilized design&lt;/strong&gt; (stable)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud:&lt;/strong&gt; Azure AKS 1.35.6&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU node:&lt;/strong&gt; Standard_NV36ads_A10_v5 (1× A10, 24 GB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image / model:&lt;/strong&gt; &lt;code&gt;vllm/vllm-openai:latest&lt;/code&gt; / Qwen2.5-7B-Instruct-AWQ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaler:&lt;/strong&gt; KEDA ScaledObject, Prometheus trigger on &lt;code&gt;vllm:num_requests_waiting&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility:&lt;/strong&gt; Prometheus + Grafana&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  - &lt;strong&gt;Constraint:&lt;/strong&gt; &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; → one pod per GPU node, so every replica needs a new node (this matters later)
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Attempt 1 — an arbitrary threshold (flaps, and over-scales)
&lt;/h2&gt;

&lt;p&gt;Pick a low, round queue number that sounds reasonable (&lt;code&gt;5&lt;/code&gt;) and move on — sound familiar?&lt;/p&gt;

&lt;p&gt;This over-scales badly because the underlying Kubernetes HPA controller — which KEDA delegates the actual scaling decision to — computes &lt;code&gt;desiredReplicas = ceil(total_waiting / threshold)&lt;/code&gt; (&lt;a href="https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/#algorithm-details" rel="noopener noreferrer"&gt;Kubernetes HPA algorithm details&lt;/a&gt;). So a transient queue of 40 demands 8 replicas. Fine for a proof-of-concept, but in production each of those replicas cost you.&lt;/p&gt;

&lt;p&gt;It's better to calculate the threshold from the hardware specs, not a guess.&lt;/p&gt;

&lt;p&gt;So ... let's calculate a real one.&lt;br&gt;&lt;br&gt;
Here is the scaledobject for attempt1 if you are curious. &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt1-guess.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt1-guess.yaml&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Attempt 2 — a calculated threshold (still flaps)
&lt;/h2&gt;

&lt;p&gt;Assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average tokens/request ≈ 1,000&lt;/li&gt;
&lt;li&gt;Average completion time = 10s (how long it takes vLLM to generate a response once processing starts — prefill + decode)&lt;/li&gt;
&lt;li&gt;Queue-wait budget &lt;code&gt;W_max&lt;/code&gt; ≤ 5 seconds (SLO)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  To calculate the threshold, we first need to determine how much cache is available to our model
&lt;/h3&gt;

&lt;p&gt;Read from the model's startup log (the engine profiles this at boot): this is VRAM left after weights + activation + overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Result: Available KV cache memory: 13.76 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next we determine the bytes per token based on the model's specifications (all from the model's config.json):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bytes_per_token = 2 (K and V) × layers × kv_heads × head_dim × dtype_bytes
                = 2 × 28 × 4 × 128 × 2 (fp16) = 57,344

Calculate how many tokens are available given the cache size and the bytes required per token
257,584 ≈ 13.76 GiB / 57,344 bytes_per_token

Determine how many requests our GPU can handle at once given our assumption(s) (~1,000 tok/req)
Concurrent_requests = 257,584 tokens / 1,000 tokens/request ≈ 258
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Full VRAM → token-budget derivation is in the &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/write-up-gpu-sizing.md" rel="noopener noreferrer"&gt;GPU sizing&lt;/a&gt; companion piece.)&lt;/p&gt;

&lt;p&gt;Finally, calculate the &lt;strong&gt;Threshold from an SLO&lt;/strong&gt; (Little's Law — how many requests may queue before wait breaches &lt;code&gt;W_max&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Calculate how many requests will be completed every second
Drain_rate = Concurrent_requests / avg_completion_time = 258 / 10s ≈ 25.8 req/s

# Calculate the threshold to increase the pod number by 1.
Threshold = W_max × Drain_rate = 5s × 25.8 ≈ 129
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;129 is a defensible number, derived from measured capacity and a stated SLO. Not a guess.&lt;/p&gt;

&lt;p&gt;However, for demonstration purposes, the rest of this article runs on a deliberately shrunk rig:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Demo vs. production numbers:&lt;/strong&gt; &lt;code&gt;--max-num-seqs 32&lt;/code&gt; caps concurrency at 32 so the queue is reachable at demo scale. Measuring with unique per-request prompts gave a real drain rate of &lt;code&gt;μ ≈ 0.46 req/s&lt;/code&gt;; with a &lt;code&gt;W_max = 15s&lt;/code&gt; budget, the same formula calibrates to &lt;code&gt;T = 15 × 0.46 ≈ 7&lt;/code&gt;, versus &lt;code&gt;C ≈ 258&lt;/code&gt; / &lt;code&gt;T ≈ 129&lt;/code&gt; for the full-size pod. Don't get caught up on the smaller numbers — same method, same core point; it's just easier to demonstrate the flapping behavior and saves me money. &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/deployment.yaml" rel="noopener noreferrer"&gt;deployment.yaml&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Apply the ScaledObject (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt2-calculated.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt2-calculated.yaml&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://kps-kube-prometheus-stack-prometheus.monitoring.svc:9090&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(vllm:num_requests_waiting)&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;7"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the load (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/load_test.py" rel="noopener noreferrer"&gt;load_test.py&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1k2y26103tqqlrbc63.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4c1k2y26103tqqlrbc63.png" alt="Attempt 2 — calculated threshold on queue depth, still flaps" width="799" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's walk through what happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Requests start arriving, running count climbs to 32 (one pod's cap) — no queue yet, all requests are actually running.&lt;/li&gt;
&lt;li&gt;Once running is saturated at 32, new arrivals start queuing instead. The queue grows.&lt;/li&gt;
&lt;li&gt;Queue crosses 7 (the threshold) a little before 20:00 → KEDA scales to 2 replicas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;While that second pod is still loading&lt;/strong&gt; (model weights + readiness, ~90s), the queue keeps growing — the new pod isn't serving yet, so it does nothing to relieve the backlog. By the time it's ready, the queue is way past 7 (peak ~50).&lt;/li&gt;
&lt;li&gt;KEDA polls again, sees the queue still far over threshold, and scales to 3 pods.&lt;/li&gt;
&lt;li&gt;Now with more capacity coming online, the queue starts draining. It falls below threshold, and KEDA scales back down (2, then 1) — the queue graph and replica graph fall together around 20:04–20:07.&lt;/li&gt;
&lt;li&gt;The load never stopped: it's the same continuous arrival rate (0.6 req/s) throughout. The moment replicas dropped to 1 again, that single pod was immediately outmatched by the same steady arrivals, so the queue re-formed and crossed 7 again almost immediately (~20:08–20:10) — a second flap cycle.&lt;/li&gt;
&lt;li&gt;Eventually the load generator's 12-minute run ends, arrivals stop, the queue drains for real, and replicas settle back to the floor of 1.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Under steady load, the oscillation isn't coming from the traffic — it's coming from the shape of the signal.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The queue reads ~0 until the pod is full, then climbs; the autoscaler is just asking "is this threshold met or not?"&lt;br&gt;&lt;br&gt;
The binary nature of the signal is what causes the flap.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why you might not have noticed this in the wild:&lt;/strong&gt; Kubernetes' HPA defaults to a 300s scale-down stabilization window (0s scale-up), so out of the box the flap is slowed to a ~5-minute cycle — infrequent enough to miss at a glance, but you pay for it on every swing. (&lt;a href="https://github.com/kubernetes/enhancements/blob/master/keps/sig-autoscaling/853-configurable-hpa-scale-velocity/README.md#default-values" rel="noopener noreferrer"&gt;Kubernetes HPA defaults&lt;/a&gt;) &lt;br&gt;
For this demo, &lt;code&gt;stabilizationWindowSeconds&lt;/code&gt; was set to 0 in Attempt 2, to expose the flap within a short window instead of stretching it to 5+ minutes:&lt;/p&gt;


&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;advanced&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;horizontalPodAutoscalerConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;

&lt;p&gt;So even picking the "right" queue-depth number doesn't save you.&lt;br&gt;
Is there a different metric we can use instead??&lt;/p&gt;

&lt;p&gt;Now you're asking the right questions.&lt;/p&gt;


&lt;h2&gt;
  
  
  Attempt 3 — the stabilized design (stable)
&lt;/h2&gt;

&lt;p&gt;Two requirements to stabilize the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scale on a signal that moves &lt;em&gt;before&lt;/em&gt; trouble, and&lt;/li&gt;
&lt;li&gt;Damp scale-down so a dip can't tear down capacity you just paid for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;First, let's look at some metrics and rank vLLM's metrics by &lt;em&gt;what they tell you and when&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;Tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_requests_waiting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 until full, then climbs&lt;/td&gt;
&lt;td&gt;you're &lt;strong&gt;already&lt;/strong&gt; overloaded (lagging)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;num_requests_running&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;proportional, flat once at &lt;code&gt;max_num_seqs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;how busy, up to the cap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;kv_cache_usage_perc&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;smooth 0–1 (fraction)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;how close to full — &lt;em&gt;before&lt;/em&gt; the queue forms (leading)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;KV-cache utilization is the right signal for vLLM because it rises &lt;strong&gt;before&lt;/strong&gt; requests queue, and it &lt;em&gt;is&lt;/em&gt; the binding resource the capacity math is about.&lt;br&gt;
Scaling up at ~80% buys you lead time — you add capacity while the current pod still has headroom, INSTEAD OF AFTER it's drowning.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://kps-kube-prometheus-stack-prometheus.monitoring.svc:9090&lt;/span&gt;
      &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum(vllm:kv_cache_usage_perc)&lt;/span&gt;   &lt;span class="c1"&gt;# V1 engine name; older builds use gpu_cache_usage_perc&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.80"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;IMPORTANT: Use &lt;code&gt;sum&lt;/code&gt;, not &lt;code&gt;avg&lt;/code&gt; — this is the part that actually kills the flap.&lt;/strong&gt; &lt;code&gt;sum(kv_cache_usage_perc)&lt;/code&gt; measures &lt;em&gt;total&lt;/em&gt; demand across pods (in "pod-fulls"), which doesn't change when you add a replica — so &lt;code&gt;ceil(sum / 0.80)&lt;/code&gt; has a &lt;strong&gt;stable fixed point&lt;/strong&gt;. An &lt;code&gt;avg&lt;/code&gt; falls as you add pods, drops back under the threshold, and reintroduces the exact oscillation you're trying to kill. That's the deeper rule behind this whole article: a good autoscaling signal is one whose &lt;strong&gt;total is invariant to replica count&lt;/strong&gt;. &lt;code&gt;num_requests_waiting&lt;/code&gt; fails it (collapses to 0 once you have enough capacity); &lt;code&gt;sum(kv_cache_usage_perc)&lt;/code&gt; passes it.&lt;/p&gt;

&lt;p&gt;OK, so how do we dampen the scale-down?&lt;/p&gt;

&lt;p&gt;Pair the signal with &lt;strong&gt;scale-down damping ≥ your measured lead time &lt;code&gt;L&lt;/code&gt;&lt;/strong&gt;, so a momentary dip never tears down capacity you just paid minutes to bring up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;advanced&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;horizontalPodAutoscalerConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;scaleDown&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;stabilizationWindowSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;gt;= L (node + engine init + readiness)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable ScaledObject &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt3-stabilized.yaml" rel="noopener noreferrer"&gt;scaledobject-attempt3-stabilized.yaml&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;Apply the load again (&lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/load_test.py" rel="noopener noreferrer"&gt;load_test.py&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70glpe0lfolbsmkaeten.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70glpe0lfolbsmkaeten.png" alt="Attempt 3 — kv_cache @ 80% + damping, replicas step up and hold, no oscillation" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We now have a &lt;strong&gt;stable&lt;/strong&gt; scale-up for processing.&lt;br&gt;
It works without trying to find the exact right threshold from the request queue!&lt;br&gt;
We don't even need to walk through the different points — the autoscaler handles it exactly like we want it to because we chose the right signal!&lt;/p&gt;

&lt;h3&gt;
  
  
  Oh, big deal... "the docs already say to add a cache trigger"
&lt;/h3&gt;

&lt;p&gt;They do — and that's the trap. Some (&lt;a href="https://dev.to/soniarotglam/why-vllm-autoscaling-on-kubernetes-breaks-and-what-to-use-instead-1231"&gt;e.g. this guide&lt;/a&gt;) suggest keeping the queue-depth trigger and &lt;em&gt;adding&lt;/em&gt; a second cache trigger, assuming the two combine into "scale only if both agree."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They don't, by default.&lt;/strong&gt; KEDA evaluates each trigger independently and takes the &lt;strong&gt;max&lt;/strong&gt; of their desired replica counts — that's OR logic, not AND. So the lagging, bistable queue-depth trigger can still drive scale-up and scale-down on its own, and the flap comes right back. (&lt;a href="https://keda.sh/docs/2.20/reference/faq/" rel="noopener noreferrer"&gt;KEDA docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;If you want the leading signal to govern scaling, &lt;strong&gt;use it alone&lt;/strong&gt; — drop the queue-depth trigger. If you genuinely need both conditions, don't just list two triggers and hope: that silently keeps the OR/max default. AND-style logic is possible, but only if you explicitly use the opt-in &lt;a href="https://keda.sh/docs/2.20/reference/scaledobject-spec/#scalingmodifiers" rel="noopener noreferrer"&gt;&lt;code&gt;scalingModifiers.formula&lt;/code&gt;&lt;/a&gt; to combine the named triggers yourself. Adding a plain second trigger feels safer and quietly isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Flapping is a signal-choice bug wearing a threshold costume. In order of impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scale on a leading, proportional signal&lt;/strong&gt; (&lt;code&gt;kv_cache_usage_perc&lt;/code&gt;), not a lagging saturation one (&lt;code&gt;num_requests_waiting&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query it as a sum, not an average&lt;/strong&gt; — &lt;code&gt;sum(vllm:kv_cache_usage_perc)&lt;/code&gt; measures &lt;em&gt;total&lt;/em&gt; demand across pods, which doesn't change when you add a replica.
Additional Suggestions:&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use scalingModifiers&lt;/strong&gt; if you need to combine metrics. Example here: &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-scalingmodifiers.yaml" rel="noopener noreferrer"&gt;scaledobject-scalingmodifiers.yaml&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Size scale-down damping to your actual lead time.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final working scaledObject used for Attempt 3 can be seen &lt;a href="https://github.com/JDoornink/vLLM_on_K8s/blob/main/scaled_Objects/scaledobject-attempt3-stabilized.yaml" rel="noopener noreferrer"&gt;here&lt;/a&gt; — trigger, threshold, and damping in one place.&lt;/p&gt;

&lt;p&gt;You will likely still want to calculate your threshold, but — it's the last decision, not the first. &lt;br&gt;
Get the signal right and most of the flapping is gone before you tune anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Keep pods warm
&lt;/h3&gt;

&lt;p&gt;You may not need the perfect signal. The cheapest lever against the timescale mismatch is &lt;strong&gt;over-provisioning&lt;/strong&gt; — set &lt;code&gt;minReplicaCount &amp;gt; 1&lt;/code&gt; so warm replicas absorb bursts without waiting minutes for a cold node.&lt;/p&gt;

&lt;p&gt;What it fixes: the lead-time problem directly, and it makes any residual flapping &lt;strong&gt;cheaper&lt;/strong&gt; (no cold starts) and &lt;strong&gt;rarer&lt;/strong&gt; (you scale from a higher floor). What it doesn't fix: the marginal 2→3 decision still rides whatever signal you chose. And it costs money — idle GPUs are the expensive kind.&lt;/p&gt;

&lt;p&gt;For a small deployment, &lt;strong&gt;warm headroom + a reasonable threshold is often good enough&lt;/strong&gt; — the leading-signal work is where it pays off at scale or when you're cost-sensitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  More pods per node
&lt;/h3&gt;

&lt;p&gt;If one-pod-per-GPU-node causes node churn, why not pack several pods per node? A couple of reasons this doesn't help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Flapping is a control-loop stability problem — pack three pods per GPU and the replica count still oscillates, just faster.&lt;/li&gt;
&lt;li&gt;Each pod gets a fraction of the 24 GB, so a fraction of the KV cache and a lower concurrent-request capacity (&lt;code&gt;C&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What changes at real scale
&lt;/h2&gt;

&lt;p&gt;Everything above is one GPU — basically a teaching rig. Once you're running a real fleet, the strategy actually flips.&lt;/p&gt;

&lt;p&gt;Instead of reacting fast to every queue blip, you want to &lt;strong&gt;over-provision warm headroom and scale infrequently&lt;/strong&gt;. If possible, use &lt;strong&gt;predictive&lt;/strong&gt; scaling — forecasting on daily/weekly patterns, not waiting on any live metric — for the traffic you can actually see coming, and let reactive scaling handle only the residual. Even that reactive layer should be the leading-signal kind from Attempt 3, not lagging queue-depth.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next: KV-cache-aware routing
&lt;/h2&gt;

&lt;p&gt;That's a topic for another article, but worth knowing it exists: at fleet scale, how you &lt;em&gt;route&lt;/em&gt; requests across pods (e.g. KV-cache-aware routing) becomes another lever alongside scaling. Not a replacement for getting the scaling signal right — just the next layer once you have more than a few pods.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/prefix-aware-routing.html" rel="noopener noreferrer"&gt;vLLM Production Stack — prefix-aware routing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.redhat.com/articles/2025/10/07/master-kv-cache-aware-routing-llm-d-efficient-ai-inference" rel="noopener noreferrer"&gt;Red Hat / llm-d — KV cache aware routing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>kubernetes</category>
      <category>autoscale</category>
      <category>gpu</category>
    </item>
  </channel>
</rss>
