Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import (
"knative.dev/serving/pkg/reconciler/route"
"knative.dev/serving/pkg/reconciler/serverlessservice"
"knative.dev/serving/pkg/reconciler/service"
"knative.dev/serving/pkg/reconciler/servicescaler"

versioned "github.com/cert-manager/cert-manager/pkg/client/clientset/versioned"
"knative.dev/serving/pkg/client/certmanager/injection/informers/acme/v1/challenge"
Expand All @@ -63,6 +64,7 @@ var ctors = []injection.ControllerConstructor{
gc.NewController,
nscert.NewController,
domainmapping.NewController,
servicescaler.NewController,
}

func main() {
Expand Down
25 changes: 24 additions & 1 deletion docs/controllers/CONTROLLERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,30 @@ The Labeler controller syncs routing metadata (labels/annotations) from Routes t

---

### 13. Garbage Collection (GC) Controller
### 13. Service Scaler Controller

**Location:** `pkg/reconciler/servicescaler/`

**Watched Resource:** `serving.knative.dev/v1.Route`

**Responsibility:**
The Service Scaler controller service scaling (currently only service minscale) annotations from Routes to their referenced Revisions. This enables
service scaling annotations to be propagated to the pod autoscaler for scaling decisions.

**Key Functions:**
- Adds/updates annotations on **Revisions** referenced by Route Status traffic:
- `serving.knative.dev/service-min-scale` - denotes minscale across route traffic
- `serving.knative.dev/service-min-scale-route` - denotes which route set the service min scale decision
- Removes scaling annotations from revisions when Routes are deleted (via Finalizer)
- Does NOT modify Route status (SkipStatusUpdates: true)

**Informers Watched:**
- Route (primary)
- Revision (tracked and annotated)

---

### 14. Garbage Collection (GC) Controller

**Location:** `pkg/reconciler/gc/`

Expand Down
10 changes: 10 additions & 0 deletions pkg/apis/autoscaling/v1alpha1/pa_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ func (pa *PodAutoscaler) ScaleBounds(asConfig *autoscalerconfig.Config) (int32,
if paMin, ok := pa.annotationInt32(autoscaling.MinScaleAnnotation); ok {
min = paMin
}
if serviceMin, ok := pa.annotationInt32(serving.ServiceMinscaleAnnotation); ok {
min = intMax(min, serviceMin)
}
}

max := asConfig.MaxScale
Expand Down Expand Up @@ -307,3 +310,10 @@ func (pas *PodAutoscalerStatus) GetActualScale() int32 {
}
return -1
}

func intMax(a, b int32) int32 {
if a < b {
return b
}
return a
}
29 changes: 29 additions & 0 deletions pkg/apis/serving/metadata_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ package serving

import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"

"k8s.io/apimachinery/pkg/api/equality"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"knative.dev/pkg/apis"
"knative.dev/pkg/kmap"
"knative.dev/serving/pkg/apis/autoscaling"
"knative.dev/serving/pkg/apis/config"
)
Expand Down Expand Up @@ -75,6 +79,11 @@ func ValidateRolloutDurationAnnotation(annos map[string]string) (errs *apis.Fiel
return errs
}

// ValidateServiceMinscaleAnnotationKey validates the service minscale annotation.
func ValidateServiceMinscaleAnnotationKey(annos map[string]string) *apis.FieldError {
return getIntGE0(annos, ServiceMinscaleAnnotation)
}

// ValidateHasNoAutoscalingAnnotation validates that the respective entity does not have
// annotations from the autoscaling group. It's to be used to validate Service and
// Configuration.
Expand Down Expand Up @@ -130,3 +139,23 @@ func SetUserInfo(ctx context.Context, oldSpec, newSpec, resource interface{}) {
}
}
}

func getIntGE0(m map[string]string, key kmap.KeyPriority) *apis.FieldError {
k, v, ok := key.Get(m)
if !ok {
return nil
}
// Parsing as uint gives a bad format error, rather than invalid range, unfortunately.
i, err := strconv.ParseInt(v, 10, 32)
if err != nil {
if errors.Is(err, strconv.ErrRange) {
return apis.ErrOutOfBoundsValue(v, 0, math.MaxInt32, k)
}
return apis.ErrInvalidValue(v, k)
}
if i < 0 {
return apis.ErrOutOfBoundsValue(v, 0, math.MaxInt32, k)
}

return nil
}
29 changes: 29 additions & 0 deletions pkg/apis/serving/metadata_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,3 +491,32 @@ func TestValidateRolloutDurationAnnotation(t *testing.T) {
})
}
}

func TestValidateServiceMinscaleAnnotationKey(t *testing.T) {
tests := []struct {
name string
value string
want string
}{{
name: "empty",
want: "invalid value: : serving.knative.dev/service-min-scale",
}, {
name: "valid",
value: "3",
}, {
name: "invalid",
value: "-1",
want: "expected 0 <= -1 <= 2147483647: serving.knative.dev/service-min-scale",
}}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateServiceMinscaleAnnotationKey(map[string]string{
ServiceMinscaleAnnotationKey: tc.value,
})
if got, want := err.Error(), tc.want; got != want {
t.Errorf("APIErr mismatch, diff(-want,+got):\n%s", cmp.Diff(want, got))
}
})
}
}
14 changes: 14 additions & 0 deletions pkg/apis/serving/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ const (
// which Service they are created.
ServiceLabelKey = GroupName + "/service"

// ServiceMinscaleAnnotationKey is the annotation key attached to a Route and Service indicating
// the minScale across all revisions
ServiceMinscaleAnnotationKey = GroupName + "/service-min-scale"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we just use the existing min-scale annotation (without the service- prefix)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then we wouldn't need to adjust the KPA code etc.

What were your thoughts about having it separate?

Copy link
Copy Markdown
Contributor Author

@Alexander-Kita Alexander-Kita May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I tried this. However, if the user removes the service minscale annotation from the service or route and updates, I could not find a good way to determine what the original revision minscale was since it was overwritten.


// ServiceMinscaleAnnotationKey is the annotation key attached to a revision indicating
// which route attached the service min scale, used in the case of multiple routes having /service-min-scale
ServiceMinscaleRouteAnnotationKey = GroupName + "/service-min-scale-route"

// DomainMappingUIDLabelKey is the label key attached to Ingress resources to indicate
// which DomainMapping triggered their creation.
// This uses a uid rather than a name because domain mapping names can exceed
Expand Down Expand Up @@ -182,6 +190,12 @@ var (
RolloutDurationKey,
GroupName + "/rolloutDuration",
}
ServiceMinscaleAnnotation = kmap.KeyPriority{
ServiceMinscaleAnnotationKey,
}
ServiceMinscaleRouteAnnotation = kmap.KeyPriority{
ServiceMinscaleRouteAnnotationKey,
}
QueueSidecarResourcePercentageAnnotation = kmap.KeyPriority{
QueueSidecarResourcePercentageAnnotationKey,
"queue.sidecar." + GroupName + "/resourcePercentage",
Expand Down
1 change: 1 addition & 0 deletions pkg/apis/serving/v1/route_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func (r *Route) Validate(ctx context.Context) *apis.FieldError {
errs = errs.Also(serving.ValidateRolloutDurationAnnotation(r.GetAnnotations()).ViaField("annotations"))
errs = errs.ViaField("metadata")
errs = errs.Also(r.Spec.Validate(apis.WithinSpec(ctx)).ViaField("spec"))
errs = errs.Also(serving.ValidateServiceMinscaleAnnotationKey(r.GetAnnotations()).ViaField("annotations"))

if apis.IsInUpdate(ctx) {
original := apis.GetBaseline(ctx).(*Route)
Expand Down
13 changes: 13 additions & 0 deletions pkg/apis/serving/v1/route_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,19 @@ func TestRouteAnnotationUpdate(t *testing.T) {
},
Spec: getRouteSpec("old"),
},
}, {
name: "service min scale validation, fail",
this: &Route{
ObjectMeta: metav1.ObjectMeta{
Name: "valid",
Annotations: map[string]string{
serving.ServiceMinscaleAnnotationKey: "-1",
},
},
Spec: getRouteSpec("new"),
},
wantErr: apis.ErrGeneric("expected 0 <= -1 <= 2147483647",
serving.ServiceMinscaleAnnotationKey).ViaField("annotations"),
}}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions pkg/apis/serving/v1/service_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func (s *Service) Validate(ctx context.Context) (errs *apis.FieldError) {
if !apis.IsInStatusUpdate(ctx) {
errs = errs.Also(serving.ValidateObjectMetadata(ctx, s.GetObjectMeta(), false))
errs = errs.Also(serving.ValidateRolloutDurationAnnotation(s.GetAnnotations()).ViaField("annotations"))
errs = errs.Also(serving.ValidateServiceMinscaleAnnotationKey(s.GetAnnotations()).ViaField("annotations"))
errs = errs.ViaField("metadata")

ctx = apis.WithinParent(ctx, s.ObjectMeta)
Expand Down
25 changes: 25 additions & 0 deletions pkg/reconciler/autoscaling/kpa/kpa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,22 @@ func TestReconcile(t *testing.T) {
WithPAMetricsService(privateSvc), WithObservedGeneration(1),
),
}},
}, {
Name: "kpa does not become ready without service min scale endpoints when reachable",
Key: key,
Objects: []runtime.Object{
kpa(testNamespace, testRevision, withServiceMinScale(4), withScales(1, defaultScale),
WithReachabilityReachable, WithPAMetricsService(privateSvc)),
defaultSKS,
metric(testNamespace, testRevision),
defaultDeployment, defaultReady,
},
WantStatusUpdates: []clientgotesting.UpdateActionImpl{{
Object: kpa(testNamespace, testRevision, WithPASKSReady,
WithBufferedTraffic, withServiceMinScale(4), WithPAMetricsService(privateSvc),
withScales(1, defaultScale), WithPAStatusService(testRevision), WithReachabilityReachable,
WithObservedGeneration(1)),
}},
}}

table.Test(t, MakeFactory(func(ctx context.Context, listers *Listers, cmw configmap.Watcher) controller.Reconciler {
Expand Down Expand Up @@ -1794,6 +1810,15 @@ func withMinScale(minScale int) PodAutoscalerOption {
}
}

func withServiceMinScale(serviceMinScale int) PodAutoscalerOption {
return func(pa *autoscalingv1alpha1.PodAutoscaler) {
pa.Annotations = kmeta.UnionMaps(
pa.Annotations,
map[string]string{serving.ServiceMinscaleAnnotationKey: strconv.Itoa(serviceMinScale)},
)
}
}

func decider(ns, name string, desiredScale, ebc int32) *scaling.Decider {
return &scaling.Decider{
ObjectMeta: metav1.ObjectMeta{
Expand Down
8 changes: 7 additions & 1 deletion pkg/reconciler/autoscaling/kpa/scaler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
pkgnet "knative.dev/pkg/network"
"knative.dev/serving/pkg/activator"
autoscalingv1alpha1 "knative.dev/serving/pkg/apis/autoscaling/v1alpha1"
"knative.dev/serving/pkg/apis/serving"
"knative.dev/serving/pkg/autoscaler/config/autoscalerconfig"
"knative.dev/serving/pkg/reconciler/autoscaling/config"
kparesources "knative.dev/serving/pkg/reconciler/autoscaling/kpa/resources"
Expand Down Expand Up @@ -333,7 +334,7 @@ func (ks *scaler) scale(ctx context.Context, pa *autoscalingv1alpha1.PodAutoscal
asConfig := config.FromContext(ctx).Autoscaler
logger := logging.FromContext(ctx)

if desiredScale < 0 && !pa.Status.IsActivating() {
if desiredScale < 0 && !hasServiceMinScale(pa) && !pa.Status.IsActivating() {
logger.Debug("Metrics are not yet being collected.")
return desiredScale, nil
}
Expand Down Expand Up @@ -379,3 +380,8 @@ func (ks *scaler) scale(ctx context.Context, pa *autoscalingv1alpha1.PodAutoscal
logger.Infof("Scaling from %d to %d", currentScale, desiredScale)
return desiredScale, ks.applyScale(ctx, pa, desiredScale, ps)
}

func hasServiceMinScale(pa *autoscalingv1alpha1.PodAutoscaler) bool {
_, _, ok := serving.ServiceMinscaleAnnotation.Get(pa.Annotations)
return ok
}
6 changes: 6 additions & 0 deletions pkg/reconciler/revision/cruds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ func TestMergeMetadata(t *testing.T) {
current: map[string]string{"autoscaling.knative.dev/target-burst-capacity": "0", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"},
want: map[string]string{"autoscaling.knative.dev/min-scale": "1", "app": "my-revision", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"},
},
{
name: "mixed knative and external service min scale",
desired: map[string]string{"serving.knative.dev/serving-min-scale": "1", "app": "my-revision"},
current: map[string]string{"autoscaling.knative.dev/target-burst-capacity": "0", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"},
want: map[string]string{"serving.knative.dev/serving-min-scale": "1", "app": "my-revision", "deployment.kubernetes.io/revision": "2", "kubectl.kubernetes.io/restartedAt": "2025-11-27T12:14:41+01:00"},
},
}

for _, tt := range tests {
Expand Down
18 changes: 15 additions & 3 deletions pkg/reconciler/revision/reconcile_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"knative.dev/pkg/logging"
"knative.dev/pkg/logging/logkey"
"knative.dev/serving/pkg/apis/autoscaling"
"knative.dev/serving/pkg/apis/serving"
v1 "knative.dev/serving/pkg/apis/serving/v1"
"knative.dev/serving/pkg/networking"
"knative.dev/serving/pkg/reconciler/revision/config"
Expand Down Expand Up @@ -233,7 +234,7 @@ func syncAnnotationsForKPA(dst, src map[string]string) {
// Exclude defaulted annotation
continue
}
if strings.HasPrefix(k, autoscaling.GroupName) {
if isScalingAnnotation(k) {
delete(dst, k)
}
}
Expand All @@ -249,7 +250,7 @@ func syncAnnotationsForKPA(dst, src map[string]string) {
func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool {
// Check for extra autoscaling annotations that don't exist in src
for k := range dst {
if !strings.HasPrefix(k, autoscaling.GroupName) {
if !isScalingAnnotation(k) {
continue
}
// Exclude defaulted annotation
Expand All @@ -259,7 +260,7 @@ func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool {

if _, ok := src[k]; !ok {
// Scaling annotation is in dst but not src
// return false to trigger reconciliation
// return true to trigger reconciliation
return true
}
}
Expand All @@ -282,6 +283,17 @@ func annotationsNeedReconcilingForKPA(dst, src map[string]string) bool {
return false
}

func isScalingAnnotation(key string) bool {
// On top of autoscaler annotations, we need to propagate service scaling annotations
additionalAnnotations := sets.NewString(
slices.Concat[[]string](
serving.ServiceMinscaleAnnotation,
serving.ServiceMinscaleRouteAnnotation,
)...)

return strings.HasPrefix(key, autoscaling.GroupName) || additionalAnnotations.Has(key)
}

func hasDeploymentTimedOut(deployment *appsv1.Deployment) bool {
// as per https://kubernetes.io/docs/concepts/workloads/controllers/deployment
for _, cond := range deployment.Status.Conditions {
Expand Down
Loading
Loading