Skip to main content

Autoscaling

A PlatformApplication running a Deployment is autoscaled by default — you get a HorizontalPodAutoscaler on CPU without writing any configuration at all. This page covers what that default gives you, how to tune it, and how to scale on a signal other than CPU.

What You Get by Default

Leave spec.autoscaling out of your manifest entirely and the operator still creates all three of these:

ResourceConfigurationWhat it does
HorizontalPodAutoscalermin 2, max 10, 80% CPUScales pod count on average CPU utilization
PodDisruptionBudgetminAvailable: 1Keeps one pod serving through node drains and cluster upgrades — see Running a Fixed Number of Pods before dropping to a single replica
VerticalPodAutoscalerupdateMode: "Off"Records CPU and memory right-sizing recommendations without acting on them
Autoscaling is on unless you turn it off

spec.autoscaling.enabled defaults to true. An application with no autoscaling block receives the CPU autoscaler described above, which is why a brand-new service starts at two pods rather than one. Two things change that: you opt out explicitly, or your application mounts ReadWriteOnce storage and must run as a single pod.

The VerticalPodAutoscaler never resizes anything — it is created in recommendation-only mode so you can compare your declared spec.deployment.resources against real usage:

kubectl describe vpa YOUR_APP -n YOUR_NAMESPACE

Tuning the CPU Autoscaler

Set a floor and a ceiling on the replica count:

.platform/kubernetes/base/application.yaml
spec:
autoscaling:
minReplicas: 3
maxReplicas: 20

The replica range is the only part of the CPU autoscaler you control. The 80% CPU utilization target is fixed for every CPU-scaled application.

80% of your CPU request, not of a whole core

Utilization is measured against the container's CPU request, so the request you declare is what decides when scaling starts. Leave spec.deployment.resources.requests.cpu unset and you get the platform default of 50m, which puts the scale-out threshold at roughly 40m — low enough that a moderately busy service adds pods sooner than you might expect. If your application scales out earlier than it should, raise the CPU request before touching the replica range.

cpuThresholdPercentage and memoryThresholdPercentage have no effect

The CRD accepts both fields and both are reserved for future use. Setting either one changes nothing about how your application scales. To scale on memory, use a memory trigger — there is a worked example below, and it needs no additional setup.

Running a Fixed Number of Pods

PlatformApplication has no replicas field — the pod count comes from the autoscaler. To hold a steady count, set the floor and the ceiling to the same value:

spec:
autoscaling:
minReplicas: 4
maxReplicas: 4

That works for any fixed count of two or more. For exactly one pod, turn autoscaling off instead — a Deployment with no autoscaler and no replica count falls back to the Kubernetes default of one:

spec:
autoscaling:
enabled: false # exactly one pod, and no PodDisruptionBudget
Do not pin a single replica with minReplicas: 1 / maxReplicas: 1

That combination keeps the PodDisruptionBudget at minAvailable: 1 while the application runs exactly one pod, which leaves it with zero allowed disruptions. Kubernetes then refuses to evict the pod at all:

Cannot evict pod as it would violate the pod's disruption budget.

Eviction is how a node is emptied, so the node your pod sits on can never be drained — kubectl drain retries forever, and cluster scale-down and node upgrades stall on it. Rolling deployments are unaffected, since those replace the pod directly rather than evicting it, so the problem stays invisible until someone tries to remove the node.

Setting enabled: false avoids it: no autoscaler means no PodDisruptionBudget, and the single pod can be evicted and rescheduled normally. At two or more replicas the budget always leaves at least one disruption available, so minReplicas / maxReplicas is the right tool there.

Scaling on Something Other Than CPU

CPU is the wrong signal for plenty of workloads. A queue consumer can sit on a million-message backlog without moving the CPU needle, and an internal tool may only need capacity during office hours. Declaring spec.autoscaling.triggers switches the application onto KEDA, which scales on the metric you name.

Scale up for the working day and back down overnight:

.platform/kubernetes/base/application.yaml
spec:
autoscaling:
minReplicas: 1
maxReplicas: 5
triggers:
- type: cron
metadata:
timezone: "UTC"
start: "0 6 * * *"
end: "0 20 * * *"
desiredReplicas: "3"

Or scale on memory as well as CPU, which the CPU autoscaler on its own cannot do:

spec:
autoscaling:
minReplicas: 2
maxReplicas: 10
triggers:
- type: memory
metadata:
type: Utilization
value: "80"
- type: cpu
metadata:
type: Utilization
value: "80"

Declare both, as above, whenever memory is what you care about. Triggers replace the CPU autoscaler rather than adding to it, so a lone memory trigger would leave you with no CPU scaling at all. Neither of these two reads anything outside the cluster, so both work with no further setup.

Each trigger is a type plus a flat map of metadata, both required — exactly the shape KEDA's own scaler documentation uses, passed straight through to the ScaledObject the operator creates for you. A trigger that has to reach a queue, a topic, or a remote metrics backend needs a credential as well; see Trigger Credentials before adopting one of those.

Every metadata value must be a quoted string

metadata is a string-to-string map, so numbers and booleans need quotes: desiredReplicas: "3", not desiredReplicas: 3. An unquoted number fails schema validation when the manifest is applied:

spec.autoscaling.triggers[0].metadata.desiredReplicas: Invalid value: "integer":
spec.autoscaling.triggers[0].metadata.desiredReplicas in body must be of type
string: "integer"

Two behaviors are worth knowing before you rely on triggers:

  • Triggers replace the platform's CPU autoscaler. When triggers is set the operator creates a KEDA ScaledObject instead of a HorizontalPodAutoscaler, and removes the HPA it previously managed — so switching an existing application onto triggers needs no cleanup on your side, and enabled: true does not bring the CPU autoscaler back. KEDA then creates and owns its own HPA named keda-hpa-YOUR_APP to carry out the scaling. That one is expected: it is how KEDA applies its decisions, not a second autoscaler competing with your ScaledObject.
  • minReplicas defaults to 0 on the trigger path, where the CPU autoscaler defaults to 2. An idle application scales all the way down to zero pods unless you set a floor — which is exactly what you want for a batch worker, and rarely what you want for anything serving traffic.

Trigger Credentials

A trigger that reads a cloud resource needs a credential you cannot declare here

KEDA reads your metric itself, from its own pod — not from inside your application — so it needs its own path to the metric source. KEDA's mechanism for that is a TriggerAuthentication object named by authenticationRef, and spec.autoscaling.triggers models neither: a trigger is a type and a flat metadata map, with nowhere to name a credential. The cloud identity attached to your workload belongs to your pods, not to KEDA's, so it does not close the gap.

Contact Ybor support before committing to a trigger that reads a queue, a topic, or a remote metrics backend. The credential is the part that needs arranging, and it is arranged outside your PlatformApplication.

That splits the trigger types cleanly:

  • Triggers that read nothing outside the cluster need no arranging. cron, memory and cpu are all self-contained, which is why the examples above use them.
  • Triggers that read a queue, a topic, or a remote metrics backend — SQS, Service Bus, Kafka, Redis, a remote Prometheus — need the credential conversation first. The YAML for these is the easy part.

The platform's own n8n installation is the worked example of the second kind: its workers scale on Redis queue depth, and making that function took a TriggerAuthentication delivered by n8n's Helm chart, bridging the Redis password Secret into the scaler. That is the shape of the answer, and it lives outside your PlatformApplication.

One practical detail if your metric source is inside the cluster: address it by fully-qualified name, my-service.my-namespace.svc.cluster.local. KEDA resolves the address from its own namespace, so a bare Service name will not find a Service in yours.

Finding Your Cluster's Scaler Reference

KEDA ships as a platform component, so the exact version — and therefore the exact set of available scalers and their metadata fields — depends on your cluster. Check what you are running before reaching for a scaler:

kubectl get deployment keda-operator -n keda \
-o jsonpath='{.spec.template.spec.containers[0].image}'

Then look up your scaler in the KEDA scaler reference, selecting the version that matches. The cron scaler is the easiest one to start with: it needs no external metrics backend, so it is a good way to confirm the wiring works before you point a trigger at a real queue.

Autoscaling and Persistent Storage

A Deployment that mounts a ReadWriteOnce PersistentVolumeClaim can only ever run one pod — a second replica would block forever waiting to attach the same volume. The platform enforces this rather than letting you deploy something that cannot scale: asking for horizontal scaling and an RWO volume together is rejected when the manifest is applied, with a message spelling out your options.

If the application has to scale, it needs storage that isn't a single ReadWriteOnce volume — either a volume type that supports ReadWriteMany, so several replicas can mount it at once, or object storage the application reaches over the network instead of through a mount. Which of those you can use depends on the cloud your cluster runs in, so confirm what's available before designing around one; Persistent Storage covers the volume side, and Ybor support can tell you what your cluster offers.

Verifying What You Deployed

Check which autoscaler your application actually got:

kubectl get scaledobject -n YOUR_NAMESPACE
kubectl get hpa -n YOUR_NAMESPACE

What you should see:

  • On the CPU path — no ScaledObject, and one HorizontalPodAutoscaler named after your application.
  • On the trigger path — a ScaledObject named after your application, plus an HPA named keda-hpa-YOUR_APP that KEDA created and owns. Read the ScaledObject rather than that HPA when you want to know whether your trigger was understood.
  • Neither — autoscaling is off, either because you set enabled: false or because an RWO volume pinned the application to a single pod.

If kubectl get scaledobject reports that the server has no resource of that type, KEDA is not installed in your cluster and triggers cannot work there — contact Ybor support.

When a ScaledObject exists but nothing scales, its events explain what KEDA made of your trigger:

kubectl describe scaledobject YOUR_APP -n YOUR_NAMESPACE