HPA behaviour and why scaling lags

Kubernetes · lesson 31 of 32 · 6 min read

The algorithm, the delays in it, and why CPU is often the wrong signal.

Open this lesson in the learning hub

Key points

  • The HPA formula is simple: desired replicas equals current replicas multiplied by current metric over target metric, rounded up. Everything difficult is in the delays around it.
  • There are several: metrics scrape interval, the metrics server window, the HPA sync period (15s by default), then pod start and warm-up. Total reaction time is commonly a minute or more.
  • For a JVM the warm-up matters as much as the scheduling. A new pod is interpreted and cold, so it is slow exactly when it is being sent its share of a traffic spike.
  • CPU is frequently the wrong signal. A service that is slow because a downstream is slow shows low CPU, so a CPU-based HPA will not scale - and if it did, it would add load to the struggling downstream.
  • Queue depth, request concurrency or consumer lag are usually better signals because they track work waiting rather than work being done.
  • Scale-down is deliberately conservative - a five-minute stabilisation window by default - to avoid flapping. Scale-up can be aggressive; the two directions should not share a policy.

Example

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: orders }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: orders }
  minReplicas: 3
  maxReplicas: 30
  metrics:
    # Concurrency tracks work WAITING, which CPU does not.
    - type: Pods
      pods:
        metric: { name: http_inflight_requests }
        target: { type: AverageValue, averageValue: "30" }

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0      # react immediately
      policies:
        - type: Percent
          value: 100                     # may double
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 300    # 5 min - avoid flapping
      policies:
        - type: Percent
          value: 10                      # shed slowly
          periodSeconds: 60

---
# desiredReplicas = ceil(currentReplicas x currentMetric / targetMetric)
#
#   4 pods, 60 in-flight each, target 30  ->  ceil(4 x 60/30) = 8
#
# The delays that make this feel slow:
#   scrape interval        ~15s
#   HPA sync period        ~15s
#   pod schedule + pull    ~10-60s
#   JVM start + warm-up    ~20-60s
#   ------------------------------
#   realistic total        ~1-3 minutes before new capacity is USEFUL
#
# Which is why minReplicas must absorb a spike on its own. Autoscaling
# handles a trend; it does not handle a step change.

# Give a cold JVM time before it counts as ready:
#   startupProbe:
#     httpGet: { path: /actuator/health/readiness, port: 8080 }
#     failureThreshold: 30
#     periodSeconds: 5        # up to 150s to start, without a slow
#                             # livenessProbe killing it mid-warm-up

Autoscaling reacts in minutes, not seconds - size minReplicas for the spike and scale on queued work rather than CPU.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Kubernetes course, and every lesson in it is listed on the Kubernetes contents page.