Merge pull request #103 from MaciekPytel/node_group_sets3

Function to balance scale-up between node groups
This commit is contained in:
MaciekPytel 2017-06-02 16:04:49 +02:00 committed by GitHub
commit c5e9f479de
2 changed files with 314 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}