Autoscaling on EKS

Autoscaling on Kubernetes is not one feature. It is two independent control loops stacked on top of each other, and most scaling problems come from one loop working while the other does not, or from the two working against each other.

load ──► pod autoscaler decides MORE PODS ──► scheduler cannot place them ──► node autoscaler adds NODES
         (HPA / KEDA / VPA)                    (pods Pending)                  (Karpenter / Cluster Autoscaler / Auto Mode)

The layers

LayerToolChangesReacts to
PodsHPAReplica countCPU, memory, custom and external metrics
PodsKEDAReplica count, including to zeroEvents: queue depth, stream lag, schedules
PodsVPACPU and memory requestsObserved usage over time
NodesKarpenter / Auto ModeInstances, chosen per pending podUnschedulable pods
NodesCluster AutoscalerSize of node groupsUnschedulable pods

The node layer responds only to pods that cannot be scheduled. It knows nothing about load. If the pod layer does not ask for more pods, no nodes are added, however hot the existing ones run.

Everything depends on requests

Both layers reason about requests, not actual usage:

  • HPA’s CPU target is a percentage of the request. A pod with no CPU request cannot be scaled on CPU at all.
  • The scheduler and the node autoscaler pack nodes by requests. Requests far above real usage mean half-empty nodes; requests far below it mean overloaded nodes that the autoscaler considers full of room.

Getting requests right is the first tuning step, before any autoscaler setting. See requests and limits; VPA in recommendation mode is a good way to find sensible values.

The timeline of a scale-up

StepTypical duration
Metric scraped and visible to HPA15–60 s
HPA evaluates and raises replicasup to 15 s
Scheduler marks the new pods unschedulableseconds
Karpenter launches an instance30–60 s
(Cluster Autoscaler + Auto Scaling group instead)1–3 min
Node boots, joins, CNI ready20–60 s
Image pullseconds to minutes
Application start and readinessseconds to minutes

End to end, new capacity for a traffic spike takes one to five minutes. If your traffic doubles faster than that, autoscaling alone will not save you.

Shortening it

LeverHow
Scale on a leading signalRequests per second or queue depth rise before CPU does. Use custom metrics or KEDA.
Lower the HPA targetA 50–60% CPU target leaves room to absorb load while new pods start
Headroom podsLow-priority placeholder pods reserve spare capacity; real pods preempt them instantly and the placeholders trigger new nodes in the background — priority and preemption
Faster nodesKarpenter over Cluster Autoscaler; Bottlerocket or minimal AMIs; avoid heavy user data
Smaller images, or pre-pulledImage size is often the largest single delay
Fast, honest readinessSlow start-up and long initial delays hold traffic back — probes
Scheduled scalingFor predictable peaks, raise minReplicas ahead of time with KEDA’s cron scaler
Tune HPA behaviourScale up aggressively, scale down slowly
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: checkout
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: checkout }
  minReplicas: 4
  maxReplicas: 60
  metrics:
    - type: Resource
      resource:
        { name: cpu, target: { type: Utilization, averageUtilization: 60 } }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies: [{ type: Percent, value: 100, periodSeconds: 30 }]
    scaleDown:
      stabilizationWindowSeconds: 300
      policies: [{ type: Percent, value: 20, periodSeconds: 60 }]

Event-driven scaling and scale to zero

HPA cannot go below one replica. KEDA can, which suits queue workers and anything idle most of the day:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-worker
spec:
  scaleTargetRef: { name: order-worker }
  minReplicaCount: 0
  maxReplicaCount: 50
  triggers:
    - type: aws-sqs-queue
      authenticationRef: { name: keda-aws }
      metadata:
        queueURL: https://sqs.eu-west-1.amazonaws.com/111122223333/orders
        queueLength: "20"
        awsRegion: eu-west-1

KEDA reads the queue with its own IAM role (Pod Identity), creates an HPA behind the scenes, and combined with Karpenter the nodes disappear too when nothing is running. Scaling from zero pays the full cold-start timeline above, so use it where a delay of a minute or two is acceptable.

Conflicts to avoid

CombinationProblemResolution
HPA and VPA both on CPU or memoryVPA changes requests, which changes HPA’s utilisation, which changes replicas — they oscillateHPA on a custom metric with VPA on resources, or VPA in recommendation-only mode
HPA and a manifest that sets replicasEvery deployment resets the countOmit replicas from the manifest; tell GitOps to ignore the field
Node consolidation and an HPA that flapsNodes are removed just before pods returnLonger scale-down stabilisation and consolidateAfter
Aggressive scale-down without PDBsNode removal evicts too many replicas at onceA PDB on every multi-replica workload
Two autoscalers managing the same nodesThey fight over the same capacityOne node autoscaler per set of nodes

Limits and guardrails

Autoscaling without bounds is a way to turn a bug into a large bill.

  • Set maxReplicas deliberately, and NodePool limits or node group maxSize.
  • Check quotas: EC2 vCPU limits per instance family, and free IP addresses per subnet — running out of pod IPs is a common ceiling on EKS (ENI allocation).
  • Remember downstream limits. Scaling the API tier tenfold moves the bottleneck to the database connection limit.
  • Alert on pods Pending for more than a few minutes and on HPAs pinned at maxReplicas — both mean scaling has hit a wall.

Checking that it works

kubectl get hpa -A                                  # current vs target, and replicas
kubectl describe hpa checkout                       # events explain each decision
kubectl get pods --field-selector=status.phase=Pending -A
kubectl get nodeclaims                              # Karpenter's view
kubectl top pods -n payments

Then prove it under load: run a load test that doubles traffic, and time each step of the table above. See the auto-scaling guide and performance testing.

Across the wiki