keda
KEDA scales a workload on events rather than CPU, down to zero: a
ScaledObject scales a Deployment on a queue’s depth, a cron window or a
PromQL query. Turn it on for workers behind a queue. Off by default, on
every cloud; the operator’s own access (services) is aws and gcp only.
Getting started
Section titled “Getting started”Turn it on; for aws queues, add their services to
aws.crossplane.allowed_services and name them in services:
kube = { crossplane = { enabled = true } keda = { enabled = true services = ["sqs", "cloudwatch"] }}On gcp, for Pub/Sub, add roles/monitoring.viewer to
gcp.crossplane.allowed_roles and name pubsub:
kube = { crossplane = { enabled = true } keda = { enabled = true services = ["pubsub"] }}Then kubectl get apiservice v1beta1.external.metrics.k8s.io is Available.
On gcp a ScaledObject reads Pub/Sub as the operator through a
TriggerAuthentication with no secret:
apiVersion: keda.sh/v1alpha1kind: TriggerAuthenticationmetadata: name: operator-principal namespace: ordersspec: podIdentity: provider: gcp # the operator's own principal---apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: orders-worker namespace: ordersspec: scaleTargetRef: name: orders-worker minReplicaCount: 0 maxReplicaCount: 20 triggers: - type: gcp-pubsub authenticationRef: name: operator-principal metadata: subscriptionName: orders-worker value: "50" # messages per replicaSettings
Section titled “Settings”| Attribute | Default | |
|---|---|---|
enabled | false | Turns the module on or off. |
services | [] | aws: the services the operator’s role may read: sqs, cloudwatch, kinesis, dynamodb. gcp: pubsub. []: no role. |
values | {} | Any keda chart value; yours win. |
values_secret | "" | A Secret in keda with a values.yaml key, for what must stay out of the OpenTofu state. |
Every setting
Section titled “Every setting”kube = { keda = { enabled = false # off by default services = [] # aws: "sqs", "cloudwatch", "kinesis", "dynamodb"; gcp: "pubsub"; [] = no role
# Any value of the keda chart 2.21.0; yours win over the socle's. values = { logging = { operator = { level = "debug", format = "json" } } }
# A Secret you create in keda, whose values.yaml key holds chart values # that must not reach the OpenTofu state, such as an operator.env proxy # URL with a password in it. values_secret = "keda-values" }}Good to know
Section titled “Good to know”servicesneeds crossplane, and names the cluster’s cloud’s services.tofu planrefuses it otherwise, even with the module off: leave it empty there. On aws, RDS, ELB and Lambda metrics go throughcloudwatch;["sqs"]on gcp or["pubsub"]on aws is refused.- The plan cannot see
aws.crossplane.allowed_servicesorgcp.crossplane.allowed_roles. A service missing on aws, orroles/monitoring.viewermissing on gcp, makes the role or the binding fail at IAM and the module wait, with the reason on itsSyncedcondition. - Use the operator’s identity. On aws,
identityOwner: keda(the default): aTriggerAuthenticationwithidentityOwner: workloador aroleArndoes not work under the socle’s boundary. On gcp, aTriggerAuthenticationwithpodIdentity.provider: gcpand nothing else; leave the chart’spodIdentity.gcp.enabledoff, it would make the operator impersonate a Google service account the binding does not name. Any other source (azure, Kafka, Redis…) takes aTriggerAuthenticationon a Secret of yours. pubsublets KEDA count, not pull. The binding reads Cloud Monitoring only. Your Deployment still pulls as its own principal (…/subject/ns/<namespace>/sa/<serviceaccount>), withroles/pubsub.subscriberon its subscription, granted in your own Terraform with the subscription.- Keep the metrics server schedulable. KEDA serves
external.metrics.k8s.io, an aggregated API: whilekeda-operator-metrics-apiservercannot run, discovery fails cluster-wide and terminating namespaces hang (measured on GKE, 2026-10-07). Keep it schedulable (capacity, no failing admission) before tearing anything down. - Credentials never go in
values:tofu planrefuses them. A scaler’s go in a Secret itsTriggerAuthenticationnames. - Upgrades: the chart moves with
socle_version. The CRDs are kept when the module is off; aScaledObjectleft behind then stops scaling, at its current replica count.
Under the hood
Installed: chart keda 2.21.0 from https://kedacore.github.io/charts, in
keda (Pod Security restricted): the operator, the metrics server and the
admission webhooks, the v1beta1.external.metrics.k8s.io APIService and six
CRDs. On aws with services, Role/keda-operator and
PodIdentityAssociation/keda-operator; on gcp with pubsub,
ProjectIAMMember/keda-operator.
What the socle sets: CRDs kept on removal, webhooks failing open
(Ignore), requests under the chart’s limits, and an annotation that rolls
the operator when services changes. Your values are merged over these.
Cloud access: on aws, the role <cluster>-keda-operator holds one
read-only statement per service named, the exact calls each scaler makes, on
every resource: depths, never messages; dynamodb grants Query on every
table. On gcp, pubsub is one project-level ProjectIAMMember/keda-operator
granting roles/monitoring.viewer to the operator’s Workload Identity
principal — the pod’s own ServiceAccount, no Google service account: KEDA’s
gcp-pubsub scaler reads the subscription’s depth from Cloud Monitoring,
never from Pub/Sub, so the grant is every metric of the project, read-only,
never a message.
Ordering: on aws the role is a step of its own, cloud, which aws adds
to the module
(oci/clusters/aws/keda/patch.yaml,
SOCLE-32): the module waits for the crossplane
ResourceSet, and the chart’s step starts only once the role and its
association are Ready (keda-access), since Pod Identity hands credentials
at admission only. On gcp the binding is gcp’s cloud step
(oci/clusters/gcp/keda/patch.yaml),
with the same keda-access wait; GKE hands out tokens at call time, so there
the order only spares the operator minutes of denied reads. Turn the module
off before crossplane, never in the same apply. Why monitoring and no Google
service account: KEDA-02.
Measured on floci, 2026-09-30: 10 messages with queueLength: 5 scaled
a Deployment from 0 to 2 in 12 s, and a purge back to 0 in 12 s.