Skip to content

Commit 35976b2

Browse files
authored
Implement scale-down prevention based on resource quotas (#9598)
* Implement scale-down protection based on min limits * Fix linting
1 parent ceed193 commit 35976b2

11 files changed

Lines changed: 321 additions & 67 deletions

File tree

cluster-autoscaler/core/scaledown/planner/planner.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ import (
2828
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/eligibility"
2929
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/nodeevaltracker"
3030
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/pdb"
31-
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/resource"
3231
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/unneeded"
3332
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/unremovable"
3433
"k8s.io/autoscaler/cluster-autoscaler/processors"
3534
"k8s.io/autoscaler/cluster-autoscaler/processors/nodes"
35+
"k8s.io/autoscaler/cluster-autoscaler/resourcequotas"
3636
"k8s.io/autoscaler/cluster-autoscaler/simulator"
3737
"k8s.io/autoscaler/cluster-autoscaler/simulator/clustersnapshot"
3838
"k8s.io/autoscaler/cluster-autoscaler/simulator/drainability/rules"
@@ -73,22 +73,21 @@ type Planner struct {
7373
minUpdateInterval time.Duration
7474
eligibilityChecker eligibilityChecker
7575
nodeUtilizationMap map[string]utilization.Info
76-
resourceLimitsFinder *resource.LimitsFinder
7776
cc controllerReplicasCalculator
77+
quotasTrackerFactory *resourcequotas.TrackerFactory
7878
scaleDownSetProcessor nodes.ScaleDownSetProcessor
7979
scaleDownContext *nodes.ScaleDownContext
8080
maxNodeSkipEvalTime *nodeevaltracker.MaxNodeSkipEvalTime
8181
}
8282

8383
// New creates a new Planner object.
84-
func New(autoscalingCtx *ca_context.AutoscalingContext, processors *processors.AutoscalingProcessors, deleteOptions options.NodeDeleteOptions, drainabilityRules rules.Rules) *Planner {
85-
resourceLimitsFinder := resource.NewLimitsFinder(processors.CustomResourcesProcessor)
84+
func New(autoscalingCtx *ca_context.AutoscalingContext, processors *processors.AutoscalingProcessors, deleteOptions options.NodeDeleteOptions, drainabilityRules rules.Rules, quotasTrackerFactory *resourcequotas.TrackerFactory) *Planner {
8685
minUpdateInterval := autoscalingCtx.AutoscalingOptions.NodeGroupDefaults.ScaleDownUnneededTime
8786
if minUpdateInterval == 0*time.Nanosecond {
8887
minUpdateInterval = 1 * time.Nanosecond
8988
}
9089

91-
unneededNodes := unneeded.NewNodes(processors.NodeGroupConfigProcessor, resourceLimitsFinder)
90+
unneededNodes := unneeded.NewNodes(processors.NodeGroupConfigProcessor)
9291
if autoscalingCtx.AutoscalingOptions.NodeDeletionCandidateTTL != 0 {
9392
unneededNodes.LoadFromExistingTaints(autoscalingCtx, time.Now())
9493
}
@@ -106,8 +105,8 @@ func New(autoscalingCtx *ca_context.AutoscalingContext, processors *processors.A
106105
actuationInjector: scheduling.NewHintingSimulator(),
107106
eligibilityChecker: eligibility.NewChecker(processors.NodeGroupConfigProcessor),
108107
nodeUtilizationMap: make(map[string]utilization.Info),
109-
resourceLimitsFinder: resourceLimitsFinder,
110108
cc: newControllerReplicasCalculator(autoscalingCtx.ListerRegistry),
109+
quotasTrackerFactory: quotasTrackerFactory,
111110
scaleDownSetProcessor: processors.ScaleDownSetProcessor,
112111
scaleDownContext: nodes.NewDefaultScaleDownContext(),
113112
minUpdateInterval: minUpdateInterval,
@@ -151,18 +150,20 @@ func (p *Planner) CleanUpUnneededNodes() {
151150
// to the Planner.
152151
func (p *Planner) NodesToDelete(_ time.Time) (empty, needDrain []*apiv1.Node) {
153152
empty, needDrain = []*apiv1.Node{}, []*apiv1.Node{}
153+
154154
nodes, err := allNodes(p.autoscalingCtx.ClusterSnapshot)
155155
if err != nil {
156-
klog.Errorf("Nothing will scale down, failed to list nodes from ClusterSnapshot: %v", err)
156+
klog.Errorf("Failed to list nodes for final limit check: %v", err)
157157
return nil, nil
158158
}
159-
resourceLimiter, err := p.autoscalingCtx.CloudProvider.GetResourceLimiter()
159+
160+
tracker, err := p.quotasTrackerFactory.NewMinQuotasTracker(p.autoscalingCtx, nodes)
160161
if err != nil {
161-
klog.Errorf("Nothing will scale down, failed to create resource limiter: %v", err)
162+
klog.Errorf("Failed to create tracker for final limit check: %v", err)
162163
return nil, nil
163164
}
164-
p.scaleDownContext.ResourcesLeft = p.resourceLimitsFinder.LimitsLeft(p.autoscalingCtx, nodes, resourceLimiter, p.latestUpdate).DeepCopy()
165-
p.scaleDownContext.ResourcesWithLimits = resourceLimiter.GetResources()
165+
p.scaleDownContext.Tracker = tracker
166+
166167
emptyRemovableNodes, needDrainRemovableNodes, unremovableNodes := p.unneededNodes.RemovableAt(p.autoscalingCtx, *p.scaleDownContext, p.latestUpdate)
167168
p.addUnremovableNodes(unremovableNodes)
168169

@@ -307,6 +308,7 @@ func (p *Planner) categorizeNodes(podDestinations map[string]bool, scaleDownCand
307308
klog.V(4).Infof("%d out of %d nodes skipped in scale down simulation: there are already %d unneeded nodes so no point in looking for more. Total atomic scale down nodes: %d", len(currentlyUnneededNodeNames)-i, len(currentlyUnneededNodeNames), len(removableList), atomicScaleDownNodesCount)
308309
break
309310
}
311+
310312
removable, unremovable := p.rs.SimulateNodeRemoval(node, podDestinations, p.latestUpdate, p.autoscalingCtx.RemainingPdbTracker)
311313
if removable != nil {
312314
_, inParallel, _ := p.autoscalingCtx.RemainingPdbTracker.CanRemovePods(removable.PodsToReschedule)

cluster-autoscaler/core/scaledown/planner/planner_test.go

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package planner
1818

1919
import (
2020
"fmt"
21+
"k8s.io/autoscaler/cluster-autoscaler/resourcequotas"
22+
2123
"testing"
2224
"time"
2325

@@ -505,7 +507,8 @@ func TestUpdateClusterState(t *testing.T) {
505507
assert.NoError(t, err)
506508
clustersnapshot.InitializeClusterSnapshotOrDie(t, autoscalingCtx.ClusterSnapshot, tc.nodes, tc.pods)
507509
deleteOptions := options.NodeDeleteOptions{}
508-
p := New(&autoscalingCtx, processors, deleteOptions, nil)
510+
factory := resourcequotas.NewTrackerFactory(resourcequotas.TrackerOptions{CustomResourcesProcessor: processors.CustomResourcesProcessor, QuotaProvider: resourcequotas.NewCloudMinProvider(provider)})
511+
p := New(&autoscalingCtx, processors, deleteOptions, nil, factory)
509512
p.eligibilityChecker = &fakeEligibilityChecker{eligible: asMap(tc.eligible)}
510513
if tc.isSimulationTimeout {
511514
autoscalingCtx.AutoscalingOptions.ScaleDownSimulationTimeout = 1 * time.Second
@@ -703,7 +706,8 @@ func TestUpdateClusterStatUnneededNodesLimit(t *testing.T) {
703706
assert.NoError(t, err)
704707
clustersnapshot.InitializeClusterSnapshotOrDie(t, autoscalingCtx.ClusterSnapshot, nodes, nil)
705708
deleteOptions := options.NodeDeleteOptions{}
706-
p := New(&autoscalingCtx, processors, deleteOptions, nil)
709+
factory := resourcequotas.NewTrackerFactory(resourcequotas.TrackerOptions{CustomResourcesProcessor: processors.CustomResourcesProcessor, QuotaProvider: resourcequotas.NewCloudMinProvider(provider)})
710+
p := New(&autoscalingCtx, processors, deleteOptions, nil, factory)
707711
p.eligibilityChecker = &fakeEligibilityChecker{eligible: asMap(nodeNames(nodes))}
708712
p.minUpdateInterval = tc.updateInterval
709713
p.unneededNodes.Update(&autoscalingCtx, previouslyUnneeded, time.Now())
@@ -838,7 +842,8 @@ func TestNewPlannerWithExistingDeletionCandidateNodes(t *testing.T) {
838842
assert.NoError(t, err)
839843

840844
deleteOptions := options.NodeDeleteOptions{}
841-
p := New(&autoscalingCtx, processors, deleteOptions, nil)
845+
factory := resourcequotas.NewTrackerFactory(resourcequotas.TrackerOptions{CustomResourcesProcessor: processors.CustomResourcesProcessor, QuotaProvider: resourcequotas.NewCloudMinProvider(provider)})
846+
p := New(&autoscalingCtx, processors, deleteOptions, nil, factory)
842847

843848
p.unneededNodes.AsList()
844849
})
@@ -847,11 +852,13 @@ func TestNewPlannerWithExistingDeletionCandidateNodes(t *testing.T) {
847852

848853
func TestNodesToDelete(t *testing.T) {
849854
testCases := []struct {
850-
name string
851-
nodes map[string][]*apiv1.Node
852-
removableNodes map[cloudprovider.NodeGroup][]simulator.NodeToBeRemoved
853-
wantEmpty []*apiv1.Node
854-
wantDrain []*apiv1.Node
855+
name string
856+
nodes map[string][]*apiv1.Node
857+
removableNodes map[cloudprovider.NodeGroup][]simulator.NodeToBeRemoved
858+
wantEmpty []*apiv1.Node
859+
wantDrain []*apiv1.Node
860+
minLimits map[string]int64
861+
wantUnremovable map[string]simulator.UnremovableReason
855862
}{
856863
{
857864
name: "empty",
@@ -1016,6 +1023,56 @@ func TestNodesToDelete(t *testing.T) {
10161023
buildRemovableNode("atomic-partial-ng-partially-registered-1", 0).Node,
10171024
},
10181025
},
1026+
{
1027+
name: "min limits race condition protection",
1028+
nodes: map[string][]*apiv1.Node{
1029+
"ng1": {
1030+
BuildTestNode("node-3", 1000, 10),
1031+
},
1032+
},
1033+
removableNodes: map[cloudprovider.NodeGroup][]simulator.NodeToBeRemoved{
1034+
sizedNodeGroup("ng1", 3, false): {
1035+
buildRemovableNode("node-1", 0),
1036+
buildRemovableNode("node-2", 1),
1037+
},
1038+
},
1039+
wantEmpty: []*apiv1.Node{
1040+
buildRemovableNode("node-1", 0).Node,
1041+
},
1042+
wantDrain: []*apiv1.Node{},
1043+
minLimits: map[string]int64{
1044+
cloudprovider.ResourceNameCores: 2,
1045+
cloudprovider.ResourceNameMemory: 20,
1046+
"nodes": 2,
1047+
},
1048+
wantUnremovable: map[string]simulator.UnremovableReason{
1049+
"node-2": simulator.MinimalResourceLimitExceeded,
1050+
},
1051+
},
1052+
{
1053+
name: "two nodes removable, quota allows both",
1054+
nodes: map[string][]*apiv1.Node{
1055+
"ng1": {
1056+
BuildTestNode("node-3", 1000, 10),
1057+
},
1058+
},
1059+
removableNodes: map[cloudprovider.NodeGroup][]simulator.NodeToBeRemoved{
1060+
sizedNodeGroup("ng1", 3, false): {
1061+
buildRemovableNode("node-1", 0),
1062+
buildRemovableNode("node-2", 0),
1063+
},
1064+
},
1065+
wantEmpty: []*apiv1.Node{
1066+
buildRemovableNode("node-1", 0).Node,
1067+
buildRemovableNode("node-2", 0).Node,
1068+
},
1069+
wantDrain: []*apiv1.Node{},
1070+
minLimits: map[string]int64{
1071+
cloudprovider.ResourceNameCores: 1,
1072+
cloudprovider.ResourceNameMemory: 10,
1073+
"nodes": 1,
1074+
},
1075+
},
10191076
}
10201077
for _, tc := range testCases {
10211078
tc := tc
@@ -1044,20 +1101,41 @@ func TestNodesToDelete(t *testing.T) {
10441101
ScaleDownUnneededTime: 10 * time.Minute,
10451102
ScaleDownUnreadyTime: 0 * time.Minute,
10461103
},
1104+
ScaleDownSimulationTimeout: 5 * time.Minute,
10471105
}
10481106
processors, templateNodeInfoRegistry := processorstest.NewTestProcessors(autoscalingOpts)
10491107
autoscalingCtx, err := NewScaleTestAutoscalingContext(autoscalingOpts, &fake.Clientset{}, nil, provider, nil, nil, templateNodeInfoRegistry)
10501108
assert.NoError(t, err)
10511109
clustersnapshot.InitializeClusterSnapshotOrDie(t, autoscalingCtx.ClusterSnapshot, allNodes, nil)
10521110
deleteOptions := options.NodeDeleteOptions{}
1053-
p := New(&autoscalingCtx, processors, deleteOptions, nil)
1111+
factory := resourcequotas.NewTrackerFactory(resourcequotas.TrackerOptions{CustomResourcesProcessor: processors.CustomResourcesProcessor, QuotaProvider: resourcequotas.NewCloudMinProvider(provider)})
1112+
p := New(&autoscalingCtx, processors, deleteOptions, nil, factory)
10541113
p.latestUpdate = time.Now()
1114+
1115+
if tc.minLimits != nil {
1116+
resourceLimiter := cloudprovider.NewResourceLimiter(tc.minLimits, nil)
1117+
provider.SetResourceLimiter(resourceLimiter)
1118+
}
1119+
10551120
p.scaleDownContext.ActuationStatus = deletiontracker.NewNodeDeletionTracker(0 * time.Second)
10561121
p.unneededNodes.Update(&autoscalingCtx, allRemovables, time.Now().Add(-1*time.Hour))
10571122
p.eligibilityChecker = &fakeEligibilityChecker{eligible: asMap(nodeNames(allNodes))}
10581123
empty, drain := p.NodesToDelete(time.Now())
10591124
assert.ElementsMatch(t, tc.wantEmpty, empty)
10601125
assert.ElementsMatch(t, tc.wantDrain, drain)
1126+
1127+
if tc.wantUnremovable != nil {
1128+
unremovable := p.unremovableNodes.AsList()
1129+
unremovableMap := make(map[string]simulator.UnremovableReason)
1130+
for _, u := range unremovable {
1131+
unremovableMap[u.Node.Name] = u.Reason
1132+
}
1133+
for nodeName, expectedReason := range tc.wantUnremovable {
1134+
reason, found := unremovableMap[nodeName]
1135+
assert.True(t, found, "Node %s not found in unremovable list", nodeName)
1136+
assert.Equal(t, expectedReason, reason, "Reason mismatch for node %s", nodeName)
1137+
}
1138+
}
10611139
})
10621140
}
10631141
}

cluster-autoscaler/core/scaledown/resource/limits.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func (lf *LimitsFinder) customResourcesTotal(autoscalingCtx *ca_context.Autoscal
146146
if !cacheHit {
147147
resourceTargets, err = lf.crp.GetNodeResourceTargets(autoscalingCtx, node, nodeGroup)
148148
if err != nil {
149-
return nil, errors.ToAutoscalerError(errors.CloudProviderError, err).AddPrefix("cannot get custom resource count for node %v when calculating cluster custom resource usage")
149+
return nil, errors.ToAutoscalerError(errors.CloudProviderError, err).AddPrefix("cannot get custom resource count for node %v when calculating cluster custom resource usage", node.Name)
150150
}
151151
if nodeGroup != nil {
152152
ngCache[nodeGroup.Id()] = resourceTargets
@@ -185,7 +185,7 @@ func (lf *LimitsFinder) DeltaForNode(autoscalingCtx *ca_context.AutoscalingConte
185185
if cloudprovider.ContainsCustomResources(resourcesWithLimits) {
186186
resourceTargets, err := lf.crp.GetNodeResourceTargets(autoscalingCtx, node, nodeGroup)
187187
if err != nil {
188-
return Delta{}, errors.ToAutoscalerError(errors.CloudProviderError, err).AddPrefix("failed to get node %v custom resources: %v", node.Name)
188+
return Delta{}, errors.ToAutoscalerError(errors.CloudProviderError, err).AddPrefix("failed to get node %q custom resources: ", node.Name)
189189
}
190190
for _, resourceTarget := range resourceTargets {
191191
resultScaleDownDelta[resourceTarget.ResourceType] = resourceTarget.ResourceCount

cluster-autoscaler/core/scaledown/unneeded/nodes.go

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ import (
2525
ca_context "k8s.io/autoscaler/cluster-autoscaler/context"
2626
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown"
2727
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/eligibility"
28-
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/resource"
29-
"k8s.io/autoscaler/cluster-autoscaler/metrics"
28+
3029
"k8s.io/autoscaler/cluster-autoscaler/processors/nodes"
3130
"k8s.io/autoscaler/cluster-autoscaler/simulator"
3231
"k8s.io/autoscaler/cluster-autoscaler/utils"
@@ -38,10 +37,10 @@ import (
3837

3938
// Nodes tracks the state of cluster nodes that are not needed.
4039
type Nodes struct {
41-
sdtg scaleDownTimeGetter
42-
limitsFinder *resource.LimitsFinder
43-
cachedList []*scaledown.UnneededNode
44-
byName map[string]*node
40+
sdtg scaleDownTimeGetter
41+
42+
cachedList []*scaledown.UnneededNode
43+
byName map[string]*node
4544
}
4645

4746
type node struct {
@@ -59,10 +58,9 @@ type scaleDownTimeGetter interface {
5958
}
6059

6160
// NewNodes returns a new initialized Nodes object.
62-
func NewNodes(sdtg scaleDownTimeGetter, limitsFinder *resource.LimitsFinder) *Nodes {
61+
func NewNodes(sdtg scaleDownTimeGetter) *Nodes {
6362
return &Nodes{
64-
sdtg: sdtg,
65-
limitsFinder: limitsFinder,
63+
sdtg: sdtg,
6664
}
6765
}
6866

@@ -290,25 +288,14 @@ func (n *Nodes) unremovableReason(autoscalingCtx *ca_context.AutoscalingContext,
290288
return reason
291289
}
292290

293-
resourceDelta, err := n.limitsFinder.DeltaForNode(autoscalingCtx, node, nodeGroup, scaleDownContext.ResourcesWithLimits)
291+
checkResult, err := scaleDownContext.Tracker.ConsumeQuota(autoscalingCtx, nodeGroup, node, 1)
294292
if err != nil {
295-
klog.Errorf("Error getting node resources: %v", err)
293+
klog.Errorf("Failed to check limits for %s: %v", node.Name, err)
296294
return simulator.UnexpectedError
297295
}
298296

299-
checkResult := scaleDownContext.ResourcesLeft.TryDecrementBy(resourceDelta)
300297
if checkResult.Exceeded() {
301-
klog.V(4).Infof("Skipping %s - minimal limit exceeded for %v", node.Name, checkResult.ExceededResources)
302-
for _, resource := range checkResult.ExceededResources {
303-
switch resource {
304-
case cloudprovider.ResourceNameCores:
305-
metrics.RegisterSkippedScaleDownCPU()
306-
case cloudprovider.ResourceNameMemory:
307-
metrics.RegisterSkippedScaleDownMemory()
308-
default:
309-
continue
310-
}
311-
}
298+
klog.V(2).Infof("Skipping scale down of %s in this batch - would violate minimum limits", node.Name)
312299
return simulator.MinimalResourceLimitExceeded
313300
}
314301

0 commit comments

Comments
 (0)