Skip to content

Commit 4f179bb

Browse files
committed
feat: added flag to set deletion candidate taint TTL
Signed-off-by: MenD32 <[email protected]>
1 parent 0ff3741 commit 4f179bb

File tree

12 files changed

+723
-42
lines changed

12 files changed

+723
-42
lines changed

cluster-autoscaler/config/autoscaling_options.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,9 @@ type AutoscalingOptions struct {
338338
ProactiveScaleupEnabled bool
339339
// PodInjectionLimit limits total number of pods while injecting fake pods.
340340
PodInjectionLimit int
341+
// NodeDeletionCandidateTTL is the maximum time a node can be marked as removable without being deleted.
342+
// This is used to prevent nodes from being stuck in the removable state during if the CA deployment becomes inactive.
343+
NodeDeletionCandidateTTL time.Duration
341344
}
342345

343346
// KubeClientOptions specify options for kube client

cluster-autoscaler/config/flags/flags.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ var (
226226
enableDynamicResourceAllocation = flag.Bool("enable-dynamic-resource-allocation", false, "Whether logic for handling DRA (Dynamic Resource Allocation) objects is enabled.")
227227
clusterSnapshotParallelism = flag.Int("cluster-snapshot-parallelism", 16, "Maximum parallelism of cluster snapshot creation.")
228228
checkCapacityProcessorInstance = flag.String("check-capacity-processor-instance", "", "Name of the processor instance. Only ProvisioningRequests that define this name in their parameters with the key \"processorInstance\" will be processed by this CA instance. It only refers to check capacity ProvisioningRequests, but if not empty, best-effort atomic ProvisioningRequests processing is disabled in this instance. Not recommended: Until CA 1.35, ProvisioningRequests with this name as prefix in their class will be also processed.")
229+
nodeDeletionCandidateTTL = flag.Duration("node-deletion-candidate-ttl", time.Duration(0), "Maximum time a node can be marked as removable before the marking becomes stale. This sets the TTL of Cluster-Autoscaler's state if the Cluste-Autoscaler deployment becomes inactive")
229230

230231
// Deprecated flags
231232
ignoreTaintsFlag = multiStringFlag("ignore-taint", "Specifies a taint to ignore in node templates when considering to scale a node group (Deprecated, use startup-taints instead)")
@@ -406,6 +407,7 @@ func createAutoscalingOptions() config.AutoscalingOptions {
406407
NodeInfoCacheExpireTime: *nodeInfoCacheExpireTime,
407408
ProactiveScaleupEnabled: *proactiveScaleupEnabled,
408409
PodInjectionLimit: *podInjectionLimit,
410+
NodeDeletionCandidateTTL: *nodeDeletionCandidateTTL,
409411
}
410412
}
411413

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,16 @@ func New(context *context.AutoscalingContext, processors *processors.Autoscaling
8585
if minUpdateInterval == 0*time.Nanosecond {
8686
minUpdateInterval = 1 * time.Nanosecond
8787
}
88+
89+
unneededNodes := unneeded.NewNodes(processors.NodeGroupConfigProcessor, resourceLimitsFinder)
90+
if context.AutoscalingOptions.NodeDeletionCandidateTTL != 0 {
91+
unneededNodes.LoadFromExistingTaints(context.ListerRegistry, time.Now(), context.AutoscalingOptions.NodeDeletionCandidateTTL)
92+
}
93+
8894
return &Planner{
8995
context: context,
9096
unremovableNodes: unremovable.NewNodes(),
91-
unneededNodes: unneeded.NewNodes(processors.NodeGroupConfigProcessor, resourceLimitsFinder),
97+
unneededNodes: unneededNodes,
9298
rs: simulator.NewRemovalSimulator(context.ListerRegistry, context.ClusterSnapshot, deleteOptions, drainabilityRules, true),
9399
actuationInjector: scheduling.NewHintingSimulator(),
94100
eligibilityChecker: eligibility.NewChecker(processors.NodeGroupConfigProcessor),

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

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ import (
3636
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/status"
3737
"k8s.io/autoscaler/cluster-autoscaler/core/scaledown/unremovable"
3838
. "k8s.io/autoscaler/cluster-autoscaler/core/test"
39+
"k8s.io/autoscaler/cluster-autoscaler/estimator"
3940
processorstest "k8s.io/autoscaler/cluster-autoscaler/processors/test"
4041
"k8s.io/autoscaler/cluster-autoscaler/simulator"
4142
"k8s.io/autoscaler/cluster-autoscaler/simulator/clustersnapshot"
4243
"k8s.io/autoscaler/cluster-autoscaler/simulator/options"
4344
"k8s.io/autoscaler/cluster-autoscaler/simulator/utilization"
45+
"k8s.io/autoscaler/cluster-autoscaler/utils/drain"
46+
"k8s.io/autoscaler/cluster-autoscaler/utils/kubernetes"
4447
kube_util "k8s.io/autoscaler/cluster-autoscaler/utils/kubernetes"
4548
"k8s.io/autoscaler/cluster-autoscaler/utils/taints"
4649
. "k8s.io/autoscaler/cluster-autoscaler/utils/test"
@@ -463,7 +466,7 @@ func TestUpdateClusterState(t *testing.T) {
463466
wantUnremovable: []string{"n1", "n2", "n3", "n4"},
464467
},
465468
{
466-
name: "Simulation timeout is hitted",
469+
name: "Simulation timeout is hit",
467470
nodes: []*apiv1.Node{
468471
BuildTestNode("n1", 1000, 10),
469472
BuildTestNode("n2", 1000, 10),
@@ -706,6 +709,137 @@ func TestUpdateClusterStatUnneededNodesLimit(t *testing.T) {
706709
}
707710
}
708711

712+
// TestNewPlannerWithExistingDeletionCandidateNodes tests that the newPlanner correctly handles existing deletion candidate taints on nodes.
713+
func TestNewPlannerWithExistingDeletionCandidateNodes(t *testing.T) {
714+
// Use a table-driven approach where each test case includes its own set of nodes and expected behavior
715+
type testCase struct {
716+
name string
717+
allNodes []*apiv1.Node
718+
expectedDeletionCandidateNodes []*apiv1.Node
719+
nodeDeletionCandidateTTL time.Duration
720+
}
721+
722+
// Common test setup
723+
deletionCandidateTaint := taints.DeletionCandidateTaint()
724+
currentTime := time.Now()
725+
726+
// Node that should be deleted
727+
n1 := BuildTestNode("n1", 1000, 1000)
728+
SetNodeReadyState(n1, true, currentTime)
729+
nt1 := deletionCandidateTaint
730+
ntt1 := currentTime.Add(-time.Minute * 2)
731+
nt1.Value = fmt.Sprint(ntt1.Unix())
732+
n1.Spec.Taints = append(n1.Spec.Taints, nt1)
733+
734+
// Node whose DeletionCandidateTaint has lapsed, shouldn't be deleted
735+
n2 := BuildTestNode("n2", 1000, 1000)
736+
SetNodeReadyState(n2, true, currentTime)
737+
nt2 := deletionCandidateTaint
738+
ntt2 := currentTime.Add(-time.Minute * 10)
739+
nt2.Value = fmt.Sprint(ntt2.Unix())
740+
n2.Spec.Taints = append(n2.Spec.Taints, nt2)
741+
742+
// Node that is marked for deletion, but should have that mark removed
743+
n3 := BuildTestNode("n3", 1000, 1000)
744+
SetNodeReadyState(n3, true, currentTime)
745+
nt3 := deletionCandidateTaint
746+
ntt3 := currentTime.Add(-time.Minute * 2)
747+
nt3.Value = fmt.Sprint(ntt3.Unix())
748+
n3.Spec.Taints = append(n3.Spec.Taints, nt3)
749+
750+
// Node with invalid DeletionCandidateTaint, taint should be deleted
751+
n4 := BuildTestNode("n4", 1000, 1000)
752+
SetNodeReadyState(n4, true, currentTime)
753+
nt4 := deletionCandidateTaint
754+
nt4.Value = "invalid-value"
755+
n4.Spec.Taints = append(n4.Spec.Taints, nt4)
756+
757+
// Node with no DeletionCandidateTaint, should not be deleted
758+
n5 := BuildTestNode("n5", 1000, 1000)
759+
SetNodeReadyState(n5, true, currentTime)
760+
761+
// Pod that blocks eviction on node n3
762+
p1 := BuildTestPod("p1", 600, 100)
763+
p1.Spec.NodeName = n3.Name
764+
p1.SetAnnotations(
765+
map[string]string{
766+
drain.PodSafeToEvictKey: "false",
767+
},
768+
)
769+
770+
testCases := []testCase{
771+
{
772+
name: "All deletion candidate nodes with standard TTL",
773+
allNodes: []*apiv1.Node{n1, n2, n3},
774+
expectedDeletionCandidateNodes: []*apiv1.Node{n1},
775+
nodeDeletionCandidateTTL: time.Minute * 5,
776+
},
777+
{
778+
name: "Node without deletion candidate taint should not be deleted",
779+
allNodes: []*apiv1.Node{n5},
780+
expectedDeletionCandidateNodes: []*apiv1.Node{},
781+
nodeDeletionCandidateTTL: time.Minute * 5,
782+
},
783+
{
784+
name: "Node with invalid deletion candidate taint should be deleted",
785+
allNodes: []*apiv1.Node{n4},
786+
expectedDeletionCandidateNodes: []*apiv1.Node{},
787+
nodeDeletionCandidateTTL: time.Minute * 5,
788+
},
789+
}
790+
791+
for _, tc := range testCases {
792+
t.Run(tc.name, func(t *testing.T) {
793+
readyNodeLister := kubernetes.NewTestNodeLister(nil)
794+
allNodeLister := kubernetes.NewTestNodeLister(nil)
795+
796+
readyNodeLister.SetNodes(tc.allNodes)
797+
allNodeLister.SetNodes(tc.allNodes)
798+
799+
autoscalingOptions := config.AutoscalingOptions{
800+
NodeGroupDefaults: config.NodeGroupAutoscalingOptions{
801+
ScaleDownUnneededTime: time.Minute,
802+
ScaleDownUnreadyTime: time.Minute,
803+
ScaleDownUtilizationThreshold: 0.5,
804+
MaxNodeProvisionTime: 10 * time.Second,
805+
},
806+
EstimatorName: estimator.BinpackingEstimatorName,
807+
EnforceNodeGroupMinSize: true,
808+
ScaleDownEnabled: true,
809+
MaxNodesTotal: 100,
810+
MaxCoresTotal: 100,
811+
MaxMemoryTotal: 100000,
812+
NodeDeletionCandidateTTL: tc.nodeDeletionCandidateTTL,
813+
}
814+
815+
provider := testprovider.NewTestCloudProviderBuilder().Build()
816+
for _, node := range tc.allNodes {
817+
provider.AddNode("ng1", node)
818+
}
819+
820+
context, err := NewScaleTestAutoscalingContext(
821+
autoscalingOptions,
822+
&fake.Clientset{},
823+
kube_util.NewListerRegistry(
824+
allNodeLister,
825+
readyNodeLister,
826+
nil, nil, nil, nil, nil, nil, nil,
827+
),
828+
829+
provider,
830+
nil,
831+
nil,
832+
)
833+
assert.NoError(t, err)
834+
835+
deleteOptions := options.NodeDeleteOptions{}
836+
p := New(&context, processorstest.NewTestProcessors(&context), deleteOptions, nil)
837+
838+
p.unneededNodes.AsList()
839+
})
840+
}
841+
}
842+
709843
func TestNodesToDelete(t *testing.T) {
710844
testCases := []struct {
711845
name string

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

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package unneeded
1818

1919
import (
20+
"fmt"
2021
"reflect"
2122
"time"
2223

@@ -30,6 +31,7 @@ import (
3031
"k8s.io/autoscaler/cluster-autoscaler/simulator"
3132
"k8s.io/autoscaler/cluster-autoscaler/utils"
3233
kube_util "k8s.io/autoscaler/cluster-autoscaler/utils/kubernetes"
34+
"k8s.io/autoscaler/cluster-autoscaler/utils/taints"
3335

3436
apiv1 "k8s.io/api/core/v1"
3537
klog "k8s.io/klog/v2"
@@ -63,19 +65,85 @@ func NewNodes(sdtg scaleDownTimeGetter, limitsFinder *resource.LimitsFinder) *No
6365
}
6466
}
6567

68+
// LoadFromExistingTaints loads any existing DeletionCandidateTaint taints from the kubernetes cluster. given a TTL for the taint
69+
func (n *Nodes) LoadFromExistingTaints(listerRegistry kube_util.ListerRegistry, ts time.Time, DeletionCandidateStalenessTTL time.Duration) error {
70+
allNodes, err := listerRegistry.AllNodeLister().List()
71+
if err != nil {
72+
return fmt.Errorf("failed to list nodes when initializing unneeded nodes: %v", err)
73+
}
74+
75+
var nodesWithTaints []simulator.NodeToBeRemoved
76+
for _, node := range allNodes {
77+
if since, err := taints.GetDeletionCandidateTime(node); err == nil && since != nil {
78+
if err != nil {
79+
klog.Errorf("Failed to get pods to move for node %s: %v", node.Name, err)
80+
continue
81+
}
82+
if since.Add(DeletionCandidateStalenessTTL).Before(ts) {
83+
klog.V(4).Infof("Skipping node %s with deletion candidate taint from %s, since it is older than TTL %s", node.Name, since.String(), DeletionCandidateStalenessTTL.String())
84+
continue
85+
}
86+
nodeToBeRemoved := simulator.NodeToBeRemoved{
87+
Node: node,
88+
}
89+
nodesWithTaints = append(nodesWithTaints, nodeToBeRemoved)
90+
klog.V(4).Infof("Found node %s with deletion candidate taint from %s", node.Name, since.String())
91+
}
92+
}
93+
94+
if len(nodesWithTaints) > 0 {
95+
klog.V(1).Infof("Initializing unneeded nodes with %d nodes that have deletion candidate taints", len(nodesWithTaints))
96+
n.initialize(nodesWithTaints, ts)
97+
}
98+
99+
return nil
100+
}
101+
102+
// initialize initializes the Nodes object with the given node list.
103+
// It sets the initial state of unneeded nodes reflect the taint status of nodes in the cluster.
104+
// This is in order the avoid state loss between deployment restarts.
105+
func (n *Nodes) initialize(nodes []simulator.NodeToBeRemoved, ts time.Time) {
106+
n.updateInternalState(nodes, ts, func(nn simulator.NodeToBeRemoved) *time.Time {
107+
name := nn.Node.Name
108+
if since, err := taints.GetDeletionCandidateTime(nn.Node); err == nil {
109+
klog.V(4).Infof("Found node %s with deletion candidate taint from %s", name, since.String())
110+
return since
111+
} else if since == nil {
112+
klog.Errorf("Failed to get deletion candidate taint time for node %s: %v", name, err)
113+
return nil
114+
}
115+
klog.V(4).Infof("Found node %s with deletion candidate taint from now", name)
116+
return nil
117+
})
118+
}
119+
66120
// Update stores nodes along with a time at which they were found to be
67121
// unneeded. Previously existing timestamps are preserved.
68122
func (n *Nodes) Update(nodes []simulator.NodeToBeRemoved, ts time.Time) {
123+
n.updateInternalState(nodes, ts, func(nn simulator.NodeToBeRemoved) *time.Time {
124+
return nil
125+
})
126+
}
127+
128+
func (n *Nodes) updateInternalState(nodes []simulator.NodeToBeRemoved, ts time.Time, timestampGetter func(simulator.NodeToBeRemoved) *time.Time) {
69129
updated := make(map[string]*node, len(nodes))
70130
for _, nn := range nodes {
71131
name := nn.Node.Name
72-
updated[name] = &node{
73-
ntbr: nn,
74-
}
75132
if val, found := n.byName[name]; found {
76-
updated[name].since = val.since
133+
updated[name] = &node{
134+
ntbr: nn,
135+
since: val.since,
136+
}
137+
} else if existingts := timestampGetter(nn); existingts != nil {
138+
updated[name] = &node{
139+
ntbr: nn,
140+
since: *existingts,
141+
}
77142
} else {
78-
updated[name].since = ts
143+
updated[name] = &node{
144+
ntbr: nn,
145+
since: ts,
146+
}
79147
}
80148
}
81149
n.byName = updated

0 commit comments

Comments
 (0)