Repository navigation
Description
/area autoscale
/area API
/kind spec
What version of Knative?
Knative Serving v1.20.0 (observed). Root-cause code is unchanged on main and in the latest
release knative-v1.22.1, so all released versions through v1.22.1 are affected.
Related (not duplicates): #11531, #2674, #11373.
Expected Behavior
Per the scale-bounds docs,
initial-scale is "the initial target scale a Revision must reach ... before it is marked as Ready."
A new Revision should not become Ready, should not be promoted to
Configuration.status.latestReadyRevisionName, and should not receive route traffic until it reaches
its initial-scale. Traffic should stay on the previous, fully-scaled Revision until then.
Actual Behavior
A new Revision can be marked Ready=True and latched as latestReadyRevisionName while still far below
its initial-scale, so the Route shifts 100% of traffic to an under-provisioned Revision. Because
latestReadyRevisionName is monotonic, the route never reverts — even after the Revision later flips
Ready=False (e.g. ProgressDeadlineExceeded). In production this abandoned a healthy fully-scaled
Revision for a new one running at ~15% of target replicas, which then returned 503/504s.
Root cause (same on release-1.20 and main):
-
Revision readiness is not gated on initial-scale —
pkg/apis/serving/v1/revision_lifecycle.go:
revisionCondSet = apis.NewLivingConditionSet(ResourcesAvailable, ContainerHealthy)(no
ScaleTargetInitialized/Activeterm). -
ResourcesAvailablecomes from the Deployment's Progressing condition, not Available —
pkg/reconciler/revision/reconcile_resources.goPropagateDeploymentStatus→
TransformDeploymentStatusreads{Progressing, ReplicaSetReady}, neverappsv1.DeploymentAvailable.
SoResourcesAvailable=Trueat any replica count, andContainerHealthy=TrueonceReadyReplicas > 0. -
The only step that holds Ready back below initial-scale (
PropagateAutoscalerStatusinreconcilePA,
which pullsResourcesAvailableback toUnknownwhile!IsScaleTargetInitialized()) is skipped
when an earlier phase errors. The reconcile is an early-return phase loop
(pkg/reconciler/revision/revision.go):for _, phase := range []func(context.Context, *v1.Revision) error{ c.reconcileDeployment, // sets ResourcesAvailable=True (Progressing) + ContainerHealthy=True c.reconcileImageCache, // if this errors, the loop returns... c.reconcilePA, // ...so reconcilePA (which would reset ResourcesAvailable) is SKIPPED } { if err := phase(ctx, rev); err != nil { return err } } A transient reconcileImageCache error (e.g. createImageCache AlreadyExists from image-lister lag, common under heavy concurrent-write churn) persists the below-initial-scale Ready=True. Then pkg/reconciler/configuration/configuration.go findAndSetLatestReadyRevision advances latestReadyRevisionName to it and never reverts (monotonic); the Route follows. (Note: a reconcileDeployment Update conflict does NOT trigger this — it returns before PropagateDeploymentStatus, so ResourcesAvailable is never set True that cycle.)
Steps to Reproduce the Problem
In production this fires stochastically under churn. To make it deterministic, the steps below
(a) wedge a new Revision below initial-scale and (b) force reconcileImageCache to error via a
ResourceQuota (a stand-in for the transient AlreadyExists). Requires image caching enabled
(caching.internal.knative.dev Image CRD + controller).
- Label 3 schedulable nodes and create a namespace:
kubectl label node repro=true
kubectl create ns repro - Deploy a healthy baseline Revision at scale 1, pinned to the labeled nodes; wait until Ready:
apiVersion: serving.knative.dev/v1
kind: Service
metadata: { name: rt, namespace: repro }
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/initial-scale: "1"
autoscaling.knative.dev/min-scale: "1"
autoscaling.knative.dev/max-scale: "1"
spec:
nodeSelector: { repro: "true" }
containers:
- image: ghcr.io/knative/helloworld-go:latest
env: [{ name: TARGET, value: "v1" }] - Cap the namespace's Image count at its current value so the next Revision's image cache can't be created:
kubectl -n repro create quota capimg
--hard=count/images.caching.internal.knative.dev=$(kubectl -n repro get images.caching.internal.knative.dev --no-headers | wc -l) - Roll a new Revision that wants initial-scale=6 but can only schedule 2 pods (one per labeled node;
one node is held by the baseline) — wedging it at 2/6:
re-apply the same Service with:
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/initial-scale: "6"
autoscaling.knative.dev/min-scale: "6"
autoscaling.knative.dev/max-scale: "6"
spec:
nodeSelector: { repro: "true" }
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels: { serving.knative.dev/service: rt }
containers:
- image: ghcr.io/knative/helloworld-go:latest
env: [{ name: TARGET, value: "v2" }] # forces a new Revision
5. Observe the new Revision become Ready=True and capture the route while below initial-scale:
kubectl -n repro get revision # new rev READY=True
kubectl -n repro get pods # 2 Running, 4 Pending (wedged at 2/6)
kubectl -n repro get images.caching.internal.knative.dev # only the baseline's image exists
kubectl -n repro get configuration rt -o jsonpath='{.status.latestReadyRevisionName}' # -> new rev
kubectl -n repro get route rt -o jsonpath='{.status.traffic}' # -> 100% new rev
- The new Revision is Ready=True at 2/6 replicas with no image cache; latestReadyRevisionName and the
Route both point at it, and never revert to the fully-scaled baseline.
Suggested fix
Make readiness reflect replica sufficiency: include the Deployment's Available condition in
TransformDeploymentStatus, or gate Revision Ready / findAndSetLatestReadyRevision advancement on the
PodAutoscaler's ScaleTargetInitialized (enforcing the documented initial-scale semantics). Either
prevents latestReadyRevisionName from advancing to an under-scaled Revision.
Activity
juanpark-dandy commentedon Jun 17, 2026
Script I used to reproduce the premature route switch.
It doesn't seem to happen all the time, so far I have been able to reproduce it ~4/5 attempts with the script.
juanpark-dandy commentedon Jun 17, 2026
juanpark-dandy commentedon Jun 17, 2026
kserve/kserve#5683
dprotaso commentedon Jun 17, 2026
Do you have a repro without using KServe and just vanilla Knative?
juanpark-dandy commentedon Jun 19, 2026
No... not at the moment
adityasrini commentedon Jul 22, 2026
@dprotaso and @juanpark-dandy I'm working on reproducing this locally before proposing a fix. I hope this is welcome!
adityasrini commentedon Jul 30, 2026
UPDATE: I reproduced this with vanilla
knative/servingv1.22.1 on a disposable three-node Kind cluster. The commands below create the environment from public release manifests.While I believe the bug can be solved with the suggested fix proposed by @juanpark-dandy, I wonder if this issue is linked to issue #5076 and is thus a part of a bigger coordination issue that deserves a more holistic solution. I won't be opposed to any patches in the meantime.
Setup
Creating the 3 node cluster and installing Knative/serving + Kourier:
The node table must show three
Ready=Truenodes withTAINTS=<none>:In a second Zsh terminal:
Creating the baseline Service with scale as
1:Curl must return
Hello v1!and HTTP 200.Issue Repro
Applying the hardcoded image cap and rolling out a new Service re-creates the issue.
These commands show the partial reconciliation with Kourier returning
Hello v1!after the failed v2 rollout:rt-00002has two ready pods out of six while its Revision reportedReady=True. Configuration and Route status namedrt-00002, with Ingress onrt-00001because the injected Image error was still active. The v2 Image and PodAutoscaler are absent. This independently reproduces the partial-reconciliation/latest-ready latch with vanilla Knativeshashankvarma499 commentedon Sep 13, 2026
Thanks for the thorough root-cause analysis and the vanilla repro — that's a solid diagnosis.
I'd like to take a crack at this. I agree with the mechanism: Revision
Readyis justResourcesAvailable && ContainerHealthy,ResourcesAvailableis derived from the Deployment'sProgressingcondition rather thanAvailable, and the phase loop's early-return on areconcileImageCacheerror skipsreconcilePA— the only step that pullsResourcesAvailableback toUnknownwhile!IsScaleTargetInitialized(). So a below-initial-scale Revision can latchReady=TrueandfindAndSetLatestReadyRevisionadvances monotonically.Of the two directions suggested, I'd lean toward the one that enforces the documented contract at the point of latching: gate
findAndSetLatestReadyRevisionon the Revision's PodAutoscaler having reachedScaleTargetInitialized(i.e. don't advancelatestReadyRevisionNameto a Revision that hasn't reached its initial-scale). That covers the image-cache-error path too, since it's the PA status that never got propagated there, rather than only fixing the deployment-condition path.Rough shape:
pkg/reconciler/configuration/configuration.go, require the candidate Revision to reportScaleTargetInitialized=True(from the PodAutoscaler propagated intoRevision.status) before advancinglatestReadyRevisionName.Ready=TruebutScaleTargetInitializedunset/Unknownmust not be latched.latestReadyRevisionName(and therefore the Route) stays on the baseline Revision.I'm not wedded to that over making
TransformDeploymentStatusconsume the Deployment'sAvailablecondition — the gating approach just seems smaller and closer to the "initial-scale before Ready" semantics. If this direction looks right I'll open a PR; happy to leave it if you'd rather handle it or prefer the other approach.