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
- Scaling out versus scaling up
- Which workloads support horizontal scaling and which do not
- Anatomy of the HorizontalPodAutoscaler in
autoscaling/v2 - The four metric types:
Resource,Pods,ObjectandExternal - The controller's exact formula, step by step
- The tolerance band and replica flapping
- Non-negotiable requirements:
requestsand metrics-server - Diagnosing the dreaded
<unknown> - The
behaviorblock: policies, periods and the stabilisation window - Several metrics at once: the greediest one wins
- The Rutas Norte HPAs, complete manifests
- Simulating a load spike and watching it live
- The conflict between the HPA and the
replicasfield - What not to scale with an HPA, and the real bottleneck
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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.
- Anatomy of the HorizontalPodAutoscaler in
autoscaling/v2
autoscaling/v2The 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:
scaleTargetRefpoints at an object that exposes the/scalesubresource. 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.minReplicasandmaxReplicasare hard barriers.maxReplicasis 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.minReplicascan be 0 from Kubernetes 1.30 onwards if theHPAScaleToZerofeature 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.
- The four metric types:
Resource, Pods, Object and External
Resource, Pods, Object and ExternalThe 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:
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:
"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: 65Without 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 podThe 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.
- 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:
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):
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 301MiAverage: (140 + 155 + 132 + 149) / 4 = 144m.
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 694MiAverage: (910 + 940 + 895 + 925) / 4 = 917.5m. Note: they are brushing against their 1000m limit, which means they are being throttled (module 3).
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:
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:
-
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.
-
Not-ready pods. Pods that have not passed their
readinessProbeare 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. -
--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. -
--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.
- 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 appliedWith 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 downBeing 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.
- Non-negotiable requirements:
requests and metrics-server
requests and metrics-serverTwo 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:
The AVAILABLE column must say True. If it says False (MissingEndpoints) or similar, the HPA will not work.
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: 1GiAnd 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:
- Declare
requestson the sidecar too (recommended, and also necessary for the QoS class of module 3). - Use
type: ContainerResourcepointing only at theapicontainer.
We apply both in Rutas Norte: belt and braces.
- Diagnosing the dreaded
<unknown>
<unknown>Sooner or later you will see this:
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:
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 -40If that call returns data and the HPA is still <unknown>, the problem is with requests, not with metrics-server.
- The
behavior block: policies, periods and the stabilisation window
behavior block: policies, periods and the stabilisation windowSo 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: 120Field by field:
policies — each policy limits how much the replica count can change within a window of periodSeconds:
type: Percentwithvalue: 100andperiodSeconds: 30means "in any 30-second window you may at most double the replica count" (increase it by 100% over the base).type: Podswithvalue: 4andperiodSeconds: 30means "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.
Maxtakes the larger: 8 replicas.
With 20 current replicas:
- Percentage: 100% of 20 = allows adding 20 → 40 replicas.
- Pods: allows adding 4 → 24 replicas.
Maxtakes 40 (later capped bymaxReplicas: 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:
It is applied on Friday morning and reverted on Monday. It is a legitimate emergency switch, and far safer than deleting the HPA.
- 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".
- 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: 120A 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: 60A 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-proNAME 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 19sThe TARGETS column shows currentValue/target. If you see numbers instead of <unknown>, everything is wired up correctly.
- 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.yamlFor 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 600Terminal 1: watching
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 -itcreates the pod, attaches to it and deletes it when you leave withCtrl+C.http://bookings-apiuses the cluster's internal DNS (04-03): it resolves to the Service in the same namespace.- The infinite
wgetloop 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 7mRead the sequence with the formula in hand:
3%/40%with 1 replica:ceil(1 × 0.075) = 1. Nothing to do.187%/40%with 1 replica:ceil(1 × 4.675) = 5. But thePods: 3 every 15spolicy caps it at 4. There isbehaviorin action.142%/40%with 4 replicas:ceil(4 × 3.55) = 15. The policy caps it at 4+3 = 7.94%/40%with 7 replicas:ceil(7 × 2.35) = 17. Capped at 10 bymaxReplicas.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 11mNotice: 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
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 targetThese 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.
- The conflict between the HPA and the
replicas field
replicas fieldThis 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: 1GiBy 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"}'If you see kube-controller-manager among the managers, the HPA is writing the field. That is the confirmation that the chain works.
- 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:
- The flood of traffic arrives.
- The
bookings-apiHPA scales from 4 to 30 replicas in two minutes. Perfect. - Each of the 30 replicas opens its pool of 20 connections to
bookings-postgres. Total: 600 connections. bookings-postgreshasmax_connections = 200.- The 400 excess connections are rejected. The
bookings-apipods return 500 errors. bookings-apiCPU rises (retries and error handling consume CPU), so the HPA wants to scale further.maxReplicascaps it at 30.- 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:
- Is the application genuinely stateless? Can any replica serve any request with no local memory and no session affinity?
- Is CPU the scarce resource? Or is it the database, the disk, the network, an external service or a global lock?
- Is the load spread evenly? If one endpoint concentrates 90% of the cost and a single client calls it, adding replicas does not help.
- 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).
- Is there room in the cluster for the new replicas? If not, the HPA will only produce
Pendingpods (09-03). - 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_replicasTip 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 216mWork 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: 15Exercise 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: 70The 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.0kubectl 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:
(b) Average consumption:
(c) Ratio and tolerance band:
(d) Desired replicas:
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: Maxtakes 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:
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: 64MiAnd, 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: 70Why 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: 180Reasoned 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-apiandweb-storeyes;bookings-postgresnever, 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
requestson every container and without metrics-server there is no HPA. The<unknown>in theTARGETScolumn almost always points at a forgotten sidecar. behavioris 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
replicasfield must disappear from the versioned manifest. Otherwise an innocentkubectl applywill 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
