Home → Engineering
How Pod Scaling Actually Works
The campaign started, traffic tripled. The autoscaler kicked in and pods went from 4 to 24. And response times got worse. Then the database stopped accepting new connections.
- Scaling moves the bottleneck, it does not remove it. Find out where it is first.
- CPU is the wrong metric for most web applications. An app that waits burns no CPU but is slow.
- Database connections multiply with pod count. Do not grow the top without protecting the bottom.
- A wrong
requestmeans wrong scaling. Utilisation is computed against the request, not the limit.
What is the autoscaler actually doing?
It looks complicated but it is one proportion:
desired_pods = current_pods × (current_value / target_value)
# example: 4 pods, average CPU 90%, target 60%
# desired = 4 × (90 / 60) = 6 pods
That is all. The whole question is what you chose as "current_value". And most teams choose CPU and stop thinking about it.
Why is CPU usually the wrong metric?
Look at the life of a typical web request: 3 ms parsing JSON, 80 ms waiting on the database, 2 ms serialising. The application spends 95% of its time waiting, and waiting costs no CPU.
So the system can be on its knees while CPU shows 25%. The autoscaler concludes everything is fine and does nothing.
| Application type | Right metric | Why |
|---|---|---|
| Image processing, encryption, report generation | CPU | Genuinely CPU bound |
| Typical REST API | Concurrent requests / RPS | Most time is spent waiting |
| Queue worker | Queue length | Backlog is the leading indicator of latency |
| Long-lived connections (WebSocket, SSE) | Open connection count | Memory and socket limits dominate |
# with KEDA: one worker per 100 pending messages
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
scaleTargetRef:
name: notification-worker
minReplicaCount: 2
maxReplicaCount: 40
triggers:
- type: rabbitmq
metadata:
queueName: notifications
queueLength: "100"
For workers this is far better than CPU: it reacts the moment the queue starts growing, and it stays correct even when workers are slow because they are waiting.
requests and limits: the two most misconfigured fields
Confuse these two and scaling is computed wrongly from the start.
The guaranteed share. The scheduler uses it when placing the pod. And crucially: the autoscaler's utilisation is computed against it.
The ceiling. Exceed it on CPU and you get throttled; exceed it on memory and you get killed. Two very different sensations.
resources:
requests:
cpu: 100m # ← it actually uses 400m
limits:
cpu: 1000m
# Say the HPA target is 70%. The maths uses the request:
# 400m / 100m = 400% utilisation
# desired = 4 × (400 / 70) ≈ 23 pods
#
# The application is perfectly comfortable. The autoscaler panicked.
The reverse happens too: set the request too high (say 2000m) and utilisation always looks low, the autoscaler never fires and the system buckles under load.
Practical rule: the request should be slightly above real usage at normal load. Not guessed, measured. Look at the 90th percentile of real usage over a week and write that.
About CPU limits
A process hitting its CPU limit is not killed, it is throttled. That is one of the hardest latency sources to diagnose: no errors, clean logs, and the CPU graph even looks below the limit — while requests stall.
The reason is that throttling is applied over very short windows. Even if your average is below the limit, short bursts get you stopped for milliseconds at a time. The only way to see it is to look at the throttling metric directly:
container_cpu_cfs_throttled_seconds_total
# If this keeps climbing, your CPU limit is too low.
# A common answer for latency-sensitive services is to set
# no CPU limit at all (a request alone is enough).
The real trap: the bottleneck underneath
Back to the opening story. Pods went from 4 to 24. Each pod opened a pool of 20 connections to the database.
4 pods × 20 connections = 80 ✓ (limit 200)
24 pods × 20 connections = 480 ✗ (limit 200)
Result: the database refuses new connections.
The application throws connection timeouts.
Health checks fail, pods are restarted.
Restarted pods ask for 20 connections again...
That is a vicious circle, and scaling accelerates it. Had the system never scaled, it would have been slow but alive.
Three defences:
-
Size the pool against pod count.
pool_per_pod × max_pods ≤ database_limit × 0.8. If you target 24 pods, the pool should be 6, not 20. - Use a connection pooler. A layer like PgBouncer reduces hundreds of application connections to tens of real ones. It is the one structural change that makes scaling possible.
-
Do not pick
maxReplicasarbitrarily. That number should be derived from what the weakest link below can take. The autoscaler's upper bound is a seatbelt; "let us put 100 to be safe" is taking the belt off.
The slow-starting application problem
We hit this on a Java service: a new pod comes up, reports itself ready, starts taking traffic — and answers very slowly for the first 30 seconds (JIT warm-up, cache filling, connections being established).
Result: the new pod raises average latency, the autoscaler reads that as insufficient capacity and starts another pod, which also starts slowly... This is called flapping and it looks like a sawtooth on a graph.
# 1) Do not take traffic until warm
readinessProbe:
httpGet: { path: /ready, port: 8080 }
initialDelaySeconds: 20
periodSeconds: 5
# 2) Do not mistake a slow start for death (breaks the restart loop)
startupProbe:
httpGet: { path: /health, port: 8080 }
failureThreshold: 30
periodSeconds: 5
# 3) Give the autoscaler time to cool down
behavior:
scaleUp:
stabilizationWindowSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
The third is especially important: scaling down should be slower than scaling up. If traffic fluctuates, a system that shrinks quickly will reopen the same pods two minutes later and pay the cold-start cost again and again.
Losing work while scaling down
The least discussed part of scaling posts. When a pod is terminated it may still hold half-finished work.
- On the termination signal, stop taking new work and finish what you hold.
terminationGracePeriodSecondsmust exceed your longest job. The default is 30 seconds; if a job takes five minutes, that is not enough.- For long jobs: put the work back on the queue as the pod shuts down so another worker can take it.
- For web services: fail the readiness probe first, wait a few seconds, then shut down — the load balancer takes time to remove you from its list.
When is vertical growth the better answer?
Horizontal is not the answer to everything. Adding pods will not help when:
- A single request is slow. An 8-second query takes 8 seconds with 40 pods too.
- The app holds a large in-memory cache. Every pod keeps its own copy; the waste multiplies.
- There is a per-connection or per-instance licence cost. Replica count goes straight onto the invoice.
In those cases you need to fix the query, the cache or the algorithm first. Scaling is a capacity solution, not a performance solution.
Checklist
- Have I measured where the bottleneck is? (App, database, external service?)
- Is the scaling metric right? (CPU, queue, or concurrent requests?)
- Are
requestvalues set from real usage? - Have I looked at the CPU throttling metric?
- Is
maxReplicas × connections_per_podbelow the database limit? - Does the readiness probe pass only when the app is genuinely ready?
- Is the scale-down window longer than scale-up?
- What happens to half-finished work when a pod shuts down?
- Have I load-tested scaling, or will I learn in production?
Conclusion
Autoscaling set up well means sleeping at night; set up badly it is a mechanism that accelerates a collapse. The difference is not the number of pods but which number you watch and whether you protected what is underneath.
In the incident above, the fix was fewer pods — we went from 24 down to 8, put a connection pooler in front of the database, and changed the metric from CPU to concurrent requests. The system got faster on fewer resources.