diff --git a/vertical-pod-autoscaler/deploy/vpa-v1-crd-gen.yaml b/vertical-pod-autoscaler/deploy/vpa-v1-crd-gen.yaml index 70adb552bb39..05f974e2bd6a 100644 --- a/vertical-pod-autoscaler/deploy/vpa-v1-crd-gen.yaml +++ b/vertical-pod-autoscaler/deploy/vpa-v1-crd-gen.yaml @@ -354,6 +354,18 @@ spec: Specifies the maximum amount of resources that will be recommended for the container. The default is no maximum. type: object + memoryPerCPU: + anyOf: + - type: integer + - type: string + description: |- + Enforce a fixed memory-per-CPU ratio for this container’s recommendations. + If set, the recommender will adjust memory or CPU so that: + memory_bytes = cpu_cores * memoryPerCPU (bytes per 1 core). + Applied to Target, LowerBound, UpperBound, and UncappedTarget. + Example: "4Gi" means 1 CPU -> 4 GiB. + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true minAllowed: additionalProperties: anyOf: diff --git a/vertical-pod-autoscaler/docs/api.md b/vertical-pod-autoscaler/docs/api.md index f7e03b0611c0..b1b4846df308 100644 --- a/vertical-pod-autoscaler/docs/api.md +++ b/vertical-pod-autoscaler/docs/api.md @@ -48,6 +48,7 @@ _Appears in:_ | `maxAllowed` _[ResourceList](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#resourcelist-v1-core)_ | Specifies the maximum amount of resources that will be recommended
for the container. The default is no maximum. | | | | `controlledResources` _[ResourceName](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#resourcename-v1-core)_ | Specifies the type of recommendations that will be computed
(and possibly applied) by VPA.
If not specified, the default of [ResourceCPU, ResourceMemory] will be used. | | | | `controlledValues` _[ContainerControlledValues](#containercontrolledvalues)_ | Specifies which resource values should be controlled.
The default is "RequestsAndLimits". | | Enum: [RequestsAndLimits RequestsOnly]
| +| `memoryPerCPU` _[Quantity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#quantity-resource-api)_ | Enforce a fixed memory-per-CPU ratio for this container’s recommendations.
If set, the recommender will adjust memory or CPU so that:
memory_bytes = cpu_cores * memoryPerCPU (bytes per 1 core).
Applied to Target, LowerBound, UpperBound, and UncappedTarget.
Example: "4Gi" means 1 CPU -> 4 GiB. | | | #### ContainerScalingMode diff --git a/vertical-pod-autoscaler/docs/features.md b/vertical-pod-autoscaler/docs/features.md index 9b3c292d1bec..772c4a7e676b 100644 --- a/vertical-pod-autoscaler/docs/features.md +++ b/vertical-pod-autoscaler/docs/features.md @@ -7,6 +7,7 @@ - [CPU Recommendation Rounding](#cpu-recommendation-rounding) - [Memory Recommendation Rounding](#memory-recommendation-rounding) - [In-Place Updates](#in-place-updates-inplaceorrecreate) +- [MemoryPerCPU](#memorypercpu-memorypercpuratio) ## Limits control @@ -153,3 +154,52 @@ VPA provides metrics to track in-place update operations: * `vpa_vpas_with_in_place_updatable_pods_total`: Number of VPAs with pods eligible for in-place updates * `vpa_vpas_with_in_place_updated_pods_total`: Number of VPAs with successfully in-place updated pods * `vpa_updater_failed_in_place_update_attempts_total`: Number of failed attempts to update pods in-place. + +## MemoryPerCPU (`MemoryPerCPURatio`) + +> [!WARNING] +> FEATURE STATE: VPA v1.5.0 [alpha] + +VPA can enforce a fixed memory-per-CPU ratio in its recommendations. +When enabled, the recommender adjusts CPU or memory so that: +``` +memory_bytes = cpu_cores * memoryPerCPU +``` + +This applies to Target, LowerBound, UpperBound, and UncappedTarget recommendations. + +### Usage + +Enable the feature on the recommender with: +```bash +--feature-gates=MemoryPerCPURatio=true +``` + +Then configure the ratio in your VPA object using the memoryPerCPU field, for example: +```yaml +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: my-app +spec: + resourcePolicy: + containerPolicies: + - containerName: app + minAllowed: + cpu: 1 + memory: 4Gi + maxAllowed: + cpu: 4 + memory: 16Gi + controlledResources: ["cpu", "memory"] + controlledValues: RequestsAndLimits + memoryPerCPU: "4Gi" +``` + +### Behavior + +* If both CPU and Memory are controlled, VPA enforces the ratio. + +### Limitations + +* If `minAllowed` or `maxAllowed` constraints conflict with the ratio, the constraints take precedence and the ratio may not be respected. diff --git a/vertical-pod-autoscaler/docs/flags.md b/vertical-pod-autoscaler/docs/flags.md index feb3dc32e371..590be65f8985 100644 --- a/vertical-pod-autoscaler/docs/flags.md +++ b/vertical-pod-autoscaler/docs/flags.md @@ -14,7 +14,7 @@ This document is auto-generated from the flag definitions in the VPA admission-c | `address` | string | ":8944" | The address to expose Prometheus metrics. | | `alsologtostderr` | | | log to standard error as well as files (no effect when -logtostderr=true) | | `client-ca-file` | string | "/etc/tls-certs/caCert.pem" | Path to CA PEM file. | -| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true) | +| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true)
MemoryPerCPURatio=true\|false (ALPHA - default=false) | | `ignored-vpa-object-namespaces` | string | | A comma-separated list of namespaces to ignore when searching for VPA objects. Leave empty to avoid ignoring any namespaces. These namespaces will not be cleaned by the garbage collector. | | `kube-api-burst` | float | 100 | QPS burst limit when making requests to Kubernetes apiserver | | `kube-api-qps` | float | 50 | QPS limit when making requests to Kubernetes apiserver | @@ -68,7 +68,7 @@ This document is auto-generated from the flag definitions in the VPA recommender | `cpu-integer-post-processor-enabled` | | | Enable the cpu-integer recommendation post processor. The post processor will round up CPU recommendations to a whole CPU for pods which were opted in by setting an appropriate label on VPA object (experimental) | | `external-metrics-cpu-metric` | string | | ALPHA. Metric to use with external metrics provider for CPU usage. | | `external-metrics-memory-metric` | string | | ALPHA. Metric to use with external metrics provider for memory usage. | -| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true) | +| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true)
MemoryPerCPURatio=true\|false (ALPHA - default=false) | | `history-length` | string | "8d" | How much time back prometheus have to be queried to get historical metrics | | `history-resolution` | string | "1h" | Resolution at which Prometheus is queried for historical metrics | | `humanize-memory` | | | DEPRECATED: Convert memory values in recommendations to the highest appropriate SI unit with up to 2 decimal places for better readability. This flag is deprecated and will be removed in a future version. Use --round-memory-bytes instead. | @@ -144,7 +144,7 @@ This document is auto-generated from the flag definitions in the VPA updater cod | `eviction-rate-burst` | int | 1 | Burst of pods that can be evicted. | | `eviction-rate-limit` | float | | Number of pods that can be evicted per seconds. A rate limit set to 0 or -1 will disable
the rate limiter. (default -1) | | `eviction-tolerance` | float | 0.5 | Fraction of replica count that can be evicted for update, if more than one pod can be evicted. | -| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true) | +| `feature-gates` | mapStringBool | | A set of key=value pairs that describe feature gates for alpha/experimental features. Options are:
AllAlpha=true\|false (ALPHA - default=false)
AllBeta=true\|false (BETA - default=false)
InPlaceOrRecreate=true\|false (BETA - default=true)
MemoryPerCPURatio=true\|false (ALPHA - default=false) | | `ignored-vpa-object-namespaces` | string | | A comma-separated list of namespaces to ignore when searching for VPA objects. Leave empty to avoid ignoring any namespaces. These namespaces will not be cleaned by the garbage collector. | | `in-recommendation-bounds-eviction-lifetime-threshold` | | 12h0m0s | duration Pods that live for at least that long can be evicted even if their request is within the [MinRecommended...MaxRecommended] range | | `kube-api-burst` | float | 100 | QPS burst limit when making requests to Kubernetes apiserver | diff --git a/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1/types.go b/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1/types.go index 6ae164ce4cac..fe707847450d 100644 --- a/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1/types.go +++ b/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1/types.go @@ -19,6 +19,7 @@ package v1 import ( autoscaling "k8s.io/api/autoscaling/v1" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -221,6 +222,14 @@ type ContainerResourcePolicy struct { // The default is "RequestsAndLimits". // +optional ControlledValues *ContainerControlledValues `json:"controlledValues,omitempty" protobuf:"bytes,6,rep,name=controlledValues"` + + // Enforce a fixed memory-per-CPU ratio for this container’s recommendations. + // If set, the recommender will adjust memory or CPU so that: + // memory_bytes = cpu_cores * memoryPerCPU (bytes per 1 core). + // Applied to Target, LowerBound, UpperBound, and UncappedTarget. + // Example: "4Gi" means 1 CPU -> 4 GiB. + // +optional + MemoryPerCPU *resource.Quantity `json:"memoryPerCPU,omitempty"` } const ( diff --git a/vertical-pod-autoscaler/pkg/features/features.go b/vertical-pod-autoscaler/pkg/features/features.go index 2c34ac400178..af552069a98f 100644 --- a/vertical-pod-autoscaler/pkg/features/features.go +++ b/vertical-pod-autoscaler/pkg/features/features.go @@ -40,6 +40,12 @@ const ( // In each feature gate description, you must specify "components". // The feature must be enabled by the --feature-gates argument on each listed component. + // alpha: v1.5.0 + // components: recommender + + // MemoryPerCPURatio enables enforcing a fixed memory-per-CPU ratio in recommendations. + MemoryPerCPURatio featuregate.Feature = "MemoryPerCPURatio" + // alpha: v1.4.0 // beta: v1.5.0 diff --git a/vertical-pod-autoscaler/pkg/features/versioned_features.go b/vertical-pod-autoscaler/pkg/features/versioned_features.go index e623061fffd9..2e25312e76a6 100644 --- a/vertical-pod-autoscaler/pkg/features/versioned_features.go +++ b/vertical-pod-autoscaler/pkg/features/versioned_features.go @@ -27,6 +27,9 @@ import ( // Entries are alphabetized. var defaultVersionedFeatureGates = map[featuregate.Feature]featuregate.VersionedSpecs{ + MemoryPerCPURatio: { + {Version: version.MustParse("1.5"), Default: false, PreRelease: featuregate.Alpha}, + }, InPlaceOrRecreate: { {Version: version.MustParse("1.4"), Default: false, PreRelease: featuregate.Alpha}, {Version: version.MustParse("1.5"), Default: true, PreRelease: featuregate.Beta}, diff --git a/vertical-pod-autoscaler/pkg/recommender/main.go b/vertical-pod-autoscaler/pkg/recommender/main.go index 4f0f2f997eaf..5b830f7c30a8 100644 --- a/vertical-pod-autoscaler/pkg/recommender/main.go +++ b/vertical-pod-autoscaler/pkg/recommender/main.go @@ -268,6 +268,10 @@ func run(ctx context.Context, healthCheck *metrics.HealthCheck, commonFlag *comm postProcessors = append(postProcessors, &routines.IntegerCPUPostProcessor{}) } + if features.Enabled(features.MemoryPerCPURatio) { + postProcessors = append(postProcessors, &routines.MemoryPerCPUPostProcessor{}) + } + globalMaxAllowed := initGlobalMaxAllowed() // CappingPostProcessor, should always come in the last position for post-processing postProcessors = append(postProcessors, routines.NewCappingRecommendationProcessor(globalMaxAllowed)) diff --git a/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor.go b/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor.go new file mode 100644 index 000000000000..7bfccc56b631 --- /dev/null +++ b/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor.go @@ -0,0 +1,103 @@ +/* +Copyright 2022 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 routines + +import ( + apiv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1" + vpa_utils "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/vpa" +) + +// MemoryPerCPUPostProcessor enforces a fixed memory-per-CPU ratio for each container's recommendation. +// The ratio is defined in the container's policy as MemoryPerCPU (bytes per 1 CPU core). +// Applied to Target, LowerBound, UpperBound, and UncappedTarget. +type MemoryPerCPUPostProcessor struct{} + +var _ RecommendationPostProcessor = &MemoryPerCPUPostProcessor{} + +// Process applies the memory-per-CPU enforcement to the recommendation if specified in the container policy. +func (p *MemoryPerCPUPostProcessor) Process( + vpa *vpa_types.VerticalPodAutoscaler, + recommendation *vpa_types.RecommendedPodResources, +) *vpa_types.RecommendedPodResources { + if vpa == nil || vpa.Spec.ResourcePolicy == nil || recommendation == nil { + return recommendation + } + + amendedRecommendation := recommendation.DeepCopy() + + for _, r := range amendedRecommendation.ContainerRecommendations { + pol := vpa_utils.GetContainerResourcePolicy(r.ContainerName, vpa.Spec.ResourcePolicy) + if pol != nil && pol.MemoryPerCPU != nil { + memPerCPUBytes := pol.MemoryPerCPU.Value() + r.Target = enforceMemoryPerCPU(r.Target, memPerCPUBytes) + r.LowerBound = enforceMemoryPerCPU(r.LowerBound, memPerCPUBytes) + r.UpperBound = enforceMemoryPerCPU(r.UpperBound, memPerCPUBytes) + r.UncappedTarget = enforceMemoryPerCPU(r.UncappedTarget, memPerCPUBytes) + } + } + + return amendedRecommendation +} + +// enforceMemoryPerCPU adjusts CPU or Memory to satisfy: +// +// memory_bytes = cpu_cores * memPerCPUBytes +// +// If memory is too low for the given CPU, increase memory. +// If memory is too high for the given CPU, increase CPU. +// enforceMemoryPerCPU adjusts CPU or Memory to satisfy: +// +// memory_bytes = cpu_cores * memPerCPUBytes +// +// If memory is too low for the given CPU, increase memory. +// If memory is too high for the given CPU, increase CPU. +func enforceMemoryPerCPU(resources apiv1.ResourceList, bytesPerCore int64) apiv1.ResourceList { + if bytesPerCore <= 0 { + return resources + } + + cpuQty, hasCPU := resources[apiv1.ResourceCPU] + memQty, hasMem := resources[apiv1.ResourceMemory] + if !hasCPU || !hasMem || cpuQty.IsZero() || memQty.IsZero() { + return resources + } + + // cpuCores = milliCPU / 1000 + cpuMilli := cpuQty.MilliValue() + memBytes := memQty.Value() + + // Desired memory in bytes = CPU cores * bytes per core + desiredMem := divCeil(cpuMilli*bytesPerCore, 1000) + + if memBytes < desiredMem { + // Not enough RAM → increase memory + resources[apiv1.ResourceMemory] = *resource.NewQuantity(desiredMem, resource.BinarySI) + } else if memBytes > desiredMem { + // Too much RAM → increase CPU + desiredMilli := divCeil(memBytes*1000, bytesPerCore) + resources[apiv1.ResourceCPU] = *resource.NewMilliQuantity(desiredMilli, resource.DecimalSI) + } + + return resources +} + +func divCeil(a, b int64) int64 { + return (a + b - 1) / b +} diff --git a/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor_test.go b/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor_test.go new file mode 100644 index 000000000000..f768423b622d --- /dev/null +++ b/vertical-pod-autoscaler/pkg/recommender/routines/memory_per_cpu_post_processor_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2022 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 routines + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + vpa_types "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/apis/autoscaling.k8s.io/v1" + "k8s.io/autoscaler/vertical-pod-autoscaler/pkg/utils/test" +) + +func TestMemoryPerCPUPostProcessor_Process(t *testing.T) { + const Gi = int64(1024 * 1024 * 1024) + + tests := []struct { + name string + vpa *vpa_types.VerticalPodAutoscaler + recommendation *vpa_types.RecommendedPodResources + want *vpa_types.RecommendedPodResources + }{ + { + name: "No policy defined - no change", + vpa: &vpa_types.VerticalPodAutoscaler{}, + recommendation: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("1", "4Gi").GetContainerResources(), + }, + }, + want: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("1", "4Gi").GetContainerResources(), + }, + }, + }, + { + name: "Policy matches - too much RAM -> increase CPU", + vpa: &vpa_types.VerticalPodAutoscaler{ + Spec: vpa_types.VerticalPodAutoscalerSpec{ + ResourcePolicy: &vpa_types.PodResourcePolicy{ + ContainerPolicies: []vpa_types.ContainerResourcePolicy{ + { + ContainerName: "c1", + MemoryPerCPU: resource.NewQuantity(4*Gi, resource.BinarySI), // 1 core -> 4Gi + }, + }, + }, + }, + }, + recommendation: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("1", "8Gi").GetContainerResources(), + }, + }, + want: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("2", "8Gi").GetContainerResources(), + }, + }, + }, + { + name: "Policy matches - not enough RAM -> increase Memory", + vpa: &vpa_types.VerticalPodAutoscaler{ + Spec: vpa_types.VerticalPodAutoscalerSpec{ + ResourcePolicy: &vpa_types.PodResourcePolicy{ + ContainerPolicies: []vpa_types.ContainerResourcePolicy{ + { + ContainerName: "c1", + MemoryPerCPU: resource.NewQuantity(4*Gi, resource.BinarySI), + }, + }, + }, + }, + }, + recommendation: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("4", "8Gi").GetContainerResources(), + }, + }, + want: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + test.Recommendation().WithContainer("c1").WithTarget("4", "16Gi").GetContainerResources(), + }, + }, + }, + { + name: "Missing CPU or Memory - no change", + vpa: &vpa_types.VerticalPodAutoscaler{ + Spec: vpa_types.VerticalPodAutoscalerSpec{ + ResourcePolicy: &vpa_types.PodResourcePolicy{ + ContainerPolicies: []vpa_types.ContainerResourcePolicy{ + { + ContainerName: "c1", + MemoryPerCPU: resource.NewQuantity(4*Gi, resource.BinarySI), + }, + }, + }, + }, + }, + recommendation: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + { + ContainerName: "c1", + Target: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + }, + }, + }, + }, + want: &vpa_types.RecommendedPodResources{ + ContainerRecommendations: []vpa_types.RecommendedContainerResources{ + { + ContainerName: "c1", + Target: v1.ResourceList{ + v1.ResourceCPU: *resource.NewMilliQuantity(1000, resource.DecimalSI), + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := MemoryPerCPUPostProcessor{} + got := c.Process(tt.vpa, tt.recommendation) + assert.True(t, equalRecommendedPodResources(tt.want, got), "Process(%v, %v)", tt.vpa, tt.recommendation) + }) + } +}