The previous lesson ended with two problems that none of the three scaling layers solves. The first: notifications-worker can have forty thousand emails waiting in the queue with CPU at 20%, because its job is to wait for replies from the mail server, not to compute; a CPU-driven HPA would see that 20% against a 70% target and would scale down exactly when consumers are needed most. The second: we know months in advance that the May bank-holiday sale opens at 10:00 sharp, and yet our whole system waits until 10:00:15 to discover through CPU something we already knew in March.
Both problems share the same root: CPU is an indirect and late signal of what the business actually cares about. What matters is not how much a pod computes, but how much work is pending, how many requests are arriving, and when the avalanche is going to hit.
This lesson teaches you to scale on business signals. We will see how the HorizontalPodAutoscaler can consume custom and external metrics through the aggregation API, what KEDA is and why it does not replace the HPA but feeds it, the ScaledObject object field by field, the catalogue of available scalers, and the complete implementation at Rutas Norte: the email queue, requests per second with PromQL, scale-to-zero, and the cron trigger that pre-warms the platform before the sale opens.
Contents
- Why CPU is a poor signal
- Custom and external metrics in the HPA
- The aggregation API and metrics adapters
- What KEDA is and what its architecture looks like
- The key idea: KEDA does not replace the HPA, it feeds it
- The
ScaledObjectobject field by field - The catalogue of scalers
TriggerAuthentication: credentials for the triggersScaledJob: one job per message- Rutas Norte:
notifications-workerby queue length - Rutas Norte:
bookings-apiby requests per second - Rutas Norte: the May bank-holiday cron trigger
- Scale-to-zero and its implications
- Debugging a
ScaledObjectthat does not scale - Common Mistakes and Tips
- Exercises
- Conclusion
- Why CPU is a poor signal
Let's start by understanding the problem properly, because the solution only makes sense once the problem is clear.
The notifications-worker case
notifications-worker consumes messages from a confirmation-email queue and sends them through an external SMTP server. Its cycle per message:
1. Read a message from the queue ~2 ms of CPU
2. Look up the booking data ~5 ms of CPU + 8 ms waiting on PostgreSQL
3. Render the email template ~12 ms of CPU
4. Connect to the SMTP server and send ~3 ms of CPU + 450 ms WAITING
5. Acknowledge the message on the queue ~1 ms of CPU
Total: ~23 ms of CPU and ~458 ms of waiting.
Ratio CPU/total time: 23 / 481 = 4.8%For 95% of the time the process is blocked waiting on the network. It does not consume CPU: it waits.
The direct consequence: with a requests.cpu of 300m and an HPA target of 70% (210m), a worker processing messages flat out consumes around 60m. The HPA would compute:
ratio = 60 / 210 = 0.286
desiredReplicas = ceil(2 x 0.286) = 1
The HPA wants to go DOWN to 1 replica.With forty thousand emails in the queue, the HPA wants to cut to one replica. It is exactly the opposite of what is needed, and it is not a bug in the HPA: we are asking it the wrong question.
The right question
The question that matters is not "how much CPU do my workers consume?" but "how much work is pending?". And that question has a direct answer: the queue length.
Queue: 40,000 messages.
One worker processes ~2 messages/second (limited by SMTP).
Goal: drain the queue in 20 minutes = 1,200 seconds.
Messages per second needed: 40,000 / 1,200 = 33.3
Workers needed: 33.3 / 2 = 17 workers.Seventeen workers, not one. And that calculation can be expressed as a trivial scaling rule: "one worker per 500 messages in the queue" (40,000 / 500 = 80... let's adjust: one worker per 2,500 messages would give 16). The exact number is calibrated by measuring, but the shape of the rule is obvious and has nothing to do with CPU.
Other cases where CPU lies
| Workload | Why CPU lies | The right signal |
|---|---|---|
| Queue consumers | Work dominated by network waiting | Queue length |
| APIs with many external calls | Time is spent waiting on third parties | Requests per second, active connections |
| File processing | CPU depends on size, not on count | Files pending in the store |
| Services with a very effective cache | Low CPU but high latency when the cache misses | Hit rate, p95 latency |
| Workloads with an external rate limit | CPU is artificially low | Requests in the waiting queue |
| WebSockets / persistent connections | Many idle connections consume memory, not CPU | Number of connections |
A general pattern: CPU is a good signal when the pod's job is to compute, and a bad signal when the job is to coordinate, to wait or to hold connection state.
And the timing problem
Even with the right metric, all reactive scaling shares one limitation: it acts afterwards. The sequence is always the same: the load arrives → the service degrades → it is detected → it scales → it recovers. Between the second and fifth steps there are users suffering.
For predictable events — the sale opening at 10:00, the month-end accounting close, the Black Friday campaign at 20:00 — waiting for the metric to rise is absurd. It is information we already have. The rational thing is to scale beforehand.
That capability is called scheduled scaling, and it is one of the things KEDA gives you out of the box.
- Custom and external metrics in the HPA
Before KEDA, let's see what the HPA can do on its own. Recall the metric types from 09-01:
| Type | API that serves it | What it measures |
|---|---|---|
Resource / ContainerResource |
metrics.k8s.io |
CPU and memory of the pods |
Pods |
custom.metrics.k8s.io |
A metric emitted by the target's pods, averaged |
Object |
custom.metrics.k8s.io |
A metric attached to another Kubernetes object |
External |
external.metrics.k8s.io |
Something from outside the cluster |
The HPA knows how to consume all four types. No extra component is needed for the HPA to understand an external metric: its code already handles it.
What is missing is who serves those APIs. metrics.k8s.io is served by metrics-server (07-02). But custom.metrics.k8s.io and external.metrics.k8s.io are served by nobody by default. If you write an HPA with type: External on a clean cluster, you will get:
Conditions:
Type Status Reason Message
---- ------ ------ -------
ScalingActive False FailedGetExternalMetric unable to get external metric
rutas-norte-pro/email_queue_length/nil:
no custom metrics API (external.metrics.k8s.io/v1beta1)
registeredThat is the gap a metrics adapter fills.
The difference between Pods, Object and External
It is worth being clear about it because it determines how the replicas are computed.
Pods: the metric is emitted by every pod of the target and the HPA averages it across them.
Formula: ceil(currentReplicas × averageAcrossPods / target). It is the same one as for CPU, with a different quantity.
Object: the metric belongs to another object in the cluster (an Ingress, a Service). It is a single value, not an average.
- type: Object
object:
describedObject:
apiVersion: networking.k8s.io/v1
kind: Ingress
name: rutas-norte-public
metric:
name: requests_per_second
target:
type: Value
value: "5000"With type: Value the HPA compares the raw value against the target. With type: AverageValue it divides the value by the current replicas before comparing.
External: the metric comes from outside the cluster. A RabbitMQ queue, a Kafka topic, a billing metric.
- type: External
external:
metric:
name: email_queue_length
selector:
matchLabels:
queue: confirmation-notifications
target:
type: AverageValue
averageValue: "2500"For queues, always AverageValue. The meaning is "each replica takes charge of 2,500 messages", and that is what makes the replica count grow linearly with the queue:
| Messages in the queue | Desired replicas |
|---|---|
| 1,000 | ceil(1000/2500) = 1 |
| 10,000 | ceil(10000/2500) = 4 |
| 40,000 | ceil(40000/2500) = 16 |
| 100,000 | ceil(100000/2500) = 40 (capped by maxReplicas) |
With type: Value the behaviour would be completely different and almost always undesirable: it would compare 40,000 against 2,500 and multiply the current replicas by 16 on every cycle, which produces violent oscillations.
- The aggregation API and metrics adapters
Understanding this piece is what turns KEDA from magic into engineering.
The aggregation layer
Kubernetes lets you extend its API by delegating certain paths to services running inside the cluster. The mechanism is the APIService object:
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True 41d
v1beta1.external.metrics.k8s.io keda/keda-operator-metrics-apiserver True 12dWhen somebody (the HPA, or you with kubectl) asks for /apis/external.metrics.k8s.io/v1beta1/..., the API server does not answer itself: it forwards the request to the registered Service. That service returns the data in the format the API defines, and the API server passes it to the client as if it were its own.
flowchart TD
HPA[HPA controller] -->|GET /apis/external.metrics.k8s.io/...| API[API server<br/>kube-apiserver]
API -->|aggregation layer| ADAPT[Metrics adapter<br/>registered as an APIService]
ADAPT -->|native query| SRC
subgraph SRC["Real data source"]
PROM[Prometheus]
RMQ[RabbitMQ]
KAFKA[Kafka]
PG[PostgreSQL]
end
ADAPT -->|returns in<br/>ExternalMetricValueList format| API
API -->|response| HPA
style ADAPT fill:#cde,stroke:#369,stroke-width:2px
There can only be one service registered per API. That is, a single provider of external.metrics.k8s.io in the whole cluster. This matters: if you already have the Prometheus adapter registered there and then install KEDA, one of the two will be left unregistered. We will see it in the common mistakes.
The Prometheus adapter
The classic option before KEDA is prometheus-adapter. It translates PromQL queries into Kubernetes API metrics through configuration rules:
# Fragment of the prometheus-adapter configuration
rules:
- seriesQuery: 'rutasnorte_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^rutasnorte_requests_total$"
as: "requests_per_second"
metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)'And once configured, the metric is available:
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/pods/*/requests_per_second" \
| python3 -m json.toolIt works, but it has drawbacks that explain why KEDA has taken over:
Drawback of prometheus-adapter |
How KEDA solves it |
|---|---|
| Configuration in a global ConfigMap, hard to reason about | Each ScaledObject carries its own query |
| Changing a rule requires restarting the adapter | Hot changes |
| It only speaks to Prometheus | More than 70 different sources |
| It cannot scale to zero | Native scale-to-zero |
| No time-based triggers | A cron scaler included |
| No credential management | TriggerAuthentication |
- What KEDA is and what its architecture looks like
KEDA (Kubernetes Event-Driven Autoscaling) is a CNCF graduated project that adds to the cluster the ability to scale workloads based on events and external metrics from practically any source.
Installation
# With Helm (we will cover it in depth in 10-03)
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
# Verification
kubectl get pods -n kedaNAME READY STATUS RESTARTS AGE
keda-admission-webhooks-6d8f7c9b4d-x2klm 1/1 Running 0 2m
keda-operator-7d9c5f8b6b-4mnpq 1/1 Running 0 2m
keda-operator-metrics-apiserver-5f7b8c9d6a-9wrtz 1/1 Running 0 2mAnd the CRDs it registers:
clustertriggerauthentications.keda.sh 2026-04-20T11:32:14Z
scaledjobs.keda.sh 2026-04-20T11:32:14Z
scaledobjects.keda.sh 2026-04-20T11:32:14Z
triggerauthentications.keda.sh 2026-04-20T11:32:14ZThe three components
1. The operator (keda-operator).
It is the main controller. It watches the ScaledObject and ScaledJob objects and, when it finds one:
- Creates and manages a HorizontalPodAutoscaler pointing at the same target.
- Translates the
ScaledObject'striggersintoExternalmetrics on the HPA. - Handles scale-to-zero by activating and deactivating the target (this the HPA cannot do on its own).
- Updates the
ScaledObject's status.
2. The metrics server (keda-operator-metrics-apiserver).
It registers itself as the provider of external.metrics.k8s.io in the aggregation layer. When the HPA asks for a metric's value, this component:
- Identifies which
ScaledObjectand which trigger it corresponds to. - Queries the real source (Prometheus, RabbitMQ, Kafka...) using the corresponding scaler.
- Returns the value in the format the HPA expects.
3. The admission webhook (keda-admission-webhooks).
It validates ScaledObjects before accepting them: it checks that the target exists, that there are not two ScaledObjects over the same workload, that the target does not already have an HPA of its own. It prevents incoherent configurations before they cause problems.
The full diagram
flowchart TD
SO[ScaledObject<br/>rutas-norte-pro]
OP[keda-operator]
HPA[HorizontalPodAutoscaler<br/>keda-hpa-notifications-worker<br/>GENERATED by KEDA]
MS[keda-operator-metrics-apiserver]
API[API server<br/>aggregation layer]
DEP[Deployment<br/>notifications-worker]
subgraph SOURCES["External sources"]
RMQ[RabbitMQ<br/>email queue]
PROM[Prometheus]
CRON[Internal clock]
end
SO -->|1. watches| OP
OP -->|2. CREATES AND MAINTAINS| HPA
OP -->|3. activates/deactivates<br/>scaling 0 to 1| DEP
HPA -->|4. asks for the value| API
API -->|5. delegates| MS
MS -->|6. queries| RMQ
MS -->|6. queries| PROM
MS -->|6. queries| CRON
MS -->|7. returns value| API
API -->|8. value| HPA
HPA -->|9. PATCH /scale<br/>from 1 upwards| DEP
style OP fill:#cde,stroke:#369
style HPA fill:#ffd,stroke:#c90,stroke-width:2px
style MS fill:#cfc,stroke:#393
- The key idea: KEDA does not replace the HPA, it feeds it
This is the idea that generates the most misunderstandings and the one to nail down properly.
KEDA is not an alternative autoscaler. KEDA creates a perfectly ordinary HorizontalPodAutoscaler and feeds it metrics.
Check it for yourself. After creating a ScaledObject:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
keda-hpa-notifications-worker Deployment/notifications-worker 1847/2500 (avg) 1 25 8There it is: an HPA with the keda-hpa- prefix, managed by the KEDA operator. And its contents:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: keda-hpa-notifications-worker
namespace: rutas-norte-pro
labels:
app.kubernetes.io/managed-by: keda-operator
scaledobject.keda.sh/name: notifications-worker
ownerReferences:
- apiVersion: keda.sh/v1alpha1
kind: ScaledObject
name: notifications-worker
controller: true
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: notifications-worker
minReplicas: 1
maxReplicas: 25
metrics:
- type: External
external:
metric:
name: s0-rabbitmq-confirmation-notifications
selector:
matchLabels:
scaledobject.keda.sh/name: notifications-worker
target:
type: AverageValue
averageValue: "2500"It is exactly the HPA of 09-01, with an External metric whose name KEDA generated (s0 = trigger 0). Everything we learned about the HPA still applies: the formula, the tolerance band, behavior, the rule that the metric asking for the most replicas wins.
Practical consequences of this design
| Consequence | Detail |
|---|---|
| Do not manage the HPA by hand | It is owned by the ScaledObject (ownerReferences). If you edit it, KEDA reverts it; if you delete it, KEDA recreates it |
| The workload cannot have another HPA | One HPA per target. If you already have one of your own, it has to go |
behavior is configured from the ScaledObject |
Through advanced.horizontalPodAutoscalerConfig |
Deleting the ScaledObject removes the HPA |
Because of the ownerReference. The Deployment keeps whatever replicas it had |
| You debug it like an HPA | kubectl describe hpa keda-hpa-... is still the tool |
The exception: scale-to-zero
There is one thing the HPA cannot do and that KEDA handles directly: going from 0 to 1 replica and from 1 to 0.
The HPA cannot scale to zero (except with a little-used optional feature gate), and even if it could, it would have an unsolvable problem: with zero pods there are no pod metrics, so it would not know when to start again.
KEDA solves it because it queries the external source directly, not through the pods. With zero replicas of notifications-worker, KEDA keeps asking RabbitMQ how many messages there are. When the queue goes from 0 to 1 message, the operator scales the Deployment from 0 to 1 and from there hands over to the HPA.
flowchart LR
Z["0 replicas<br/>service switched off"] -->|"KEDA: there is activity<br/>(operator)"| U["1 replica"]
U -->|"HPA: the metric rises"| M["2 to 25 replicas"]
M -->|"HPA: the metric falls"| U
U -->|"KEDA: cooldownPeriod with no activity<br/>(operator)"| Z
style Z fill:#eee,stroke:#999
style U fill:#cfc,stroke:#393
style M fill:#cde,stroke:#369
The 0↔1 stretch is governed by the KEDA operator; the 1↔N stretch is governed by the HPA. This division explains many behaviours: for instance, that cooldownPeriod only affects the drop to zero, and that the behavior stabilisation window governs the rest.
- The
ScaledObject object field by field
ScaledObject object field by fieldapiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: notifications-worker
namespace: rutas-norte-pro
spec:
# WHICH workload it applies to.
scaleTargetRef:
apiVersion: apps/v1 # Optional, apps/v1 by default
kind: Deployment # Optional, Deployment by default
name: notifications-worker
envSourceContainerName: worker # Which container to read env vars from
# HOW OFTEN KEDA queries the external source.
pollingInterval: 15
# HOW LONG it waits with no activity before dropping to ZERO.
cooldownPeriod: 300
# Replicas when there is NO activity. 0 = scale-to-zero.
minReplicaCount: 0
maxReplicaCount: 25
# Replicas in an "idle" state OTHER than zero.
idleReplicaCount: 0
# What to do if the metrics source DOES NOT RESPOND.
fallback:
failureThreshold: 3
replicas: 6
# Advanced configuration.
advanced:
restoreToOriginalReplicaCount: false
horizontalPodAutoscalerConfig:
name: hpa-notifications-worker
behavior:
scaleUp: {}
scaleDown: {}
# WHAT it watches. It is a list: there can be several.
triggers:
- type: rabbitmq
metadata:
queueName: confirmation-notifications
mode: QueueLength
value: "2500"
authenticationRef:
name: rabbitmq-credentialsThe fields, one by one
scaleTargetRef — the target workload. It accepts a Deployment, a StatefulSet and any resource with a /scale subresource, including third-party CRDs (Argo Rollouts, for example). The envSourceContainerName field says which container to read environment variables from when a trigger references them.
pollingInterval (default: 30 s) — how often KEDA queries the external source. It is a trade-off:
| Value | Advantage | Drawback |
|---|---|---|
| 5-10 s | Fast reaction | Many queries against the source; it can overload it |
| 15-30 s | Balanced. Recommended | — |
| 60 s+ | Little load on the source | Slow reaction |
An important note: pollingInterval only governs KEDA's queries for scale-to-zero purposes. Once there is at least one replica, the HPA queries the metrics on its own 15-second cycle through KEDA's metrics server. So a pollingInterval of 60 s does not make scaling from 1 to 25 slow; it only makes activation from zero slow.
cooldownPeriod (default: 300 s) — time with no activity before dropping to zero. It only applies to the 1→0 transition. Going from 25 down to 1 is governed by the HPA's stabilisation window.
minReplicaCount (default: 0) — the minimum replicas. With 0, scale-to-zero is enabled.
maxReplicaCount (default: 100) — the ceiling. Just like the HPA's maxReplicas, and with the same capacity warnings from 09-03.
idleReplicaCount — a subtle but useful nuance. It allows an "idle" state with more than zero replicas:
minReplicaCount: 3 # When there is activity, at least 3
idleReplicaCount: 1 # When there is NO activity, drop to 1It means: with no activity, 1 replica (so the first request does not wait for a cold start); with activity, at least 3. It is only valid if idleReplicaCount < minReplicaCount. It is an excellent middle ground between scale-to-zero and keeping capacity.
fallback — what to do if the metrics source fails:
fallback:
failureThreshold: 3 # After 3 consecutive failed queries...
replicas: 6 # ...pin 6 replicas and hold themThis field is essential in production. Without it, if RabbitMQ stops responding, the HPA is left with the metric at <unknown> and does not scale: the replica count freezes wherever it was. With fallback, KEDA pins a safe, known number.
The replicas value must be enough for normal traffic: not the minimum nor the maximum, but a number the platform works fine on while the metrics source is being fixed.
A limitation to know: fallback only works with triggers whose target is AverageValue, and it does not apply when minReplicaCount is 0 with the workload scaled to zero.
advanced.restoreToOriginalReplicaCount (default: false) — what happens when the ScaledObject is deleted. With false, the Deployment keeps whatever replicas it had at that moment. With true, it goes back to the number it had before KEDA managed it. Set it to true if you want deleting the ScaledObject to be reversible without surprises.
advanced.horizontalPodAutoscalerConfig — the doorway to the behavior of 09-01:
advanced:
horizontalPodAutoscalerConfig:
name: hpa-notifications-worker # Custom name for the generated HPA
behavior:
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
selectPolicy: Min
policies:
- type: Percent
value: 25
periodSeconds: 60Everything learned in 09-01 about behavior is still valid, and it is configured here. It is the clearest proof that KEDA feeds the HPA instead of replacing it.
triggers — the list of sources. With several triggers, the one asking for the most replicas wins, exactly as with several metrics on an HPA. This rule is what makes it possible to combine "scale by queue" with "scale by time of day" naturally.
- The catalogue of scalers
KEDA ships more than seventy scalers. These are the ones that get real use:
| Scaler | type |
Metric it watches | Typical use case |
|---|---|---|---|
| Prometheus | prometheus |
The result of a PromQL query | The most versatile. Any metric you already have |
| RabbitMQ | rabbitmq |
Queue length or publish rate | Queue consumers |
| Kafka | kafka |
Consumer-group lag | Stream processing |
| AWS SQS | aws-sqs-queue |
Visible + in-flight messages | Queues on AWS |
| Azure Service Bus | azure-servicebus |
Active messages | Queues on Azure |
| Google Pub/Sub | gcp-pubsub |
Unacknowledged messages | Queues on GCP |
| PostgreSQL | postgresql |
The result of an SQL query | Pending jobs in a table |
| Redis | redis |
Length of a list or stream | Lightweight queues |
| Cron | cron |
The time of day | Scheduled scaling |
| CPU | cpu |
CPU utilisation | Same as the classic HPA |
| Memory | memory |
Memory utilisation | Same as the classic HPA |
| MongoDB | mongodb |
The result of a query | Pending documents |
| Elasticsearch | elasticsearch |
The result of a query | Documents to process |
| Generic external metric | external |
Your own gRPC service | Bespoke sources |
One detail that surprises people: KEDA also ships CPU and memory scalers. Why, if the HPA already does that? Because it lets you combine CPU with external metrics in the same ScaledObject, and because KEDA manages the HPA for you. It is common to see:
triggers:
- type: rabbitmq # Primary signal: the queue
metadata: {...}
- type: cpu # Safety net: if CPU spikes, scale anyway
metricType: Utilization
metadata:
value: "80"With the "the greediest one wins" rule, that second trigger is a free safeguard: if the queue is empty but the pods are at 90% CPU for some unforeseen reason, it scales all the same.
Configuration examples for the key scalers
Prometheus:
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
query: |
sum(rate(rutasnorte_requests_total{namespace="rutas-norte-pro"}[2m]))
threshold: "85"
# What value to use if the query returns nothing (an empty series)
ignoreNullValues: "true"
unsafeSsl: "false"RabbitMQ:
- type: rabbitmq
metadata:
protocol: amqp
queueName: confirmation-notifications
mode: QueueLength # QueueLength or MessageRate
value: "2500"
# activationValue: below this, it is considered INACTIVE (for the zero)
activationValue: "10"
authenticationRef:
name: rabbitmq-credentialsCron:
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 * * *" # At 9:30
end: "0 14 * * *" # Until 14:00
desiredReplicas: "25"PostgreSQL:
- type: postgresql
metadata:
query: "SELECT COUNT(*) FROM pending_tasks WHERE status = 'pending'"
targetQueryValue: "50"
activationTargetQueryValue: "1"
authenticationRef:
name: postgres-credentialsactivationValue: the line between zero and one
A concept specific to KEDA that is worth understanding. Every scaler distinguishes two thresholds:
value/threshold: the target for the replica calculation (used by the HPA).activationValue: the threshold below which the workload is considered inactive and may drop to zero.
metadata:
value: "2500" # 1 replica per 2,500 messages
activationValue: "10" # With fewer than 10 messages, it counts as inactiveThe reason these are two different numbers: without activationValue, any value greater than zero would activate the workload. With a queue that always has 2 or 3 residual messages, scale-to-zero would never happen. activationValue defines the acceptable background noise.
TriggerAuthentication: credentials for the triggers
TriggerAuthentication: credentials for the triggersThe triggers need to talk to external systems, and that requires credentials. Putting them in the ScaledObject in the clear would be a security disaster, after the whole of module 8.
KEDA solves it with TriggerAuthentication:
# 1. The Secret with the credentials (module 3 and 08-05)
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-credentials
namespace: rutas-norte-pro
type: Opaque
stringData:
# Full connection string with user and password.
host: "amqp://keda_reader:[email protected]:5672/"
---
# 2. The TriggerAuthentication that references it
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-credentials
namespace: rutas-norte-pro
spec:
secretTargetRef:
- parameter: host # Name of the parameter the scaler expects
name: rabbitmq-credentials
key: host # Key inside the SecretAnd in the ScaledObject:
triggers:
- type: rabbitmq
metadata:
queueName: confirmation-notifications
mode: QueueLength
value: "2500"
authenticationRef:
name: rabbitmq-credentialsOther credential sources
TriggerAuthentication accepts more than Secrets:
| Source | Field | Use |
|---|---|---|
| Secret | secretTargetRef |
The general case |
| Environment variable of the target pod | env |
Reuse the config the application already has |
| Cloud-provider identity | podIdentity |
AWS IRSA, Azure Workload Identity, GCP |
| HashiCorp Vault | hashiCorpVault |
Centralised secret management |
| Azure Key Vault | azureKeyVault |
The same on Azure |
An example with a cloud identity, which is the correct approach in production because it removes the long-lived secret:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: aws-sqs-identity
namespace: rutas-norte-pro
spec:
podIdentity:
provider: aws
roleArn: arn:aws:iam::000000000000:role/rutas-norte-keda-sqsIt connects directly with the least-privilege principle of 08-01: the user KEDA uses to read the queue only needs metadata read permission, not permission to consume messages. In RabbitMQ, a user with read permission on the queue and nothing else.
ClusterTriggerAuthentication
The cluster-scoped variant, for credentials shared across namespaces:
apiVersion: keda.sh/v1alpha1
kind: ClusterTriggerAuthentication
metadata:
name: prometheus-read # No namespace
spec:
secretTargetRef:
- parameter: bearerToken
name: prometheus-token
key: tokenYou reference it with kind: ClusterTriggerAuthentication in authenticationRef. Useful for Prometheus, which is a single instance queried from every namespace.
ScaledJob: one job per message
ScaledJob: one job per messageScaledObject scales a Deployment: long-lived pods consuming in a loop. ScaledJob is different: it creates a Job (06-03) for each unit of work.
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: invoice-processor
namespace: rutas-norte-pro
spec:
jobTargetRef:
parallelism: 1
completions: 1
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: processor
image: registry.rutasnorte.example/invoice-processor:2.1.0
resources:
requests: {cpu: 500m, memory: 512Mi}
limits: {cpu: "2", memory: 2Gi}
pollingInterval: 30
maxReplicaCount: 20
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 10
# How to work out how many Jobs to create from the metric value.
scalingStrategy:
strategy: "default"
triggers:
- type: rabbitmq
metadata:
queueName: pending-invoices
mode: QueueLength
value: "1" # ONE Job per message
authenticationRef:
name: rabbitmq-credentialsWhen to use each one
| Criterion | ScaledObject (Deployment) |
ScaledJob (Job per message) |
|---|---|---|
| Task duration | Short (ms to seconds) | Long (minutes to hours) |
| Start-up cost | Amortised: the pod lives a long time | Paid for every task |
| Isolation between tasks | They share a process | Total: one pod per task |
| Resources per task | The same for all | Can be tuned per kind of work |
| If a task fails | It can affect the whole pod | Only that Job fails |
| Retries | Managed by the application | Kubernetes backoffLimit |
| Memory leaks | They accumulate | Impossible: the pod dies when it finishes |
| Example at Rutas Norte | notifications-worker (450 ms/email) |
Generating a 20-minute PDF report |
The rule: if the task takes less time than starting the pod, ScaledObject; if it takes much longer, ScaledJob.
For notifications-worker, each email takes 450 ms and starting a pod takes 15 seconds. A ScaledJob would spend thirty times more time starting up than working. ScaledObject, without a doubt.
But imagine Rutas Norte adds PDF invoice generation for corporate customers: each invoice takes 12 minutes, peaks at 2 GiB of memory, and occasionally fails on a corrupt PDF. There ScaledJob shines: total isolation, retries managed by Kubernetes, memory released on completion.
- Rutas Norte:
notifications-worker by queue length
notifications-worker by queue lengthLet's do the complete implementation, the central case of the lesson.
The situation
- Out of season, the email queue is practically empty. Around 200 emails a day go out, in bursts.
- During the May bank holiday, the queue builds up 40,000 emails in two hours.
- Each worker processes ~2 emails/second (limited by SMTP).
- Confirmation emails can tolerate a few minutes of delay, but not hours.
The manifest
# k8s/environments/pro/scaledobject-notifications-worker.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: notifications-worker
namespace: rutas-norte-pro
labels:
app: notifications-worker
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: notifications-worker
# Query RabbitMQ every 15 s. Fast enough that reactivation from zero is
# almost immediate, and slow enough not to hammer the RabbitMQ management
# API.
pollingInterval: 15
# 10 minutes with no activity before switching off entirely. It is generous
# on purpose: bookings arrive in bursts, and switching off and on every two
# minutes would cost more (in cold starts) than keeping one pod.
cooldownPeriod: 600
# SCALE-TO-ZERO. Out of season there is no reason to keep workers running
# 24 hours a day waiting for one email every few minutes.
minReplicaCount: 0
# Ceiling: 25 workers x 2 emails/s = 50 emails/s = 3,000 emails/minute.
# The bank holiday's 40,000 emails drain in ~14 minutes. Enough.
# Also: 25 x 120m = 3 cores, which fits the capacity plan of 09-03.
maxReplicaCount: 25
# If RabbitMQ stops responding, we do not freeze: we pin 6 replicas, which
# is a normal day's throughput with margin. Better a known safe number than
# an unknown frozen one.
fallback:
failureThreshold: 3
replicas: 6
advanced:
# On deleting the ScaledObject, return the Deployment to its original replicas.
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-notifications-worker
behavior:
scaleUp:
# No wait: if there is a queue, there is work pending NOW.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 5 minutes of calm. Less conservative than bookings-api because
# starting a worker is cheap (it has no cache to warm) and because
# there is no user waiting for a response.
stabilizationWindowSeconds: 300
selectPolicy: Min
policies:
- type: Percent
value: 30
periodSeconds: 60
triggers:
# PRIMARY TRIGGER: the queue length.
- type: rabbitmq
metadata:
protocol: amqp
queueName: confirmation-notifications
mode: QueueLength
# 1 worker per 2,500 messages in the queue.
#
# Calculation: we want to drain 40,000 messages in ~15 minutes.
# 40,000 / 900 s = 44.4 messages/s needed
# 44.4 / 2 (per worker) = 22.2 workers
# 40,000 / 22.2 = 1,800 messages per worker
# We round to 2,500 so as not to hit the ceiling with a tight margin:
# 40,000 / 2,500 = 16 workers -> drains in ~21 minutes. Acceptable.
value: "2500"
# Below 10 messages, it counts as inactive and may drop to zero.
# We do not use 0 because there are always 1-2 residual messages in flight.
activationValue: "10"
authenticationRef:
name: rabbitmq-credentials
# SECONDARY TRIGGER: a CPU safety net.
# If for some unforeseen reason the workers saturate on CPU (a pathological
# email template, say) we scale anyway, even with a short queue. The
# trigger asking for MORE replicas wins.
- type: cpu
metricType: Utilization
metadata:
value: "80"Verification
kubectl apply -f k8s/environments/pro/scaledobject-notifications-worker.yaml
kubectl get scaledobject -n rutas-norte-proNAME SCALETARGETKIND SCALETARGETNAME MIN MAX TRIGGERS AUTHENTICATION READY ACTIVE FALLBACK AGE
notifications-worker apps/v1.Deployment notifications-worker 0 25 rabbitmq rabbitmq-credentials True False False 34sThe key columns:
| Column | Meaning |
|---|---|
READY |
KEDA has configured everything correctly |
ACTIVE |
There is activity right now (above the activationValue) |
FALLBACK |
It is using the fallback because the source is failing |
ACTIVE: False with an empty queue means the Deployment is at zero replicas. Let's confirm:
Reactivation live
We open two terminals. In the first, we watch:
In the second, we push messages onto the queue:
# Simulate 30,000 confirmation emails (the sale opening)
kubectl exec -n rutas-norte-pro rabbitmq-0 -- \
rabbitmqadmin publish routing_key=confirmation-notifications \
payload='{"booking":"RN-2026-054321","recipient":"[email protected]"}' \
--count=30000And in the first terminal:
NAME READY UP-TO-DATE AVAILABLE AGE
notifications-worker 0/0 0 0 89d
notifications-worker 0/1 0 0 89d <- KEDA activates: 0 -> 1
notifications-worker 1/1 1 1 89d
notifications-worker 1/6 1 1 89d <- The HPA takes over
notifications-worker 6/6 6 6 89d
notifications-worker 6/12 6 6 89d
notifications-worker 12/12 12 12 89d
notifications-worker 12/12 12 12 89d <- Stable: 30000/2500 = 12You can see the division of responsibilities perfectly: the 0→1 jump is made by the KEDA operator as soon as it detects activity; from there on it is the HPA applying its formula and its behavior.
And the generated HPA confirms the arithmetic:
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
hpa-notifications-worker Deployment/notifications-worker 2483/2500 (avg) 1 25 122483/2500 (avg): there are 12 replicas and 29,800 messages; 29,800/12 = 2,483 messages per replica, against a target of 2,500. The system is at equilibrium.
When the queue drains:
notifications-worker 12/12 12 12 89d
notifications-worker 12/8 12 12 89d <- HPA scales down (after stabilisation)
notifications-worker 8/3 8 8 89d
notifications-worker 3/1 3 3 89d
notifications-worker 1/1 1 1 89d <- It stays at 1 (the HPA minimum)
...10 minutes of cooldownPeriod pass...
notifications-worker 0/0 0 0 89d <- KEDA switches off: 1 -> 0
- Rutas Norte:
bookings-api by requests per second
bookings-api by requests per secondNow we replace the CPU-based HPA of bookings-api with one based on the Prometheus metrics we instrumented in 07-03.
Why change
CPU is not a bad signal for bookings-api — it is an API that genuinely computes — but it is indirect. Requests per second is better because:
- It is the business quantity. "Each replica serves 85 requests per second" is a sentence anybody in the company understands.
- It does not depend on
requests. A change torequests.cpu(recommended by the VPA of 09-02) does not alter the scaling behaviour. - It is faster. CPU rises once the work is already coming in; requests are counted on arrival.
- It removes the conflict with the VPA. As we saw in 09-02, with the HPA on a business metric, the VPA can govern CPU and memory with no loop.
The instrumented metrics
From 07-03, bookings-api exposes at /metrics:
# HELP rutasnorte_requests_total Total HTTP requests served
# TYPE rutasnorte_requests_total counter
rutasnorte_requests_total{method="GET",route="/routes",code="200"} 1847293
rutasnorte_requests_total{method="POST",route="/bookings",code="201"} 92841
# HELP rutasnorte_request_duration_seconds Request duration
# TYPE rutasnorte_request_duration_seconds histogram
rutasnorte_request_duration_seconds_bucket{route="/bookings",le="0.1"} 78201
rutasnorte_request_duration_seconds_bucket{route="/bookings",le="0.5"} 91043
...
# HELP rutasnorte_bookings_confirmed_total Confirmed bookings
# TYPE rutasnorte_bookings_confirmed_total counter
rutasnorte_bookings_confirmed_total 92841The manifest
# k8s/environments/pro/scaledobject-bookings-api.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
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
pollingInterval: 15
cooldownPeriod: 300
# We do NOT scale to zero. bookings-api is the API that carries the sale: a
# cold start in front of a user who wants to buy is unacceptable.
# 4 replicas is the agreed availability floor (09-01, and 09-05).
minReplicaCount: 4
maxReplicaCount: 30
fallback:
failureThreshold: 3
# If Prometheus goes down, we pin 12 replicas: triple the minimum. It is a
# conservative number that comfortably handles a normal day and gives us
# time to fix Prometheus with no risk of an outage.
replicas: 12
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-bookings-api
behavior:
# The SAME asymmetric behavior as 09-01: up in zero seconds,
# down after ten minutes of sustained calm.
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 6
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 20
periodSeconds: 120
- type: Pods
value: 3
periodSeconds: 120
triggers:
# PRIMARY TRIGGER: requests per second, measured in Prometheus.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
# Name the metric will appear under in the generated HPA.
metricName: bookings_api_requests_per_second
query: |
sum(
rate(
rutasnorte_requests_total{
namespace="rutas-norte-pro",
app="bookings-api"
}[2m]
)
)
# 85 requests per second and replica.
#
# Calculation (from the load test of 09-06): one bookings-api replica
# with 412m of CPU sustains ~120 rps before the p95 latency exceeds
# 300 ms. We leave a 30% margin to absorb the start-up time of the new
# replicas: 120 x 0.7 = 84 -> 85 rps.
threshold: "85"
activationThreshold: "5"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-read
# SAFETY NET 1: CPU. If for whatever reason the requests are much more
# expensive than usual (a pathological query, an abusive client), CPU will
# detect it even if the request count is normal.
- type: cpu
metricType: Utilization
metadata:
value: "70"The PromQL query, explained
Taken apart from the inside out:
-
rutasnorte_requests_total{...}— selects the request counter, filtered by namespace and application. It returns one series per pod and per label combination (method, route, code). -
rate(...[2m])— turns the cumulative counter into requests per second, averaged over the last 2 minutes. It is the correct function for counters: it handles pod restarts (when the counter goes back to zero) without producing false spikes.Why 2 minutes? It is a trade-off. With
[1m]the signal is more reactive but very noisy (and with ascrape_intervalof 30 s there would be only 2 samples, which makes therateunreliable). With[5m]the signal is smooth but late. Rule: theratewindow must be at least 4 times the scrape interval. With scraping every 30 s,[2m]is the reasonable minimum. -
sum(...)— adds up all the series. Without it we would have one series per pod and per route; KEDA needs a single scalar number.
It is essential that the query returns a single value. If it returns several series, KEDA logs an error. Always test the query in the Prometheus interface before putting it in a ScaledObject.
Checking the arithmetic
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
hpa-bookings-api Deployment/bookings-api 1247/85 (avg), 34%/70% 4 30 15There are two metrics. The first says 1247/85 (avg)... and here there is an important detail that confuses a lot of people.
KEDA uses AverageValue for Prometheus triggers. That means the HPA divides the total value by the replicas:
Total value of the query: 1,247 rps (all replicas together)
Current replicas: 15
Value per replica: 1,247 / 15 = 83.1 rps
Target: 85 rps
ratio = 83.1 / 85 = 0.978 -> inside the tolerance band. Stable.The 1247/85 display can mislead, because it shows the total against the per-replica target. What it compares internally is 83.1 against 85.
And the second metric, CPU: 34%/70%. It would ask to scale down, but the greediest one wins, so requests per second is in charge.
Migrating from the previous HPA
Important: two HPAs cannot coexist over the same Deployment. The manual HPA of 09-01 has to be deleted first.
# 1. Delete the manual HPA
kubectl delete hpa bookings-api -n rutas-norte-pro
# 2. Create the ScaledObject
kubectl apply -f k8s/environments/pro/scaledobject-bookings-api.yaml
# 3. Verify that KEDA has created its own
kubectl get hpa -n rutas-norte-proIf you skip step 1, KEDA's admission webhook rejects the ScaledObject:
Error from server: admission webhook "vscaledobject.kb.io" denied the request:
the workload 'bookings-api' of type 'apps/v1.Deployment' is already managed by
the hpa 'bookings-api'It is a clear message and a very welcome validation: without it, you would have two HPAs fighting over the same replicas.
- Rutas Norte: the May bank-holiday cron trigger
And now the piece that solves the second problem: scaling before the traffic arrives.
The problem, with a stopwatch
09:59:50 4 bookings-api replicas. Normal traffic: 180 rps.
10:00:00 The sale opens. Traffic jumps to 2,400 rps in 40 seconds.
10:00:15 Prometheus scrapes. The rate[2m] still reflects the average of the
last 2 minutes, which includes 1m50s of calm: ~400 rps.
10:00:15 KEDA/HPA: 400/4 = 100 rps per replica. Ratio 1.18. Asks for 5 replicas.
<- FAR BELOW what is needed. The rate[2m] SMOOTHS the spike.
10:00:45 The rate now reflects reality better: ~1,500 rps. Asks for 18 replicas.
10:01:15 The first new pods are Ready.
10:02:30 The 28 replicas needed are reached.
DEGRADATION: two and a half minutes at the most valuable moment of the year.Note the perverse effect of the rate's 2-minute window: it smooths exactly the spike we want to detect. It is unavoidable: without that window the signal would be unusably noisy. It is an intrinsic limitation of any reactive scaling based on rates.
The solution: the cron trigger
# k8s/environments/pro/scaledobject-bookings-api.yaml (added fragment)
triggers:
- type: prometheus
# ... (the primary trigger above, unchanged)
- type: cpu
# ... (the safety net, unchanged)
# PRE-WARMING TRIGGER: the May bank holiday.
#
# The sale opens at 10:00 on 25 April. By 9:30 we already want
# 25 replicas BOOTED AND WARM: PostgreSQL connection pool
# established, route cache populated, Node.js JIT warmed up.
#
# Remember the HPA rule: THE TRIGGER ASKING FOR THE MOST REPLICAS WINS.
# Between 9:30 and 14:00, the floor is 25 replicas. If real traffic
# asks for more, the Prometheus trigger takes it up to 30.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 25 4 *" # 25 April at 9:30
end: "0 14 25 4 *" # 25 April at 14:00
desiredReplicas: "25"Syntax for start and end: standard five-field cron (minute hour day month day-of-week). 30 9 25 4 * = at 9:30 on 25 April, any day of the week.
The timezone is mandatory and critical. Without it, KEDA uses UTC, and in Spanish summer time that is two hours out: your 25 replicas would appear at 11:30, an hour and a half after the opening. Always use the IANA zone name (Europe/Madrid), never a fixed offset, so that clock changes are handled for you.
A more maintainable pattern: a weekly cron
Fixed dates have to be updated every year. For a recurring pattern:
# Daily pre-warming of the busiest sales window.
# Monday to Sunday, from 9:30 to 13:00, a floor of 10 replicas.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 9 * * *"
end: "0 13 * * *"
desiredReplicas: "10"
# Weekend reinforcement: Friday afternoon and Saturday morning concentrate
# the short-break bookings.
- type: cron
metadata:
timezone: Europe/Madrid
start: "0 16 * * 5" # Friday at 16:00
end: "0 22 * * 6" # Saturday at 22:00
desiredReplicas: "15"With several overlapping cron triggers, the greediest one wins. On a Friday at 17:00 both the daily one (10) and the weekend one (15) would be active: the floor is 15.
The complete scenario with every trigger
Let's see what happens on 25 April with all four triggers active:
| Time | Bank-holiday cron | Daily cron | Prometheus | CPU | Replicas | Who is in charge |
|---|---|---|---|---|---|---|
| 08:00 | — | — | 3 (180 rps) | 2 | 4 | minReplicaCount |
| 09:29 | — | — | 3 | 2 | 4 | minReplicaCount |
| 09:31 | 25 | 10 | 3 | 2 | 25 | Bank-holiday cron |
| 09:59 | 25 | 10 | 3 | 2 | 25 | Bank-holiday cron |
| 10:01 | 25 | 10 | 5 | 8 | 25 | Bank-holiday cron (the spike is already covered!) |
| 10:05 | 25 | 10 | 29 | 22 | 29 | Prometheus |
| 11:30 | 25 | 10 | 21 | 16 | 25 | Bank-holiday cron |
| 13:05 | 25 | — | 12 | 9 | 25 | Bank-holiday cron |
| 14:01 | — | — | 9 | 7 | 9 | Prometheus (with a 10-min stabilisation) |
At 10:01, with traffic already through the roof, the Prometheus trigger only asks for 5 replicas (because of the rate smoothing) but we already have 25 thanks to the cron. Zero degradation. The spike is absorbed without anyone noticing.
And at 10:05, when the rate finally reflects reality and asks for 29, the system adds those 4 extra replicas at its leisure.
This is the difference between reacting and anticipating. The cron does not replace reactive scaling: it takes the responsibility for the critical moment away from it.
The same pattern for the cluster
The cron scales pods, but remember 09-03: pods need nodes. 25 replicas of bookings-api require nodes that take 3-6 minutes to boot.
A coherent solution: scale the over-provisioning cushion beforehand as well:
# k8s/environments/pro/scaledobject-capacity-filler.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: capacity-filler
namespace: rutas-norte-pro
spec:
scaleTargetRef:
name: capacity-filler
minReplicaCount: 2
maxReplicaCount: 10
cooldownPeriod: 300
triggers:
# Expand the cushion ONE HOUR BEFORE the bookings-api replicas.
# That way the Cluster Autoscaler has time to boot the nodes, and at
# 9:30, when the bookings-api cron asks for 25 replicas, there is room.
- type: cron
metadata:
timezone: Europe/Madrid
start: "30 8 25 4 *" # 8:30, one hour earlier
end: "0 15 25 4 *"
desiredReplicas: "10"The full timeline:
08:30 The cron expands the filler from 2 to 10 pods.
8 filler pods go to Pending.
08:31 The Cluster Autoscaler detects the pending pods, asks for 3 nodes.
08:35 The nodes are Ready. The filler is placed.
CAPACITY READY AND PAID FOR, but occupied by pods that do nothing.
09:30 The bookings-api cron asks for 25 replicas.
The scheduler EVICTS the filler pods (priority -10).
09:30 The 25 replicas are placed in SECONDS. No waiting for nodes.
09:31 The filler pods go to Pending -> the CA boots more nodes ->
the cushion regenerates for whatever comes next.
09:32 25 replicas Ready and warm.
10:00 OPENING. Zero degradation.Here you can see the module's four pieces working together: scheduled scaling (KEDA), over-provisioning with preemption (09-03), node autoscaling (09-03) and the reactive HPA (09-01) as a safety net.
- Scale-to-zero and its implications
Scale-to-zero is KEDA's most eye-catching feature, and the one that needs the most thought before use.
What it means
With minReplicaCount: 0, when there is no activity for the cooldownPeriod, KEDA takes the Deployment to zero replicas. No pods. No containers. CPU and memory consumption is literally zero.
The cost: the cold start
The trade-off is unavoidable. From work appearing to work being processed:
t=0 A message arrives on the queue.
t=0-15 KEDA detects it on its next cycle (pollingInterval).
t=15 KEDA scales the Deployment from 0 to 1.
t=16 The scheduler places the pod. If there is NO room -> +3 min (09-03).
t=17 The kubelet pulls the image. If cached, ~2 s; if not, 20-60 s.
t=20 The container starts. Node.js: ~15 s until it is ready.
t=35 Connections to PostgreSQL and RabbitMQ established.
t=36 The first message is processed.
TOTAL DELAY: between 35 seconds and 4 minutes.Which workloads tolerate it and which do not
| Workload | Scale to zero? | Reason |
|---|---|---|
notifications-worker |
Yes | Nobody is waiting. An email 40 s later makes no difference |
occupancy-reports |
Not applicable | It is already a CronJob: it only exists when it runs |
| A batch-job processor | Yes | Likewise: no user waiting |
| Development and test environments | Yes, strongly recommended | Huge saving outside working hours |
bookings-api |
No | A user waiting 40 s to see routes goes to the competition |
web-store |
No | It is the front door |
bookings-postgres |
Never | A stateful database |
redis-cache |
No | Starting empty dumps everything onto PostgreSQL |
The rule: if a human being is waiting for a response, do not scale to zero.
The rutas-norte-dev case: the most profitable saving
The development environment is used Monday to Friday, from 8:00 to 19:00. That is 55 hours out of 168 in a week: 67% of the time it is switched on with nobody using it.
# k8s/environments/dev/scaledobject-nightly-shutdown.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: bookings-api-schedule
namespace: rutas-norte-dev
labels:
app: bookings-api
environment: dev
spec:
scaleTargetRef:
name: bookings-api
# Outside working hours: ZERO. The environment switches itself off.
minReplicaCount: 0
maxReplicaCount: 3
cooldownPeriod: 300
triggers:
# Monday to Friday, from 7:45 to 19:30: 1 replica running.
# 7:45 so that it is warm when the team arrives at 8:00.
- type: cron
metadata:
timezone: Europe/Madrid
start: "45 7 * * 1-5"
end: "30 19 * * 1-5"
desiredReplicas: "1"Estimated saving: the whole development environment (5 components, ~4 cores and 8 GiB) switched off for 113 hours a week. Roughly 67% of the environment's cost, which on a managed cluster can be an entire node.
And there is an additional, non-financial benefit: it forces the team to make the services start fast and without manual intervention. An environment that switches off and on every day quickly exposes badly resolved start-up dependencies.
The idleReplicaCount pattern: the best of both worlds
For workloads that cannot tolerate a cold start but do not justify keeping full capacity either:
spec:
# When there IS activity: at least 4 replicas.
minReplicaCount: 4
# When there is NO activity: 1 replica running.
idleReplicaCount: 1That single replica keeps the database connection, the warm cache and the process ready. The first request is served in milliseconds, and the HPA goes up to 4 on the next cycle. It is the right compromise for most services with users, and many teams prefer it to absolute zero.
Activation from zero: an important detail
With zero replicas, KEDA cannot use pod metrics. That is why CPU and memory triggers cannot activate from zero: if there are no pods, there is no CPU to measure.
A practical consequence: a ScaledObject with minReplicaCount: 0 needs at least one trigger that queries an external source (a queue, Prometheus, cron, a database). If it only has CPU and memory triggers, it will stay at zero forever.
It is a frequent mistake and KEDA's message does not always make it clear. Keep it in mind.
- Debugging a
ScaledObject that does not scale
ScaledObject that does not scaleA systematic procedure, from the most general to the most specific.
Step 1: the ScaledObject's status
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE FALLBACK AGE
notifications-worker apps/v1.Deployment notifications-worker 0 25 False False False 4mREADY: False is the first problem. It means KEDA has not been able to configure it.
Status:
Conditions:
Type: Ready
Status: False
Reason: ScalerFailed
Message: error creating scaler for trigger 0: error parsing rabbitmq metadata:
no host setting given
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning ScalerFailed 4m keda-operator error creating scaler for trigger 0The message points at the exact problem: the RabbitMQ trigger's host is missing, meaning the TriggerAuthentication is not right.
A diagnostic table by condition
READY |
ACTIVE |
Symptom | Likely cause |
|---|---|---|---|
False |
False |
It does nothing | A configuration error in the trigger or the TriggerAuthentication |
True |
False |
It stays at minReplicaCount |
The metric is below the activationThreshold |
True |
True |
It scales, but badly | The threshold is badly calibrated |
True |
True |
FALLBACK: True |
The metrics source is not responding |
Unknown |
— | No status | The KEDA operator is not running |
Step 2: the generated HPA
Remember: KEDA feeds an HPA. If the HPA cannot see the metric, it does not scale.
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True SucceededGetScale the HPA controller was able to get the target's current scale
ScalingActive False FailedGetExternalMetric unable to get external metric
rutas-norte-pro/s0-rabbitmq-confirmation-notifications/&LabelSelector{...}:
unable to fetch metrics from external metrics API:
rpc error: code = Unknown desc = error inspecting rabbitMQ:
Object Not FoundObject Not Found from RabbitMQ: the queue does not exist under that name. A typo in queueName, or the queue has not been created yet.
Step 3: query the metric directly
Bypass the HPA and ask the aggregation API:
kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/rutas-norte-pro/s0-rabbitmq-confirmation-notifications?labelSelector=scaledobject.keda.sh%2Fname%3Dnotifications-worker" \
| python3 -m json.tool{
"kind": "ExternalMetricValueList",
"apiVersion": "external.metrics.k8s.io/v1beta1",
"items": [
{
"metricName": "s0-rabbitmq-confirmation-notifications",
"metricLabels": null,
"timestamp": "2026-05-01T10:14:32Z",
"value": "29800"
}
]
}If this returns a correct value, the problem is between the HPA and the API, not in KEDA nor in the source. If it returns an error, the problem is in the scaler or in the source.
Step 4: the operator logs
ERROR scale_handler error getting scale decision {"scaledObject.Namespace": "rutas-norte-pro",
"scaledObject.Name": "notifications-worker",
"error": "dial tcp 10.96.42.17:5672: connect: connection refused"}connection refused: KEDA cannot connect to RabbitMQ. Causes: the Service does not exist, a NetworkPolicy is blocking it (04-06), or RabbitMQ is down.
And from the metrics server:
Step 5: verify network connectivity
A very frequent case after module 8: the NetworkPolicies are blocking KEDA.
# Can KEDA reach RabbitMQ?
kubectl run connectivity-test --rm -it --restart=Never \
-n keda --image=busybox:1.36 -- \
wget -qO- --timeout=5 http://rabbitmq.rutas-norte-pro.svc.cluster.local:15672/api/overviewIf it fails and RabbitMQ is working, review the NetworkPolicies of the rutas-norte-pro namespace. KEDA lives in the keda namespace and needs explicit ingress permission:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-keda-to-rabbitmq
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: rabbitmq
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: keda
ports:
- protocol: TCP
port: 5672
- protocol: TCP
port: 15672This is mistake number one after rolling out the NetworkPolicies of 04-06 and 08-04. Everything was working, the security policies go in, and suddenly KEDA stops scaling without anybody connecting the two events.
Step 6: test the PromQL query by hand
For Prometheus triggers:
And in the web interface, run the exact query from the ScaledObject. Check:
- Does it return any data? If it is empty, the selector labels do not match.
- Does it return A SINGLE value? If it returns several series, a
sum()is missing. - Does the value make sense? A
rategiving 0.003 instead of 1,247 suggests the units are wrong or the window is off.
A quick checklist
| Check | Command |
|---|---|
| Is KEDA alive? | kubectl get pods -n keda |
| Is the external API registered? | kubectl get apiservices v1beta1.external.metrics.k8s.io |
Is the ScaledObject READY? |
kubectl get scaledobject -n rutas-norte-pro |
| Can the HPA see the metric? | kubectl describe hpa keda-hpa-... |
| Can the metric be queried? | kubectl get --raw "/apis/external.metrics.k8s.io/..." |
| Are there errors in the operator? | kubectl logs -n keda deployment/keda-operator |
| Is there network connectivity? | A test pod from the keda namespace |
| Is the PromQL query correct? | The Prometheus interface |
| Is there another HPA in conflict? | kubectl get hpa -n rutas-norte-pro |
| Is there room for the new pods? | kubectl get pods --field-selector status.phase=Pending |
That last check connects with 09-03 and deserves emphasis: a ScaledObject can be working perfectly and still not produce more running pods, because the pods it asked for are Pending. The ScaledObject would say ACTIVE: True and the HPA would show 20 replicas, while only 8 are really serving. Always look at the pods, not just at the autoscaler.
Common Mistakes and Tips
Mistake 1: type: Value instead of AverageValue for a queue. With Value, the HPA compares the raw value against the target and the replicas oscillate violently. For queues, always AverageValue: the meaning is "each replica takes charge of N units".
Mistake 2: the PromQL query returns several series. KEDA needs a scalar. Without sum() (or avg(), max(), depending on the case), the trigger fails. Always test the query in Prometheus before putting it in a manifest.
Mistake 3: scaling to zero a service with users in front of it. The cold start is 35 seconds at minimum. Nobody waits 35 seconds for a ticket page. Use idleReplicaCount: 1 if you want to save without that cost.
Mistake 4: leaving a manual HPA over the same Deployment. KEDA's webhook detects it and rejects the ScaledObject with a clear message. Delete the old HPA first.
Mistake 5: forgetting the timezone on the cron trigger. KEDA uses UTC by default. In Spanish summer time, your 25 replicas turn up two hours late. Always use the IANA name (Europe/Madrid), never a fixed offset.
Mistake 6: NetworkPolicies that block KEDA. The operator lives in the keda namespace and needs explicit permission to reach RabbitMQ, Prometheus or the database. It is the most frequent mistake after rolling out the policies of 04-06 and 08-04, and the hardest to connect to its cause.
Mistake 7: not configuring fallback. If the metrics source goes down, the HPA is left with the metric at <unknown> and the replicas frozen wherever they were. With fallback, you pin a safe, known number while you fix the source.
Mistake 8: minReplicaCount: 0 with only CPU or memory triggers. With zero pods there are no pod metrics, so it will never reactivate. Scale-to-zero needs at least one external-source trigger.
Tip 1: start with minReplicaCount equal to your current number. Deploy the ScaledObject, watch for a week what metrics it reports and what it would have done, and only then open the range. Just like the HPA tip in 09-01: prior observation is free and avoids nasty surprises.
Tip 2: calibrate the threshold with data, not intuition. The number must come out of a load test (09-06): how many requests per second does one replica sustain before the p95 latency exceeds the target? That number, minus a 30% margin for the start-up time, is your threshold.
Tip 3: combine triggers. The primary signal (queue, requests) plus a CPU safety net plus a pre-warming cron. With the "the greediest one wins" rule, each covers a failure of the others and none gets in the way.
Tip 4: monitor KEDA itself. The operator exposes Prometheus metrics:
# Scaler errors: if this rises, KEDA cannot query the source
sum(rate(keda_scaler_errors_total[5m])) by (scaledObject, scaler) > 0
# The value of the metric KEDA is reporting
keda_scaler_metrics_value{scaledObject="notifications-worker"}
# ScaledObjects in fallback: a sign that a source is down
keda_scaled_object_errors_total > 0The first is the indispensable alert: if KEDA cannot read the metric, your scaling does not exist, even though everything looks normal in kubectl get deployment.
Tip 5: in Grafana, overlay the business metric and the replicas. Seeing the queue length and the number of workers on the same graph makes it obvious at a glance whether the threshold is well calibrated: the queue should rise, the replicas should follow it, and the queue should fall. If the queue rises and the replicas do not, the threshold is too high.
Tip 6: document the threshold calculation in the manifest. A comment explaining where the 2,500 comes from turns a magic number into a reviewable decision. Whoever reads it next will know whether they can change it and on what basis.
Tip 7: use ClusterTriggerAuthentication for Prometheus. It is a single instance in the cluster and every namespace queries it. One object instead of one per namespace.
Exercises
Exercise 1: calculating a queue's threshold
Rutas Norte adds invoices-worker, which generates PDF invoices for bookings and uploads them to an object store. Measured data:
- Each invoice takes 8 seconds to generate (2 s of CPU, 6 s waiting on the store).
- A worker processes invoices one at a time (there is no internal concurrency).
- During the May bank holiday, 12,000 invoices are generated in the 4 hours after the sale closes.
- Business goal: no invoice may take more than 30 minutes from the booking.
- Each worker uses
requests.cpu: 250mandrequests.memory: 512Mi. - The namespace's ResourceQuota has 8 free cores at that moment.
Compute: (a) the invoices per second one worker generates; (b) the workers needed to meet the 30-minute goal; (c) whether they fit in the quota; (d) the threshold (messages per replica) that produces that number of workers at the peak; (e) write the complete ScaledObject, deciding as well whether you would use ScaledObject or ScaledJob and why.
Exercise 2: diagnosing a silent ScaledObject
notifications-worker is not scaling. The RabbitMQ queue has had 18,000 messages for twenty minutes and there is still 1 replica. This is what you see:
NAME SCALETARGETKIND MIN MAX READY ACTIVE FALLBACK AGE
notifications-worker apps/v1.Deployment 0 25 True True True 3hConditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True ReadyForNewScale recommended size matches current size
ScalingActive True ValidMetricFound the HPA was able to successfully calculate a replica count
ScalingLimited False DesiredWithinRange the desired count is within the acceptable range
Metrics:
( current / target )
"s0-rabbitmq-confirmation-notifications" (target average value): 6 / 2500
Events: <none>ERROR scale_handler error getting metric for scaler {"scaledObject.Name": "notifications-worker",
"scaler": "rabbitmqScaler",
"error": "Get \"https://rabbitmq.rutas-norte-pro.svc.cluster.local:15671/api/queues/%2F/confirmation-notifications\":
dial tcp 10.96.42.17:15671: i/o timeout"}Explain exactly what is happening, why the HPA says everything is fine, why the metric value is 6, what has to be fixed and how to stop it happening again without anybody noticing.
Exercise 3: designing the scaling strategy for a new component
Rutas Norte launches pricing-engine, a service that recalculates the dynamic prices of every route based on occupancy. Its profile:
- It is an internal HTTP service queried by
bookings-api. It is not public. - It receives requests from
bookings-apievery time somebody searches for routes: between 50 and 2,500 requests per second. - Each request takes 40 ms and consumes a fair amount of CPU (pure mathematical computation).
- One replica sustains ~180 requests per second before the p95 latency exceeds 80 ms.
- In addition, every night at 3:00 it recalculates the complete price matrix: a 45-minute process that consumes 4 cores and that today runs inside the same service (which degrades the HTTP responses during those 45 minutes).
- This service's latency feeds directly into
bookings-api's latency: if it is slow, the route search is slow. - It is instrumented with Prometheus: it exposes
pricing_engine_requests_totalandpricing_engine_duration_seconds.
Design the complete strategy. One ScaledObject or several? Which triggers? Scale to zero? What would you do with the nightly process? Write the manifests and justify every decision.
Solutions
Solution 1
(a) Invoices per second per worker:
Each invoice: 8 seconds, processed one at a time.
Invoices per second per worker = 1 / 8 = 0.125 invoices/s(b) Workers needed:
The goal is that no invoice waits more than 30 minutes. The worst case is the invoice that arrives last at the moment of greatest backlog.
12,000 invoices in 4 hours = 12,000 / 14,400 s = 0.833 invoices/s of input.
If the system processed exactly at the input rate, the queue would not grow
but nor would it drain, and one invoice would wait... it depends on the
accumulated backlog. We need to process FASTER than the input.
Approach via the 30-minute goal:
In 30 minutes (1,800 s) 0.833 x 1,800 = 1,500 invoices arrive.
For none to wait more than 30 min, we must process 1,500 invoices
in less than 1,800 s: capacity >= 1,500 / 1,800 = 0.833 invoices/s.
With a 100% margin (to absorb bursts, since input is not uniform):
Target capacity: 1.67 invoices/s
Workers = 1.67 / 0.125 = 13.3 -> 14 workersA check against a realistic worst case: if the invoices arrive bunched up (half in the first hour):
First hour: 6,000 invoices = 1.67 invoices/s of input.
With 14 workers: 14 x 0.125 = 1.75 invoices/s of capacity.
Capacity > input: the queue does not grow in a sustained way. Correct.
Estimated maximum backlog: ~500 invoices.
Time to drain those 500: 500 / 1.75 = 286 s = 4.8 minutes.
WELL below the 30 minutes. Plenty of room.14 workers is the answer, with a wide margin.
(c) Do they fit in the quota?
14 workers x 250m = 3.5 cores
Free quota: 8 cores.
They fit. And there is room to raise maxReplicaCount to 25:
25 x 250m = 6.25 cores < 8. That fits too.
Memory: 14 x 512Mi = 7 GiB. No problem.We set maxReplicaCount: 20 (14 with a 40% margin), which uses 5 cores and leaves headroom.
(d) The threshold:
At the peak, how many messages are in the queue?
The estimated maximum backlog is ~500 invoices (computed above). But if
scaling is slow to start, the initial backlog is larger: in the first
2 minutes with just 1 replica, 1.67 x 120 = 200 invoices arrive
and 0.125 x 120 = 15 are processed. Backlog: ~185.
We want ~500 messages in the queue to give 14 workers:
threshold = 500 / 14 = 35.7 -> round to 35
Check with other queue values:
100 messages -> ceil(100/35) = 3 workers
300 messages -> ceil(300/35) = 9 workers
500 messages -> ceil(500/35) = 15 workers (goal reached)
700 messages -> ceil(700/35) = 20 workers (ceiling)threshold: "35"
(e) ScaledObject or ScaledJob?
We apply the rule from section 9:
Task duration: 8 seconds.
Pod start-up time: ~15 seconds.
8 < 15: the task takes LESS than the start-up.
-> ScaledObject. A ScaledJob would spend more time starting than working.With ScaledJob, generating 12,000 invoices would mean creating 12,000 pods, with 15 seconds of start-up each: 50 hours of compute time wasted purely on starting up. Absurd.
Complete manifest:
# k8s/environments/pro/scaledobject-invoices-worker.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: invoices-worker
namespace: rutas-norte-pro
labels:
app: invoices-worker
app.kubernetes.io/part-of: rutas-norte
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: invoices-worker
pollingInterval: 20
# 15 minutes with no activity before switching off. Generous because the
# invoices arrive in bursts after the sale closes, and switching off between
# bursts would cost more in cold starts than keeping one pod.
cooldownPeriod: 900
# SCALE-TO-ZERO. Nobody waits for an invoice in real time: the goal is
# 30 minutes, and the cold start is 35 seconds. Plenty of margin.
# Out of season only a few invoices a day are generated.
minReplicaCount: 0
# 20 workers = 5 cores, within the 8 free in the quota.
# 20 x 0.125 = 2.5 invoices/s = 9,000 invoices/hour. Well above
# the 3,000/hour of the bank-holiday peak.
maxReplicaCount: 20
fallback:
failureThreshold: 3
# If the queue store does not respond, 5 workers: enough for the
# usual throughput while it is investigated.
replicas: 5
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-invoices-worker
behavior:
scaleUp:
# No wait: if there is a queue, there is work pending.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 10 minutes: the invoices arrive in bursts after each sale
# closes, and we do not want to destroy workers between bursts.
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 30
periodSeconds: 120
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: pending-invoices
mode: QueueLength
# 1 worker per 35 invoices in the queue.
#
# Calculation: a worker processes 1 invoice every 8 s = 0.125 invoices/s.
# Input at the bank-holiday peak is 0.833 invoices/s (12,000 in 4 h).
# With a 100% margin for bursts: target capacity 1.67 invoices/s.
# Workers needed: 1.67 / 0.125 = 14.
# Expected queue backlog at the peak: ~500 messages.
# threshold = 500 / 14 = 35.
#
# Business goal: no invoice waits more than 30 minutes.
# With 14 workers, draining 500 invoices takes ~5 minutes.
value: "35"
# Below 3 invoices, it counts as inactive. It allows
# scale-to-zero without 1-2 residual messages preventing it.
activationValue: "3"
authenticationRef:
name: rabbitmq-credentials
# Safety net: if the object store slows down and the invoices take
# 30 s instead of 8, the queue will grow and the primary trigger will
# detect it. But if the problem is CPU (a pathological PDF), this
# trigger covers it.
- type: cpu
metricType: Utilization
metadata:
value: "80"Solution 2
What is happening: KEDA cannot read the queue and is using the fallback.
The three pieces of evidence fit together like this:
Piece 1: FALLBACK: True on the ScaledObject. This is the main clue and the one that gives everything away. It means KEDA has exceeded the failureThreshold of failed queries and is returning an artificial value.
Piece 2: the HPA says everything is fine. And it is right from its point of view: it is receiving a value from the external API (6), it compares it with its target (2500), computes ceil(1 × 6/2500) = 1 replica, and concludes that the current size matches the recommended one. The HPA does not know that 6 is a made-up value.
This is important and it is the trap of the exercise: ScalingActive: True does not mean the metric is correct, only that a number could be obtained.
Piece 3: the logs reveal the root cause.
Get "https://rabbitmq.rutas-norte-pro.svc.cluster.local:15671/api/queues/..."
dial tcp 10.96.42.17:15671: i/o timeoutTwo observations about that line:
- Port 15671 and the HTTPS protocol. RabbitMQ's management port is 15672 over HTTP and 15671 over HTTPS. KEDA is trying HTTPS.
i/o timeout, notconnection refused. The difference is diagnostic:connection refusedmeans it got there and something said no;i/o timeoutmeans the packet was dropped with no answer, which is the characteristic signature of a firewall or a NetworkPolicy.
Why the metric value is 6:
The fallback is configured with replicas: 6. When KEDA enters fallback mode, it does not return the replica count directly: it returns a metric value computed so that the HPA arrives at that replica count.
KEDA wants the HPA to compute 6 replicas.
The HPA computes: ceil(currentReplicas x value / threshold)
But since the HPA uses AverageValue, KEDA's fallback mechanism works by
reporting a value such that the result is the fallback replicas.
In the output we see "6 / 2500", which is the reported fallback value.
The HPA with 1 replica computes ceil(1 x 6/2500) = ceil(0.0024) = 1.And here is the second finding of the exercise: the fallback is not working as expected. The HPA has stayed at 1 replica, not 6.
The reason is a documented KEDA limitation: fallback does not work correctly when minReplicaCount is 0 and the workload was scaled to zero or near it. With minReplicaCount: 0 and the activation mechanism involved, the fallback behaviour is not what you would expect.
What has to be fixed:
Fix 1 (immediate): restore connectivity.
# 1. Does the Service exist and respond?
kubectl get svc rabbitmq -n rutas-norte-pro
kubectl get endpoints rabbitmq -n rutas-norte-pro
# 2. Is there a new NetworkPolicy?
kubectl get networkpolicy -n rutas-norte-pro
kubectl describe networkpolicy -n rutas-norte-pro
# 3. Test connectivity FROM the KEDA namespace
kubectl run test --rm -it --restart=Never -n keda --image=busybox:1.36 -- \
wget -qO- --timeout=5 http://rabbitmq.rutas-norte-pro.svc.cluster.local:15672/api/overviewThe most likely cause, given the i/o timeout: a default-deny NetworkPolicy applied in rutas-norte-pro (module 4 and 08-04) that does not account for traffic coming from the keda namespace.
# k8s/environments/pro/networkpolicy-keda-rabbitmq.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-keda-to-rabbitmq
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels:
app: rabbitmq
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: keda
ports:
- protocol: TCP
port: 15672 # HTTP management API
- protocol: TCP
port: 5672 # AMQPFix 2: review the trigger's port and protocol.
# If RabbitMQ does not have TLS configured on the management port, the
# TriggerAuthentication must point at http://...:15672, not https://...:15671
stringData:
host: "amqp://keda_reader:[email protected]:5672/"Fix 3: make the fallback robust.
Since minReplicaCount: 0 compromises the fallback, the safe option for a critical workload in high season is not to scale to zero:
spec:
# During high season, we do not scale to zero. The fallback then works
# reliably and the cold start does not penalise us at the worst moment.
minReplicaCount: 2
idleReplicaCount: 1 # Outside activity, 1 replica running
fallback:
failureThreshold: 3
replicas: 8How to stop it happening again without anybody noticing:
This is the most important point of the exercise. The system failed silently for twenty minutes. Everything looked correct under the usual checks.
Alerts needed in Alertmanager (07-04):
# 1. THE MOST IMPORTANT ONE: KEDA in fallback mode.
# If this fires, scaling is NOT working even though everything looks normal.
keda_scaled_object_errors_total > 0
# 2. Scaler errors: KEDA cannot query the source.
sum(rate(keda_scaler_errors_total[5m])) by (scaledObject, scaler) > 0
# 3. A business alert: the queue grows without the workers growing.
# This is the one that would have caught the problem in 2 minutes.
(
rabbitmq_queue_messages{queue="confirmation-notifications"} > 5000
)
and
(
kube_deployment_status_replicas{deployment="notifications-worker"} < 5
)
# 4. Age of the oldest message in the queue: the real business indicator.
rabbitmq_queue_head_message_timestamp_seconds > 600Alert 3 is especially valuable because it does not depend on KEDA working: it looks at the outcome (a big queue, few workers) instead of at the mechanism. It is the module 7 principle of alerting on symptoms rather than on causes.
And a structural check: include KEDA in your NetworkPolicy testing. Every time a default-deny policy is added to a namespace, verify explicitly that KEDA can still query its sources. It is easy to forget because KEDA is not "application traffic".
Solution 3
Analysis: these are two different workloads crammed into a single service.
The first observation, and the most important of the exercise, is architectural rather than about scaling:
| Workload | Profile | Need |
|---|---|---|
| Synchronous HTTP API | 50-2,500 rps, 40 ms/request, latency critical | Many small replicas, fast scaling |
| Nightly recalculation | 45 minutes, 4 cores, once a day | One big pod, no users |
Putting them in the same Deployment is a design error, and the brief already flags it: the nightly process "degrades the HTTP responses during those 45 minutes". No KEDA configuration fixes that.
Decision 1: separate the two workloads.
pricing-engine -> Deployment + ScaledObject (HTTP API)
price-recalculation -> CronJob (nightly process)The nightly process becomes a CronJob (06-03) with its own resources, isolated from the HTTP service. This:
- Removes the 45 minutes of degradation every night.
- Lets each workload be sized separately.
- Lets a VPA in
Initialmode (09-02) tune the CronJob over time. - Means a failure of the recalculation does not affect the HTTP service.
Decision 2: scale to zero for the API? NO.
pricing-engine sits on the critical path of every route search. Its latency feeds directly into bookings-api's. A 35-second cold start with a user waiting is unacceptable.
There is also an important second-order effect: if pricing-engine is at zero and a request arrives, bookings-api is left waiting. With many simultaneous requests, bookings-api exhausts its outbound connection pool and degrades in a cascade. Scaling a synchronous internal service to zero can bring down whoever calls it.
Decision 3: the metric. Requests per second, not CPU.
Even though pricing-engine's work is pure computation (where CPU does correlate well), requests per second is still the better signal because:
- It is the quantity we know: 180 rps per replica, measured.
- It anticipates: requests are counted on arrival, before the CPU is consumed.
- It does not depend on
requests, so the VPA can work freely.
But we add CPU as a safety net, because in a pure-computation service a pathological query (a route with many combinations) could burn a lot of CPU on few requests.
And we add a third trigger that almost nobody considers: latency. It is the metric the business really cares about.
The manifests:
# k8s/environments/pro/scaledobject-pricing-engine.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: pricing-engine
namespace: rutas-norte-pro
labels:
app: pricing-engine
app.kubernetes.io/part-of: rutas-norte
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: pricing-engine
pollingInterval: 15
cooldownPeriod: 300
# We do NOT scale to zero. It sits on the critical path of every route
# search: its latency feeds directly into bookings-api's. A 35 s cold start
# with users waiting is unacceptable, and it would also exhaust the outbound
# connection pool of bookings-api: a cascading degradation.
#
# A floor of 3 replicas: 3 x 180 = 540 rps, well above the minimum of 50.
# The floor is set by AVAILABILITY (09-05), not by capacity.
minReplicaCount: 3
# Ceiling: 2,500 rps / 180 rps per replica = 13.9 -> 14.
# With a 40% margin: 20 replicas.
maxReplicaCount: 20
fallback:
failureThreshold: 3
# If Prometheus goes down, 8 replicas = 1,440 rps of capacity. It covers
# normal traffic comfortably while it is fixed.
replicas: 8
advanced:
restoreToOriginalReplicaCount: true
horizontalPodAutoscalerConfig:
name: hpa-pricing-engine
behavior:
scaleUp:
# No wait. It is an internal service with critical latency: any
# delay propagates to the end user's route search.
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 5
periodSeconds: 30
scaleDown:
# 10 minutes. Search traffic is very irregular (bursts of
# users comparing routes) and we do not want to destroy capacity
# between bursts.
stabilizationWindowSeconds: 600
selectPolicy: Min
policies:
- type: Percent
value: 20
periodSeconds: 120
triggers:
# PRIMARY TRIGGER: requests per second.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
metricName: pricing_engine_requests_per_second
query: |
sum(
rate(
pricing_engine_requests_total{
namespace="rutas-norte-pro",
app="pricing-engine"
}[2m]
)
)
# 125 rps per replica.
#
# Calculation: one replica sustains 180 rps before the p95 latency
# exceeds 80 ms. We apply a 30% margin to cover the start-up time
# of the new replicas: 180 x 0.7 = 126 -> 125.
#
# With 2,500 rps: ceil(2500/125) = 20 replicas. Exactly maxReplicaCount.
threshold: "125"
activationThreshold: "10"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-read
# SECONDARY TRIGGER: LATENCY, which is what the business cares about.
#
# If the p95 exceeds 60 ms (75% of the 80 ms target), we scale even
# when requests per second are within normal range. This covers the
# case of abnormally EXPENSIVE requests: routes with many combinations,
# or a degraded dependency.
#
# IMPORTANT NOTE: scaling on latency is a double-edged sword. If the
# latency rises for a cause that is NOT solved with more replicas (for
# example, a saturated PostgreSQL), scaling makes things worse (09-01, the
# real bottleneck). That is why the threshold is conservative and
# maxReplicaCount is bounded.
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoring.svc.cluster.local:9090
metricName: pricing_engine_p95_latency
query: |
histogram_quantile(0.95,
sum(rate(pricing_engine_duration_seconds_bucket{
namespace="rutas-norte-pro"
}[2m])) by (le)
) * 1000
threshold: "60"
activationThreshold: "1"
ignoreNullValues: "true"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-read
# SAFETY NET: CPU.
- type: cpu
metricType: Utilization
metadata:
value: "70"And the nightly process, separated out:
# k8s/environments/pro/cronjob-price-recalculation.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: price-recalculation
namespace: rutas-norte-pro
labels:
app: price-recalculation
app.kubernetes.io/part-of: rutas-norte
spec:
schedule: "0 3 * * *"
timeZone: "Europe/Madrid" # CronJob supports timeZone from 1.27
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
# If it has not finished in 2 hours, cut it off. Stops a hung process
# from pinning a node (09-03) and overlapping with the morning traffic.
activeDeadlineSeconds: 7200
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: price-recalculation
annotations:
# Do NOT evict during the run: we would lose 45 minutes of
# work. The Cluster Autoscaler honours this annotation (09-03).
cluster-autoscaler.kubernetes.io/safe-to-evict: "false"
spec:
restartPolicy: OnFailure
containers:
- name: recalculation
image: registry.rutasnorte.example/pricing-engine:4.2.0
command: ["node", "recalculate-matrix.js"]
resources:
requests:
cpu: "4"
memory: 4Gi
limits:
cpu: "4"
memory: 4Gi
# requests == limits -> Guaranteed QoS. It runs at 3:00
# with the cluster empty: we take capacity from nobody and
# we guarantee it is not evicted under memory pressure.And its VPA, applying what we learned in 09-02:
# k8s/environments/pro/vpa-price-recalculation.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: price-recalculation
namespace: rutas-norte-pro
spec:
targetRef:
apiVersion: batch/v1
kind: CronJob
name: price-recalculation
updatePolicy:
# Initial: every new run is born with the learned resources. We never
# evict a Job halfway through.
updateMode: "Initial"
resourcePolicy:
containerPolicies:
- containerName: recalculation
minAllowed:
cpu: "2"
memory: 2Gi
maxAllowed:
cpu: "8"
memory: 8Gi
controlledValues: RequestsAndLimitsSummary of the decisions and their justification:
| Decision | Choice | Justification |
|---|---|---|
One or several ScaledObjects? |
One, plus a separate CronJob | They are two workloads with opposite profiles; mixing them degrades the API |
| Scale to zero? | No (minReplicaCount: 3) |
Synchronous critical path; the cold start would degrade bookings-api in a cascade |
| Primary metric | Requests per second | A measured, known quantity (180 rps/replica); it does not depend on requests |
threshold |
125 rps | 180 measured × 0.7 of margin for the start-up |
| Secondary metric | p95 latency | It is the real business objective; it detects abnormally expensive requests |
| Safety net | CPU at 70% | Covers unforeseen cases; with "the greediest one wins", it never gets in the way |
maxReplicaCount |
20 | 2,500 rps / 125 = 20; bounded so as not to mask somebody else's problems |
fallback |
8 replicas | 1,440 rps of capacity: covers normal traffic while Prometheus is fixed |
| Nightly process | A CronJob with Guaranteed QoS |
Total isolation; dedicated resources; it runs with the cluster empty |
| CronJob VPA | Initial mode |
It learns from previous runs without evicting halfway |
A final warning worth putting on the record: scaling on latency is useful but dangerous. If the latency rises because a dependency is saturated (PostgreSQL, say), adding pricing-engine replicas makes things worse: more replicas, more connections, more pressure on the dependency. It is exactly the 09-01 mistake of scaling the wrong layer. That is why the threshold is conservative (60 ms out of 80) and the ceiling is bounded at 20. And that is why you need an alert that distinguishes the two situations:
# High latency WITH the replicas at maximum: the problem is NOT solved by scaling
(
histogram_quantile(0.95, sum(rate(pricing_engine_duration_seconds_bucket[5m])) by (le)) * 1000 > 80
)
and
(
kube_deployment_status_replicas{deployment="pricing-engine"} >= 20
)Conclusion
Event-driven scaling turns an indirect and late signal — CPU consumption — into the signal that genuinely describes the pending work: the length of a queue, the requests per second, or simply the time of day.
The essentials:
- CPU is a good signal when the pod's job is to compute, and a bad one when it is to coordinate, wait or hold connections.
notifications-workerspends 95% of its time waiting on the SMTP server: a CPU-driven HPA would have cut replicas with forty thousand emails queued. - The HPA already knows how to consume custom and external metrics (
Pods,Object,External). What is missing is who serves those APIs, and that is where an adapter registered in the aggregation layer comes in. - KEDA does not replace the HPA: it creates one and feeds it. Everything from 09-01 still holds, including
behavior, which is configured fromadvanced.horizontalPodAutoscalerConfig. The only stretch KEDA governs directly is 0↔1. - The
ScaledObjectdefines the target, the polling interval, the replica range, thefallbackand the triggers. With several triggers, the one asking for the most replicas wins: combine the primary signal, a CPU safety net and a pre-warming cron. TriggerAuthenticationkeeps credentials out of the manifest and lets you use cloud identities, consistent with the least privilege of 08-01.ScaledJobcreates one Job per unit of work. Use it when the task takes far longer than starting a pod; otherwise,ScaledObject.- The cron trigger is what breaks reactive scaling's limitation. For a predictable event like the sale opening, waiting for the metric to rise is absurd: on top of that, the
rate[2m]smooths precisely the spike we want to detect. Scaling at 9:30 removes the 10:00 degradation entirely. - Scale-to-zero is enormously powerful for batch jobs and development environments, and unacceptable where a human being is waiting.
idleReplicaCountis the middle ground that serves for almost everything else. - Debugging follows a chain:
ScaledObject→ generated HPA → aggregation API → operator logs → network connectivity → the query at the source. And NetworkPolicies blocking KEDA are mistake number one after rolling out the module 8 policies.
Rutas Norte now has notifications-worker scaling on the real length of its queue with a complete shutdown out of season, bookings-api scaling on requests per second with the metrics we instrumented in 07-03, and a cron trigger that boots and warms 25 replicas half an hour before the May bank-holiday sale opens, with the node cushion expanded an hour before that.
The platform scales up when it is needed and before it is needed. But the whole module has so far been about growing, and there is a question we have not asked: what happens when something breaks or when somebody has to touch the cluster?
Next Tuesday the nodes' Kubernetes version is due for an upgrade. Somebody will run kubectl drain on a node to empty it. If three of bookings-api's four replicas are on that node — because the scheduler put them there and nobody told it not to — that drain will wipe out 75% of the API's capacity in an instant. And if the node that goes down is not drained by anyone but simply switches itself off because of a hardware failure in availability zone B at three in the morning, the result will be the same but with no warning.
In the next lesson, High Availability: PodDisruptionBudgets and Topology, we will look at the distinction between the disruptions you can control and the ones you cannot, how the PodDisruptionBudget protects you from the former (and how a badly set PDB blocks maintenance forever, as we already glimpsed in 09-03), how to spread replicas across nodes and zones with topologySpreadConstraints, and the complete node-maintenance procedure from start to finish.
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
