Lesson 51 of 66 · 17 min read · Updated

Event-Driven Autoscaling with KEDA

The first two articles in this section scaled on CPU and memory. That works for an API, because a busy API burns CPU. It does not work for a background worker that reads from a queue. A worker that is waiting for messages uses no CPU at all, and a worker with 10,000 messages behind it may still look calm if each message mostly waits on I/O. The number that matters is the backlog, and the HPA cannot see it.

KEDA (Kubernetes Event-Driven Autoscaling) fills that gap. It reads metrics from more than 70 sources (queues, streams, databases, cloud services, cron schedules) and feeds them to the HPA. It also does something the HPA cannot do on its own: it scales a workload down to zero when there is nothing to do and back up when work arrives.

In this article a .NET 10 worker consumes orders from RabbitMQ. With an empty queue it runs zero Pods. Publish 300 messages and it grows to ten, drains the queue, and disappears again. If RabbitMQ itself is new to you, the site's RabbitMQ category covers queues, exchanges and acknowledgements in depth.

We will cover the following topics:

  1. How KEDA extends the HPA instead of replacing it
  2. ScaledObject, TriggerAuthentication and ScaledJob
  3. Activation versus scaling, and how scale to zero works
  4. A RabbitMQ QueueLength trigger for a .NET worker built on RabbitMQ.Client 7
  5. Pausing autoscaling and reading KEDA's metrics directly

Technical Requirements

  1. A local multi-node cluster. The examples use Docker Desktop's Kubernetes with the kind provisioner (one control-plane node and three workers), Kubernetes v1.36.
  2. Helm 3 or 4.
  3. The .NET 10 SDK and Docker to build the worker image.
  4. metrics-server is not required for this article. KEDA serves its own metrics.

Core Concepts

KEDA Is an HPA Feeder

KEDA does not have its own scaling algorithm for the range from one Pod to many. It installs three components:

ComponentJob
Operator (keda-operator)Watches ScaledObject resources, creates a matching HPA for each, polls the event sources, and handles the move between 0 and 1 replica itself.
Metrics server (keda-operator-metrics-apiserver)Registers itself as the cluster's external.metrics.k8s.io API, so the HPA can ask it for values like "messages in the orders queue".
Admission webhooksValidate KEDA resources, for example rejecting two ScaledObjects that target the same Deployment.

So when you create a ScaledObject, you get an ordinary HPA named keda-hpa-<name>. Everything from the previous articles about the HPA formula, behavior and stabilization still applies between 1 and the maximum. KEDA adds the 0 to 1 and 1 to 0 steps, which the HPA cannot take, because the HPA has no Pods to measure at zero.

Because KEDA occupies the external.metrics.k8s.io API, only one component can serve it per cluster. If another adapter already does, the two conflict.

ScaledObject

A ScaledObject connects a workload to one or more triggers:

FieldMeaning
scaleTargetRefThe Deployment (or StatefulSet, or any resource with a /scale subresource) to scale.
minReplicaCountLowest replica count. 0 enables scale to zero. The default is 0.
maxReplicaCountHighest replica count. The default is 100.
pollingIntervalHow often KEDA checks the triggers, in seconds. The default is 30.
cooldownPeriodHow long all triggers must be inactive before scaling from 1 to 0. The default is 300.
triggersThe event sources, each with a type and type-specific metadata.
advanced.horizontalPodAutoscalerConfigLets you set behavior on the generated HPA.

Do not create your own HPA for a workload that has a ScaledObject. KEDA owns that HPA.

Activation and Scaling

Every trigger has two thresholds, and they do different jobs:

  1. activationValue decides whether the workload should exist at all. KEDA scales from 0 to 1 when the metric goes above it, and back to 0 when it drops to or below it for longer than cooldownPeriod. The default is 0.
  2. value is the per-Pod target that the HPA uses between 1 and the maximum. With value: "10" and 95 messages waiting, the HPA wants ceil(95 / 10) = 10 Pods.

With activationValue: "0", a single message wakes the worker. With activationValue: "50", small backlogs wait until a bigger batch is worth starting a Pod for.

The RabbitMQ Trigger

The rabbitmq scaler reads queue statistics. Its main settings:

MetadataMeaning
queueNameThe queue to watch.
modeQueueLength (messages waiting) or MessageRate (publish rate per second). Other modes exist for specific needs.
valueTarget per Pod, in the unit of the mode.
activationValueThe 0-to-1 threshold described above.
protocolamqp, http, or auto, which picks from the host URL.

The connection string, including the password, should not sit in the ScaledObject. It goes into a Secret, and a TriggerAuthentication maps a Secret key to the trigger's host parameter. A ClusterTriggerAuthentication does the same for every namespace.

ScaledJob

A ScaledObject keeps long-running Pods and changes how many there are. A ScaledJob creates Kubernetes Jobs instead: one Job per unit of work, each running to completion and exiting.

UseChoose
A worker that loops forever and handles many messagesScaledObject on a Deployment
Long or heavy tasks (video encoding, report generation) that should not be interrupted by scale-downScaledJob

Scaling down a Deployment kills Pods that might be halfway through a message. A Job is never killed by KEDA scaling down. It finishes and exits. That is the main reason to choose ScaledJob for long tasks.

Hands-On: Kubernetes Commands

List ScaledObjects and their state

READY means the trigger configuration works. ACTIVE means the activation threshold is currently exceeded.

kubectl get scaledobject -n as-keda

See the HPA that KEDA created

Its target is an External metric named after the trigger.

kubectl get hpa -n as-keda

Read KEDA's events

Scaler errors (unreachable host, missing queue, bad credentials) show up here first.

kubectl describe scaledobject order-worker -n as-keda

Query the external metric directly

This is exactly what the HPA receives.

kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/as-keda/s0-rabbitmq-orders?labelSelector=scaledobject.keda.sh%2Fname%3Dorder-worker"

Pause autoscaling at a fixed replica count

Useful during maintenance. Remove the annotation to resume.

kubectl annotate scaledobject order-worker -n as-keda autoscaling.keda.sh/paused-replicas="2" --overwrite
kubectl annotate scaledobject order-worker -n as-keda autoscaling.keda.sh/paused-replicas-

Check the queue inside RabbitMQ

Messages waiting and consumers attached, straight from the broker.

kubectl exec -n as-keda deploy/rabbitmq -- rabbitmqctl list_queues name messages consumers

Step-by-Step Example

The Scenario

An online shop publishes orders to a durable RabbitMQ queue called orders. A .NET worker takes one order at a time and spends about a second on each. Most of the day the queue is empty, and paying for idle workers makes no sense. During a campaign, hundreds of orders arrive within seconds. You want zero workers when idle, up to ten during a burst, and each worker handling a share of about ten messages.

Step 1 — Install KEDA

KEDA 2.21 is the current release. Install it from the official chart into its own namespace:

helm install keda keda --repo https://kedacore.github.io/charts \
--version 2.21.0 --namespace keda --create-namespace --wait
kubectl get pods -n keda
NAME READY STATUS RESTARTS AGE
keda-admission-webhooks-56b7b7c6c8-pv8z4 1/1 Running 0 2m52s
keda-operator-7bc858d9d8-qzsv8 1/1 Running 1 (101s ago) 2m52s
keda-operator-metrics-apiserver-7d67c4b8c8-z57xs 1/1 Running 0 2m52s

Wait until the external metrics API reports True. It can take a minute after the Pods start:

kubectl get apiservice v1beta1.external.metrics.k8s.io
NAME SERVICE AVAILABLE AGE
v1beta1.external.metrics.k8s.io keda/keda-operator-metrics-apiserver True 3m48s

Step 2 — Build the Worker

The same small program is both the consumer and, with the argument publish <count>, the producer you will use to create a burst. Create it:

dotnet new console -n OrderWorker
cd OrderWorker
dotnet add package RabbitMQ.Client

The current RabbitMQ.Client is version 7, and its API is fully asynchronous: CreateConnectionAsync, CreateChannelAsync, BasicConsumeAsync, and an AsyncEventingBasicConsumer with a ReceivedAsync event. Code written for version 6 (CreateModel, EventingBasicConsumer) does not compile against it.

Replace Program.cs:

using System.Text;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

string host = Environment.GetEnvironmentVariable("RABBITMQ_HOST") ?? "localhost";
string user = Environment.GetEnvironmentVariable("RABBITMQ_USER") ?? "guest";
string pass = Environment.GetEnvironmentVariable("RABBITMQ_PASSWORD") ?? "guest";
string podName = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
const string QueueName = "orders";

var factory = new ConnectionFactory { HostName = host, UserName = user, Password = pass };
await using var connection = await factory.CreateConnectionAsync();
await using var channel = await connection.CreateChannelAsync();

await channel.QueueDeclareAsync(queue: QueueName, durable: true, exclusive: false, autoDelete: false);

// "publish <count>" fills the queue; anything else runs the consumer.
if (args.Length == 2 && args[0] == "publish")
{
int count = int.Parse(args[1]);
var props = new BasicProperties { Persistent = true };
for (int i = 1; i <= count; i++)
{
byte[] body = Encoding.UTF8.GetBytes($"order-{i}");
await channel.BasicPublishAsync(exchange: "", routingKey: QueueName, mandatory: false, basicProperties: props, body: body);
}
Console.WriteLine($"Published {count} messages to '{QueueName}'.");
return;
}

// One unacknowledged message at a time, so extra Pods share the backlog fairly.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);

var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (_, ea) =>
{
string order = Encoding.UTF8.GetString(ea.Body.ToArray());
await Task.Delay(TimeSpan.FromSeconds(1)); // simulate work
await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss} {podName} processed {order}");
};

await channel.BasicConsumeAsync(queue: QueueName, autoAck: false, consumer: consumer);
Console.WriteLine($"{podName} waiting for messages on '{QueueName}'.");

var stop = new TaskCompletionSource();
AppDomain.CurrentDomain.ProcessExit += (_, _) => stop.TrySetResult();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.TrySetResult(); };
await stop.Task;

Two choices in this code matter for autoscaling:

  1. prefetchCount: 1 stops RabbitMQ from pushing a large batch of messages to the first Pod that connects. Without it, one Pod would hoard the backlog and the new Pods would sit idle.
  2. autoAck: false with an explicit BasicAckAsync after the work is done means that a Pod killed during scale-down loses nothing. Its unacknowledged message goes back to the queue for another Pod.

A Dockerfile using the smaller runtime image, since this is not a web app:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish OrderWorker.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/runtime:10.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "OrderWorker.dll"]
docker build -t order-worker:1.0 .

Docker Desktop's kind cluster can use local images directly. With a standalone kind cluster, run kind load docker-image order-worker:1.0 first.

Step 3 — Run RabbitMQ

Save as 2-rabbitmq.yaml. A single broker is enough for the demo. The Secret holds the broker credentials and the AMQP URI that KEDA will use.

apiVersion: v1
kind: Namespace
metadata:
name: as-keda
---
apiVersion: v1
kind: Secret
metadata:
name: rabbitmq-credentials
namespace: as-keda
type: Opaque
stringData:
username: orders
password: orders-pass
# Connection string KEDA uses to read the queue length (AMQP protocol).
amqp-uri: amqp://orders:[email protected]:5672/
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rabbitmq
namespace: as-keda
spec:
replicas: 1
selector:
matchLabels:
app: rabbitmq
template:
metadata:
labels:
app: rabbitmq
spec:
containers:
- name: rabbitmq
image: rabbitmq:4.1-management
ports:
- name: amqp
containerPort: 5672
- name: management
containerPort: 15672
env:
- name: RABBITMQ_DEFAULT_USER
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: username
- name: RABBITMQ_DEFAULT_PASS
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: password
readinessProbe:
exec:
command: ["rabbitmq-diagnostics", "-q", "check_port_connectivity"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: rabbitmq
namespace: as-keda
spec:
selector:
app: rabbitmq
ports:
- name: amqp
port: 5672
targetPort: amqp
- name: management
port: 15672
targetPort: management
kubectl apply -f 2-rabbitmq.yaml
kubectl -n as-keda rollout status deploy/rabbitmq

For production, run RabbitMQ with the RabbitMQ Cluster Operator and quorum queues, not a single Deployment.

Step 4 — Deploy the Worker at Zero Replicas

Save as 3-order-worker.yaml. It starts with replicas: 0. KEDA decides from here on.

apiVersion: apps/v1
kind: Deployment
metadata:
name: order-worker
namespace: as-keda
spec:
replicas: 0
selector:
matchLabels:
app: order-worker
template:
metadata:
labels:
app: order-worker
spec:
containers:
- name: order-worker
image: order-worker:1.0
imagePullPolicy: IfNotPresent
env:
- name: RABBITMQ_HOST
value: rabbitmq
- name: RABBITMQ_USER
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: username
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: password
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
requests:
cpu: 50m
memory: 64Mi
kubectl apply -f 3-order-worker.yaml

Step 5 — Create the TriggerAuthentication and ScaledObject

Save as 4-scaledobject.yaml. The short pollingInterval and cooldownPeriod make the demo quick to watch. In production the defaults of 30 and 300 seconds are more sensible.

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-auth
namespace: as-keda
spec:
secretTargetRef:
- parameter: host # the scaler's "host" parameter...
name: rabbitmq-credentials # ...comes from this Secret
key: amqp-uri
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-worker
namespace: as-keda
spec:
scaleTargetRef:
name: order-worker
minReplicaCount: 0
maxReplicaCount: 10
pollingInterval: 5
cooldownPeriod: 30
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: orders
mode: QueueLength
value: "10" # aim for 10 waiting messages per Pod
activationValue: "0" # any message at all wakes the worker
authenticationRef:
name: rabbitmq-auth
kubectl apply -f 4-scaledobject.yaml
kubectl get scaledobject,hpa -n as-keda
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE FALLBACK PAUSED TRIGGERS AUTHENTICATIONS AGE
scaledobject.keda.sh/order-worker apps/v1.Deployment order-worker 0 10 True Unknown False False rabbitmq rabbitmq-auth 0s

NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/keda-hpa-order-worker Deployment/order-worker <unknown>/10 (avg) 1 10 0 0s

KEDA has created keda-hpa-order-worker. Its MINPODS is 1, not 0, because an HPA cannot go to zero. KEDA handles that step itself. The HPA's metric is an external one:

kubectl get hpa keda-hpa-order-worker -n as-keda -o jsonpath='{.spec.metrics}'
[{"external":{"metric":{"name":"s0-rabbitmq-orders","selector":{"matchLabels":{"scaledobject.keda.sh/name":"order-worker"}}},"target":{"averageValue":"10","type":"AverageValue"}},"type":"External"}]

The s0- prefix is the trigger's index, which keeps metric names unique when a ScaledObject has several triggers.

Step 6 — Create a Burst

In two extra terminals, watch the Deployment and the HPA:

kubectl get deploy order-worker -n as-keda -w
kubectl get hpa keda-hpa-order-worker -n as-keda -w

Save as 5-publish-job.yaml. It runs the same image in publish mode and puts 300 orders in the queue:

apiVersion: batch/v1
kind: Job
metadata:
name: publish-orders
namespace: as-keda
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: publisher
image: order-worker:1.0
imagePullPolicy: IfNotPresent
args: ["publish", "300"]
env:
- name: RABBITMQ_HOST
value: rabbitmq
- name: RABBITMQ_USER
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: username
- name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
name: rabbitmq-credentials
key: password
kubectl apply -f 5-publish-job.yaml
kubectl logs job/publish-orders -n as-keda
Published 300 messages to 'orders'.

Step 7 — Watch Zero Become Ten

The Deployment watch, trimmed to the lines where something changed:

NAME READY UP-TO-DATE AVAILABLE AGE
order-worker 0/0 0 0 42s
order-worker 0/1 1 0 46s
order-worker 1/1 1 1 47s
order-worker 1/4 4 1 51s
order-worker 4/4 4 4 52s
order-worker 4/8 8 4 66s
order-worker 8/8 8 8 67s
order-worker 8/10 10 8 81s
order-worker 10/10 10 10 82s

And the HPA watch:

NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
keda-hpa-order-worker Deployment/order-worker <unknown>/10 (avg) 1 10 0 6s
keda-hpa-order-worker Deployment/order-worker 295/10 (avg) 1 10 1 15s
keda-hpa-order-worker Deployment/order-worker 58750m/10 (avg) 1 10 4 30s
keda-hpa-order-worker Deployment/order-worker 14375m/10 (avg) 1 10 8 45s
keda-hpa-order-worker Deployment/order-worker 0/10 (avg) 1 10 10 60s

Read the two together:

  1. KEDA saw messages above the activation value and moved the Deployment from 0 to 1 on its own. That is the KEDAScaleTargetActivated event below.
  2. From there the HPA took over. With 295 messages and one Pod, the average was 295 against a target of 10, so it wanted 30 Pods. The default scale-up policy (double the Pods or add four, whichever is more, every 15 seconds) took it to 4 and then 8, and maxReplicaCount capped it at 10.
  3. 58750m is Kubernetes notation for 58.75. That is 235 messages divided across 4 Pods.

Each worker logs the orders it handles:

kubectl logs deploy/order-worker -n as-keda
order-worker-7d58d557df-h4dkv waiting for messages on 'orders'.
08:38:13 order-worker-7d58d557df-h4dkv processed order-1
08:38:14 order-worker-7d58d557df-h4dkv processed order-2
08:38:15 order-worker-7d58d557df-h4dkv processed order-3

Midway through, the broker shows the backlog shrinking with all ten consumers attached:

kubectl exec -n as-keda deploy/rabbitmq -- rabbitmqctl list_queues name messages consumers
name messages consumers
orders 115 10

Step 8 — Watch Ten Become Zero

The queue empties within seconds. The HPA alone would now spend five minutes stepping down, because of its default 300-second scale-down window. KEDA does not wait for that. Once the trigger has been inactive for the 30-second cooldownPeriod, it sets the Deployment straight to zero:

order-worker 10/0 10 10 2m1s

The ScaledObject's events tell the whole story, including one detail worth knowing:

kubectl describe scaledobject order-worker -n as-keda
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal KEDAScalersStarted 89s keda-operator Scaler rabbitmq is built
Normal ScaledObjectReady 89s keda-operator ScaledObject is ready for scaling
Warning KEDAScalerFailed 89s keda-operator error inspecting RabbitMQ: Exception (404) Reason: "NOT_FOUND - no queue 'orders' in vhost '/'"
Warning KEDAScalerFailed 84s keda-operator error inspecting RabbitMQ: Exception (404) Reason: "NOT_FOUND - no queue 'orders' in vhost '/'"
Normal KEDAScaleTargetActivated 79s keda-operator Scaled apps/v1.Deployment as-keda/order-worker from 0 to 1, triggered by s0-rabbitmq-orders
Normal KEDAScaleTargetDeactivated 4s keda-operator Deactivated apps/v1.Deployment as-keda/order-worker from 10 to 0

The NOT_FOUND - no queue 'orders' warnings appeared because the ScaledObject was created before anything had declared the queue. The worker declares it, but the worker was at zero Pods. Only the publisher's QueueDeclareAsync created it. This chicken-and-egg problem is common with scale to zero. In production, declare queues ahead of time, through the broker's definitions, an operator, or a migration step, so that the scaler never looks at a queue that does not exist.

Step 9 — Pause and Inspect

Look at the metric the HPA is reading now that the queue is empty:

kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/as-keda/s0-rabbitmq-orders?labelSelector=scaledobject.keda.sh%2Fname%3Dorder-worker"
{
"kind": "ExternalMetricValueList",
"apiVersion": "external.metrics.k8s.io/v1beta1",
"metadata": {},
"items": [
{
"metricName": "s0-rabbitmq-orders",
"metricLabels": null,
"timestamp": "2026-10-21T08:34:07Z",
"value": "0"
}
]
}

Pin the worker at two replicas, for example while you debug it:

kubectl annotate scaledobject order-worker -n as-keda autoscaling.keda.sh/paused-replicas="2" --overwrite
kubectl get scaledobject order-worker -n as-keda
NAME SCALETARGETKIND SCALETARGETNAME MIN MAX READY ACTIVE FALLBACK PAUSED TRIGGERS AUTHENTICATIONS AGE
order-worker apps/v1.Deployment order-worker 0 10 True False False True rabbitmq rabbitmq-auth 105s

Two Pods start even though the queue is empty, and PAUSED is True. Remove the annotation and KEDA takes them back to zero:

kubectl annotate scaledobject order-worker -n as-keda autoscaling.keda.sh/paused-replicas-

Step 10 — The Same Trigger as a ScaledJob

If each message were a ten-minute report instead of a one-second order, you would not want scale-down to kill a Pod halfway through. The ScaledJob version reuses the same TriggerAuthentication and starts one Job per message:

apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
name: invoice-renderer
namespace: as-keda
spec:
jobTargetRef:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: renderer
image: invoice-renderer:1.0
pollingInterval: 10
maxReplicaCount: 20
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 5
scalingStrategy:
strategy: default
triggers:
- type: rabbitmq
metadata:
protocol: amqp
queueName: invoices
mode: QueueLength
value: "1"
authenticationRef:
name: rabbitmq-auth

The container for a ScaledJob must take one message (or a fixed batch), process it, acknowledge it and exit. A consumer that loops forever, like order-worker, would turn every Job into a permanent Pod.

Step 11 — Clean Up

kubectl delete namespace as-keda
helm uninstall keda --namespace keda
kubectl delete namespace keda

Summary

  1. CPU is the wrong signal for queue workers. KEDA scales on the event source itself: queue length, stream lag, a cron schedule, and 70+ other scalers.
  2. KEDA does not replace the HPA. A ScaledObject creates an HPA (keda-hpa-<name>) that handles 1 to N, and the KEDA operator handles 0 to 1 and 1 to 0 itself.
  3. activationValue decides whether the workload runs at all. value is the per-Pod target used by the HPA. cooldownPeriod is how long the trigger must be quiet before KEDA scales to zero.
  4. Keep credentials in a Secret and connect them with a TriggerAuthentication.
  5. For RabbitMQ consumers, set prefetchCount low and acknowledge manually, so new Pods share the backlog and scale-down never loses a message. RabbitMQ.Client 7 is async-only.
  6. Make sure the queue exists before the ScaledObject looks at it. Otherwise the scaler logs NOT_FOUND until a producer creates it.
  7. Use a ScaledJob for long tasks. Each unit of work gets its own Job, which scale-down never interrupts.

That completes the scaling triad: the HPA for how many Pods, the VPA for how big they are, and KEDA for scaling on events, down to zero. The next lesson in this section shows Goldilocks, which turns VPA recommendations into a dashboard for every workload in a namespace.