Skip to content

Commit 8dcd3b3

Browse files
authored
Merge pull request kubernetes#5118 from x13n/scaledown
Extract scale down eligibility checking to a separate object
2 parents cb1ba27 + c2a0329 commit 8dcd3b3

File tree

9 files changed

+382
-168
lines changed

9 files changed

+382
-168
lines changed

cluster-autoscaler/core/scaledown/actuation/actuator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ func (a *Actuator) scaleDownNodeToReport(node *apiv1.Node, drain bool) (*status.
221221
if err != nil {
222222
return nil, err
223223
}
224-
utilInfo, err := utilization.Calculate(node, nodeInfo, a.ctx.IgnoreDaemonSetsUtilization, a.ctx.IgnoreMirrorPodsUtilization, a.ctx.CloudProvider.GPULabel(), time.Now())
224+
utilInfo, err := utilization.Calculate(nodeInfo, a.ctx.IgnoreDaemonSetsUtilization, a.ctx.IgnoreMirrorPodsUtilization, a.ctx.CloudProvider.GPULabel(), time.Now())
225225
if err != nil {
226226
return nil, err
227227
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/*
2+
Copyright 2022 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package eligibility
18+
19+
import (
20+
"reflect"
21+
"time"
22+
23+
"k8s.io/autoscaler/cluster-autoscaler/cloudprovider"
24+
"k8s.io/autoscaler/cluster-autoscaler/context"
25+
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/actuation"
26+
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/unremovable"
27+
"k8s.io/autoscaler/cluster-autoscaler/simulator"
28+
"k8s.io/autoscaler/cluster-autoscaler/simulator/utilization"
29+
"k8s.io/autoscaler/cluster-autoscaler/utils/gpu"
30+
"k8s.io/autoscaler/cluster-autoscaler/utils/klogx"
31+
32+
apiv1 "k8s.io/api/core/v1"
33+
klog "k8s.io/klog/v2"
34+
schedulerframework "k8s.io/kubernetes/pkg/scheduler/framework"
35+
)
36+
37+
const (
38+
// ScaleDownDisabledKey is the name of annotation marking node as not eligible for scale down.
39+
ScaleDownDisabledKey = "cluster-autoscaler.kubernetes.io/scale-down-disabled"
40+
)
41+
42+
// Checker is responsible for deciding which nodes pass the criteria for scale down.
43+
type Checker struct {
44+
thresholdGetter utilizationThresholdGetter
45+
}
46+
47+
type utilizationThresholdGetter interface {
48+
// GetScaleDownUtilizationThreshold returns ScaleDownUtilizationThreshold value that should be used for a given NodeGroup.
49+
GetScaleDownUtilizationThreshold(context *context.AutoscalingContext, nodeGroup cloudprovider.NodeGroup) (float64, error)
50+
// GetScaleDownGpuUtilizationThreshold returns ScaleDownGpuUtilizationThreshold value that should be used for a given NodeGroup.
51+
GetScaleDownGpuUtilizationThreshold(context *context.AutoscalingContext, nodeGroup cloudprovider.NodeGroup) (float64, error)
52+
}
53+
54+
// NewChecker creates a new Checker object.
55+
func NewChecker(thresholdGetter utilizationThresholdGetter) *Checker {
56+
return &Checker{
57+
thresholdGetter: thresholdGetter,
58+
}
59+
}
60+
61+
// FilterOutUnremovable accepts a list of nodes that are candidates for
62+
// scale down and filters out nodes that cannot be removed, along with node
63+
// utilization info.
64+
// TODO(x13n): Node utilization could actually be calculated independently for
65+
// all nodes and just used here. Next refactor...
66+
func (c *Checker) FilterOutUnremovable(context *context.AutoscalingContext, scaleDownCandidates []*apiv1.Node, timestamp time.Time, unremovableNodes *unremovable.Nodes) ([]string, map[string]utilization.Info) {
67+
unremovableNodes.Update(context.ClusterSnapshot.NodeInfos(), timestamp)
68+
69+
skipped := 0
70+
utilizationMap := make(map[string]utilization.Info)
71+
currentlyUnneededNodeNames := make([]string, 0, len(scaleDownCandidates))
72+
utilLogsQuota := klogx.NewLoggingQuota(20)
73+
74+
for _, node := range scaleDownCandidates {
75+
nodeInfo, err := context.ClusterSnapshot.NodeInfos().Get(node.Name)
76+
if err != nil {
77+
klog.Errorf("Can't retrieve scale-down candidate %s from snapshot, err: %v", node.Name, err)
78+
unremovableNodes.AddReason(node, simulator.UnexpectedError)
79+
continue
80+
}
81+
82+
// Skip nodes that were recently checked.
83+
if unremovableNodes.IsRecent(node.Name) {
84+
unremovableNodes.AddReason(node, simulator.RecentlyUnremovable)
85+
skipped++
86+
continue
87+
}
88+
89+
reason, utilInfo := c.unremovableReasonAndNodeUtilization(context, timestamp, nodeInfo, utilLogsQuota)
90+
if utilInfo != nil {
91+
utilizationMap[node.Name] = *utilInfo
92+
}
93+
if reason != simulator.NoReason {
94+
unremovableNodes.AddReason(node, reason)
95+
continue
96+
}
97+
98+
currentlyUnneededNodeNames = append(currentlyUnneededNodeNames, node.Name)
99+
}
100+
101+
klogx.V(4).Over(utilLogsQuota).Infof("Skipped logging utilization for %d other nodes", -utilLogsQuota.Left())
102+
if skipped > 0 {
103+
klog.V(1).Infof("Scale-down calculation: ignoring %v nodes unremovable in the last %v", skipped, context.AutoscalingOptions.UnremovableNodeRecheckTimeout)
104+
}
105+
return currentlyUnneededNodeNames, utilizationMap
106+
}
107+
108+
func (c *Checker) unremovableReasonAndNodeUtilization(context *context.AutoscalingContext, timestamp time.Time, nodeInfo *schedulerframework.NodeInfo, utilLogsQuota *klogx.Quota) (simulator.UnremovableReason, *utilization.Info) {
109+
node := nodeInfo.Node()
110+
111+
// Skip nodes marked to be deleted, if they were marked recently.
112+
// Old-time marked nodes are again eligible for deletion - something went wrong with them
113+
// and they have not been deleted.
114+
if actuation.IsNodeBeingDeleted(node, timestamp) {
115+
klog.V(1).Infof("Skipping %s from delete consideration - the node is currently being deleted", node.Name)
116+
return simulator.CurrentlyBeingDeleted, nil
117+
}
118+
119+
// Skip nodes marked with no scale down annotation
120+
if HasNoScaleDownAnnotation(node) {
121+
klog.V(1).Infof("Skipping %s from delete consideration - the node is marked as no scale down", node.Name)
122+
return simulator.ScaleDownDisabledAnnotation, nil
123+
}
124+
125+
utilInfo, err := utilization.Calculate(nodeInfo, context.IgnoreDaemonSetsUtilization, context.IgnoreMirrorPodsUtilization, context.CloudProvider.GPULabel(), timestamp)
126+
if err != nil {
127+
klog.Warningf("Failed to calculate utilization for %s: %v", node.Name, err)
128+
}
129+
130+
nodeGroup, err := context.CloudProvider.NodeGroupForNode(node)
131+
if err != nil {
132+
klog.Warning("Node group not found for node %v: %v", node.Name, err)
133+
return simulator.UnexpectedError, nil
134+
}
135+
if nodeGroup == nil || reflect.ValueOf(nodeGroup).IsNil() {
136+
// We should never get here as non-autoscaled nodes should not be included in scaleDownCandidates list
137+
// (and the default PreFilteringScaleDownNodeProcessor would indeed filter them out).
138+
klog.Warningf("Skipped %s from delete consideration - the node is not autoscaled", node.Name)
139+
return simulator.NotAutoscaled, nil
140+
}
141+
142+
underutilized, err := c.isNodeBelowUtilizationThreshold(context, node, nodeGroup, utilInfo)
143+
if err != nil {
144+
klog.Warningf("Failed to check utilization thresholds for %s: %v", node.Name, err)
145+
return simulator.UnexpectedError, nil
146+
}
147+
if !underutilized {
148+
klog.V(4).Infof("Node %s is not suitable for removal - %s utilization too big (%f)", node.Name, utilInfo.ResourceName, utilInfo.Utilization)
149+
return simulator.NotUnderutilized, &utilInfo
150+
}
151+
152+
klogx.V(4).UpTo(utilLogsQuota).Infof("Node %s - %s utilization %f", node.Name, utilInfo.ResourceName, utilInfo.Utilization)
153+
154+
return simulator.NoReason, &utilInfo
155+
}
156+
157+
// isNodeBelowUtilizationThreshold determines if a given node utilization is below threshold.
158+
func (c *Checker) isNodeBelowUtilizationThreshold(context *context.AutoscalingContext, node *apiv1.Node, nodeGroup cloudprovider.NodeGroup, utilInfo utilization.Info) (bool, error) {
159+
var threshold float64
160+
var err error
161+
if gpu.NodeHasGpu(context.CloudProvider.GPULabel(), node) {
162+
threshold, err = c.thresholdGetter.GetScaleDownGpuUtilizationThreshold(context, nodeGroup)
163+
if err != nil {
164+
return false, err
165+
}
166+
} else {
167+
threshold, err = c.thresholdGetter.GetScaleDownUtilizationThreshold(context, nodeGroup)
168+
if err != nil {
169+
return false, err
170+
}
171+
}
172+
if utilInfo.Utilization >= threshold {
173+
return false, nil
174+
}
175+
return true, nil
176+
}
177+
178+
// HasNoScaleDownAnnotation checks whether the node has an annotation blocking it from being scaled down.
179+
func HasNoScaleDownAnnotation(node *apiv1.Node) bool {
180+
return node.Annotations[ScaleDownDisabledKey] == "true"
181+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/*
2+
Copyright 2022 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package eligibility
18+
19+
import (
20+
"strconv"
21+
"testing"
22+
"time"
23+
24+
"k8s.io/autoscaler/cluster-autoscaler/cloudprovider"
25+
testprovider "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/test"
26+
"k8s.io/autoscaler/cluster-autoscaler/config"
27+
"k8s.io/autoscaler/cluster-autoscaler/context"
28+
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/unremovable"
29+
. "k8s.io/autoscaler/cluster-autoscaler/core/test"
30+
"k8s.io/autoscaler/cluster-autoscaler/simulator"
31+
"k8s.io/autoscaler/cluster-autoscaler/utils/deletetaint"
32+
. "k8s.io/autoscaler/cluster-autoscaler/utils/test"
33+
34+
"github.com/stretchr/testify/assert"
35+
apiv1 "k8s.io/api/core/v1"
36+
"k8s.io/client-go/kubernetes/fake"
37+
)
38+
39+
func TestFilterOutUnremovable(t *testing.T) {
40+
now := time.Now()
41+
42+
regularNode := BuildTestNode("regular", 1000, 10)
43+
SetNodeReadyState(regularNode, true, time.Time{})
44+
45+
justDeletedNode := BuildTestNode("justDeleted", 1000, 10)
46+
justDeletedNode.Spec.Taints = []apiv1.Taint{{Key: deletetaint.ToBeDeletedTaint, Value: strconv.FormatInt(now.Unix()-30, 10)}}
47+
SetNodeReadyState(justDeletedNode, true, time.Time{})
48+
49+
tooOldDeletedNode := BuildTestNode("tooOldDeleted", 1000, 10)
50+
tooOldDeletedNode.Spec.Taints = []apiv1.Taint{{Key: deletetaint.ToBeDeletedTaint, Value: strconv.FormatInt(now.Unix()-301, 10)}}
51+
SetNodeReadyState(tooOldDeletedNode, true, time.Time{})
52+
53+
noScaleDownNode := BuildTestNode("noScaleDown", 1000, 10)
54+
noScaleDownNode.Annotations = map[string]string{ScaleDownDisabledKey: "true"}
55+
SetNodeReadyState(noScaleDownNode, true, time.Time{})
56+
57+
bigPod := BuildTestPod("bigPod", 600, 0)
58+
bigPod.Spec.NodeName = "regular"
59+
60+
smallPod := BuildTestPod("smallPod", 100, 0)
61+
smallPod.Spec.NodeName = "regular"
62+
63+
testCases := []struct {
64+
desc string
65+
nodes []*apiv1.Node
66+
pods []*apiv1.Pod
67+
want []string
68+
}{
69+
{
70+
desc: "regular node stays",
71+
nodes: []*apiv1.Node{regularNode},
72+
want: []string{"regular"},
73+
},
74+
{
75+
desc: "recently deleted node is filtered out",
76+
nodes: []*apiv1.Node{regularNode, justDeletedNode},
77+
want: []string{"regular"},
78+
},
79+
{
80+
desc: "deleted long time ago stays",
81+
nodes: []*apiv1.Node{regularNode, tooOldDeletedNode},
82+
want: []string{"regular", "tooOldDeleted"},
83+
},
84+
{
85+
desc: "marked no scale down is filtered out",
86+
nodes: []*apiv1.Node{noScaleDownNode, regularNode},
87+
want: []string{"regular"},
88+
},
89+
{
90+
desc: "highly utilized node is filtered out",
91+
nodes: []*apiv1.Node{regularNode},
92+
pods: []*apiv1.Pod{bigPod},
93+
want: []string{},
94+
},
95+
{
96+
desc: "underutilized node stays",
97+
nodes: []*apiv1.Node{regularNode},
98+
pods: []*apiv1.Pod{smallPod},
99+
want: []string{"regular"},
100+
},
101+
}
102+
for _, tc := range testCases {
103+
tc := tc
104+
t.Run(tc.desc, func(t *testing.T) {
105+
t.Parallel()
106+
c := NewChecker(&staticThresholdGetter{0.5})
107+
options := config.AutoscalingOptions{
108+
UnremovableNodeRecheckTimeout: 5 * time.Minute,
109+
}
110+
provider := testprovider.NewTestCloudProvider(nil, nil)
111+
provider.AddNodeGroup("ng1", 1, 10, 2)
112+
for _, n := range tc.nodes {
113+
provider.AddNode("ng1", n)
114+
}
115+
context, err := NewScaleTestAutoscalingContext(options, &fake.Clientset{}, nil, provider, nil, nil)
116+
simulator.InitializeClusterSnapshotOrDie(t, context.ClusterSnapshot, tc.nodes, tc.pods)
117+
if err != nil {
118+
t.Fatalf("Could not create autoscaling context: %v", err)
119+
}
120+
unremovableNodes := unremovable.NewNodes()
121+
got, _ := c.FilterOutUnremovable(&context, tc.nodes, now, unremovableNodes)
122+
assert.Equal(t, tc.want, got)
123+
})
124+
}
125+
}
126+
127+
type staticThresholdGetter struct {
128+
threshold float64
129+
}
130+
131+
func (s *staticThresholdGetter) GetScaleDownUtilizationThreshold(_ *context.AutoscalingContext, _ cloudprovider.NodeGroup) (float64, error) {
132+
return s.threshold, nil
133+
}
134+
135+
func (s *staticThresholdGetter) GetScaleDownGpuUtilizationThreshold(_ *context.AutoscalingContext, _ cloudprovider.NodeGroup) (float64, error) {
136+
return s.threshold, nil
137+
}

0 commit comments

Comments
 (0)