Kubernetes Autoscaling 2025 — HPA, VPA, KEDA, and Cluster Autoscaler

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

Static replica counts are a liability in production. Under-provisioning causes latency spikes and errors during traffic peaks. Over-provisioning wastes cloud spend — often 30–50% of compute costs are idle capacity that autoscaling would reclaim.

Kubernetes provides several autoscaling mechanisms that work at different levels: pod replicas (HPA), pod resource allocation (VPA), event-driven scaling (KEDA), and node count (Cluster Autoscaler). Production clusters typically use all four in combination.

In 2025, cost pressure on cloud bills has made autoscaling one of the highest-ROI investments in any Kubernetes platform. Teams that implement proper autoscaling typically reduce cloud spend by 20–40% while improving availability.

Horizontal Pod Autoscaler (HPA)

HPA scales the number of Pod replicas based on observed metrics. The most common trigger is CPU utilization, but HPA v2 supports memory and custom metrics.

Prerequisites

HPA requires the Metrics Server:

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
 
# Verify
kubectl top nodes
kubectl top pods -n production

CPU-Based HPA

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70  # scale up when avg CPU > 70%
kubectl apply -f hpa.yaml
kubectl get hpa -n production -w
kubectl describe hpa api-hpa -n production

CPU + Memory HPA

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 50
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60   # wait 60s before scaling up again
      policies:
      - type: Pods
        value: 4
        periodSeconds: 60              # add up to 4 pods per 60 seconds
    scaleDown:
      stabilizationWindowSeconds: 300  # wait 5 min before scaling down
      policies:
      - type: Pods
        value: 2
        periodSeconds: 60              # remove at most 2 pods per 60 seconds

The behavior field prevents thrashing — rapid scale-up and scale-down cycles that cause instability.

Custom Metrics HPA (HTTP Requests Per Second)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-rps-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 30
  metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "1000"  # target 1000 RPS per pod

This requires Prometheus Adapter or a custom metrics adapter to expose http_requests_per_second from Prometheus.

Vertical Pod Autoscaler (VPA)

VPA adjusts the CPU and memory requests and limits of containers based on actual usage. It right-sizes containers, reducing waste from over-provisioning.

# Install VPA
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-up.sh
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  updatePolicy:
    updateMode: "Auto"       # automatically applies recommendations (requires pod restart)
    # Options: "Off" (recommend only), "Initial" (apply on new pods only), "Auto"
  resourcePolicy:
    containerPolicies:
    - containerName: api
      minAllowed:
        cpu: 50m
        memory: 64Mi
      maxAllowed:
        cpu: 2
        memory: 2Gi
      controlledResources: ["cpu", "memory"]
# Check VPA recommendations (works in any mode)
kubectl describe vpa api-vpa -n production
# Shows: Lower Bound, Target, Upper Bound recommendations

HPA + VPA caveat: Do not use HPA on CPU and VPA on CPU simultaneously — they conflict. Use VPA in Recommend mode for CPU right-sizing, and HPA for replica scaling.

KEDA — Kubernetes Event-Driven Autoscaling

KEDA scales deployments based on event sources: queue depth, Kafka lag, database query results, cron schedules, HTTP request rate, and 60+ other scalers.

helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace

Scale on SQS Queue Depth

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: queue-worker
  minReplicaCount: 0     # KEDA can scale to zero!
  maxReplicaCount: 50
  cooldownPeriod: 300    # seconds before scaling to zero
  triggers:
  - type: aws-sqs-queue
    metadata:
      queueURL: https://sqs.us-east-1.amazonaws.com/123456/my-queue
      queueLength: "10"         # scale up when > 10 messages per pod
      awsRegion: us-east-1
    authenticationRef:
      name: keda-aws-credentials

Scale on Kafka Consumer Lag

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-consumer-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: kafka-consumer
  minReplicaCount: 1
  maxReplicaCount: 20
  triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka.production.svc:9092
      consumerGroup: my-consumer-group
      topic: events
      lagThreshold: "100"       # 100 messages of lag per pod

Scale on Cron Schedule (Predictive Scaling)

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: api-cron-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: api
  triggers:
  - type: cron
    metadata:
      timezone: America/New_York
      start: "0 8 * * 1-5"    # 8am weekdays
      end: "0 20 * * 1-5"     # 8pm weekdays
      desiredReplicas: "10"
  - type: cron
    metadata:
      timezone: America/New_York
      start: "0 20 * * 1-5"   # 8pm weekdays (off hours)
      end: "0 8 * * 1-5"      # 8am next day
      desiredReplicas: "2"

Cluster Autoscaler — Scaling Nodes

Cluster Autoscaler adds nodes when pods cannot be scheduled (insufficient resources) and removes nodes when they are underutilized for 10 minutes.

AWS EKS Setup

# cluster-autoscaler deployment (simplified)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  template:
    spec:
      containers:
      - image: registry.k8s.io/autoscaling/cluster-autoscaler:v1.29.0
        name: cluster-autoscaler
        command:
        - ./cluster-autoscaler
        - --cloud-provider=aws
        - --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/my-cluster
        - --scale-down-utilization-threshold=0.5  # remove nodes below 50% utilization
        - --skip-nodes-with-local-storage=false
        - --expander=least-waste

Tag your Auto Scaling Groups for discovery:

# Terraform
resource "aws_autoscaling_group" "workers" {
  tag {
    key                 = "k8s.io/cluster-autoscaler/enabled"
    value               = "true"
    propagate_at_launch = true
  }
  tag {
    key                 = "k8s.io/cluster-autoscaler/my-cluster"
    value               = "owned"
    propagate_at_launch = true
  }
}

Karpenter (AWS Alternative, Faster)

Karpenter provisions nodes in 60 seconds vs Cluster Autoscaler's 3–5 minutes:

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      requirements:
      - key: karpenter.sh/capacity-type
        operator: In
        values: ["on-demand", "spot"]
      - key: node.kubernetes.io/instance-type
        operator: In
        values: ["m5.large", "m5.xlarge", "m5.2xlarge"]
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 30s

Monitoring Autoscaling

# HPA status
kubectl get hpa -A
kubectl describe hpa api-hpa -n production
 
# Pod scaling events
kubectl get events -n production --sort-by='.lastTimestamp' | grep -i scale
 
# Resource usage
kubectl top pods -n production --sort-by=cpu
 
# KEDA ScaledObject status
kubectl get scaledobject -n production

Common Mistakes

  • Setting minReplicas: 1 — one pod is still a single point of failure; use at least 2
  • Not configuring scale-down stabilization — HPA can thrash (scale up, scale down, scale up) within minutes
  • Using HPA on CPU without setting CPU requests — HPA calculates utilization as a percentage of requests; without requests, HPA does not work
  • Scaling to zero without a scale-from-zero trigger — pods must be triggered to wake up (KEDA handles this; HPA does not)
  • Not testing autoscaling behavior under load before production — use tools like k6 or Locust to simulate traffic

Best Practices

  • Set HPA minReplicas to at least 2 for high-availability; use PodDisruptionBudgets to prevent all replicas from being removed during node drain
  • Use stabilization windows to prevent HPA thrashing during brief load spikes
  • Use KEDA for queue and event-driven workloads — it scales to zero, reducing costs for sporadic jobs
  • Combine HPA (replicas) with VPA in Recommend mode (right-size requests) — do not use both on the same metric
  • Use Karpenter on AWS EKS instead of Cluster Autoscaler — faster provisioning and better bin-packing

Key Takeaways

  • HPA scales pod replicas based on CPU, memory, or custom metrics — requires Metrics Server and CPU/memory requests to be set
  • HPA behavior with stabilization windows prevents thrashing during brief traffic spikes
  • VPA right-sizes container resource requests and limits based on historical usage — reduces waste from over-provisioning
  • KEDA enables event-driven autoscaling from SQS, Kafka, cron, HTTP, and 60+ other sources — and can scale to zero
  • Cluster Autoscaler adds and removes nodes based on scheduling pressure and utilization thresholds
  • Karpenter (AWS) provisions nodes 3–5x faster than Cluster Autoscaler and supports Spot interruption handling
  • Autoscaling requires proper CPU requests on all containers — without requests, HPA cannot calculate utilization correctly
  • Testing autoscaling with load tests (k6, Locust) before production is essential — autoscaling behavior under real load is unpredictable without testing

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading