We closed module 8 with a secure, auditable platform, but with an uncomfortable confession: the Rutas Norte components still have a fixed, hand-written replica count. bookings-api has replicas: 4 and web-store has replicas: 3 because someone, months ago, took a measurement on an ordinary Tuesday and thought it was enough. That number knows nothing about the May bank-holiday weekend. Last year the platform fell over eleven minutes after the sale opened, and the team's answer was a hurried kubectl scale from a laptop, on a station platform, with the train pulling out.

This lesson removes that scramble. The HorizontalPodAutoscaler (HPA) is the Kubernetes object that automatically adjusts a Deployment's replica count according to observed metrics. It is not magic: it is one more controller, with its reconciliation loop — the same one we studied in module 1 —, a very concrete piece of arithmetic and a handful of parameters you need to understand properly if the result is to be stability rather than a pendulum of pods starting up and dying.

We are going to look at which workloads can be scaled horizontally and which cannot, the full autoscaling/v2 API, the exact formula used to compute the desired replicas, the behavior block that governs how fast it scales up and down, and the two definitive manifests for bookings-api and web-store. We will finish with a conflict that bites almost every team the first time round: the HPA and the replicas field of the versioned YAML fighting over the same number.

Contents

  1. Scaling out versus scaling up
  2. Which workloads support horizontal scaling and which do not
  3. Anatomy of the HorizontalPodAutoscaler in autoscaling/v2
  4. The four metric types: Resource, Pods, Object and External
  5. The controller's exact formula, step by step
  6. The tolerance band and replica flapping
  7. Non-negotiable requirements: requests and metrics-server
  8. Diagnosing the dreaded <unknown>
  9. The behavior block: policies, periods and the stabilisation window
  10. Several metrics at once: the greediest one wins
  11. The Rutas Norte HPAs, complete manifests
  12. Simulating a load spike and watching it live
  13. The conflict between the HPA and the replicas field
  14. What not to scale with an HPA, and the real bottleneck
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. Scaling out versus scaling up

There are exactly two ways to give a service more capacity, and it is worth keeping the names straight because the rest of the module leans on them.

Scaling up (scale up, vertical scaling) means giving each instance more resources: raising the CPU requests/limits from 200m to 800m, the memory from 256Mi to 1Gi. The number of pods does not change; each pod is bigger. In Kubernetes this means recreating the pod, because a container's resources are part of its immutable specification (with the caveat of in-place resizing, which we will see in 09-02).

Scaling out (scale out, horizontal scaling) means adding more instances of the same size: going from 4 bookings-api pods to 12 identical pods. Each pod is still exactly as big; there are simply more of them. In Kubernetes this is changing one integer: the Deployment's replicas.

Aspect Vertical scaling (up) Horizontal scaling (out)
What changes The size of each pod (requests/limits) The number of pods
Ceiling The largest node available Practically unlimited (given nodes)
Requires restarting the pod Yes (unless in-place resizing) No, existing pods are untouched
Requirement on the application That it can make use of more CPU/RAM That it has no local state or session affinity
Fault tolerance No improvement: there is still a single point Improves: load is spread across more instances
Granularity cost Jumps in big steps Fine-grained: one pod at a time
Kubernetes object VPA (09-02) or manual editing HPA (this lesson), KEDA (09-04)
Reaction speed Slow (pod recreation) Fast (seconds, if the image is cached)

The rule of thumb: vertical scaling solves the problem of a badly sized instance; horizontal scaling solves the problem of traffic volume. They are not alternatives, they are different axes. Rutas Norte needs both: getting each pod's size right (09-02) and multiplying the number of pods when the May bank holiday arrives (this lesson).

One important detail many people overlook: horizontal scaling also improves availability, whereas vertical scaling does not. Twelve bookings-api pods spread across nodes survive the loss of a node; a single enormous pod does not. We will return to this idea in depth in 09-05.

  1. Which workloads support horizontal scaling and which do not

Horizontal scaling only works if any replica can serve any request. This is called being stateless, and it is a property of the application, not of Kubernetes. Kubernetes will happily let you scale anything; whether the result is correct depends on how the application is written.

Let's go over the Rutas Norte components:

Component Horizontally scalable? Reason
web-store (nginx) Yes, no reservations Serves static assets and acts as a proxy. Keeps nothing between requests.
bookings-api (Node.js) Yes Stateless REST API: the session lives in a signed token, the state in PostgreSQL and redis-cache.
notifications-worker Yes, but CPU is not the right signal It consumes from a queue; adding consumers drains it faster. The useful metric is queue length (09-04).
redis-cache Not with an HPA It is a cache with partitioned state. Adding replicas does not spread the load without explicit sharding.
bookings-postgres Emphatically not Primary database. Adding pods does not create more databases: it creates replicas that fight over the same volume or end up orphaned.
occupancy-reports (CronJob) Not applicable Its parallelism is controlled with parallelism on the Job (06-03), not with an HPA.

The bookings-postgres case deserves a paragraph. It is a StatefulSet with one persistent volume per replica. If you put an HPA on it and the HPA decided to go from 1 to 5 replicas, Kubernetes would create five pods bookings-postgres-0 through bookings-postgres-4, each with its own empty PVC, each starting an independent, empty database. The Service would spread requests across all five. The result would be silent data corruption: some bookings would go to one database and some to another. Scaling a relational database is a data-architecture problem (read replicas, partitioning, an operator that knows how to do it — 06-07), not a problem of counting pods.

A rule worth memorising: if adding a replica does not make the system answer more correct requests per second, the HPA is not your tool.

  1. Anatomy of the HorizontalPodAutoscaler in autoscaling/v2

The HPA is an API object like any other. Its stable, current version is autoscaling/v2; the v2beta1 and v2beta2 versions have been removed for several releases now, and autoscaling/v1 only supports CPU (the server still serves it for compatibility, but do not use it: you lose behavior and every metric that is not CPU).

Here is the complete skeleton, with comments on each field:

apiVersion: autoscaling/v2          # Stable version. NEVER autoscaling/v1 in new material.
kind: HorizontalPodAutoscaler
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
spec:
  # WHICH object gets its replica count changed.
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment              # A StatefulSet, or any resource with a /scale subresource, also works
    name: bookings-api            # Must exist in the SAME namespace as the HPA

  minReplicas: 4                  # Floor. The HPA will never go below this.
  maxReplicas: 30                 # Ceiling. The HPA will never go above this, whatever happens.

  # WHAT it looks at to decide. It is a LIST: there can be several.
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65  # Target: 65% of the CPU requests, averaged across pods

  # HOW fast it goes up and down. Optional but almost always necessary.
  behavior:
    scaleUp: {}
    scaleDown: {}

Three starting points worth nailing down before we go on:

  • scaleTargetRef points at an object that exposes the /scale subresource. Deployments, ReplicaSets and StatefulSets expose it. A DaemonSet does not, because its pod count is determined by the number of nodes (06-02), and it would make no sense.
  • minReplicas and maxReplicas are hard barriers. maxReplicas is your financial and operational safety net: if an application bug drives CPU to 100% with no real traffic, the HPA will try to scale forever; the ceiling stops it. Always set it, and set it thinking about what would happen if it were reached.
  • minReplicas can be 0 from Kubernetes 1.30 onwards if the HPAScaleToZero feature gate is enabled, but that requires an external or object metric and is not enabled by default in most clusters. Real scale-to-zero we will do with KEDA in 09-04.

The controller loop

The HPA is driven by the horizontal-pod-autoscaler that lives inside the kube-controller-manager. Its default cycle is 15 seconds (--horizontal-pod-autoscaler-sync-period). On each cycle:

flowchart TD
    A[Every 15 s] --> B[Read the HPA and the target object]
    B --> C[Query the metrics<br/>metrics.k8s.io or aggregation APIs]
    C --> D{Metrics available?}
    D -- No --> E[TARGETS = unknown<br/>no scaling, emits event]
    D -- Yes --> F[Apply the formula<br/>desired replicas]
    F --> G{Inside the<br/>tolerance band?}
    G -- Yes --> H[Does nothing]
    G -- No --> I[Apply behavior:<br/>stabilisation and policies]
    I --> J[Clamp between min and maxReplicas]
    J --> K[PATCH the /scale subresource]
    K --> L[The Deployment adjusts its ReplicaSet]

Note the end of the flow: the HPA does not create pods. It modifies the Deployment's replicas field through the /scale subresource, and from there the Deployment controller and the ReplicaSet controller do their usual work (module 2). It is chained reconciliation, exactly the pattern we saw in 01-02.

  1. The four metric types: Resource, Pods, Object and External

The metrics block accepts four types. You will use two of them today; the other two are defined here and put to work in 09-04.

Type Where the data comes from What it measures Scope Used in
Resource metrics-server (metrics.k8s.io) CPU or memory of the target's pods Per pod, averaged 09-01
ContainerResource metrics-server CPU/memory of one specific container in the pod Per container 09-01
Pods Custom metrics API (custom.metrics.k8s.io) Any metric emitted by the target's pods Per pod, averaged 09-04
Object Custom metrics API A metric attached to another Kubernetes object (an Ingress, a Service) Single value 09-04
External External metrics API (external.metrics.k8s.io) Something from outside the cluster: a queue length, messages on a topic Single value or divided among pods 09-04

Resource with Utilization

This is the most common form. It expresses the target as a percentage of the container's requests:

metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 65

This means: "keep the average CPU consumption of the bookings-api pods at around 65% of what each one has reserved in requests.cpu". If requests.cpu is 500m, the target is 325m per pod.

It is essential to understand that Utilization is computed against requests, not against limits nor against the node's CPU. A pod can show 180% utilisation if it consumes 900m with a requests of 500m; that is perfectly possible because requests is a minimum reservation, not a cap (module 3).

Resource with AverageValue

This expresses the target in absolute units, ignoring requests:

metrics:
  - type: Resource
    resource:
      name: memory
      target:
        type: AverageValue
        averageValue: 700Mi

"Keep the average memory consumption per pod at 700Mi."

When should you use each one?

Utilization AverageValue
Expressed in Percentage of requests Units (m of CPU, Mi of memory)
Requires requests to be declared Yes, mandatory No
Breaks if you change requests Yes: the real target moves on its own No
Readability for the team High ("at 65% of its own") Medium
Recommended for CPU, the general case Memory, or when requests changes often (VPA)

A warning about memory as an HPA metric: it is almost never a good idea. Many runtimes (the JVM, the Node.js garbage collector, PostgreSQL) do not return memory to the system even when they no longer need it. Consumption goes up and stays up. An HPA driven by memory would scale out and never come back down, because the memory of the old pods never drops below the target. Use CPU, or better still, a business metric (09-04).

ContainerResource

A variant of Resource that looks at one specific container instead of adding up all of the pod's. It is very useful in Rutas Norte, where several pods carry sidecars:

metrics:
  - type: ContainerResource
    containerResource:
      name: cpu
      container: api                # Only the "api" container, not the metrics sidecar
      target:
        type: Utilization
        averageUtilization: 65

Without this, a sidecar burning a constant 50m distorts the percentage for the whole pod, and in small pods the distortion is large. If your target has sidecars, prefer ContainerResource.

Pods, Object and External (definition)

We define them now so that you recognise the syntax, but their real use arrives in 09-04.

# Pods: a metric emitted by the target's own pods, averaged across them.
- type: Pods
  pods:
    metric:
      name: requests_per_second
    target:
      type: AverageValue
      averageValue: "80"          # 80 requests per second and pod

# Object: a metric attached to ANOTHER object in the cluster.
- type: Object
  object:
    describedObject:
      apiVersion: networking.k8s.io/v1
      kind: Ingress
      name: rutas-norte-public
    metric:
      name: requests_per_second
    target:
      type: Value
      value: "2000"               # TOTAL value of the object, not per pod

# External: something from outside the cluster.
- type: External
  external:
    metric:
      name: email_queue_length
      selector:
        matchLabels:
          queue: confirmation-notifications
    target:
      type: AverageValue
      averageValue: "500"         # 500 pending messages per pod

The key difference between Value and AverageValue in Object and External: with Value the HPA compares the raw value against the target; with AverageValue it divides the value by the number of replicas before comparing. For a queue you always want AverageValue ("each worker takes charge of 500 messages"), because that is what makes the replica count grow with the queue.

The Pods, Object and External types do not work out of the box: they need a metrics adapter registered in the API aggregation layer. Without one, the HPA will report <unknown> forever. That is exactly what KEDA solves in 09-04.

  1. The controller's exact formula, step by step

Here is the heart of the lesson. All of the HPA's behaviour comes out of a single expression:

desiredReplicas = ceil( currentReplicas × ( currentValue / targetValue ) )

Where ceil() is rounding upwards to the next integer. Let's apply it to bookings-api with real numbers.

Starting situation

bookings-api is deployed with these resources (we set them in module 3):

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: 1Gi

And its HPA has averageUtilization: 65, minReplicas: 4, maxReplicas: 30.

Absolute target per pod: 65% of 500m = 325m.

Case 1: Tuesday afternoon, all quiet

There are 4 replicas. kubectl top pods shows:

NAME                            CPU(cores)   MEMORY(bytes)
bookings-api-7c9d4f8b6d-2mk8p   140m         310Mi
bookings-api-7c9d4f8b6d-5xqzn   155m         298Mi
bookings-api-7c9d4f8b6d-9jw4t   132m         305Mi
bookings-api-7c9d4f8b6d-hb7rc   149m         301Mi

Average: (140 + 155 + 132 + 149) / 4 = 144m.

desiredReplicas = ceil( 4 × (144 / 325) )
                = ceil( 4 × 0.443 )
                = ceil( 1.772 )
                = 2

The formula asks for 2 replicas. But minReplicas is 4, so it stays at 4. Correct: we do not want to go below 4 in production for availability reasons.

Case 2: the May bank-holiday sale opens

There are still 4 replicas, but traffic goes through the roof:

NAME                            CPU(cores)   MEMORY(bytes)
bookings-api-7c9d4f8b6d-2mk8p   910m         680Mi
bookings-api-7c9d4f8b6d-5xqzn   940m         702Mi
bookings-api-7c9d4f8b6d-9jw4t   895m         671Mi
bookings-api-7c9d4f8b6d-hb7rc   925m         694Mi

Average: (910 + 940 + 895 + 925) / 4 = 917.5m. Note: they are brushing against their 1000m limit, which means they are being throttled (module 3).

desiredReplicas = ceil( 4 × (917.5 / 325) )
                = ceil( 4 × 2.823 )
                = ceil( 11.29 )
                = 12

The HPA wants 12 replicas. That is below maxReplicas: 30, so it is applied (subject to behavior, which we will see in section 9).

Notice the elegance of the formula: it multiplies the current count by the excess factor. If you are at three times the target with 4 pods, it asks for 12 pods. It is a rule of three that implicitly assumes that load is spread evenly across the replicas, which is true if the Service balances reasonably and the requests are homogeneous.

Case 3: the spike passes

Now there are 12 replicas and the average drops to 120m:

desiredReplicas = ceil( 12 × (120 / 325) )
                = ceil( 12 × 0.369 )
                = ceil( 4.43 )
                = 5

It asks for 5 replicas. It will go from 12 down to 5, but not all at once: the stabilisation window and the scaleDown policies control the pace (section 9).

Fine details of the formula that almost nobody knows

These nuances explain behaviours that otherwise look erratic:

  1. Pods without metrics. If a pod has no metrics yet (it has just started), the HPA excludes it from the average but counts it conservatively: when scaling up it assumes 0 consumption for that pod (so as not to overestimate), and when scaling down it assumes consumption equal to the target (so as not to underestimate). The result is that the HPA is cautious in both directions while pods are starting.

  2. Not-ready pods. Pods that have not passed their readinessProbe are excluded from the calculation when scaling up. This avoids the classic vicious loop: new pods that are not yet receiving traffic, with high CPU because they are starting, would make the HPA believe even more scaling is needed.

  3. --horizontal-pod-autoscaler-initial-readiness-delay (30 s by default): during a pod's first 30 seconds of life, its metrics are ignored entirely. The rationale is clear: Node.js start-up burns a lot of CPU compiling and loading modules, and we do not want that spike to contaminate the decision.

  4. --horizontal-pod-autoscaler-cpu-initialization-period (5 min by default): an extra margin for the CPU metrics of freshly ready pods.

The practical consequence of points 3 and 4 is that the HPA has a start-up latency of at least half a minute per decision, and that is why the over-provisioning of 09-03 and the cron-based pre-warming of 09-04 make sense.

  1. The tolerance band and replica flapping

If the HPA applied the formula literally every 15 seconds, the replica count would oscillate endlessly. With 4 pods at 320m and a target of 325m the formula gives ceil(4 × 0.984) = 4; with 330m it gives ceil(4 × 1.015) = 5. A 10m fluctuation would send one pod up and down indefinitely. That is called flapping or thrashing, and it is expensive: every start-up burns CPU, warms caches from scratch and pollutes the metrics.

Kubernetes prevents it with a tolerance band: if the ratio currentValue / targetValue lies between 0.9 and 1.1 (that is, within ±10%), the HPA does nothing.

ratio = currentValue / targetValue

|ratio - 1| <= 0.10   →   no scaling
|ratio - 1|  > 0.10   →   the formula is applied

With our target of 325m, the dead zone is:

Average consumption ratio Does the HPA act?
280m 0.86 Yes, scales down
300m 0.92 No, inside the band
325m 1.00 No
355m 1.09 No, inside the band
380m 1.17 Yes, scales up

This threshold is cluster-wide (--horizontal-pod-autoscaler-tolerance) and not configurable per HPA... until recently. Since Kubernetes 1.33 there is the HPAConfigurableTolerance feature gate, which lets you set the tolerance per metric inside behavior:

behavior:
  scaleUp:
    tolerance: 0.05     # More sensitive on the way up: reacts at 5% excess
  scaleDown:
    tolerance: 0.15     # Lazier on the way down

Being an optional feature gate, do not take it for granted: check your cluster's version and configuration before using it in production. On the course's minikube cluster we will work with the default 10% tolerance.

  1. Non-negotiable requirements: requests and metrics-server

Two requirements, and neither is negotiable.

Requirement 1: metrics-server installed

An HPA using Resource metrics reads from the metrics.k8s.io API, which metrics-server serves. We installed it in 07-02 with the minikube addon. Verify it:

# Is the metrics API registered?
kubectl get apiservices v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   True        41d

The AVAILABLE column must say True. If it says False (MissingEndpoints) or similar, the HPA will not work.

# Does it return data?
kubectl top pods -n rutas-norte-pro -l app=bookings-api

If kubectl top works, the HPA will have data. If it does not, no HPA is going to help. This is always the first check.

On minikube, if you do not have it yet:

minikube -p rutas-norte addons enable metrics-server
# It takes between 30 and 60 seconds to start serving data.

Requirement 2: requests declared on the container

This is the most frequent trap. The Utilization target is computed as a percentage of requests. If a container does not declare requests.cpu, the HPA has no denominator, and its metric stays <unknown> forever. There is no spectacular error message; it simply does not scale.

# Fragment of the bookings-api Deployment. WITHOUT THIS THERE IS NO HPA.
spec:
  template:
    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:1.14.2
          resources:
            requests:
              cpu: 500m           # <-- The HPA NEEDS this value
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 1Gi

And beware of sidecars: if the pod has a container without requests.cpu, the metric for the whole pod becomes invalid for the HPA when you use type: Resource. In Rutas Norte, bookings-api carries a metrics-exporter sidecar; if that sidecar does not declare requests, the HPA for the entire Deployment goes blind. Two solutions:

  1. Declare requests on the sidecar too (recommended, and also necessary for the QoS class of module 3).
  2. Use type: ContainerResource pointing only at the api container.

We apply both in Rutas Norte: belt and braces.

  1. Diagnosing the dreaded <unknown>

Sooner or later you will see this:

kubectl get hpa -n rutas-norte-pro
NAME           REFERENCE                 TARGETS              MINPODS  MAXPODS  REPLICAS  AGE
bookings-api   Deployment/bookings-api   <unknown>/65%        4        30       4         3m12s

<unknown> means: "the HPA could not obtain the current value of this metric". It does not scale, full stop. Diagnosis always follows the same order:

kubectl describe hpa bookings-api -n rutas-norte-pro

Look at the Conditions block and at the events at the bottom. The causes, ordered by frequency:

Symptom in describe Cause Fix
FailedGetResourceMetric ... unable to get metrics for resource cpu: no metrics returned from resource metrics API metrics-server is missing or not responding Install/repair metrics-server (07-02)
missing request for cpu on container X A container in the pod does not declare requests.cpu Add requests to all containers, sidecars included
ScalingActive=False, reason: FailedGetScale The scaleTargetRef points at an object that does not exist or is misspelled Fix name/kind/apiVersion
did not receive metrics for any ready pods All the pods have been ready for less than 30 s, or none passes readiness Wait; review the probes (07-01)
<unknown> only on Pods/External metrics No custom metrics adapter is registered Install the adapter or KEDA (09-04)

An example of real output with the missing-requests error:

Conditions:
  Type           Status  Reason                   Message
  ----           ------  ------                   -------
  AbleToScale    True    SucceededGetScale        the HPA controller was able to get the target's current scale
  ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count:
                                                  failed to get cpu utilization: missing request for cpu
                                                  in container metrics-exporter of Pod bookings-api-7c9d4f8b6d-2mk8p
Events:
  Type     Reason                        Age                 From                       Message
  ----     ------                        ----                ----                       -------
  Warning  FailedGetResourceMetric       12s (x8 over 2m)    horizontal-pod-autoscaler  missing request for cpu
  Warning  FailedComputeMetricsReplicas  12s (x8 over 2m)    horizontal-pod-autoscaler  invalid metrics (1 invalid out of 1)

The message names the guilty container in full: metrics-exporter. Adding requests.cpu to it fixes the problem on the next 15-second cycle.

A quick diagnostic trick: query the metrics API directly to see what the HPA is seeing.

kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods" \
  | python3 -m json.tool | head -40

If that call returns data and the HPA is still <unknown>, the problem is with requests, not with metrics-server.

  1. The behavior block: policies, periods and the stabilisation window

So far we have seen how many replicas the HPA wants. The behavior block decides how fast it gets to that number. It is the difference between autoscaling that works and autoscaling that frightens you.

Structure

behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    selectPolicy: Max
    policies:
      - type: Percent
        value: 100
        periodSeconds: 30
      - type: Pods
        value: 4
        periodSeconds: 30
  scaleDown:
    stabilizationWindowSeconds: 600
    selectPolicy: Min
    policies:
      - type: Percent
        value: 20
        periodSeconds: 120

Field by field:

policies — each policy limits how much the replica count can change within a window of periodSeconds:

  • type: Percent with value: 100 and periodSeconds: 30 means "in any 30-second window you may at most double the replica count" (increase it by 100% over the base).
  • type: Pods with value: 4 and periodSeconds: 30 means "in any 30-second window you may add at most 4 pods".

selectPolicy — when there are several policies, it decides which one rules:

Value Effect When to use it
Max (default in scaleUp) Allows the most aggressive change of all the policies Scaling up fast
Min (default in scaleDown) Allows the most conservative change Scaling down slowly
Disabled Disables scaling in that direction Freezing scale-downs during a campaign

An example of what selectPolicy: Max does in scaleUp with our policies and 4 current replicas:

  • Percentage policy: 100% of 4 = allows adding 4 → 8 replicas.
  • Pods policy: allows adding 4 → 8 replicas.
  • Max takes the larger: 8 replicas.

With 20 current replicas:

  • Percentage: 100% of 20 = allows adding 20 → 40 replicas.
  • Pods: allows adding 4 → 24 replicas.
  • Max takes 40 (later capped by maxReplicas: 30 → 30).

With selectPolicy: Min it would have picked 24. This illustrates why Max on the way up makes sense: once you already have many replicas, the percentage gives you room to grow fast.

stabilizationWindowSeconds — this is the most important parameter and the worst understood. It works like this: the HPA keeps a history of the recommendations from the last N seconds and uses the most conservative value in that window.

  • In scaleUp, "most conservative" means the minimum of the recent recommendations. With a 60 s window, if over the last minute the HPA recommended 12, 8 and 14 replicas, it will scale to 8. Effect: it ignores isolated spikes.
  • In scaleDown, "most conservative" means the maximum of the recent recommendations. With a 600 s window, if over the last 10 minutes it recommended 12, 5 and 6, it will keep 12. Effect: it does not scale down until the calm has held for ten full minutes.

Default values if you do not define behavior:

scaleUp scaleDown
stabilizationWindowSeconds 0 300 (5 minutes)
policies 100% every 15 s and 4 pods every 15 s 100% every 15 s
selectPolicy Max Min

In other words, by default the HPA can double every 15 seconds and can drop straight to minReplicas after 5 minutes of calm. The first is usually fine; the second is far too abrupt for Rutas Norte.

The reasoned Rutas Norte configuration: up fast, down slowly

Our policy is deliberately asymmetric, and it is worth understanding why:

Up fast. The cost of over-scaling for a few minutes is a few pennies of compute. The cost of under-scaling for a few minutes is that the May bank-holiday sale falls over and Rutas Norte loses thousands of euros in unsold tickets, plus the reputational damage. The costs are radically asymmetric, and therefore so must be the response. stabilizationWindowSeconds: 0 in scaleUp: the moment load rises, we act.

Down slowly. Traffic on a ticketing site is irregular by nature: a spike at 10:00, a trough at 10:03, another spike at 10:05. If we scaled down quickly we would be destroying pods that have to be recreated thirty seconds later, with the cost of a cold start (PostgreSQL connections, empty route cache) and worse latency for the user. stabilizationWindowSeconds: 600 in scaleDown: we do not scale down until the calm has lasted ten minutes. And even then, we drop 20% every two minutes, not all at once.

The mental framing: scaling up is an availability mechanism; scaling down is a cost mechanism. Availability is urgent; savings can wait ten minutes.

Freezing scale-down during a campaign

An advanced and very practical use of selectPolicy: Disabled. During the three days of the May bank holiday, the Rutas Norte team may decide it wants no reduction in replicas at all, not even a slow one:

behavior:
  scaleDown:
    selectPolicy: Disabled       # During the bank holiday: never scale down. Only up.

It is applied on Friday morning and reverted on Monday. It is a legitimate emergency switch, and far safer than deleting the HPA.

  1. Several metrics at once: the greediest one wins

The metrics field is a list. When there are several, the HPA computes the formula for each one separately and then applies a very simple rule:

It keeps the HIGHEST replica count of all the calculations.

It is an OR of need: it is enough for one single metric to ask for more replicas for scaling to happen. They are never averaged or combined.

An example with two metrics on bookings-api (5 current replicas):

Metric Target Current value Replicas it asks for
CPU (Utilization) 65% 42% ceil(5 × 0.646) = 4
Requests per second (Pods) 80 rps/pod 190 rps/pod ceil(5 × 2.375) = 12
Final decision 12

CPU says there are pods to spare; requests per second says a lot are missing. 12 wins. This asymmetry is intentional and correct: the HPA prefers to err on the side of too much capacity rather than too little.

A practical consequence worth keeping in mind: if one of the metrics goes <unknown>, the HPA cannot guarantee that scaling is not needed on its account. Its behaviour in that case is conservative: if the valid metric asks to scale up, it scales up; if it asks to scale down, it does not, because it does not know whether the broken metric would justify keeping the replicas. This explains the usual bewilderment of "my HPA has a metric on <unknown> and it is stuck at the top".

  1. The Rutas Norte HPAs, complete manifests

Let's write the two definitive manifests. They go in k8s/environments/pro/, because the minReplicas values differ by environment.

bookings-api HPA

# k8s/environments/pro/hpa-bookings-api.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
  labels:
    app: bookings-api
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: bookings-api

  minReplicas: 4          # Availability floor: 4 replicas spread across zones (09-05)
  maxReplicas: 30         # Ceiling: 30 x 500m = 15 cores of requests. Fits the capacity plan.

  metrics:
    # Primary metric: CPU of the "api" container, excluding the metrics sidecar.
    - type: ContainerResource
      containerResource:
        name: cpu
        container: api
        target:
          type: Utilization
          averageUtilization: 65
          # 65% of 500m = 325m per pod. Leaves 35% of headroom to absorb
          # the time the new replicas take to start.

  behavior:
    scaleUp:
      # Immediate reaction: zero wait. Availability rules.
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        # Double the replica count every 30 s...
        - type: Percent
          value: 100
          periodSeconds: 30
        # ...or add 6 pods every 30 s, whichever allows more.
        # With few replicas the pods policy rules; with many, the percentage one.
        - type: Pods
          value: 6
          periodSeconds: 30

    scaleDown:
      # Ten minutes of sustained calm before starting to scale down.
      stabilizationWindowSeconds: 600
      selectPolicy: Min
      policies:
        # At most, remove 20% of the replicas every 2 minutes.
        - type: Percent
          value: 20
          periodSeconds: 120
        # And never more than 3 pods every 2 minutes. With selectPolicy: Min the gentler one rules.
        - type: Pods
          value: 3
          periodSeconds: 120

A calculation that justifies maxReplicas: 30: each pod reserves 500m of CPU and 512Mi of memory. Thirty pods are 15 cores and 15 GiB for bookings-api alone. Adding web-store, notifications-worker and the databases, that defines the cluster size we will need in 09-03. maxReplicas is not a number you pick at random: it is a capacity commitment.

Another calculation, this time about how fast it scales up. Starting from 4 replicas with Max between "double" and "+6 pods":

Moment Replicas Policy that rules
t = 0 s 4
t = 30 s 10 +6 pods (doubling would give 8)
t = 60 s 20 doubling (+6 would give 16)
t = 90 s 30 doubling would give 40, maxReplicas caps at 30

From 4 to 30 replicas in 90 seconds. Adding some 20-30 seconds of start-up for each Node.js pod, the platform goes from its Tuesday capacity to its maximum capacity in about two minutes. That is what you have to compare against the speed at which the traffic arrives.

web-store HPA

web-store is nginx serving static assets. Its profile is different: it burns very little CPU per request, starts in a second, and its real limit is bandwidth and connection count, not compute.

# k8s/environments/pro/hpa-web-store.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-store
  namespace: rutas-norte-pro
  labels:
    app: web-store
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-store

  minReplicas: 3
  maxReplicas: 15

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          # LOWER threshold than the API's: nginx starts in ~1 s and consumes little,
          # so we can afford to scale earlier at no appreciable cost.
          # It is also the front door: if it saturates, nobody gets in.
          averageUtilization: 50

  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 100
          periodSeconds: 15     # Even more aggressive: nginx starts in one second
        - type: Pods
          value: 4
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300   # 5 min: less conservative than the API,
                                        # because nginx has no expensive cold start
      selectPolicy: Min
      policies:
        - type: Percent
          value: 25
          periodSeconds: 60

A reasoned comparison of the two HPAs:

Parameter bookings-api web-store Reason for the difference
CPU target 65% 50% nginx is cheap to scale; the API is not
minReplicas 4 3 Minimum availability agreed per component
maxReplicas 30 15 The API is the CPU-expensive component
periodSeconds on the way up 30 s 15 s nginx starts in 1 s; Node.js in 20-30 s
Stabilisation on the way down 600 s 300 s The API has an expensive cold start (connection pool, cache)
Metric type ContainerResource Resource The API has a metrics sidecar; nginx does not

Applying them

kubectl apply -f k8s/environments/pro/hpa-bookings-api.yaml
kubectl apply -f k8s/environments/pro/hpa-web-store.yaml

kubectl get hpa -n rutas-norte-pro
NAME           REFERENCE                 TARGETS       MINPODS  MAXPODS  REPLICAS  AGE
bookings-api   Deployment/bookings-api   28%/65%       4        30       4         22s
web-store      Deployment/web-store      11%/50%       3        15       3         19s

The TARGETS column shows currentValue/target. If you see numbers instead of <unknown>, everything is wired up correctly.

  1. Simulating a load spike and watching it live

Let's provoke a spike in the development environment and watch the HPA at work. We will use the course's minikube profile.

Preparing the ground

minikube -p rutas-norte addons enable metrics-server
kubectl -n rutas-norte-dev apply -f k8s/environments/dev/hpa-bookings-api.yaml

For the demonstration, a development HPA with low thresholds so that it reacts quickly:

# k8s/environments/dev/hpa-bookings-api.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: bookings-api
  namespace: rutas-norte-dev
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: bookings-api
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 40     # Low threshold: scales early, the demo shows better
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 3
          periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 120   # Short for the demo; in pro it would be 600

Terminal 1: watching

kubectl get hpa bookings-api -n rutas-norte-dev --watch

Terminal 2: generating load

An ephemeral pod that hammers the route search endpoint in a loop:

kubectl -n rutas-norte-dev run load-generator \
  --image=busybox:1.36 \
  --restart=Never \
  --rm -it \
  -- /bin/sh -c \
  'while true; do wget -q -O- http://bookings-api/routes?origin=BIL\&destination=SDR > /dev/null; done'

An explanation of the command:

  • --rm -it creates the pod, attaches to it and deletes it when you leave with Ctrl+C.
  • http://bookings-api uses the cluster's internal DNS (04-03): it resolves to the Service in the same namespace.
  • The infinite wget loop generates requests as fast as it can.

For a more serious spike, several replicas of the generator:

kubectl -n rutas-norte-dev create deployment load-generator \
  --image=busybox:1.36 --replicas=6 \
  -- /bin/sh -c 'while true; do wget -q -O- http://bookings-api/routes > /dev/null; done'

What you see in terminal 1

NAME           REFERENCE                 TARGETS    MINPODS  MAXPODS  REPLICAS  AGE
bookings-api   Deployment/bookings-api   3%/40%     1        10       1         5m
bookings-api   Deployment/bookings-api   3%/40%     1        10       1         5m15s
bookings-api   Deployment/bookings-api   187%/40%   1        10       1         5m30s
bookings-api   Deployment/bookings-api   187%/40%   1        10       4         5m30s
bookings-api   Deployment/bookings-api   142%/40%   1        10       4         5m45s
bookings-api   Deployment/bookings-api   142%/40%   1        10       7         5m45s
bookings-api   Deployment/bookings-api   94%/40%    1        10       7         6m
bookings-api   Deployment/bookings-api   94%/40%    1        10       10        6m
bookings-api   Deployment/bookings-api   61%/40%    1        10       10        6m30s
bookings-api   Deployment/bookings-api   38%/40%    1        10       10        7m

Read the sequence with the formula in hand:

  1. 3%/40% with 1 replica: ceil(1 × 0.075) = 1. Nothing to do.
  2. 187%/40% with 1 replica: ceil(1 × 4.675) = 5. But the Pods: 3 every 15s policy caps it at 4. There is behavior in action.
  3. 142%/40% with 4 replicas: ceil(4 × 3.55) = 15. The policy caps it at 4+3 = 7.
  4. 94%/40% with 7 replicas: ceil(7 × 2.35) = 17. Capped at 10 by maxReplicas.
  5. 38%/40% with 10 replicas: ratio 0.95, inside the tolerance band. Stable. Target reached.

Now stop the generator (Ctrl+C or by deleting the Deployment) and watch the way down:

bookings-api   Deployment/bookings-api   2%/40%     1        10       10        9m
bookings-api   Deployment/bookings-api   2%/40%     1        10       10        10m
bookings-api   Deployment/bookings-api   2%/40%     1        10       1         11m

Notice: CPU drops to 2% immediately, but the replicas stay at 10 for two full minutes. That is stabilizationWindowSeconds: 120. Once the window has elapsed, it drops to 1 in one go (in this demo we did not define scaleDown policies, so the default applies: 100% every 15 s).

The scaling events

kubectl describe hpa bookings-api -n rutas-norte-dev
Events:
  Type    Reason             Age    From                       Message
  ----    ------             ----   ----                       -------
  Normal  SuccessfulRescale  5m30s  horizontal-pod-autoscaler  New size: 4;  reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  5m15s  horizontal-pod-autoscaler  New size: 7;  reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  5m     horizontal-pod-autoscaler  New size: 10; reason: cpu resource utilization (percentage of request) above target
  Normal  SuccessfulRescale  30s    horizontal-pod-autoscaler  New size: 1;  reason: All metrics below target

These events are pure gold for the postmortem of an incident: they tell you exactly when it scaled, to what and why. Remember that events expire (one hour by default); for a long history you need the centralised logging of module 7.

  1. The conflict between the HPA and the replicas field

This is the mistake that bites every team exactly once, and it had better not be during the May bank holiday.

The problem

The bookings-api Deployment we have been writing since module 2 says:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-api
spec:
  replicas: 4        # <-- This field
  ...

And now an HPA also governs that same field. We have two authorities over the same number.

As long as nobody applies the YAML, nothing happens: the HPA modifies the field on the server and the object lives happily with 22 replicas. The problem shows up when someone applies the manifest again.

A real scenario:

10:00  The HPA scales bookings-api to 22 replicas. The sale is going well.
10:14  A colleague fixes a label on the Deployment and runs:
       kubectl apply -f k8s/base/deployment-bookings-api.yaml
10:14  The manifest says replicas: 4. The API accepts it.
       22 pods -> 4 pods, INSTANTLY.
10:14  The platform falls over.
10:14  The HPA (next cycle, up to 15 s later) detects CPU at 400%
       and scales back up. But that is already 15-90 seconds of outage.

Fifteen seconds of total outage at the year's peak sales moment. All because of a field that should not be there.

And it is worse with GitOps: if Argo CD (10-05) has the repository as the source of truth and detects that the cluster has 22 replicas when Git says 4, it will mark the resource as OutOfSync and, with automatic sync, will "correct" it to 4. Every single time. You will have a permanent tug of war between the HPA and the GitOps operator, with the platform oscillating between 4 and 22 replicas indefinitely.

The solution: remove replicas from the manifest

# k8s/base/deployment-bookings-api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
  labels:
    app: bookings-api
    app.kubernetes.io/part-of: rutas-norte
spec:
  # NO "replicas" field: it is governed by the HorizontalPodAutoscaler bookings-api.
  # See k8s/environments/pro/hpa-bookings-api.yaml
  # If you put it back here, every "kubectl apply" will bring the platform down
  # during the traffic peak. Do not do it.
  selector:
    matchLabels:
      app: bookings-api
  template:
    metadata:
      labels:
        app: bookings-api
        app.kubernetes.io/part-of: rutas-norte
    spec:
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:1.14.2
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              cpu: "1"
              memory: 1Gi

By omitting replicas, the API's default value is 1, but only on initial creation. On subsequent applies the field is simply left alone: kubectl apply compares against the kubectl.kubernetes.io/last-applied-configuration annotation, sees that replicas was not there before and is not there now, and leaves it as it is. The HPA remains in charge.

One consequence to keep in mind: the first time you create the Deployment without replicas, it will start with 1 pod until the HPA brings it up to minReplicas on the next cycle (less than 15 seconds). That is acceptable on a fresh deployment; if it bothers you, create the HPA first.

That explicit comment in the YAML is not decoration: it is documentation placed exactly where somebody will make the wrong decision. Write it.

Alternatives and their nuances

Approach How When to use it
Omit replicas Delete it from the YAML Recommended. Simple, works with apply and with GitOps
Server-Side Apply with shared ownership kubectl apply --server-side; the HPA owns the field Good, but requires the whole workflow to use SSA
ignoreDifferences in Argo CD Configure Argo CD to ignore /spec/replicas Necessary if for some reason you cannot remove the field
Kustomize without a replicas patch Do not use replicas: in the overlays Complements the first option (10-04)

Beware of one Server-Side Apply trap: if you apply with SSA and the replicas field is in your manifest, you will take ownership of the field and the HPA will get a conflict. The API will tell you so with an explicit ownership-conflict error, which is better than the silent failure of classic kubectl apply, but it is still a failure.

Checking who is in charge

kubectl get deployment bookings-api -n rutas-norte-pro \
  -o jsonpath='{.metadata.managedFields[*].manager}{"\n"}'
kubectl-client-side-apply kube-controller-manager

If you see kube-controller-manager among the managers, the HPA is writing the field. That is the confirmation that the chain works.

  1. What not to scale with an HPA, and the real bottleneck

We finish with the most valuable lesson of all, and the one that saves the most money.

The mistake of scaling the wrong layer

Picture this sequence during the May bank holiday:

  1. The flood of traffic arrives.
  2. The bookings-api HPA scales from 4 to 30 replicas in two minutes. Perfect.
  3. Each of the 30 replicas opens its pool of 20 connections to bookings-postgres. Total: 600 connections.
  4. bookings-postgres has max_connections = 200.
  5. The 400 excess connections are rejected. The bookings-api pods return 500 errors.
  6. bookings-api CPU rises (retries and error handling consume CPU), so the HPA wants to scale further. maxReplicas caps it at 30.
  7. The platform is down with 30 replicas where before it went down with 4. It has cost more money and achieved nothing.

This pattern has a name: moving the bottleneck without solving it. The HPA does not detect it because the HPA only knows about CPU: it has no idea the database is saturated.

flowchart LR
    A[Traffic x10] --> B[web-store<br/>HPA: 3 to 15]
    B --> C[bookings-api<br/>HPA: 4 to 30]
    C --> D[bookings-postgres<br/>1 replica<br/>max_connections=200]
    D -.->|BOTTLENECK| E[500 errors]
    C --> F[redis-cache<br/>1 replica]
    style D fill:#f88,stroke:#900,stroke-width:3px
    style E fill:#f88,stroke:#900

The layer that does not scale determines the capacity of the entire system. It is the weakest-link law applied to architecture.

What to do instead

With bookings-postgres as the limit, the real levers are:

Lever What it does Where it is covered
Cap the pool per replica With 30 replicas × 6 connections = 180 < 200 09-06
Put in a shared pool (PgBouncer) Multiplexes thousands of application connections onto a few database ones 09-06
Cache in redis-cache The availability query never reaches PostgreSQL 09-06
Read replicas Searches go to the replica; only bookings to the primary 06-07 (operator)
Scale PostgreSQL vertically More CPU/RAM and faster disk 09-02 and 09-06

None of them is an HPA. The lesson: before adding an HPA, identify the scarce resource. If it is not the CPU of the pods you are going to scale, the HPA is not going to help you.

Checklist before adding an HPA

Ask yourself:

  1. Is the application genuinely stateless? Can any replica serve any request with no local memory and no session affinity?
  2. Is CPU the scarce resource? Or is it the database, the disk, the network, an external service or a global lock?
  3. Is the load spread evenly? If one endpoint concentrates 90% of the cost and a single client calls it, adding replicas does not help.
  4. How long does a new pod take to become useful? If it takes three minutes, the HPA will arrive late to fast spikes. You will need to work on start-up (09-06) or pre-warm (09-04).
  5. Is there room in the cluster for the new replicas? If not, the HPA will only produce Pending pods (09-03).
  6. Can the layer below take N times more load? The connection pool, the external API quota, the rate limit.

Only if all six answers are satisfactory will the HPA do what you expect.

Common Mistakes and Tips

Mistake 1: leaving the replicas field in the manifest. We already covered this in section 13, but it bears repeating because it is mistake number one and its consequences are immediate and visible. Remove it and leave a comment in its place.

Mistake 2: scaling on memory. Garbage-collected runtimes do not return memory to the system. The HPA scales up and never comes back down. Use CPU, or a business metric (09-04). If you genuinely need memory, use AverageValue rather than Utilization and keep your expectations realistic.

Mistake 3: setting the utilisation target too high. An averageUtilization: 90 looks efficient, but it leaves only 10% of headroom. By the time the HPA detects the rise, the new pods take 30 seconds to become ready, and during those 30 seconds the existing pods are at 130% and being throttled. The target must leave room for the start-up time. Between 50% and 70% is the healthy range; the slower your application starts, the lower the target.

Mistake 4: maxReplicas with no capacity plan. A maxReplicas: 200 on a three-node cluster does not give you 200 replicas: it gives you a pile of Pending pods and a false sense of security. Compute maxReplicas × requests and compare it against the real capacity (09-03).

Mistake 5: forgetting requests on the sidecars. A single container without requests.cpu blinds the HPA for the whole Deployment. In Rutas Norte, the bookings-api metrics exporter is the usual suspect.

Mistake 6: not checking that the new pods are useful. A pod that starts and passes readiness but has an empty connection pool and a cold cache serves worse than a warm one. During the first few seconds, scaling can worsen average latency. Tune the probes (07-01) so that a pod only declares itself ready when it really is.

Tip 1: put the HPA in observation mode first. Create it with minReplicas equal to maxReplicas (for example, both at 4). The HPA will not be able to scale, but it will compute and publish the metrics in kubectl get hpa. Leave it for a week, see what it would have done, and only then open up the range. It is free and it avoids nasty surprises.

Tip 2: alert when the HPA is pinned to the ceiling. If bookings-api has been at 30 replicas for half an hour, maxReplicas is capping capacity and nobody knows. An alert in Alertmanager (07-04):

kube_horizontalpodautoscaler_status_current_replicas
  >= kube_horizontalpodautoscaler_spec_max_replicas

Tip 3: build a dashboard with replicas and metric side by side. In Grafana, overlaying "current replicas" and "average CPU" on the same graph makes it obvious whether the HPA is reacting well or lagging behind. It is the most useful visual diagnosis there is for an HPA.

Tip 4: test the HPA before you need it. A load rehearsal against rutas-norte-pre a week before the May bank holiday costs an afternoon and prevents the outage of the year. We will do it formally with k6 in 09-06.

Tip 5: HPA events expire. One hour by default. If you want the history of the incident, you need EFK (07-05) or the kube-state-metrics metrics in Prometheus.

Exercises

Exercise 1: applying the formula

web-store is deployed with requests.cpu: 200m and an HPA with averageUtilization: 50, minReplicas: 3, maxReplicas: 15. Right now there are 6 replicas and kubectl top gives:

web-store-6f7d9c4b58-2wq4m   210m
web-store-6f7d9c4b58-4kjnx   198m
web-store-6f7d9c4b58-7hgpz   225m
web-store-6f7d9c4b58-9mzbc   187m
web-store-6f7d9c4b58-kx2vt   204m
web-store-6f7d9c4b58-tn8fr   216m

Work out: (a) the absolute target per pod; (b) the average consumption; (c) the ratio and whether it is inside the tolerance band; (d) the desired replicas; (e) what the HPA will do with this behavior:

behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    selectPolicy: Max
    policies:
      - type: Percent
        value: 100
        periodSeconds: 15
      - type: Pods
        value: 4
        periodSeconds: 15

Exercise 2: diagnosing a silent HPA

A colleague has deployed notifications-worker with this HPA and complains that it never scales:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: notifications-worker
  namespace: rutas-norte-pro
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: notifications-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

The Deployment is:

spec:
  template:
    spec:
      containers:
        - name: worker
          image: registry.rutasnorte.example/notifications-worker:2.3.0
          resources:
            limits:
              cpu: 500m
              memory: 512Mi
        - name: metrics-exporter
          image: registry.rutasnorte.example/exporter:1.2.0

kubectl get hpa shows <unknown>/70%. Identify all the problems, write the diagnostic commands you would run and the fixes. Also explain why, even after fixing all of it, this HPA will still be a bad idea for this particular component.

Exercise 3: designing a behavior for a new scenario

Rutas Norte is launching drivers-dashboard, an internal web application used by the fleet's 40 drivers. Load profile:

  • Monday to Friday, from 5:30 to 7:00, every driver logs in to check the day's route: load goes from zero to its maximum in fifteen minutes.
  • The rest of the day there is sporadic, very low usage.
  • At night there is nobody.
  • The application is Node.js and takes about 25 seconds to become ready.
  • It is internal: a two-minute outage is annoying but does not cost money.
  • The team is under cost pressure: the cluster is shared and there is a ResourceQuota.

Write the complete HPA (autoscaling/v2) justifying every decision: minReplicas, maxReplicas, metric, target and the whole behavior. Compare at least three decisions with those of the bookings-api HPA and explain why they differ.


Solutions

Solution 1

(a) Absolute target per pod:

50% of 200m = 100m per pod

(b) Average consumption:

(210 + 198 + 225 + 187 + 204 + 216) / 6 = 1240 / 6 = 206.67m

(c) Ratio and tolerance band:

ratio = 206.67 / 100 = 2.067
|2.067 - 1| = 1.067 > 0.10   ->  OUTSIDE the band. The HPA acts.

(d) Desired replicas:

desiredReplicas = ceil( 6 × 2.067 ) = ceil( 12.4 ) = 13

13 replicas, below maxReplicas: 15.

(e) What the behavior does:

Current replicas: 6.

  • Policy Percent 100% / 15 s: allows adding 100% of 6 = 6 → up to 12 replicas.
  • Policy Pods 4 / 15 s: allows adding 4 → up to 10 replicas.
  • selectPolicy: Max takes the higher ceiling: 12.

The HPA wants 13 but can only reach 12 in this step. It will scale to 12 replicas now.

On the next cycle (15 s later), with 12 replicas and assuming total load does not change, average consumption will drop to around 103m per pod. The new ratio would be 1.03, inside the tolerance band, so it will stay at 12 and never reach 13. This is normal and correct behaviour: the tolerance absorbs the last step.

An additional observation: the pods are at 103% of their requests (206m against 200m). They are not being throttled because their limit is higher, but they are consuming more than they reserved, which means they depend on the node's spare CPU. It is a sign that web-store's requests is undersized and that the VPA of 09-02 would have something to say about it.

Solution 2

There are four problems, one after another.

Problem 1: the worker container does not declare requests.cpu.

It only has limits. Without requests, the HPA has no denominator for the percentage.

An important nuance many people are unaware of: when you declare limits without requests, Kubernetes automatically fills requests with the value of limits. So this container does end up with requests.cpu: 500m. As a side effect, the pod's QoS would be Guaranteed if every container were like this (module 3). But...

Problem 2: the metrics-exporter container declares no resources at all.

This one really is fatal. With neither limits nor requests, there is no automatic fill-in, and the HPA cannot compute the pod's utilisation. This is the one producing the <unknown>. It also makes the pod's QoS Burstable instead of Guaranteed.

Problem 3: we do not know whether metrics-server is available.

That has to be checked first of all.

Problem 4 (a design one, and the most important): CPU is the wrong signal for this component.

Diagnostic commands, in order:

# 1. Is metrics-server working?
kubectl top pods -n rutas-norte-pro -l app=notifications-worker

# 2. What exactly does the HPA say?
kubectl describe hpa notifications-worker -n rutas-norte-pro

# 3. What resources does each container declare?
kubectl get deployment notifications-worker -n rutas-norte-pro \
  -o jsonpath='{range .spec.template.spec.containers[*]}{.name}{": "}{.resources}{"\n"}{end}'

The output of step 3 would give the problem away:

worker: {"limits":{"cpu":"500m","memory":"512Mi"}}
metrics-exporter: {}

The corrected Deployment:

spec:
  template:
    spec:
      containers:
        - name: worker
          image: registry.rutasnorte.example/notifications-worker:2.3.0
          resources:
            requests:
              cpu: 300m          # Explicit, we do not rely on the automatic fill-in
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
        - name: metrics-exporter
          image: registry.rutasnorte.example/exporter:1.2.0
          resources:
            requests:
              cpu: 20m           # <-- THE KEY FIX
              memory: 32Mi
            limits:
              cpu: 50m
              memory: 64Mi

And, defensively, switch the HPA to ContainerResource so that the sidecar does not distort the percentage:

  metrics:
    - type: ContainerResource
      containerResource:
        name: cpu
        container: worker
        target:
          type: Utilization
          averageUtilization: 70

Why it is still a bad idea even once it works:

notifications-worker consumes messages from an email queue. Its work per message is mostly input/output waiting: talking to the SMTP server, waiting for the network reply. That burns very little CPU.

During the May bank holiday there may be forty thousand emails waiting in the queue while the worker sits at 20% CPU, because it is blocked waiting for SMTP most of the time. A CPU-driven HPA would see 20% against a target of 70% and conclude that there are replicas to spare: it would scale down exactly when consumers are needed most.

The correct metric is the queue length: "I want one worker per 500 pending messages". The HPA cannot read that out of the box; you need an external metric, and that is precisely KEDA's reason for existing in 09-04.

Solution 3

# k8s/environments/pro/hpa-drivers-dashboard.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: drivers-dashboard
  namespace: rutas-norte-pro
  labels:
    app: drivers-dashboard
    app.kubernetes.io/part-of: rutas-norte
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: drivers-dashboard

  # minReplicas: 1. It is not a revenue-generating service and at night there is
  # nobody. One replica is enough for the sporadic use of the rest of the day and
  # so that the first request at 5:30 does not find the service switched off.
  # We do not put 2 because there is cost pressure and a 2-min outage is bearable.
  minReplicas: 1

  # maxReplicas: 6. There are 40 concurrent drivers at most. With 6 replicas that
  # is fewer than 7 users per pod: plenty. A low ceiling protects the
  # ResourceQuota of the shared namespace.
  maxReplicas: 6

  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          # 50%, not 65-70%. The application takes 25 s to become ready, so we
          # need headroom to cover that start-up. Besides, the 5:30 ramp is
          # abrupt: better to stay ahead of it.
          averageUtilization: 50

  behavior:
    scaleUp:
      # Zero wait. The critical window lasts 90 minutes a day and all the load
      # arrives in the first 15. Any delay eats the whole window.
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        # periodSeconds 30, not 15: the application takes 25 s to become ready.
        # With 15 s we would scale again BEFORE the previous pods started
        # absorbing load, and we would over-scale.
        - type: Percent
          value: 100
          periodSeconds: 30
        - type: Pods
          value: 2
          periodSeconds: 30
        # With maxReplicas 6, "+2 pods" covers the 1->3->6 range nicely.

    scaleDown:
      # 300 s (5 min), not 600. The peak lasts 90 minutes and then does not come
      # back until the next day: there are no intermittent spikes justifying a
      # ten-minute wait, and cost pressure asks us to free resources early.
      stabilizationWindowSeconds: 300
      selectPolicy: Min
      policies:
        # Gentle descent: one pod every 3 minutes. From 6 to 1 in 15 minutes.
        # Enough not to strand a straggler at 7:05.
        - type: Pods
          value: 1
          periodSeconds: 180

Reasoned comparison with the bookings-api HPA:

Decision bookings-api drivers-dashboard Why they differ
minReplicas 4 1 The API generates revenue and needs multi-zone availability; the dashboard is internal, under cost pressure and with a tolerable outage
maxReplicas 30 6 API traffic is public and unpredictable (x10 over the bank holiday); the dashboard has a known ceiling of 40 users
CPU target 65% 50% Both are Node.js, but the dashboard suffers a more abrupt ramp (zero to maximum in 15 min) and needs more headroom to cover the 25 s start-up
periodSeconds on the way up 30 s 30 s Identical: both are Node.js with a 20-30 s start-up. A coincidence justified by the same technical cause
Stabilisation on the way down 600 s 300 s API traffic is intermittent for hours; the dashboard's is a single daily block that does not return
Scale-down policy 20% / 120 s 1 pod / 180 s With only 6 replicas, a 20% figure would give useless fractions; with small numbers it is easier to reason in absolute pods

An additional consideration worth noting: this load profile — zero at night, an avalanche at a fixed time — is the perfect candidate for a cron trigger that pre-warms the service at 5:15, instead of waiting for CPU to rise and reacting late. That is no longer something the stock HPA does: it is KEDA, and we will see it in 09-04. A reactive HPA will always lag behind a load that rises in a step; only an anticipatory signal really solves that case.

Conclusion

The HorizontalPodAutoscaler turns the replica count from a fixed, hand-written figure into an observed consequence of real traffic. It is the difference between sizing for an ordinary Tuesday and surviving the May bank holiday without anyone having to open a laptop on a station platform.

The essentials you take away from this lesson:

  • Scaling out and scaling up are different axes. Horizontal solves traffic volume and improves availability as well; vertical solves the sizing of each instance. Rutas Norte needs both.
  • Only what is genuinely stateless can be scaled horizontally. bookings-api and web-store yes; bookings-postgres never, and the attempt would produce data corruption, not capacity.
  • The formula is a rule of three: ceil(currentReplicas × currentValue / targetValue), with a ±10% tolerance band that prevents flapping.
  • Without requests on every container and without metrics-server there is no HPA. The <unknown> in the TARGETS column almost always points at a forgotten sidecar.
  • behavior is where it is decided whether autoscaling works. The Rutas Norte asymmetry — up in zero seconds, down after ten minutes of calm — reflects a real asymmetry of costs: losing sales is far more expensive than having spare capacity for a while.
  • With several metrics, the one asking for the most replicas wins. An OR of need, deliberately conservative.
  • The replicas field must disappear from the versioned manifest. Otherwise an innocent kubectl apply will bring the platform down at the sales peak, and with GitOps it will be a permanent war (10-05).
  • Before adding an HPA, find the scarce resource. Scaling the web layer when the limit is the database multiplies the cost without adding a single ticket sold.

Rutas Norte now has bookings-api and web-store responding to traffic on their own. But we are still dragging along a problem we have mentioned several times without fully solving: the HPA leans on the CPU requests as the reference for its entire calculation, and that requests is still a number we picked by eye. In 07-02 we recalibrated it by comparing against real consumption, but we did it by hand, once, staring at kubectl top for an afternoon. If requests is wrong, the HPA's percentage lies, and with it every one of its decisions.

In the next lesson, Vertical Pod Autoscaling, we will look at the component that solves exactly that problem: the VPA observes real consumption over days, computes percentiles and tells you with data what requests and what limits your containers should have. We will also find out why the VPA and the HPA cannot govern the same metric without fighting, and what workflow serious teams use: the VPA as an adviser, not as an autopilot.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved