#8182·keda

ScaledObject targeting apps/v1 StatefulSet resolves to statefulsets.apps.kruise.io when OpenKruise CRDs are installed (RESTMapper group-prefix match in scale client)

Author: wuwuduCreated Sep 14, 2026Updated Sep 18, 2026

Report

On a cluster where the OpenKruise CRDs are installed (no Kruise CRs needed — just the CRDs), a ScaledObject whose scaleTargetRef points at a plain apps/v1 StatefulSet never becomes Ready. KEDA looks the object up as statefulsets.apps.kruise.io instead of statefulsets.apps and reports not found.

Root cause (verified against the same library versions KEDA 2.20.2 is built with; details below): the operator calls the client-go scale client with a GroupResource (no version). The scale client resolves that via RESTMapper.ResourceFor(gr.WithVersion("")), and apimachinery's DefaultRESTMapper.ResourcesFor falls back to group-prefix matching when it does not find an exact group match. apps is a prefix of apps.kruise.io, so Kruise's statefulsets resource wins the lookup.

Deployment targets are unaffected because the apps.kruise.io group has no deployments resource. Kruise CloneSet/Advanced StatefulSet targets are also unaffected (exact group match).

Expected Behavior

A ScaledObject targeting apps/v1 StatefulSet should resolve /scale on statefulsets.apps, regardless of which other API groups (with apps as a name prefix) exist in the cluster.

Actual Behavior

ScaledObject sample-sts: Ready=False
  message: statefulsets.apps.kruise.io "sample-sts" not found

Same result for a brand-new, empty apps/v1 StatefulSet with only a cron trigger, so it is not specific to the workload.

Operator log:

ERROR  Reconciler error  {"controller": "scaledobject", ..., "namespace": "demo", "name": "sample-sts",
       "error": "statefulsets.apps.kruise.io \"sample-sts\" not found"}

Steps to Reproduce the Problem

  1. Install OpenKruise (v1.9.1 here; only the CRDs matter). No CloneSet / Advanced StatefulSet objects are required.

  2. Install KEDA 2.20.2.

  3. Create any apps/v1 StatefulSet, e.g.

    yaml
    apiVersion: apps/v1
    kind: StatefulSet
    metadata: { name: sts-probe, namespace: default }
    spec:
      serviceName: sts-probe
      replicas: 1
      selector: { matchLabels: { app: sts-probe } }
      template:
        metadata: { labels: { app: sts-probe } }
        spec:
          containers: [{ name: pause, image: registry.k8s.io/pause:3.9 }]
  4. Create a ScaledObject for it:

    yaml
    apiVersion: keda.sh/v1alpha1
    kind: ScaledObject
    metadata: { name: sts-probe, namespace: default }
    spec:
      scaleTargetRef:
        apiVersion: apps/v1
        kind: StatefulSet
        name: sts-probe
      minReplicaCount: 0
      maxReplicaCount: 1
      triggers:
        - type: cron
          metadata: { timezone: Etc/UTC, start: "0 0 * * *", end: "0 1 * * *", desiredReplicas: "1" }
  5. kubectl get scaledobject sts-probeREADY=False, condition message statefulsets.apps.kruise.io "sts-probe" not found.

Minimal Go reproduction outside KEDA (client-go v0.35.5 + controller-runtime v0.23.3, i.e. the versions KEDA 2.20.2 depends on; controller-runtime dynamic RESTMapper + scale.NewForConfig exactly as in pkg/k8s/scaleclient.go):

RESTMapping(apps/StatefulSet, v1)   -> apps/v1, Resource=statefulsets          OK
mapper.ResourceFor(apps//statefulsets) -> apps.kruise.io/v1beta1, statefulsets   WRONG
scaleClient.Scales(ns).Get(ctx, {apps statefulsets}, "sample-sts")
    -> statefulsets.apps.kruise.io "sample-sts" not found                       (what KEDA reports)
client.Get(unstructured apps/v1 StatefulSet)  -> OK

Where it happens (line numbers from main at the time of writing):

  • controllers/keda/scaledobject_controller.go L424-425 and L472-478: gr := gvkr.GroupResource() then ScaleClient.Scales(ns).Get(ctx, gr, name, ...) — the version that was already resolved into gvkr is dropped here.
  • pkg/k8s/scaleclient.go L52-54: scale client is built with mgr.GetRESTMapper() (controller-runtime dynamic mapper, backed by apimachinery DefaultRESTMapper).
  • client-go scale/client.go L105 (v0.35.5): c.mapper.ResourceFor(resource.WithVersion("")).
  • apimachinery pkg/api/meta/restmapper.go L233-246 (v0.35.5): "if you didn't find an exact match, match on group prefixing. This allows storageclass.storage to match storageclass.storage.k8s.io" — apps prefix-matches apps.kruise.io.

Possible fixes on the KEDA side (either avoids the version-less lookup):

  • Resolve the scale subresource with the full GroupVersionResource from gvkr (e.g. use RESTMapping / the already-known gvkr.GVR()), instead of passing a bare GroupResource to the scale client; or
  • Wrap the RESTMapper handed to scale.NewForConfig so that ResourceFor with an empty version only accepts exact group matches.

Workaround for users: use Deployment, or switch the target to Kruise Advanced StatefulSet (apps.kruise.io/v1beta1, which KEDA resolves correctly).

Logs from KEDA operator

ERROR  Reconciler error  {"controller": "scaledobject", "controllerGroup": "keda.sh", "controllerKind": "ScaledObject",
       "ScaledObject": {"name":"sample-sts","namespace":"demo"}, "namespace": "demo", "name": "sample-sts",
       "error": "statefulsets.apps.kruise.io \"sample-sts\" not found"}

KEDA Version

2.20.2

Kubernetes Version

1.36 (Alibaba Cloud ACK, v1.36.2-aliyun)

Platform

Alibaba Cloud

Scaler Details

Any — reproduced with cron; first hit with the HTTP add-on (external-push). The scaler is irrelevant, the failure is in target resolution.

Would you be open to contributing a fix?

  • Yes, I am willing to contribute a fix

Anything else?

This also affects any other CRD whose group starts with apps and defines a statefulsets resource; OpenKruise is just the common case. Other API groups that are prefixes of other groups (batch vs batch.something.io) would hit the same path if resource names overlap.

Related but different: #4924 (wrong apiVersion from GVKR resolution) and #7332 (empty GVKR for CloneSet) — those are about gvkr itself; here gvkr is correct and the version is lost afterwards.