diff --git a/cluster-autoscaler/utils/nodegroupset/scale_up.go b/cluster-autoscaler/utils/nodegroupset/scale_up.go new file mode 100644 index 0000000000..0d644c4f93 --- /dev/null +++ b/cluster-autoscaler/utils/nodegroupset/scale_up.go @@ -0,0 +1,154 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodegroupset + +import ( + "fmt" + "sort" + + "k8s.io/autoscaler/cluster-autoscaler/cloudprovider" + "k8s.io/autoscaler/cluster-autoscaler/utils/errors" + + "github.com/golang/glog" +) + +// ScaleUpInfo contains information about planned scale-up of a single NodeGroup +type ScaleUpInfo struct { + // Group is the group to be scaled-up + Group cloudprovider.NodeGroup + // CurrentSize is the current size of the Group + CurrentSize int + // NewSize is the size the Group will be scaled-up to + NewSize int + // MaxSize is the maximum allowed size of the Group + MaxSize int +} + +// String is used for printing ScaleUpInfo for logging, etc +func (s ScaleUpInfo) String() string { + return fmt.Sprintf("{%v %v->%v (max: %v)}", s.Group.Id(), s.CurrentSize, s.NewSize, s.MaxSize) +} + +// BalanceScaleUpBetweenGroups distributes a given number of nodes between +// given set of NodeGroups. The nodes are added to smallest group first, trying +// to make the group sizes as evenly balanced as possible. +// +// Returns ScaleUpInfos for groups that need to be resized. +// +// MaxSize of each group will be respected. If newNodes > total free capacity +// of all NodeGroups it will be capped to total capacity. In particular if all +// group already have MaxSize, empty list will be returned. +func BalanceScaleUpBetweenGroups(groups []cloudprovider.NodeGroup, newNodes int) ([]ScaleUpInfo, *errors.AutoscalerError) { + if len(groups) == 0 { + return []ScaleUpInfo{}, errors.NewAutoscalerError( + errors.InternalError, "Can't balance scale up between 0 groups") + } + + // get all data from cloudprovider, build data structure + scaleUpInfos := make([]ScaleUpInfo, 0) + totalCapacity := 0 + for _, ng := range groups { + currentSize, err := ng.TargetSize() + if err != nil { + return []ScaleUpInfo{}, errors.NewAutoscalerError( + errors.CloudProviderError, + "failed to get node group size: %v", err) + } + maxSize := ng.MaxSize() + if currentSize == maxSize { + // group already maxed, ignore it + continue + } + info := ScaleUpInfo{ + Group: ng, + CurrentSize: currentSize, + NewSize: currentSize, + MaxSize: maxSize} + scaleUpInfos = append(scaleUpInfos, info) + totalCapacity += maxSize - currentSize + } + if totalCapacity < newNodes { + glog.Infof("Requested scale-up (%v) exceeds node group set capacity, capping to %v", newNodes, totalCapacity) + newNodes = totalCapacity + } + + // The actual balancing algorithm. + // Sort the node groups by current size and just loop over nodes adding + // to smallest group. If a group hits max size remove it from the list + // (by moving it to start of the list and increasing startIndex). + // + // In each iteration we either allocate one node, or 'remove' a maxed out + // node group, so this will terminate in O(#nodes + #node groups) steps. + // We already know that newNodes <= total capacity, so we don't have to + // worry about accidentally removing all node groups while we still + // have nodes to allocate. + // + // Loop invariants: + // 1. i < startIndex -> scaleUpInfos[i].CurrentSize == scaleUpInfos[i].MaxSize + // 2. i >= startIndex -> scaleUpInfos[i].CurrentSize < scaleUpInfos[i].MaxSize + // 3. startIndex <= currentIndex < len(scaleUpInfos) + // 4. currentIndex <= i < j -> scaleUpInfos[i].CurrentSize <= scaleUpInfos[j].CurrentSize + // 5. startIndex <= i < j < currentIndex -> scaleUpInfos[i].CurrentSize == scaleUpInfos[j].CurrentSize + // 6. startIndex <= i < currentIndex <= j -> scaleUpInfos[i].CurrentSize <= scaleUpInfos[j].CurrentSize + 1 + sort.Slice(scaleUpInfos, func(i, j int) bool { + return scaleUpInfos[i].CurrentSize < scaleUpInfos[j].CurrentSize + }) + startIndex := 0 + currentIndex := 0 + for newNodes > 0 { + currentInfo := &scaleUpInfos[currentIndex] + + if currentInfo.NewSize < currentInfo.MaxSize { + // Add a node to group on currentIndex + currentInfo.NewSize++ + newNodes-- + } else { + // Group on currentIndex is full. Remove it from the array. + // Removing is done by swapping the group with the first + // group still in array and moving the start of the array. + // Every group between startIndex and currentIndex has the + // same size, so we can swap without breaking ordering. + scaleUpInfos[startIndex], scaleUpInfos[currentIndex] = scaleUpInfos[currentIndex], scaleUpInfos[startIndex] + startIndex++ + } + + // Update currentIndex. + // If we removed a group in this loop currentIndex may be equal to startIndex-1, + // in which case both branches of below if will make currentIndex == startIndex. + if currentIndex < len(scaleUpInfos)-1 && currentInfo.NewSize > scaleUpInfos[currentIndex+1].NewSize { + // Next group has exactly one less node, than current one. + // We will increase it in next iteration. + currentIndex++ + } else { + // We reached end of array, or a group larger than the current one. + // All groups from startIndex to currentIndex have the same size. + // So we're moving to the beginning of array to loop over all of + // them once again. + currentIndex = startIndex + } + } + + // Filter out groups that haven't changed size + result := make([]ScaleUpInfo, 0) + for _, info := range scaleUpInfos { + if info.NewSize != info.CurrentSize { + result = append(result, info) + } + } + + return result, nil +} diff --git a/cluster-autoscaler/utils/nodegroupset/scale_up_test.go b/cluster-autoscaler/utils/nodegroupset/scale_up_test.go new file mode 100644 index 0000000000..88db8290bf --- /dev/null +++ b/cluster-autoscaler/utils/nodegroupset/scale_up_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodegroupset + +import ( + "testing" + + "k8s.io/autoscaler/cluster-autoscaler/cloudprovider" + "k8s.io/autoscaler/cluster-autoscaler/cloudprovider/test" + + "github.com/stretchr/testify/assert" +) + +func TestBalanceSingleGroup(t *testing.T) { + provider := testprovider.NewTestCloudProvider(nil, nil) + provider.AddNodeGroup("ng1", 1, 10, 1) + + // just one node + scaleUpInfo, err := BalanceScaleUpBetweenGroups(provider.NodeGroups(), 1) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, 2, scaleUpInfo[0].NewSize) + + // multiple nodes + scaleUpInfo, err = BalanceScaleUpBetweenGroups(provider.NodeGroups(), 4) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, 5, scaleUpInfo[0].NewSize) +} + +func TestBalanceUnderMaxSize(t *testing.T) { + provider := testprovider.NewTestCloudProvider(nil, nil) + provider.AddNodeGroup("ng1", 1, 10, 1) + provider.AddNodeGroup("ng2", 1, 10, 3) + provider.AddNodeGroup("ng3", 1, 10, 5) + provider.AddNodeGroup("ng4", 1, 10, 5) + + // add a single node + scaleUpInfo, err := BalanceScaleUpBetweenGroups(provider.NodeGroups(), 1) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, 2, scaleUpInfo[0].NewSize) + + // add multiple nodes to single group + scaleUpInfo, err = BalanceScaleUpBetweenGroups(provider.NodeGroups(), 2) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, 3, scaleUpInfo[0].NewSize) + + // add nodes to groups of different sizes, divisible + scaleUpInfo, err = BalanceScaleUpBetweenGroups(provider.NodeGroups(), 4) + assert.NoError(t, err) + assert.Equal(t, 2, len(scaleUpInfo)) + assert.Equal(t, 4, scaleUpInfo[0].NewSize) + assert.Equal(t, 4, scaleUpInfo[1].NewSize) + assert.True(t, scaleUpInfo[0].Group.Id() == "ng1" || scaleUpInfo[1].Group.Id() == "ng1") + assert.True(t, scaleUpInfo[0].Group.Id() == "ng2" || scaleUpInfo[1].Group.Id() == "ng2") + + // add nodes to groups of different sizes, non-divisible + // we expect new sizes to be 4 and 5, doesn't matter which group gets how many + scaleUpInfo, err = BalanceScaleUpBetweenGroups(provider.NodeGroups(), 5) + assert.NoError(t, err) + assert.Equal(t, 2, len(scaleUpInfo)) + assert.Equal(t, 9, scaleUpInfo[0].NewSize+scaleUpInfo[1].NewSize) + assert.True(t, scaleUpInfo[0].NewSize == 4 || scaleUpInfo[0].NewSize == 5) + assert.True(t, scaleUpInfo[0].Group.Id() == "ng1" || scaleUpInfo[1].Group.Id() == "ng1") + assert.True(t, scaleUpInfo[0].Group.Id() == "ng2" || scaleUpInfo[1].Group.Id() == "ng2") + + // add nodes to all groups, divisible + scaleUpInfo, err = BalanceScaleUpBetweenGroups(provider.NodeGroups(), 10) + assert.NoError(t, err) + assert.Equal(t, 4, len(scaleUpInfo)) + for _, info := range scaleUpInfo { + assert.Equal(t, 6, info.NewSize) + } +} + +func TestBalanceHittingMaxSize(t *testing.T) { + provider := testprovider.NewTestCloudProvider(nil, nil) + provider.AddNodeGroup("ng1", 1, 1, 1) + provider.AddNodeGroup("ng2", 1, 3, 1) + provider.AddNodeGroup("ng3", 1, 10, 3) + provider.AddNodeGroup("ng4", 1, 7, 5) + groupsMap := make(map[string]cloudprovider.NodeGroup) + for _, group := range provider.NodeGroups() { + groupsMap[group.Id()] = group + } + + getGroups := func(names ...string) []cloudprovider.NodeGroup { + result := make([]cloudprovider.NodeGroup, 0) + for _, n := range names { + result = append(result, groupsMap[n]) + } + return result + } + + toMap := func(suiList []ScaleUpInfo) map[string]ScaleUpInfo { + result := make(map[string]ScaleUpInfo, 0) + for _, sui := range suiList { + result[sui.Group.Id()] = sui + } + return result + } + + // Just one maxed out group + scaleUpInfo, err := BalanceScaleUpBetweenGroups(getGroups("ng1"), 1) + assert.NoError(t, err) + assert.Equal(t, 0, len(scaleUpInfo)) + + // Smallest group already maxed out, add one node + scaleUpInfo, err = BalanceScaleUpBetweenGroups(getGroups("ng1", "ng2"), 1) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, "ng2", scaleUpInfo[0].Group.Id()) + assert.Equal(t, 2, scaleUpInfo[0].NewSize) + + // Smallest group already maxed out, too many nodes (should cap to max capacity) + scaleUpInfo, err = BalanceScaleUpBetweenGroups(getGroups("ng1", "ng2"), 5) + assert.NoError(t, err) + assert.Equal(t, 1, len(scaleUpInfo)) + assert.Equal(t, "ng2", scaleUpInfo[0].Group.Id()) + assert.Equal(t, 3, scaleUpInfo[0].NewSize) + + // First group maxes out before proceeding to next one + scaleUpInfo, err = BalanceScaleUpBetweenGroups(getGroups("ng2", "ng3"), 4) + assert.Equal(t, 2, len(scaleUpInfo)) + scaleUpMap := toMap(scaleUpInfo) + assert.Equal(t, 3, scaleUpMap["ng2"].NewSize) + assert.Equal(t, 5, scaleUpMap["ng3"].NewSize) + + // Last group maxes out before previous one + scaleUpInfo, err = BalanceScaleUpBetweenGroups(getGroups("ng2", "ng3", "ng4"), 9) + assert.Equal(t, 3, len(scaleUpInfo)) + scaleUpMap = toMap(scaleUpInfo) + assert.Equal(t, 3, scaleUpMap["ng2"].NewSize) + assert.Equal(t, 8, scaleUpMap["ng3"].NewSize) + assert.Equal(t, 7, scaleUpMap["ng4"].NewSize) + + // Use all capacity, cap to max + scaleUpInfo, err = BalanceScaleUpBetweenGroups(getGroups("ng2", "ng3", "ng4"), 900) + assert.Equal(t, 3, len(scaleUpInfo)) + scaleUpMap = toMap(scaleUpInfo) + assert.Equal(t, 3, scaleUpMap["ng2"].NewSize) + assert.Equal(t, 10, scaleUpMap["ng3"].NewSize) + assert.Equal(t, 7, scaleUpMap["ng4"].NewSize) +}