Skip to content

Commit b015acb

Browse files
authored
Merge pull request #113 from XenitAB/eviction-logic
2 parents aa57e47 + c924347 commit b015acb

7 files changed

Lines changed: 85 additions & 102 deletions

File tree

.golangci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ linters:
3030
max-complexity: 15
3131
package-average: 0
3232
dogsled:
33-
max-blank-identifiers: 2
33+
max-blank-identifiers: 3
3434
dupl:
3535
threshold: 100
3636
errcheck:

Makefile

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ IMG ?= ghcr.io/xenitab/node-ttl:$(TAG)
33

44
all: fmt vet lint
55

6+
fmt:
7+
go fmt ./...
8+
9+
vet:
10+
go vet ./...
11+
612
lint:
713
golangci-lint run ./...
814

915
test:
10-
go test --cover ./...
16+
go test -v --cover ./...
1117

1218
docker-build:
1319
docker build -t ${IMG} .
@@ -41,17 +47,14 @@ e2e: docker-build
4147
# Start node ttl
4248
helm upgrade --kubeconfig $$KIND_KUBECONFIG --install --create-namespace --namespace="node-ttl" node-ttl ./charts/node-ttl --set "image.pullPolicy=Never" --set "nodeTtl.interval=10s" --set "image.tag=${TAG}"
4349

44-
# Run capcity check tests
45-
go test ./e2e/e2e_test.go -cover -v -timeout 300s -run TestCapcityCheck
46-
4750
# Start pause workloads
4851
kubectl --kubeconfig $$KIND_KUBECONFIG apply -f ./e2e/pause-workloads.yaml
4952
kubectl --kubeconfig $$KIND_KUBECONFIG --namespace default wait --timeout=300s --for=jsonpath="{.status.active}"=1 job/pause
5053
kubectl --kubeconfig $$KIND_KUBECONFIG --namespace default wait --timeout=300s --for=jsonpath="{.status.availableReplicas}"=3 deployment/pause
5154
kubectl --kubeconfig $$KIND_KUBECONFIG --namespace default wait --timeout=300s --for=jsonpath="{.status.availableReplicas}"=3 statefulset/pause
5255

5356
# Run TTL eviction tests
54-
go test ./e2e/e2e_test.go -cover -v -timeout 300s -run TestTTLEviction
57+
go test ./e2e/e2e_test.go -cover -v -timeout 600s -run TestTTLEviction
5558

5659
# Delete cluster
5760
#kind delete cluster

e2e/cluster-autoscaler.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ spec:
315315
- ./cluster-autoscaler
316316
- --cloud-provider=kubemark
317317
- --namespace=cluster-autoscaler
318-
- --nodes=1:10:asg1
318+
- --nodes=2:3:asg1
319319
- --logtostderr=true
320320
- --scale-down-delay-after-add=10s
321321
- --scale-down-delay-after-delete=10s

e2e/e2e_test.go

Lines changed: 30 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package e2e
22

33
import (
44
"context"
5-
"fmt"
65
"os"
76
"sort"
87
"testing"
@@ -16,93 +15,56 @@ import (
1615
"k8s.io/client-go/tools/clientcmd"
1716
)
1817

19-
func TestCapcityCheck(t *testing.T) {
20-
path := os.Getenv("KIND_KUBECONFIG")
21-
cfg, err := clientcmd.BuildConfigFromFlags("", path)
22-
require.NoError(t, err)
23-
client, err := kubernetes.NewForConfig(cfg)
24-
require.NoError(t, err)
25-
26-
require.Never(t, func() bool {
27-
nodeList, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: "xkf.xenit.io/node-ttl"})
28-
require.NoError(t, err)
29-
for _, node := range nodeList.Items {
30-
t.Log("checking that node is not evicted", node.Name)
31-
// TODO: There should be a better way to check that eviction is due to node ttl
32-
return node.Spec.Unschedulable
33-
}
34-
return false
35-
}, 1*time.Minute, 5*time.Second)
36-
}
37-
3818
func TestTTLEviction(t *testing.T) {
3919
path := os.Getenv("KIND_KUBECONFIG")
4020
cfg, err := clientcmd.BuildConfigFromFlags("", path)
4121
require.NoError(t, err)
4222
client, err := kubernetes.NewForConfig(cfg)
4323
require.NoError(t, err)
4424

45-
nodeList, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: "xkf.xenit.io/node-ttl"})
25+
nodeList, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: "xkf.xenit.io/node-ttl,autoscaling.k8s.io/nodegroup=asg1"})
4626
require.NoError(t, err)
4727
nodes := nodeList.Items
4828
sort.SliceStable(nodes, func(i, j int) bool {
4929
return nodes[j].CreationTimestamp.After(nodes[i].CreationTimestamp.Time)
5030
})
5131

52-
nodeNames := []string{}
53-
for _, node := range nodes {
54-
nodeNames = append(nodeNames, node.Name)
55-
}
32+
nodeMap := getNodesMap(nodes)
33+
nodeNames := getNodeKeys(nodeMap)
5634
t.Log("checking eviction of nodes", nodeNames)
5735

58-
for _, node := range nodeList.Items {
59-
t.Log("waiting for node to be evicted due to TTL", node.Name)
60-
61-
require.Eventually(t, func() bool {
62-
getNode, err := client.CoreV1().Nodes().Get(context.TODO(), node.Name, metav1.GetOptions{})
63-
require.NoError(t, err)
64-
// TODO: There should be a better way to check that eviction is due to node ttl
65-
if !getNode.Spec.Unschedulable {
66-
return false
67-
}
68-
return true
69-
}, 2*time.Minute, 1*time.Second, "node should be evicted due to TTL")
70-
t.Log("node has been marked unschedulable by node ttl", node.Name)
71-
72-
require.Eventually(t, func() bool {
73-
podList, err := client.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{FieldSelector: fmt.Sprintf("spec.nodeName=%s", node.Name)})
74-
require.NoError(t, err)
75-
pods := testFilterDaemonset(podList.Items)
76-
if len(pods) != 0 {
77-
return false
36+
// What we want to test now is that all the nodes eventually get replaced by new ones
37+
require.Eventually(t, func() bool {
38+
for _, name := range nodeMap {
39+
_, err := client.CoreV1().Nodes().Get(context.TODO(), name, metav1.GetOptions{})
40+
if apierrors.IsNotFound(err) {
41+
delete(nodeMap, name)
42+
t.Logf("node %s doesn't exist anymore, continuing with next one", name)
43+
continue
7844
}
79-
return true
80-
}, 30*time.Second, 1*time.Second, "node should be drained")
81-
t.Log("node has been drained", node.Name)
45+
}
46+
return len(nodeMap) == 0
47+
}, 5*time.Minute, 5*time.Second, "all nodes should have been evicted and replaced by new nodes")
8248

83-
// TODO: Make sure only one node is beeing evicted at once
49+
nodeList, err = client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{LabelSelector: "xkf.xenit.io/node-ttl,autoscaling.k8s.io/nodegroup=asg1"})
50+
require.NoError(t, err)
51+
nodeMap = getNodesMap(nodeList.Items)
52+
nodeNames = getNodeKeys(nodeMap)
53+
t.Log("nodes after all nodes have been evicted", nodeNames)
54+
}
8455

85-
require.Eventually(t, func() bool {
86-
_, err := client.CoreV1().Nodes().Get(context.TODO(), node.Name, metav1.GetOptions{})
87-
if !apierrors.IsNotFound(err) {
88-
return false
89-
}
90-
return true
91-
}, 2*time.Minute, 1*time.Second, "node should be delted")
92-
t.Log("underutilized node has been deleted", node.Name)
56+
func getNodesMap(nodes []corev1.Node) map[string]string {
57+
nodeNames := make(map[string]string)
58+
for _, node := range nodes {
59+
nodeNames[node.Name] = node.Name
9360
}
61+
return nodeNames
9462
}
9563

96-
func testFilterDaemonset(pods []corev1.Pod) []corev1.Pod {
97-
filteredPods := []corev1.Pod{}
98-
OUTER:
99-
for _, pod := range pods {
100-
for _, ownerRef := range pod.OwnerReferences {
101-
if ownerRef.APIVersion == "apps/v1" && ownerRef.Kind == "DaemonSet" {
102-
continue OUTER
103-
}
104-
}
105-
filteredPods = append(filteredPods, pod)
64+
func getNodeKeys(m map[string]string) []string {
65+
nodeKeys := []string{}
66+
for _, node := range m {
67+
nodeKeys = append(nodeKeys, node)
10668
}
107-
return filteredPods
69+
return nodeKeys
10870
}

internal/status/status.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,20 @@ type ClusterAutoscalerStatusConfigMap struct {
7272
NodeGroups []*NodeGroupsType `yaml:"nodeGroups"`
7373
}
7474

75-
func HasScaleDownCapacity(status string, node *corev1.Node) (bool, error) {
75+
func CanEvictNode(status string, node *corev1.Node) (bool, error) {
7676
nodePoolName, err := getNodePoolName(node)
7777
if err != nil {
7878
return false, err
7979
}
8080

81-
ready, min, err := getNodePoolReadyAndMinCount(node.Status.NodeInfo.KubeletVersion, status, nodePoolName)
81+
ready, min, max, err := getNodePoolCounts(node.Status.NodeInfo.KubeletVersion, status, nodePoolName)
8282
if err != nil {
8383
return false, err
8484
}
85-
if ready <= min {
85+
// We evict the node if we can temporarily scale down or add a new node
86+
//nolint:staticcheck // QF1001: this is exactly what the above comment states
87+
if !(ready-1 >= min || ready+1 <= max) {
88+
log.Printf("not safe to evict node (ready: %d, min: %d, max: %d)", ready, min, max)
8689
return false, nil
8790
}
8891
return true, nil
@@ -119,29 +122,29 @@ func getNodePoolName(node *corev1.Node) (string, error) {
119122
return "", fmt.Errorf("could not find node pool label in node: %s", node.Name)
120123
}
121124

122-
func getNodePoolReadyAndMinCount(kubeletVersion, status, nodePoolName string) (int, int, error) {
125+
func getNodePoolCounts(kubeletVersion, status, nodePoolName string) (int, int, int, error) {
123126
// Assume we are running at least v1.2.X
124127
preV130 := strings.Contains(kubeletVersion, "v1.2")
125128
if preV130 {
126129
health, err := getNodePoolHealthPreV130(status, nodePoolName)
127130
if err != nil {
128-
return 0, 0, err
131+
return 0, 0, 0, err
129132
}
130-
ready, min, err := getReadyAndMinCountPreV130(health)
131-
return ready, min, err
133+
ready, min, max, err := getNodePoolCountsPreV130(health)
134+
return ready, min, max, err
132135
}
133136

134137
// v1.3.X or later
135138
health, err := getNodePoolHealth(status, nodePoolName)
136139
if err != nil {
137140
fmt.Printf("Error: %s", err.Error())
138-
return 0, 0, err
141+
return 0, 0, 0, err
139142
}
140143

141144
if health.NodeCounts != nil && health.NodeCounts.Registered != nil {
142-
return health.NodeCounts.Registered.Ready, health.MinSize, nil
145+
return health.NodeCounts.Registered.Ready, health.MinSize, health.MaxSize, nil
143146
}
144-
return 0, 0, nil
147+
return 0, 0, 0, nil
145148
}
146149

147150
func getNodePoolHealthPreV130(status string, nodePoolName string) (string, error) {
@@ -179,21 +182,26 @@ func getNodePoolHealth(s string, nodePoolName string) (*HealthType, error) {
179182
return nil, fmt.Errorf("could not find status for node pool: %s", nodePoolName)
180183
}
181184

182-
func getReadyAndMinCountPreV130(health string) (int, int, error) {
183-
reg := regexp.MustCompile(`Healthy \(ready=(\d+).*minSize=(\d+)`)
185+
func getNodePoolCountsPreV130(health string) (int, int, int, error) {
186+
reg := regexp.MustCompile(`Healthy \(ready=(\d+).*minSize=(\d+).*maxSize=(\d+)`)
184187
matches := reg.FindStringSubmatch(health)
185188
if len(matches) != 3 {
186-
return 0, 0, fmt.Errorf("expected match list to be of length 3: %d", len(matches))
189+
return 0, 0, 0, fmt.Errorf("expected match list to be of length 3: %d", len(matches))
187190
}
188191

189192
ready, err := strconv.Atoi(matches[1])
190193
if err != nil {
191-
return 0, 0, fmt.Errorf("could not convert ready count to int: %w", err)
194+
return 0, 0, 0, fmt.Errorf("could not convert ready count to int: %w", err)
192195
}
193196

194197
min, err := strconv.Atoi(matches[2])
195198
if err != nil {
196-
return 0, 0, fmt.Errorf("could not convert min count to int: %w", err)
199+
return 0, 0, 0, fmt.Errorf("could not convert min count to int: %w", err)
197200
}
198-
return ready, min, nil
201+
202+
max, err := strconv.Atoi(matches[3])
203+
if err != nil {
204+
return 0, 0, 0, fmt.Errorf("could not convert min count to int: %w", err)
205+
}
206+
return ready, min, max, nil
199207
}

internal/status/status_test.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,40 @@ import (
99
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1010
)
1111

12-
func TestGetNodePoolReadyAndMinCountExisting(t *testing.T) {
12+
func TestGetNodePoolReadyAndMinMaxCountExisting(t *testing.T) {
1313
nodePools := []testNodePool{
1414
{
1515
name: "foo",
1616
ready: 10,
1717
min: 22,
18+
max: 30,
1819
},
1920
{
2021
name: "bar",
2122
ready: 25,
2223
min: 1,
24+
max: 30,
2325
},
2426
{
2527
name: "nodePool",
2628
ready: 2,
2729
min: 100,
30+
max: 500,
2831
},
2932
{
3033
name: "baz",
3134
ready: 87,
3235
min: 35,
36+
max: 100,
3337
},
3438
}
3539
status := mockClusterAutoscalerStatus(t, nodePools)
3640
for _, nodePool := range nodePools {
37-
ready, min, err := getNodePoolReadyAndMinCount("v1.31.2", status, nodePool.name)
41+
ready, min, max, err := getNodePoolCounts("v1.31.2", status, nodePool.name)
3842
require.NoError(t, err)
3943
require.Equal(t, nodePool.ready, ready)
4044
require.Equal(t, nodePool.min, min)
45+
require.Equal(t, nodePool.max, max)
4146
}
4247
}
4348

@@ -50,29 +55,32 @@ func TestGetNodePoolReadyAndMinCountNotFound(t *testing.T) {
5055
},
5156
}
5257
status := mockClusterAutoscalerStatus(t, nodePools)
53-
_, _, err := getNodePoolReadyAndMinCount("v1.31.2", status, "bar")
58+
_, _, _, err := getNodePoolCounts("v1.31.2", status, "bar")
5459
require.EqualError(t, err, "could not find status for node pool: bar")
5560
}
5661

57-
func TestHasScaleDownCapacity(t *testing.T) {
62+
func TestCanEvictNode(t *testing.T) {
5863
type test struct {
5964
name string
6065
ready int
6166
min int
67+
max int
6268
isSafe bool
6369
}
6470

6571
tests := []test{
6672
{
67-
name: "safe to scale down node pool foo",
73+
name: "safe to evict node in pool foo",
6874
ready: 2,
6975
min: 1,
76+
max: 3,
7077
isSafe: true,
7178
},
7279
{
73-
name: "not safe to scale down node pool bar",
80+
name: "not safe to evict node in pool bar",
7481
ready: 1,
7582
min: 1,
83+
max: 1,
7684
isSafe: false,
7785
},
7886
}
@@ -85,9 +93,10 @@ func TestHasScaleDownCapacity(t *testing.T) {
8593
name: nodePoolName,
8694
ready: tt.ready,
8795
min: tt.min,
96+
max: tt.max,
8897
}
8998
status := mockClusterAutoscalerStatus(t, []testNodePool{nodePool})
90-
ok, err := HasScaleDownCapacity(status, node)
99+
ok, err := CanEvictNode(status, node)
91100
require.NoError(t, err)
92101
require.Equal(t, tt.isSafe, ok)
93102
}
@@ -99,6 +108,7 @@ type testNodePool struct {
99108
name string
100109
ready int
101110
min int
111+
max int
102112
}
103113

104114
func mockClusterAutoscalerStatus(t *testing.T, nodePools []testNodePool) string {
@@ -143,9 +153,9 @@ nodeGroups:`
143153
unregistered: 0
144154
cloudProviderTarget: %[3]d
145155
minSize: %[4]d
146-
maxSize: 10
156+
maxSize: %[5]d
147157
lastProbeTime: "2025-04-22T14:29:08.360891242Z"
148-
lastTransitionTime: "2025-04-17T23:46:40.655271485Z"`, status, nodePool.name, nodePool.ready, nodePool.min)
158+
lastTransitionTime: "2025-04-17T23:46:40.655271485Z"`, status, nodePool.name, nodePool.ready, nodePool.min, nodePool.max)
149159
}
150160

151161
return status

0 commit comments

Comments
 (0)