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

  1. Why CPU is a poor signal
  2. Custom and external metrics in the HPA
  3. The aggregation API and metrics adapters
  4. What KEDA is and what its architecture looks like
  5. The key idea: KEDA does not replace the HPA, it feeds it
  6. The ScaledObject object field by field
  7. The catalogue of scalers
  8. TriggerAuthentication: credentials for the triggers
  9. ScaledJob: one job per message
  10. Rutas Norte: notifications-worker by queue length
  11. Rutas Norte: bookings-api by requests per second
  12. Rutas Norte: the May bank-holiday cron trigger
  13. Scale-to-zero and its implications
  14. Debugging a ScaledObject that does not scale
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. 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.

  1. 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)
                         registered

That 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.

- type: Pods
  pods:
    metric:
      name: requests_per_second
    target:
      type: AverageValue
      averageValue: "85"

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.

  1. 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:

kubectl get apiservices | grep metrics
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        12d

When 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.tool

It 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

  1. 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 keda
NAME                                              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          2m

And the CRDs it registers:

kubectl get crd | grep keda
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:14Z

The 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's triggers into External metrics 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 ScaledObject and 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

  1. 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:

kubectl get hpa -n rutas-norte-pro
NAME                                  REFERENCE                          TARGETS        MINPODS  MAXPODS  REPLICAS
keda-hpa-notifications-worker         Deployment/notifications-worker    1847/2500 (avg)  1        25       8

There it is: an HPA with the keda-hpa- prefix, managed by the KEDA operator. And its contents:

kubectl get hpa keda-hpa-notifications-worker -n rutas-norte-pro -o yaml
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.

  1. The ScaledObject object field by field

apiVersion: 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-credentials

The 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 1

It 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 them

This 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: 60

Everything 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.

  1. 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-credentials

Cron:

- 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-credentials

activationValue: 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 inactive

The 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.

  1. TriggerAuthentication: credentials for the triggers

The 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 Secret

And in the ScaledObject:

triggers:
  - type: rabbitmq
    metadata:
      queueName: confirmation-notifications
      mode: QueueLength
      value: "2500"
    authenticationRef:
      name: rabbitmq-credentials

Other 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-sqs

It 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: token

You reference it with kind: ClusterTriggerAuthentication in authenticationRef. Useful for Prometheus, which is a single instance queried from every namespace.

  1. ScaledJob: one job per message

ScaledObject 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-credentials

When 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.

  1. Rutas Norte: notifications-worker by queue length

Let'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-pro
NAME                   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     34s

The 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:

kubectl get deployment notifications-worker -n rutas-norte-pro
NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
notifications-worker   0/0     0            0           89d

Reactivation live

We open two terminals. In the first, we watch:

kubectl get deployment notifications-worker -n rutas-norte-pro --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=30000

And 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 = 12

You 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:

kubectl get hpa hpa-notifications-worker -n rutas-norte-pro
NAME                       REFERENCE                         TARGETS            MINPODS  MAXPODS  REPLICAS
hpa-notifications-worker   Deployment/notifications-worker   2483/2500 (avg)    1        25       12

2483/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

  1. Rutas Norte: bookings-api by requests per second

Now 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:

  1. It is the business quantity. "Each replica serves 85 requests per second" is a sentence anybody in the company understands.
  2. It does not depend on requests. A change to requests.cpu (recommended by the VPA of 09-02) does not alter the scaling behaviour.
  3. It is faster. CPU rises once the work is already coming in; requests are counted on arrival.
  4. 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 92841

The 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

sum(
  rate(
    rutasnorte_requests_total{
      namespace="rutas-norte-pro",
      app="bookings-api"
    }[2m]
  )
)

Taken apart from the inside out:

  1. 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).

  2. 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 a scrape_interval of 30 s there would be only 2 samples, which makes the rate unreliable). With [5m] the signal is smooth but late. Rule: the rate window must be at least 4 times the scrape interval. With scraping every 30 s, [2m] is the reasonable minimum.

  3. 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

kubectl get hpa hpa-bookings-api -n rutas-norte-pro
NAME               REFERENCE                 TARGETS                   MINPODS  MAXPODS  REPLICAS
hpa-bookings-api   Deployment/bookings-api   1247/85 (avg), 34%/70%    4        30       15

There 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-pro

If 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.

  1. 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.

  1. 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: 1

That 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.

  1. Debugging a ScaledObject that does not scale

A systematic procedure, from the most general to the most specific.

Step 1: the ScaledObject's status

kubectl get scaledobject -n rutas-norte-pro
NAME                   SCALETARGETKIND      SCALETARGETNAME        MIN  MAX  READY  ACTIVE  FALLBACK  AGE
notifications-worker   apps/v1.Deployment   notifications-worker   0    25   False  False   False     4m

READY: False is the first problem. It means KEDA has not been able to configure it.

kubectl describe scaledobject notifications-worker -n rutas-norte-pro
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 0

The 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.

kubectl describe hpa keda-hpa-notifications-worker -n rutas-norte-pro
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 Found

Object 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

kubectl logs -n keda deployment/keda-operator --tail=100 | grep -i "notifications-worker"
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:

kubectl logs -n keda deployment/keda-operator-metrics-apiserver --tail=50

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/overview

If 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: 15672

This 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:

kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090

And in the web interface, run the exact query from the ScaledObject. Check:

  1. Does it return any data? If it is empty, the selector labels do not match.
  2. Does it return A SINGLE value? If it returns several series, a sum() is missing.
  3. Does the value make sense? A rate giving 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 > 0

The 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: 250m and requests.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:

kubectl get scaledobject -n rutas-norte-pro
NAME                   SCALETARGETKIND      MIN  MAX  READY  ACTIVE  FALLBACK  AGE
notifications-worker   apps/v1.Deployment   0    25   True   True    True      3h
kubectl describe hpa keda-hpa-notifications-worker -n rutas-norte-pro
Conditions:
  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>
kubectl logs -n keda deployment/keda-operator --tail=20 | grep worker
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-api every 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_total and pricing_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 workers

A 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 timeout

Two observations about that line:

  1. Port 15671 and the HTTPS protocol. RabbitMQ's management port is 15672 over HTTP and 15671 over HTTPS. KEDA is trying HTTPS.
  2. i/o timeout, not connection refused. The difference is diagnostic: connection refused means it got there and something said no; i/o timeout means 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/overview

The 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       # AMQP

Fix 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: 8

How 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 > 600

Alert 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 Initial mode (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: RequestsAndLimits

Summary 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-worker spends 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 from advanced.horizontalPodAutoscalerConfig. The only stretch KEDA governs directly is 0↔1.
  • The ScaledObject defines the target, the polling interval, the replica range, the fallback and 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.
  • TriggerAuthentication keeps credentials out of the manifest and lets you use cloud identities, consistent with the least privilege of 08-01.
  • ScaledJob creates 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. idleReplicaCount is 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved