Skip to content

Commit 679092c

Browse files
authoredJun 6, 2024
add the ability to remove nodes older than a certain age even when the node group has reached the min (#240)
* add the ability to remove nodes older than a certain age even when the node group has reached the min - adds max_node_age field to node group configuration - max_node_age by default is disabled, and needs to be a positive, greater than zero duration to enable - when the node group has reached the minimum and there are nodes older than the max_node_age, the scale delta will be set to +1 - this will then force escalator to replace the oldest node fixes #239 Signed-off-by: Alex Price <aprice@atlassian.com> * add test case for scale down to zero, also fix edge case that tries to rotate nodes when at the ASG min, but there is a tainted node Signed-off-by: Alex Price <aprice@atlassian.com> * add docs Signed-off-by: Alex Price <aprice@atlassian.com> --------- Signed-off-by: Alex Price <aprice@atlassian.com>
1 parent 97246b1 commit 679092c

File tree

6 files changed

+388
-8
lines changed

6 files changed

+388
-8
lines changed
 

‎docs/configuration/nodegroup.md

+15
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ node_groups:
2727
soft_delete_grace_period: 1m
2828
hard_delete_grace_period: 10m
2929
taint_effect: NoExecute
30+
max_node_age: 24h
3031
aws:
3132
fleet_instance_ready_timeout: 1m
3233
launch_template_id: lt-1a2b3c4d
@@ -258,3 +259,17 @@ be taken from the launch template.
258259
Tag ASG and Fleet Request resources used by Escalator with the metatdata key-value pair
259260
`k8s.io/atlassian-escalator/enabled`:`true`. Tagging doesn't alter the functionality of Escalator. Read more about
260261
tagging your AWS resources [here](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html).
262+
263+
### `max_node_age`
264+
265+
`max_node_age` allows the configuration of a maximum age for nodes in the node group when the node group has scaled down
266+
to the minimum node group size. When at the minimum node grop size, Escalator will trigger a scale up by a minimum of 1
267+
if there are any nodes exceeding this max node age in an effort to have the old node rotated out.
268+
269+
This is to ensure that nodes in the node group are kept relatively new to pick up any changes to the launch
270+
configuration or launch template.
271+
272+
When not at the minimum, the natural scaling up and down of the node group will ensure new nodes are introduced to the
273+
node group.
274+
275+
This is an optional feature and by default is disabled.

‎pkg/controller/controller.go

+34
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,12 @@ func (c *Controller) scaleNodeGroup(nodegroup string, nodeGroup *NodeGroupState)
373373
nodesDelta = int(math.Max(float64(nodesDelta), 1))
374374
}
375375

376+
if c.scaleOnMaxNodeAge(nodeGroup, untaintedNodes, taintedNodes) {
377+
log.WithField("nodegroup", nodegroup).
378+
Info("Setting scale to minimum of 1 to rotate out a node older than the max node age")
379+
nodesDelta = int(math.Max(float64(nodesDelta), 1))
380+
}
381+
376382
log.WithField("nodegroup", nodegroup).Debugf("Delta: %v", nodesDelta)
377383

378384
scaleOptions := scaleOpts{
@@ -431,6 +437,34 @@ func (c *Controller) isScaleOnStarve(
431437
len(untaintedNodes) < nodeGroup.Opts.MaxNodes
432438
}
433439

440+
// scaleOnMaxNodeAge returns true if the node group is at the minimum and there is a node in the untaintedNodes older
441+
// than the configured node group's maxNodeAge. The idea here is to constantly rotate out the oldest nodes
442+
// when the node group is at the minimum. This is to ensure the nodes are receiving the most up-to-date configuration
443+
// from the cloud provider.
444+
func (c *Controller) scaleOnMaxNodeAge(nodeGroup *NodeGroupState, untaintedNodes []*v1.Node, taintedNodes []*v1.Node) bool {
445+
// Only enable this functionality if the node group has the feature enabled
446+
if nodeGroup.Opts.MaxNodeAgeDuration() <= 0 {
447+
return false
448+
}
449+
450+
// We don't want to attempt to rotate nodes that have reached the max age if we haven't reached the minimum node
451+
// count, as the scaling down of the node group will remove the oldest first anyway.
452+
// We also don't want to try to rotate out nodes if there are already nodes tainted.
453+
if len(untaintedNodes) != nodeGroup.Opts.MinNodes || len(untaintedNodes) == 0 || len(taintedNodes) > 0 {
454+
return false
455+
}
456+
457+
// Determine if there is an untainted node that exceeds the max age, if so then we should scale up
458+
// to trigger that node to be replaced.
459+
for _, n := range untaintedNodes {
460+
if time.Since(n.CreationTimestamp.Time) > nodeGroup.Opts.MaxNodeAgeDuration() {
461+
return true
462+
}
463+
}
464+
465+
return false
466+
}
467+
434468
// RunOnce performs the main autoscaler logic once
435469
func (c *Controller) RunOnce() error {
436470
startTime := time.Now()

‎pkg/controller/controller_scale_node_group_test.go

+255-8
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package controller
22

33
import (
44
"testing"
5-
duration "time"
5+
stdtime "time"
66

77
"github.com/atlassian/escalator/pkg/k8s"
88
"github.com/atlassian/escalator/pkg/k8s/resource"
@@ -39,7 +39,7 @@ func buildTestClient(nodes []*v1.Node, pods []*v1.Pod, nodeGroups []NodeGroupOpt
3939
opts := Opts{
4040
K8SClient: fakeClient,
4141
NodeGroups: nodeGroups,
42-
ScanInterval: 1 * duration.Minute,
42+
ScanInterval: 1 * stdtime.Minute,
4343
DryMode: false,
4444
}
4545
allPodLister, err := test.NewTestPodWatcher(pods, listerOptions.podListerOptions)
@@ -853,7 +853,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
853853
args args
854854
scaleUpWithCachedCapacity bool
855855
runs int
856-
runInterval duration.Duration
856+
runInterval stdtime.Duration
857857
want int
858858
err error
859859
}{
@@ -879,7 +879,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
879879
},
880880
false,
881881
1,
882-
duration.Minute,
882+
stdtime.Minute,
883883
-4,
884884
nil,
885885
},
@@ -905,7 +905,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
905905
},
906906
false,
907907
5,
908-
duration.Minute,
908+
stdtime.Minute,
909909
-2,
910910
nil,
911911
},
@@ -931,7 +931,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
931931
},
932932
false,
933933
1,
934-
duration.Minute,
934+
stdtime.Minute,
935935
-4,
936936
nil,
937937
},
@@ -958,7 +958,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
958958
},
959959
false,
960960
1,
961-
duration.Minute,
961+
stdtime.Minute,
962962
1,
963963
nil,
964964
},
@@ -985,7 +985,7 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
985985
},
986986
true,
987987
1,
988-
duration.Minute,
988+
stdtime.Minute,
989989
6,
990990
nil,
991991
},
@@ -1060,3 +1060,250 @@ func TestScaleNodeGroup_MultipleRuns(t *testing.T) {
10601060
})
10611061
}
10621062
}
1063+
1064+
func TestScaleNodeGroupNodeMaxAge(t *testing.T) {
1065+
buildNode := func(creation stdtime.Time, tainted bool) *v1.Node {
1066+
return test.BuildTestNode(test.NodeOpts{
1067+
CPU: int64(1000),
1068+
Mem: int64(1000),
1069+
Creation: creation,
1070+
Tainted: tainted,
1071+
})
1072+
}
1073+
1074+
type args struct {
1075+
nodes []*v1.Node
1076+
pods []*v1.Pod
1077+
nodeGroupOptions NodeGroupOptions
1078+
listerOptions ListerOptions
1079+
}
1080+
1081+
tests := []struct {
1082+
name string
1083+
args args
1084+
expectedNodeDelta int
1085+
err error
1086+
}{
1087+
{
1088+
"max_node_age disabled",
1089+
args{
1090+
nodes: []*v1.Node{
1091+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1092+
buildNode(time.Now().Add(-24*stdtime.Hour), false),
1093+
buildNode(time.Now().Add(-36*stdtime.Hour), false),
1094+
},
1095+
pods: nil,
1096+
nodeGroupOptions: NodeGroupOptions{
1097+
Name: "default",
1098+
CloudProviderGroupName: "default",
1099+
MinNodes: 3,
1100+
MaxNodes: 10,
1101+
ScaleUpThresholdPercent: 70,
1102+
MaxNodeAge: "0",
1103+
},
1104+
listerOptions: ListerOptions{},
1105+
},
1106+
0,
1107+
nil,
1108+
},
1109+
{
1110+
"max_node_age enabled, max node age 12 hours",
1111+
args{
1112+
nodes: []*v1.Node{
1113+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1114+
buildNode(time.Now().Add(-24*stdtime.Hour), false),
1115+
buildNode(time.Now().Add(-36*stdtime.Hour), false),
1116+
},
1117+
pods: nil,
1118+
nodeGroupOptions: NodeGroupOptions{
1119+
Name: "default",
1120+
CloudProviderGroupName: "default",
1121+
MinNodes: 3,
1122+
MaxNodes: 10,
1123+
ScaleUpThresholdPercent: 70,
1124+
MaxNodeAge: "12h",
1125+
},
1126+
listerOptions: ListerOptions{},
1127+
},
1128+
1,
1129+
nil,
1130+
},
1131+
{
1132+
"max_node_age enabled, max node age 48 hours",
1133+
args{
1134+
nodes: []*v1.Node{
1135+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1136+
buildNode(time.Now().Add(-24*stdtime.Hour), false),
1137+
buildNode(time.Now().Add(-36*stdtime.Hour), false),
1138+
},
1139+
pods: nil,
1140+
nodeGroupOptions: NodeGroupOptions{
1141+
Name: "default",
1142+
CloudProviderGroupName: "default",
1143+
MinNodes: 3,
1144+
MaxNodes: 10,
1145+
ScaleUpThresholdPercent: 70,
1146+
MaxNodeAge: "48h",
1147+
},
1148+
listerOptions: ListerOptions{},
1149+
},
1150+
0,
1151+
nil,
1152+
},
1153+
{
1154+
"max_node_age enabled, but not at node minimum",
1155+
args{
1156+
nodes: []*v1.Node{
1157+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1158+
buildNode(time.Now().Add(-24*stdtime.Hour), false),
1159+
buildNode(time.Now().Add(-36*stdtime.Hour), false),
1160+
},
1161+
pods: nil,
1162+
nodeGroupOptions: NodeGroupOptions{
1163+
Name: "default",
1164+
CloudProviderGroupName: "default",
1165+
MinNodes: 1,
1166+
MaxNodes: 10,
1167+
ScaleUpThresholdPercent: 70,
1168+
MaxNodeAge: "12h",
1169+
},
1170+
listerOptions: ListerOptions{},
1171+
},
1172+
0,
1173+
nil,
1174+
},
1175+
{
1176+
"max_node_age enabled, but no nodes",
1177+
args{
1178+
nodes: nil,
1179+
pods: nil,
1180+
nodeGroupOptions: NodeGroupOptions{
1181+
Name: "default",
1182+
CloudProviderGroupName: "default",
1183+
MinNodes: 1,
1184+
MaxNodes: 10,
1185+
ScaleUpThresholdPercent: 70,
1186+
MaxNodeAge: "12h",
1187+
},
1188+
listerOptions: ListerOptions{},
1189+
},
1190+
0,
1191+
nil,
1192+
},
1193+
{
1194+
"max_node_age enabled, some nodes are tainted",
1195+
args{
1196+
nodes: []*v1.Node{
1197+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1198+
buildNode(time.Now().Add(-24*stdtime.Hour), false),
1199+
buildNode(time.Now().Add(-36*stdtime.Hour), true),
1200+
},
1201+
pods: nil,
1202+
nodeGroupOptions: NodeGroupOptions{
1203+
Name: "default",
1204+
CloudProviderGroupName: "default",
1205+
MinNodes: 1,
1206+
MaxNodes: 10,
1207+
ScaleUpThresholdPercent: 70,
1208+
MaxNodeAge: "12h",
1209+
},
1210+
listerOptions: ListerOptions{},
1211+
},
1212+
0,
1213+
nil,
1214+
},
1215+
{
1216+
"max_node_age enabled, scaled down to zero",
1217+
args{
1218+
nodes: []*v1.Node{},
1219+
pods: nil,
1220+
nodeGroupOptions: NodeGroupOptions{
1221+
Name: "default",
1222+
CloudProviderGroupName: "default",
1223+
MinNodes: 0,
1224+
MaxNodes: 10,
1225+
ScaleUpThresholdPercent: 70,
1226+
MaxNodeAge: "12h",
1227+
},
1228+
listerOptions: ListerOptions{},
1229+
},
1230+
0,
1231+
nil,
1232+
},
1233+
{
1234+
"max_node_age enabled, 1 tainted, 1 untainted",
1235+
args{
1236+
nodes: []*v1.Node{
1237+
buildNode(time.Now().Add(-1*stdtime.Hour), false),
1238+
buildNode(time.Now().Add(-24*stdtime.Hour), true),
1239+
},
1240+
pods: nil,
1241+
nodeGroupOptions: NodeGroupOptions{
1242+
Name: "default",
1243+
CloudProviderGroupName: "default",
1244+
MinNodes: 1,
1245+
MaxNodes: 10,
1246+
ScaleUpThresholdPercent: 70,
1247+
MaxNodeAge: "30m",
1248+
},
1249+
listerOptions: ListerOptions{},
1250+
},
1251+
0,
1252+
nil,
1253+
},
1254+
}
1255+
1256+
for _, tt := range tests {
1257+
t.Run(tt.name, func(t *testing.T) {
1258+
nodeGroups := []NodeGroupOptions{tt.args.nodeGroupOptions}
1259+
ngName := tt.args.nodeGroupOptions.Name
1260+
client, opts, err := buildTestClient(tt.args.nodes, tt.args.pods, nodeGroups, tt.args.listerOptions)
1261+
require.NoError(t, err)
1262+
1263+
// For these test cases we only use 1 node group/cloud provider node group
1264+
nodeGroupSize := 1
1265+
1266+
// Create a test (mock) cloud provider
1267+
testCloudProvider := test.NewCloudProvider(nodeGroupSize)
1268+
testNodeGroup := test.NewNodeGroup(
1269+
tt.args.nodeGroupOptions.CloudProviderGroupName,
1270+
tt.args.nodeGroupOptions.Name,
1271+
int64(tt.args.nodeGroupOptions.MinNodes),
1272+
int64(tt.args.nodeGroupOptions.MaxNodes),
1273+
int64(len(tt.args.nodes)),
1274+
)
1275+
testCloudProvider.RegisterNodeGroup(testNodeGroup)
1276+
1277+
// Create a node group state with the mapping of node groups to the cloud providers node groups
1278+
nodeGroupsState := BuildNodeGroupsState(nodeGroupsStateOpts{
1279+
nodeGroups: nodeGroups,
1280+
client: *client,
1281+
})
1282+
1283+
controller := &Controller{
1284+
Client: client,
1285+
Opts: opts,
1286+
stopChan: nil,
1287+
nodeGroups: nodeGroupsState,
1288+
cloudProvider: testCloudProvider,
1289+
}
1290+
1291+
nodesDelta, err := controller.scaleNodeGroup(ngName, nodeGroupsState[ngName])
1292+
1293+
// Ensure there were no errors
1294+
if tt.err == nil {
1295+
require.NoError(t, err)
1296+
} else {
1297+
require.EqualError(t, tt.err, err.Error())
1298+
}
1299+
1300+
assert.Equal(t, tt.expectedNodeDelta, nodesDelta)
1301+
if nodesDelta <= 0 {
1302+
return
1303+
}
1304+
1305+
// Ensure the node group on the cloud provider side scales up to the correct amount
1306+
assert.Equal(t, int64(len(tt.args.nodes)+nodesDelta), testNodeGroup.TargetSize())
1307+
})
1308+
}
1309+
}

0 commit comments

Comments
 (0)
Failed to load comments.