Skip to main content

KEDA 2.20's Event-RBAC Migration and Two Panic Fixes: What Breaks When Scale-to-Zero Runs Through One Controller

8 min readDora NodaDora Noda
Share
On this page

The upgrade note told you exactly what to do, and doing it still broke. When KEDA 2.20.0 shipped on June 1, 2026, its headline warning was unambiguous: events had moved to the events.k8s.io API group, so grant the operator create/patch on the new group before upgrading. One operator did everything right — Helm chart keda-2.20.1, default values, EKS — and was greeted shortly after startup by this:

text
event.go:359 "Server rejected event (will not retry!)" err="events is forbidden:
User \"system:serviceaccount:keda:keda-operator\" cannot create resource
\"events\" in API group \"\" in the namespace \"keda\""

Note the API group in the denial: "". Not events.k8s.io — the legacy core group the migration was supposed to leave behind. The bundled chart had added the new permission and dropped the old one, and client-go's event broadcaster kept writing to the old one anyway. That contradiction, plus two concurrent-map panics fixed across the 2.20 patch releases, makes KEDA 2.20 a near-perfect case study in what it costs to route all of scale-to-zero through a single controller — and what a self-hosted platform needs to do differently.

One controller owns zero: the blast-radius table

KEDA's architecture splits scaling across two components with very different blast radii. The operator reconciles ScaledObject and ScaledJob resources, creates and owns the per-workload HPA objects, and — critically — performs the 0-to-1 activation and 1-to-0 deactivation that vanilla HPA cannot do (HPA's floor is minReplicas: 1). The metrics API server sits behind it, serving the external.metrics.k8s.io API the HPAs poll for trigger values.

So "KEDA is broken" is never one failure. It is at least three, with different costs:

FailureActivation 0→1 / 1→0Scaling 1↔NEvent visibility
Events RBAC broken (charts#883)UnaffectedUnaffectedLost: kubectl describe goes quiet
Operator panicking / crashloopingStalled: zero stays zeroCoasts: existing HPAs keep working off live metrics, but no new or updated HPAsDegraded
Metrics server downStill fires (the operator scales directly)Blind: HPAs hold current replicasUnaffected

This table is the whole post in miniature. The RBAC break that opens this story was, in the reporter's own words, cosmetic — scaling itself was unaffected. But the two panics in the same release line hit the middle row: a crashlooping operator stops reconciling, which means a workload sitting at zero never wakes up no matter how deep its queue gets. When one controller owns the only path from zero to one, its uptime is your cold-start story.

The RBAC migration that dropped the floor

The 2.20.0 upgrade note (keda#7781) reads reasonably: with the Kubernetes 0.35 dependency bump, KEDA records events via events.k8s.io instead of the legacy core events resource, and custom-RBAC installs should grant the new permission before upgrading. The note even reassured chart users that bundled manifests already carried the update.

What the chart actually rendered for the keda-operator ClusterRole was:

yaml
- apiGroups: [events.k8s.io]
  resources: [events]
  verbs: [create, patch]

No rule for events on the core "" group. And here is the trap, documented upstream in kubernetes/kubernetes#94857: client-go's event broadcaster still writes events to the legacy core API group even when configured for events.k8s.io. Granting only the new group is insufficient — the broadcaster needs both. The old entry had to be retained alongside the new one, not replaced by it.

The reporter of kedacore/charts#883 confirmed the workaround the same day: a sidecar ClusterRole granting create,patch on core events to the operator service account made ScaledObjectReady and KEDAScalersStarted events land again immediately. The durable fix — restoring "" next to events.k8s.io — shipped in the core repo as keda#7922 with 2.20.2, and the chart followed via charts#886.

There is a second-order lesson hiding here for anyone running restricted RBAC, which on a multi-tenant self-hosted platform should be everyone: the failure mode of a missing event permission is silence. Nothing crashloops. No alert fires unless you wrote one for events is forbidden in the operator logs. Your kubectl describe scaledobject output just quietly stops telling you things — exactly when, mid-upgrade, you most need it to talk.

Two panics in two patches

While the RBAC story played out in the chart repo, the operator itself needed two crash fixes in back-to-back patches:

2.20.1 (June 8, 2026) fixed a concurrent map read/write data race in the fallback updateStatus path (keda#7838) that panicked when multiple triggers were scaling simultaneously. Fallback is the feature that parks a workload at a fixed replica count when its triggers go blind — the safety net. A panic in the safety net, triggered precisely under the multi-trigger load where you need it, is the kind of bug that only shows up in production-shaped traffic. The same patch fixed a quieter events-migration casualty: the KEDAScalersStarted event was never emitted for ScaledJobs because it shared an events.k8s.io aggregation key with the per-scaler event (keda#7820).

2.20.2 (July 31, 2026) fixed a concurrent map-writes panic in the shared root CA CertPool (keda#7910) alongside a whole cluster of nil-pointer guards: AWS Secret Manager auth with no credentials and no pod identity (keda#7927), an omitted customScalingQueueLengthDeduction (keda#7798), informer-cache objects with undefaulted spec.replicas (keda#7863), and a nil Status.ScaleTargetGVKR under a known cache race (keda#4389 / keda#4955).

Read that list as an operator, not a release-notes tourist. Every one of these panics lands in the same place: the single reconcile loop that owns activation. A crashlooping operator does not scale anything down — your running workloads keep their pods — but the 0→1 path is dead until the loop recovers. If your platform sells scale-to-zero as its cost story (idle tenants cost nothing), an operator panic converts directly into stuck-at-zero tenants and a support queue that says "my app won't wake up."

The 2.20.2 notes also contain the release line's quiet admission that the single-controller shape needs hardening from the other direction: a dedicated HPAActive condition on ScaledObject mirroring the HPA's own ScalingActive status, so transient metric gaps stop flipping Ready to False (keda#7914). When one controller's status rollup is the only signal tenants see, every flap in it is a false page.

Running KEDA on a fleet you own: the upgrade checklist

On a Cluster-API-managed fleet — the shape this site's readers run, where the platform team owns the machines, the control planes, and the autoscaler — KEDA rhymes with cluster-autoscaler: a single controller per cluster whose outage freezes one scaling direction while the other coasts. CAPI operators already learned this discipline for machine lifecycle; KEDA 2.20 is the prompt to apply it to workload autoscaling. Concretely:

  1. Diff RBAC before every KEDA upgrade, not after. Render the chart with your values and diff the ClusterRoles against what's live. The 2.20 migration was visible in that diff — a removed "" group next to an added events.k8s.io — to anyone who looked before helm upgrade.
  2. Keep both event API groups until client-go says otherwise. The fixed rule is apiGroups: ["", "events.k8s.io"] with create,patch on events. Treat upstream issue kubernetes#94857 as the constraint: the day the broadcaster truly stops writing core events, the changelog will say so. Until then, narrowing the rule is a self-inflicted silence.
  3. Run the operator HA. KEDA supports leader election, and 2.20.0 even added a --leader-election-id flag for configuring the Lease name (keda#7564). Two replicas with leader election turn a panic from "activation is down until CrashLoopBackOff recovers" into "the standby takes the lease." The panics above still need patching, but their blast radius shrinks to a lease handover.
  4. Alert on the three signals this saga produced. Operator pod restarts (panic), events is forbidden in operator logs (RBAC regression), and HPA ScalingActive=False sustained across ScaledObjects (metrics path degrading). Each maps to one row of the blast-radius table; together they cover the controller before tenants notice.
  5. Stage chart and CRD upgrades separately. KEDA's CRDs and RBAC ship in the same chart flow, and 2.20 changed both the event path and validation markers. Upgrading CRDs first, verifying kubectl describe still narrates, then moving the operator image keeps a bad chart render from taking down reconciliation and observability in the same deploy.

None of this is KEDA-specific wisdom. It is the generic single-controller playbook — diff the permissions, run two, watch the lease — with the 2.20 issue numbers attached so the next upgrade has receipts.

Ready stops flapping

KEDA 2.20 will be remembered as the release that moved events to events.k8s.io and immediately had to un-move half of it. But the more durable artifact is the failure taxonomy: a visibility-only RBAC break, two panics in the activation path, and a new HPAActive condition that finally separates "the HPA has no metrics right now" from "this ScaledObject is not ready." Each fix narrows the gap between what the controller reports and what your tenants experience.

For a self-hosted PaaS, that gap is the product. Tenants on machines you own cannot page a cloud provider when their app sits at zero replicas; they page you, and your first question will be what the autoscaler said. KEDA 2.20's lesson is to make sure it can still speak — both API groups granted, two replicas holding the lease, and an upgrade runbook that diffs first. The controller is single; the excuses for it being a single point of failure don't have to be.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex