diff --git a/docs/TektonConfig.md b/docs/TektonConfig.md index ecbb8ada3a..9332c07bf2 100644 --- a/docs/TektonConfig.md +++ b/docs/TektonConfig.md @@ -975,7 +975,44 @@ spec: - The event-based pruner responds to resource events in real-time, providing more efficient cleanup - When `enforcedConfigLevel` is set to `namespace`, individual namespaces can override these settings using ConfigMaps +#### Resource Limits +The operator applies default resource limits to pruner deployments based on benchmark data: + +**Controller:** `requests: 100m CPU, 256Mi memory` | `limits: 2Gi memory` +**Webhook:** `requests: 50m CPU, 64Mi memory` | `limits: 512Mi memory` + +CPU limits are omitted to avoid CFS throttling during reconciliation bursts. Memory limits are based on linear growth (~13.6 MB per 1k resident PipelineRuns). The 2Gi controller limit supports up to ~50k runs with some safety margin. + +**Benchmark data:** + +| Scale | Resident PRs | Heap (MB) | Container (MB) | +|-------|--------------|-----------|----------------| +| Baseline | 0 | 23 | - | +| Low | 1,000 | 65 | 58 | +| Medium | 6,000 | 148 | 69 | +| High | 16,000 | 301 | 104 | +| Production | 41,000 | 579 | 170 | + + +**Override via options:** +```yaml + tektonpruner: + options: + deployments: + tekton-pruner-controller: + spec: + template: + spec: + containers: + - name: controller + resources: + limits: + memory: 4Gi + cpu: 1000m +``` + +For >50k resident runs, calculate required memory: `memory_mb = 23 + (13.6 × runs_in_thousands)` and apply 1.5x safety factor. ### Additional fields as `options` diff --git a/pkg/reconciler/common/transformer_resource_limits.go b/pkg/reconciler/common/transformer_resource_limits.go new file mode 100644 index 0000000000..0bb797ade0 --- /dev/null +++ b/pkg/reconciler/common/transformer_resource_limits.go @@ -0,0 +1,93 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + mf "github.com/manifestival/manifestival" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + apimachineryRuntime "k8s.io/apimachinery/pkg/runtime" +) + +// AddDefaultResourceRequirements returns a transformer that injects default resource +// requests and limits into Deployments and StatefulSets based on their name. +// +// The resourceMap parameter maps deployment/statefulset names to their default +// ResourceRequirements. Only resources with empty or missing resource specifications +// are updated - existing resource configurations are preserved. +// +// This allows components to define resource defaults without overriding user-specified +// values or values set by the additional options transformer. +func AddDefaultResourceRequirements(resourceMap map[string]corev1.ResourceRequirements) mf.Transformer { + return func(u *unstructured.Unstructured) error { + kind := u.GetKind() + if kind != "Deployment" && kind != "StatefulSet" { + return nil + } + + // Check if this resource has defaults defined + defaultResources, found := resourceMap[u.GetName()] + if !found { + return nil + } + + // Both Deployment and StatefulSet have containers at spec.template.spec.containers + containers, found, err := unstructured.NestedSlice(u.Object, "spec", "template", "spec", "containers") + if !found || err != nil { + return err + } + + modified := false + for i := range containers { + containerMap := containers[i].(map[string]interface{}) + + // Check if resources field exists + resources, hasResources := containerMap["resources"] + if !hasResources { + // No resources field, add defaults + resourcesMap, err := apimachineryRuntime.DefaultUnstructuredConverter.ToUnstructured(&defaultResources) + if err != nil { + return err + } + containerMap["resources"] = resourcesMap + modified = true + } else { + // Has resources field, check if both requests and limits are empty + resourcesMap := resources.(map[string]interface{}) + _, hasRequests := resourcesMap["requests"] + _, hasLimits := resourcesMap["limits"] + if !hasRequests && !hasLimits { + // Both nil, replace with defaults + resourcesMap, err := apimachineryRuntime.DefaultUnstructuredConverter.ToUnstructured(&defaultResources) + if err != nil { + return err + } + containerMap["resources"] = resourcesMap + modified = true + } + } + } + + if modified { + if err := unstructured.SetNestedSlice(u.Object, containers, "spec", "template", "spec", "containers"); err != nil { + return err + } + } + + return nil + } +} diff --git a/pkg/reconciler/kubernetes/tektonpruner/defaults.go b/pkg/reconciler/kubernetes/tektonpruner/defaults.go new file mode 100644 index 0000000000..312e9a2629 --- /dev/null +++ b/pkg/reconciler/kubernetes/tektonpruner/defaults.go @@ -0,0 +1,60 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tektonpruner + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +// Default resource limits for pruner deployments based on empirical benchmark data. +const ( + defaultControllerCPURequest = "100m" + defaultControllerMemoryRequest = "256Mi" + defaultControllerMemoryLimit = "2Gi" + + defaultWebhookCPURequest = "50m" + defaultWebhookMemoryRequest = "64Mi" + defaultWebhookMemoryLimit = "512Mi" +) + +var ( + DefaultControllerResources = corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(defaultControllerCPURequest), + corev1.ResourceMemory: resource.MustParse(defaultControllerMemoryRequest), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(defaultControllerMemoryLimit), + }, + } + + DefaultWebhookResources = corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(defaultWebhookCPURequest), + corev1.ResourceMemory: resource.MustParse(defaultWebhookMemoryRequest), + }, + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse(defaultWebhookMemoryLimit), + }, + } + + DefaultResourcesMap = map[string]corev1.ResourceRequirements{ + "tekton-pruner-controller": DefaultControllerResources, + "tekton-pruner-webhook": DefaultWebhookResources, + } +) diff --git a/pkg/reconciler/kubernetes/tektonpruner/transform.go b/pkg/reconciler/kubernetes/tektonpruner/transform.go index 304be585ee..3c7c743f63 100644 --- a/pkg/reconciler/kubernetes/tektonpruner/transform.go +++ b/pkg/reconciler/kubernetes/tektonpruner/transform.go @@ -36,6 +36,7 @@ func filterAndTransform(extension common.Extension) client.FilterAndTransform { common.DeploymentImages(prunerImages), common.AddDeploymentRestrictedPSA(), common.AddConfigMapValues(PrunerConfigMapName, prunerCR.Spec.TektonPrunerConfig), + common.AddDefaultResourceRequirements(DefaultResourcesMap), } extra = append(extra, extension.Transformers(prunerCR)...) err := common.Transform(ctx, manifest, prunerCR, extra...)