This commit is contained in:
Kam Saiyed 2025-09-17 04:25:20 +00:00 committed by GitHub
commit 37af3e8ef8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 763 additions and 11 deletions

View File

@ -24,6 +24,7 @@ This document is auto-generated from the flag definitions in the VPA admission-c
| `log-file` | string | | If non-empty, use this log file (no effect when -logtostderr=true) |
| `log-file-max-size` | int | 1800 | uDefines the maximum size a log file can grow to (no effect when -logtostderr=true). Unit is megabytes. If the value is 0, the maximum file size is unlimited. |
| `logtostderr` | | true | log to standard error instead of files |
| `max-allowed-cpu-boost` | string | | Maximum amount of CPU that will be applied for a container with boost. |
| `min-tls-version` | string | | The minimum TLS version to accept. Must be set to either tls1_2 or tls1_3. (default "tls1_2") |
| `one-output` | severity | | If true, only write logs to their native level (vs also writing to each lower severity level; no effect when -logtostderr=true) |
| `port` | int | 8000 | The port to listen on. |

View File

@ -25,6 +25,7 @@ import (
"time"
"github.com/spf13/pflag"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/client-go/informers"
kube_client "k8s.io/client-go/kubernetes"
typedadmregv1 "k8s.io/client-go/kubernetes/typed/admissionregistration/v1"
@ -78,6 +79,7 @@ var (
registerWebhook = flag.Bool("register-webhook", true, "If set to true, admission webhook object will be created on start up to register with the API server.")
webhookLabels = flag.String("webhook-labels", "", "Comma separated list of labels to add to the webhook object. Format: key1:value1,key2:value2")
registerByURL = flag.Bool("register-by-url", false, "If set to true, admission webhook will be registered by URL (webhookAddress:webhookPort) instead of by service name")
maxAllowedCPUBoost = flag.String("max-allowed-cpu-boost", "", "Maximum amount of CPU that will be applied for a container with boost.")
)
func main() {
@ -93,6 +95,13 @@ func main() {
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
if *maxAllowedCPUBoost != "" {
if _, err := resource.ParseQuantity(*maxAllowedCPUBoost); err != nil {
klog.ErrorS(err, "Failed to parse maxAllowedCPUBoost")
klog.FlushAndExit(klog.ExitFlushTimeout, 1)
}
}
healthCheck := metrics.NewHealthCheck(time.Minute)
metrics_admission.Register()
server.Initialize(&commonFlags.EnableProfiling, healthCheck, address)
@ -145,7 +154,7 @@ func main() {
hostname,
)
calculators := []patch.Calculator{patch.NewResourceUpdatesCalculator(recommendationProvider), patch.NewObservedContainersCalculator()}
calculators := []patch.Calculator{patch.NewResourceUpdatesCalculator(recommendationProvider, *maxAllowedCPUBoost), patch.NewObservedContainersCalculator()}
as := logic.NewAdmissionServer(podPreprocessor, vpaPreprocessor, limitRangeCalculator, vpaMatcher, calculators)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
as.Serve(w, r)

View File

@ -21,10 +21,14 @@ import (
"strings"
core "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/klog/v2"
resource_admission "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/admission-controller/resource"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/admission-controller/resource/pod/recommendation"
vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/features"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/annotations"
resourcehelpers "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/resources"
vpa_api_util "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/vpa"
)
@ -37,13 +41,19 @@ const (
type resourcesUpdatesPatchCalculator struct {
recommendationProvider recommendation.Provider
maxAllowedCPUBoost resource.Quantity
}
// NewResourceUpdatesCalculator returns a calculator for
// resource update patches.
func NewResourceUpdatesCalculator(recommendationProvider recommendation.Provider) Calculator {
func NewResourceUpdatesCalculator(recommendationProvider recommendation.Provider, maxAllowedCPUBoost string) Calculator {
var maxAllowedCPUBoostQuantity resource.Quantity
if maxAllowedCPUBoost != "" {
maxAllowedCPUBoostQuantity = resource.MustParse(maxAllowedCPUBoost)
}
return &resourcesUpdatesPatchCalculator{
recommendationProvider: recommendationProvider,
maxAllowedCPUBoost: maxAllowedCPUBoostQuantity,
}
}
@ -52,11 +62,22 @@ func (*resourcesUpdatesPatchCalculator) PatchResourceTarget() PatchResourceTarge
}
func (c *resourcesUpdatesPatchCalculator) CalculatePatches(pod *core.Pod, vpa *vpa_types.VerticalPodAutoscaler) ([]resource_admission.PatchRecord, error) {
klog.Infof("Calculating patches for pod %s/%s with VPA %s", pod.Namespace, pod.Name, vpa.Name)
result := []resource_admission.PatchRecord{}
containersResources, annotationsPerContainer, err := c.recommendationProvider.GetContainersResourcesForPod(pod, vpa)
if err != nil {
return []resource_admission.PatchRecord{}, fmt.Errorf("failed to calculate resource patch for pod %s/%s: %v", pod.Namespace, pod.Name, err)
return nil, fmt.Errorf("failed to calculate resource patch for pod %s/%s: %v", pod.Namespace, pod.Name, err)
}
if vpa_api_util.GetUpdateMode(vpa) == vpa_types.UpdateModeOff {
// If update mode is "Off", we don't want to apply any recommendations,
// but we still want to apply startup boost.
for i := range containersResources {
containersResources[i].Requests = nil
containersResources[i].Limits = nil
}
annotationsPerContainer = vpa_api_util.ContainerToAnnotationsMap{}
}
if annotationsPerContainer == nil {
@ -65,10 +86,66 @@ func (c *resourcesUpdatesPatchCalculator) CalculatePatches(pod *core.Pod, vpa *v
updatesAnnotation := []string{}
for i, containerResources := range containersResources {
// Apply startup boost if configured
if features.Enabled(features.CPUStartupBoost) {
policy := vpa_api_util.GetContainerResourcePolicy(pod.Spec.Containers[i].Name, vpa.Spec.ResourcePolicy)
if policy != nil && policy.Mode != nil && *policy.Mode == vpa_types.ContainerScalingModeOff {
klog.V(4).InfoS("Not applying startup boost for container", "containerName", pod.Spec.Containers[i].Name, "reason", "scaling mode is Off")
continue
} else {
startupBoostPolicy := getContainerStartupBoostPolicy(&pod.Spec.Containers[i], vpa)
if startupBoostPolicy != nil {
originalRequest := pod.Spec.Containers[i].Resources.Requests[core.ResourceCPU]
boostedRequest, err := calculateBoostedCPU(originalRequest, startupBoostPolicy)
if err != nil {
return nil, err
}
if !c.maxAllowedCPUBoost.IsZero() && boostedRequest.Cmp(c.maxAllowedCPUBoost) > 0 {
boostedRequest = &c.maxAllowedCPUBoost
}
if containerResources.Requests == nil {
containerResources.Requests = core.ResourceList{}
}
controlledValues := vpa_api_util.GetContainerControlledValues(pod.Spec.Containers[i].Name, vpa.Spec.ResourcePolicy)
resourceList := core.ResourceList{core.ResourceCPU: *boostedRequest}
if controlledValues == vpa_types.ContainerControlledValuesRequestsOnly {
vpa_api_util.CapRecommendationToContainerLimit(resourceList, pod.Spec.Containers[i].Resources.Limits)
}
containerResources.Requests[core.ResourceCPU] = resourceList[core.ResourceCPU]
if controlledValues == vpa_types.ContainerControlledValuesRequestsAndLimits {
if containerResources.Limits == nil {
containerResources.Limits = core.ResourceList{}
}
originalLimit := pod.Spec.Containers[i].Resources.Limits[core.ResourceCPU]
if originalLimit.IsZero() {
originalLimit = pod.Spec.Containers[i].Resources.Requests[core.ResourceCPU]
}
boostedLimit, err := calculateBoostedCPU(originalLimit, startupBoostPolicy)
if err != nil {
return nil, err
}
if !c.maxAllowedCPUBoost.IsZero() && boostedLimit.Cmp(c.maxAllowedCPUBoost) > 0 {
boostedLimit = &c.maxAllowedCPUBoost
}
containerResources.Limits[core.ResourceCPU] = *boostedLimit
}
originalResources, err := annotations.GetOriginalResourcesAnnotationValue(&pod.Spec.Containers[i])
if err != nil {
return nil, err
}
result = append(result, GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, originalResources))
}
}
}
newPatches, newUpdatesAnnotation := getContainerPatch(pod, i, annotationsPerContainer, containerResources)
if len(newPatches) > 0 {
result = append(result, newPatches...)
updatesAnnotation = append(updatesAnnotation, newUpdatesAnnotation)
}
}
if len(updatesAnnotation) > 0 {
vpaAnnotationValue := fmt.Sprintf("Pod resources updated by %s: %s", vpa.Name, strings.Join(updatesAnnotation, "; "))
@ -77,6 +154,49 @@ func (c *resourcesUpdatesPatchCalculator) CalculatePatches(pod *core.Pod, vpa *v
return result, nil
}
func getContainerStartupBoostPolicy(container *core.Container, vpa *vpa_types.VerticalPodAutoscaler) *vpa_types.StartupBoost {
policy := vpa_api_util.GetContainerResourcePolicy(container.Name, vpa.Spec.ResourcePolicy)
startupBoost := vpa.Spec.StartupBoost
if policy != nil && policy.StartupBoost != nil {
startupBoost = policy.StartupBoost
}
return startupBoost
}
func calculateBoostedCPU(baseCPU resource.Quantity, startupBoost *vpa_types.StartupBoost) (*resource.Quantity, error) {
if startupBoost == nil {
return &baseCPU, nil
}
boostType := startupBoost.CPU.Type
if boostType == "" {
boostType = vpa_types.FactorStartupBoostType
}
switch boostType {
case vpa_types.FactorStartupBoostType:
if startupBoost.CPU.Factor == nil {
return nil, fmt.Errorf("startupBoost.CPU.Factor is required when Type is Factor or not specified")
}
factor := *startupBoost.CPU.Factor
if factor < 1 {
return nil, fmt.Errorf("boost factor must be >= 1")
}
boostedCPU := baseCPU.MilliValue()
boostedCPU = int64(float64(boostedCPU) * float64(factor))
return resource.NewMilliQuantity(boostedCPU, resource.DecimalSI), nil
case vpa_types.QuantityStartupBoostType:
if startupBoost.CPU.Quantity == nil {
return nil, fmt.Errorf("startupBoost.CPU.Quantity is required when Type is Quantity")
}
quantity := *startupBoost.CPU.Quantity
boostedCPU := baseCPU.MilliValue() + quantity.MilliValue()
return resource.NewMilliQuantity(boostedCPU, resource.DecimalSI), nil
default:
return nil, fmt.Errorf("unsupported startup boost type: %s", startupBoost.CPU.Type)
}
}
func getContainerPatch(pod *core.Pod, i int, annotationsPerContainer vpa_api_util.ContainerToAnnotationsMap, containerResources vpa_api_util.ContainerResources) ([]resource_admission.PatchRecord, string) {
var patches []resource_admission.PatchRecord
// Add empty resources object if missing.

View File

@ -24,9 +24,12 @@ import (
"github.com/stretchr/testify/assert"
core "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
featuregatetesting "k8s.io/component-base/featuregate/testing"
resource_admission "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/admission-controller/resource"
vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/features"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/annotations"
"k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/test"
vpa_api_util "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/vpa"
)
@ -289,11 +292,22 @@ func TestCalculatePatches_ResourceUpdates(t *testing.T) {
addAnnotationRequest([][]string{{cpu}}, limit),
},
},
{
name: "no recommendation present",
pod: test.Pod().
AddContainer(core.Container{}).
AddContainerStatus(test.ContainerStatus().
WithCPULimit(resource.MustParse("0")).Get()).Get(),
namespace: "default",
recommendResources: make([]vpa_api_util.ContainerResources, 1),
recommendAnnotations: vpa_api_util.ContainerToAnnotationsMap{},
expectPatches: []resource_admission.PatchRecord{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
frp := fakeRecommendationProvider{tc.recommendResources, tc.recommendAnnotations, tc.recommendError}
c := NewResourceUpdatesCalculator(&frp)
c := NewResourceUpdatesCalculator(&frp, "")
patches, err := c.CalculatePatches(tc.pod, test.VerticalPodAutoscaler().WithContainer("test").WithName("name").Get())
if tc.expectError == nil {
assert.NoError(t, err)
@ -335,7 +349,7 @@ func TestGetPatches_TwoReplacementResources(t *testing.T) {
}
recommendAnnotations := vpa_api_util.ContainerToAnnotationsMap{}
frp := fakeRecommendationProvider{recommendResources, recommendAnnotations, nil}
c := NewResourceUpdatesCalculator(&frp)
c := NewResourceUpdatesCalculator(&frp, "")
patches, err := c.CalculatePatches(pod, test.VerticalPodAutoscaler().WithName("name").WithContainer("test").Get())
assert.NoError(t, err)
// Order of updates for cpu and unobtanium depends on order of iterating a map, both possible results are valid.
@ -350,3 +364,333 @@ func TestGetPatches_TwoReplacementResources(t *testing.T) {
AssertPatchOneOf(t, patches[2], []resource_admission.PatchRecord{cpuFirstUnobtaniumSecond, unobtaniumFirstCpuSecond})
}
}
func TestCalculatePatches_StartupBoost(t *testing.T) {
factor := int32(2)
quantity := resource.MustParse("500m")
invalidFactor := int32(0)
invalidQuantity := resource.MustParse("200m")
factor3 := int32(3)
tests := []struct {
name string
pod *core.Pod
vpa *vpa_types.VerticalPodAutoscaler
recommendResources []vpa_api_util.ContainerResources
recommendAnnotations vpa_api_util.ContainerToAnnotationsMap
recommendError error
maxAllowedCpu string
expectPatches []resource_admission.PatchRecord
expectError error
featureGateEnabled bool
}{
{
name: "startup boost factor",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor, nil, "10s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"100m\"},\"limits\":{}}"),
addResourceRequestPatch(0, cpu, "200m"),
addLimitsPatch(0),
addResourceLimitPatch(0, cpu, "200m"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request, cpu limit"),
},
},
{
name: "startup boost quantity",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.QuantityStartupBoostType, nil, &quantity, "10s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"100m\"},\"limits\":{}}"),
addResourceRequestPatch(0, cpu, "600m"),
addLimitsPatch(0),
addResourceLimitPatch(0, cpu, "600m"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request, cpu limit"),
},
},
{
name: "feature gate disabled",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor, nil, "10s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: false,
expectPatches: []resource_admission.PatchRecord{
addResourceRequestPatch(0, cpu, "100m"),
addAnnotationRequest([][]string{{cpu}}, "request"),
},
},
{
name: "invalid factor",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &invalidFactor, nil, "10s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectError: fmt.Errorf("boost factor must be >= 1"),
},
{
name: "quantity less than request",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("500m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.QuantityStartupBoostType, nil, &invalidQuantity, "10s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("500m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"500m\"},\"limits\":{}}"),
addResourceRequestPatch(0, cpu, "700m"),
addLimitsPatch(0),
addResourceLimitPatch(0, cpu, "700m"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request, cpu limit"),
},
},
{
name: "startup boost capped",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("1"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor3, nil, "1s").Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("1"),
},
},
},
maxAllowedCpu: "2",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"1\"},\"limits\":{}}"),
addResourceRequestPatch(0, cpu, "2"),
addLimitsPatch(0),
addResourceLimitPatch(0, cpu, "2"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request, cpu limit"),
},
},
{
name: "startup boost with scaling mode off",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor, nil, "10s").WithScalingMode("container1", vpa_types.ContainerScalingModeOff).Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("1"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{},
},
{
name: "startup boost no recommendation",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor, nil, "10s").Get(),
recommendResources: make([]vpa_api_util.ContainerResources, 1),
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"100m\"},\"limits\":{}}"),
addResourceRequestPatch(0, cpu, "200m"),
addLimitsPatch(0),
addResourceLimitPatch(0, cpu, "200m"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request, cpu limit"),
},
},
{
name: "startup boost with ControlledValues=RequestsOnly",
pod: &core.Pod{
Spec: core.PodSpec{
Containers: []core.Container{
{
Name: "container1",
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
Limits: core.ResourceList{
cpu: resource.MustParse("200m"),
},
},
},
},
},
},
vpa: test.VerticalPodAutoscaler().WithName("name").WithContainer("container1").WithCPUStartupBoost(vpa_types.FactorStartupBoostType, &factor, nil, "10s").WithControlledValues("container1", vpa_types.ContainerControlledValuesRequestsOnly).Get(),
recommendResources: []vpa_api_util.ContainerResources{
{
Requests: core.ResourceList{
cpu: resource.MustParse("100m"),
},
},
},
maxAllowedCpu: "",
featureGateEnabled: true,
expectPatches: []resource_admission.PatchRecord{
GetAddAnnotationPatch(annotations.StartupCPUBoostAnnotation, "{\"requests\":{\"cpu\":\"100m\"},\"limits\":{\"cpu\":\"200m\"}}"),
addResourceRequestPatch(0, cpu, "200m"),
GetAddAnnotationPatch(ResourceUpdatesAnnotation, "Pod resources updated by name: container 0: cpu request"),
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
featuregatetesting.SetFeatureGateDuringTest(t, features.MutableFeatureGate, features.CPUStartupBoost, tc.featureGateEnabled)
frp := fakeRecommendationProvider{tc.recommendResources, tc.recommendAnnotations, tc.recommendError}
c := NewResourceUpdatesCalculator(&frp, tc.maxAllowedCpu)
patches, err := c.CalculatePatches(tc.pod, tc.vpa)
if tc.expectError == nil {
assert.NoError(t, err)
} else {
if assert.Error(t, err) {
assert.Equal(t, tc.expectError.Error(), err.Error())
}
}
if assert.Len(t, patches, len(tc.expectPatches), fmt.Sprintf("got %+v, want %+v", patches, tc.expectPatches)) {
for i, gotPatch := range patches {
if !EqPatch(gotPatch, tc.expectPatches[i]) {
t.Errorf("Expected patch at position %d to be %+v, got %+v", i, tc.expectPatches[i], gotPatch)
}
}
}
})
}
}

View File

@ -69,7 +69,7 @@ func (m *matcher) GetMatchingVPA(ctx context.Context, pod *core.Pod) *vpa_types.
var controllingVpa *vpa_types.VerticalPodAutoscaler
for _, vpaConfig := range configs {
if vpa_api_util.GetUpdateMode(vpaConfig) == vpa_types.UpdateModeOff {
if vpa_api_util.GetUpdateMode(vpaConfig) == vpa_types.UpdateModeOff && vpaConfig.Spec.StartupBoost == nil {
continue
}
if vpaConfig.Spec.TargetRef == nil {

View File

@ -0,0 +1,71 @@
/*
Copyright 2025 The Kubernetes 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 annotations
import (
"encoding/json"
core "k8s.io/api/core/v1"
)
const (
// StartupCPUBoostAnnotation is the annotation set on a pod when a CPU boost is applied.
// The value of the annotation is the original resource specification of the container.
StartupCPUBoostAnnotation = "startup-cpu-boost"
)
// OriginalResources contains the original resources of a container.
type OriginalResources struct {
Requests core.ResourceList `json:"requests"`
Limits core.ResourceList `json:"limits"`
}
// GetOriginalResourcesAnnotationValue returns the annotation value for the original resources.
func GetOriginalResourcesAnnotationValue(container *core.Container) (string, error) {
original := OriginalResources{
Requests: core.ResourceList{},
Limits: core.ResourceList{},
}
if cpu, ok := container.Resources.Requests[core.ResourceCPU]; ok {
original.Requests[core.ResourceCPU] = cpu
}
if mem, ok := container.Resources.Requests[core.ResourceMemory]; ok {
original.Requests[core.ResourceMemory] = mem
}
if cpu, ok := container.Resources.Limits[core.ResourceCPU]; ok {
original.Limits[core.ResourceCPU] = cpu
}
if mem, ok := container.Resources.Limits[core.ResourceMemory]; ok {
original.Limits[core.ResourceMemory] = mem
}
b, err := json.Marshal(original)
return string(b), err
}
// GetOriginalResourcesFromAnnotation returns the original resources from the annotation.
func GetOriginalResourcesFromAnnotation(pod *core.Pod) (*OriginalResources, error) {
val, ok := pod.Annotations[StartupCPUBoostAnnotation]
if !ok {
return nil, nil
}
var original OriginalResources
err := json.Unmarshal([]byte(val), &original)
if err != nil {
return nil, err
}
return &original, nil
}

View File

@ -0,0 +1,185 @@
/*
Copyright 2025 The Kubernetes 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 annotations
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
core "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestGetOriginalResourcesAnnotationValue(t *testing.T) {
testCases := []struct {
name string
container *core.Container
expected *OriginalResources
expectErr bool
}{
{
name: "full resources",
container: &core.Container{
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: core.ResourceList{
core.ResourceCPU: resource.MustParse("2"),
core.ResourceMemory: resource.MustParse("2Gi"),
},
},
},
expected: &OriginalResources{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: core.ResourceList{
core.ResourceCPU: resource.MustParse("2"),
core.ResourceMemory: resource.MustParse("2Gi"),
},
},
expectErr: false,
},
{
name: "only requests",
container: &core.Container{
Resources: core.ResourceRequirements{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
},
},
expected: &OriginalResources{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: core.ResourceList{},
},
expectErr: false,
},
{
name: "no resources",
container: &core.Container{
Resources: core.ResourceRequirements{},
},
expected: &OriginalResources{
Requests: core.ResourceList{},
Limits: core.ResourceList{},
},
expectErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
val, err := GetOriginalResourcesAnnotationValue(tc.container)
if tc.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
var got OriginalResources
err = json.Unmarshal([]byte(val), &got)
assert.NoError(t, err)
assert.True(t, tc.expected.Requests.Cpu().Equal(*got.Requests.Cpu()), "CPU requests do not match")
assert.True(t, tc.expected.Requests.Memory().Equal(*got.Requests.Memory()), "Memory requests do not match")
assert.True(t, tc.expected.Limits.Cpu().Equal(*got.Limits.Cpu()), "CPU limits do not match")
assert.True(t, tc.expected.Limits.Memory().Equal(*got.Limits.Memory()), "Memory limits do not match")
})
}
}
func TestGetOriginalResourcesFromAnnotation(t *testing.T) {
testCases := []struct {
name string
pod *core.Pod
expected *OriginalResources
expectErr bool
}{
{
name: "valid annotation",
pod: &core.Pod{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
StartupCPUBoostAnnotation: `{"requests":{"cpu":"1","memory":"1Gi"},"limits":{"cpu":"2","memory":"2Gi"}}`,
},
},
},
expected: &OriginalResources{
Requests: core.ResourceList{
core.ResourceCPU: resource.MustParse("1"),
core.ResourceMemory: resource.MustParse("1Gi"),
},
Limits: core.ResourceList{
core.ResourceCPU: resource.MustParse("2"),
core.ResourceMemory: resource.MustParse("2Gi"),
},
},
expectErr: false,
},
{
name: "no annotation",
pod: &core.Pod{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{},
},
},
expected: nil,
expectErr: false,
},
{
name: "invalid json",
pod: &core.Pod{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
StartupCPUBoostAnnotation: "invalid-json",
},
},
},
expected: nil,
expectErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := GetOriginalResourcesFromAnnotation(tc.pod)
if tc.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
if tc.expected == nil {
assert.Nil(t, got)
} else {
assert.NotNil(t, got)
assert.True(t, tc.expected.Requests.Cpu().Equal(*got.Requests.Cpu()), "CPU requests do not match")
assert.True(t, tc.expected.Requests.Memory().Equal(*got.Requests.Memory()), "Memory requests do not match")
assert.True(t, tc.expected.Limits.Cpu().Equal(*got.Limits.Cpu()), "CPU limits do not match")
assert.True(t, tc.expected.Limits.Memory().Equal(*got.Limits.Memory()), "Memory limits do not match")
}
})
}
}

View File

@ -21,6 +21,7 @@ import (
autoscaling "k8s.io/api/autoscaling/v1"
core "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1"
@ -47,6 +48,7 @@ type VerticalPodAutoscalerBuilder interface {
WithGroupVersion(gv meta.GroupVersion) VerticalPodAutoscalerBuilder
WithEvictionRequirements([]*vpa_types.EvictionRequirement) VerticalPodAutoscalerBuilder
WithMinReplicas(minReplicas *int32) VerticalPodAutoscalerBuilder
WithCPUStartupBoost(boostType vpa_types.StartupBoostType, factor *int32, quantity *resource.Quantity, duration string) VerticalPodAutoscalerBuilder
AppendCondition(conditionType vpa_types.VerticalPodAutoscalerConditionType,
status core.ConditionStatus, reason, message string, lastTransitionTime time.Time) VerticalPodAutoscalerBuilder
AppendRecommendation(vpa_types.RecommendedContainerResources) VerticalPodAutoscalerBuilder
@ -81,6 +83,7 @@ type verticalPodAutoscalerBuilder struct {
maxAllowed map[string]core.ResourceList
controlledValues map[string]*vpa_types.ContainerControlledValues
scalingMode map[string]*vpa_types.ContainerScalingMode
startupBoost *vpa_types.StartupBoost
recommendation RecommendationBuilder
conditions []vpa_types.VerticalPodAutoscalerCondition
annotations map[string]string
@ -232,6 +235,24 @@ func (b *verticalPodAutoscalerBuilder) AppendRecommendation(recommendation vpa_t
return &c
}
func (b *verticalPodAutoscalerBuilder) WithCPUStartupBoost(boostType vpa_types.StartupBoostType, factor *int32, quantity *resource.Quantity, duration string) VerticalPodAutoscalerBuilder {
c := *b
parsedDuration, _ := time.ParseDuration(duration)
cpuStartupBoost := &vpa_types.GenericStartupBoost{
Type: boostType,
Duration: &meta.Duration{Duration: parsedDuration},
}
if factor != nil {
cpuStartupBoost.Factor = factor
} else {
cpuStartupBoost.Quantity = quantity
}
c.startupBoost = &vpa_types.StartupBoost{
CPU: cpuStartupBoost,
}
return &c
}
func (b *verticalPodAutoscalerBuilder) Get() *vpa_types.VerticalPodAutoscaler {
if len(b.containerNames) == 0 {
panic("Must call WithContainer() before Get()")
@ -280,6 +301,7 @@ func (b *verticalPodAutoscalerBuilder) Get() *vpa_types.VerticalPodAutoscaler {
ResourcePolicy: &resourcePolicy,
TargetRef: b.targetRef,
Recommenders: recommenders,
StartupBoost: b.startupBoost,
},
Status: vpa_types.VerticalPodAutoscalerStatus{
Recommendation: recommendation,

View File

@ -136,7 +136,7 @@ func getCappedRecommendationForContainer(
}
// TODO: If limits and policy are conflicting, set some condition on the VPA.
if containerControlledValues == vpa_types.ContainerControlledValuesRequestsOnly {
annotations = capRecommendationToContainerLimit(recommendation, containerLimits)
annotations = CapRecommendationToContainerLimit(recommendation, containerLimits)
if genAnnotations {
cappingAnnotations = append(cappingAnnotations, annotations...)
}
@ -150,9 +150,9 @@ func getCappedRecommendationForContainer(
return cappedRecommendations, cappingAnnotations, nil
}
// capRecommendationToContainerLimit makes sure recommendation is not above current limit for the container.
// CapRecommendationToContainerLimit makes sure recommendation is not above current limit for the container.
// If this function makes adjustments appropriate annotations are returned.
func capRecommendationToContainerLimit(recommendation apiv1.ResourceList, containerLimits apiv1.ResourceList) []string {
func CapRecommendationToContainerLimit(recommendation apiv1.ResourceList, containerLimits apiv1.ResourceList) []string {
annotations := make([]string, 0)
// Iterate over limits set in the container. Unset means Infinite limit.
for resourceName, limit := range containerLimits {