1
0
mirror of https://github.com/kubernetes-sigs/descheduler.git synced 2026-01-26 13:29:11 +01:00

feat: move classifier to its own package

move the classifier to its own package. introduces a generic way of
classifying usages against thresholds.
This commit is contained in:
Ricardo Maraschini
2025-03-20 11:02:47 +01:00
parent 89535b9b9b
commit 95a631f6a5
6 changed files with 1241 additions and 74 deletions

View File

@@ -17,13 +17,17 @@ limitations under the License.
package nodeutilization
import (
"math"
"reflect"
"testing"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"sigs.k8s.io/descheduler/pkg/api"
"sigs.k8s.io/descheduler/pkg/framework/plugins/nodeutilization/classifier"
"sigs.k8s.io/descheduler/pkg/framework/plugins/nodeutilization/normalizer"
)
func BuildTestNodeInfo(name string, apply func(*NodeInfo)) *NodeInfo {
@@ -214,3 +218,362 @@ func TestResourceUsageToResourceThreshold(t *testing.T) {
})
}
}
func ResourceListUsageNormalizer(usages, totals v1.ResourceList) api.ResourceThresholds {
result := api.ResourceThresholds{}
for rname, value := range usages {
total, ok := totals[rname]
if !ok {
continue
}
used, avail := value.Value(), total.Value()
if rname == v1.ResourceCPU {
used, avail = value.MilliValue(), total.MilliValue()
}
pct := math.Max(math.Min(float64(used)/float64(avail)*100, 100), 0)
result[rname] = api.Percentage(pct)
}
return result
}
// This is a test for thresholds being defined as deviations from the average
// usage. This is expected to be a little longer test case. We are going to
// comment the steps to make it easier to follow.
func TestClassificationUsingDeviationThresholds(t *testing.T) {
// These are the two thresholds defined by the user. These thresholds
// mean that our low limit will be 5 pct points below the average and
// the high limit will be 5 pct points above the average.
userDefinedThresholds := map[string]api.ResourceThresholds{
"low": {v1.ResourceCPU: 5, v1.ResourceMemory: 5},
"high": {v1.ResourceCPU: 5, v1.ResourceMemory: 5},
}
// Create a fake total amount of resources for all nodes. We define
// the total amount to 1000 for both memory and cpu. This is so we
// can easily calculate (manually) the percentage of usages here.
nodesTotal := normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
v1.ResourceList{
v1.ResourceCPU: resource.MustParse("1000"),
v1.ResourceMemory: resource.MustParse("1000"),
},
)
// Create a fake usage per server per resource. We are aiming to
// have the average of these resources in 50%. When applying the
// thresholds we should obtain the low threhold at 45% and the high
// threshold at 55%.
nodesUsage := map[string]v1.ResourceList{
"node1": {
v1.ResourceCPU: resource.MustParse("100"),
v1.ResourceMemory: resource.MustParse("100"),
},
"node2": {
v1.ResourceCPU: resource.MustParse("480"),
v1.ResourceMemory: resource.MustParse("480"),
},
"node3": {
v1.ResourceCPU: resource.MustParse("520"),
v1.ResourceMemory: resource.MustParse("520"),
},
"node4": {
v1.ResourceCPU: resource.MustParse("500"),
v1.ResourceMemory: resource.MustParse("500"),
},
"node5": {
v1.ResourceCPU: resource.MustParse("900"),
v1.ResourceMemory: resource.MustParse("900"),
},
}
// Normalize the usage to percentages and then calculate the average
// among all nodes.
usage := normalizer.Normalize(nodesUsage, nodesTotal, ResourceListUsageNormalizer)
average := normalizer.Average(usage)
// Create the thresholds by first applying the deviations and then
// replicating once for each node. Thresholds are supposed to be per
// node even though the user provides them only once. This is by
// design as it opens the possibility for further implementations of
// thresholds per node.
thresholds := normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
[]api.ResourceThresholds{
normalizer.Sum(average, normalizer.Negate(userDefinedThresholds["low"])),
normalizer.Sum(average, userDefinedThresholds["high"]),
},
)
// Classify the nodes according to the thresholds. Nodes below the low
// threshold (45%) are underutilized, nodes above the high threshold
// (55%) are overutilized and nodes in between are properly utilized.
result := classifier.Classify(
usage, thresholds,
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(usage - limit)
},
),
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(limit - usage)
},
),
)
// we expect the node1 to be undertilized (10%), node2, node3 and node4
// to be properly utilized (48%, 52% and 50% respectively) and node5 to
// be overutilized (90%).
expected := []map[string]api.ResourceThresholds{
{"node1": {v1.ResourceCPU: 10, v1.ResourceMemory: 10}},
{"node5": {v1.ResourceCPU: 90, v1.ResourceMemory: 90}},
}
if !reflect.DeepEqual(result, expected) {
t.Fatalf("unexpected result: %v, expecting: %v", result, expected)
}
}
// This is almost a copy of TestUsingDeviationThresholds but we are using
// pointers here. This is for making sure our generic types are in check. To
// understand this code better read comments on TestUsingDeviationThresholds.
func TestUsingDeviationThresholdsWithPointers(t *testing.T) {
userDefinedThresholds := map[string]api.ResourceThresholds{
"low": {v1.ResourceCPU: 5, v1.ResourceMemory: 5},
"high": {v1.ResourceCPU: 5, v1.ResourceMemory: 5},
}
nodesTotal := normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
map[v1.ResourceName]*resource.Quantity{
v1.ResourceCPU: ptr.To(resource.MustParse("1000")),
v1.ResourceMemory: ptr.To(resource.MustParse("1000")),
},
)
nodesUsage := map[string]map[v1.ResourceName]*resource.Quantity{
"node1": {
v1.ResourceCPU: ptr.To(resource.MustParse("100")),
v1.ResourceMemory: ptr.To(resource.MustParse("100")),
},
"node2": {
v1.ResourceCPU: ptr.To(resource.MustParse("480")),
v1.ResourceMemory: ptr.To(resource.MustParse("480")),
},
"node3": {
v1.ResourceCPU: ptr.To(resource.MustParse("520")),
v1.ResourceMemory: ptr.To(resource.MustParse("520")),
},
"node4": {
v1.ResourceCPU: ptr.To(resource.MustParse("500")),
v1.ResourceMemory: ptr.To(resource.MustParse("500")),
},
"node5": {
v1.ResourceCPU: ptr.To(resource.MustParse("900")),
v1.ResourceMemory: ptr.To(resource.MustParse("900")),
},
}
ptrNormalizer := func(
usages, totals map[v1.ResourceName]*resource.Quantity,
) api.ResourceThresholds {
newUsages := v1.ResourceList{}
for name, usage := range usages {
newUsages[name] = *usage
}
newTotals := v1.ResourceList{}
for name, total := range totals {
newTotals[name] = *total
}
return ResourceListUsageNormalizer(newUsages, newTotals)
}
usage := normalizer.Normalize(nodesUsage, nodesTotal, ptrNormalizer)
average := normalizer.Average(usage)
thresholds := normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
[]api.ResourceThresholds{
normalizer.Sum(average, normalizer.Negate(userDefinedThresholds["low"])),
normalizer.Sum(average, userDefinedThresholds["high"]),
},
)
result := classifier.Classify(
usage, thresholds,
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(usage - limit)
},
),
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(limit - usage)
},
),
)
expected := []map[string]api.ResourceThresholds{
{"node1": {v1.ResourceCPU: 10, v1.ResourceMemory: 10}},
{"node5": {v1.ResourceCPU: 90, v1.ResourceMemory: 90}},
}
if !reflect.DeepEqual(result, expected) {
t.Fatalf("unexpected result: %v, expecting: %v", result, expected)
}
}
func TestNormalizeAndClassify(t *testing.T) {
for _, tt := range []struct {
name string
usage map[string]v1.ResourceList
totals map[string]v1.ResourceList
thresholds map[string][]api.ResourceThresholds
expected []map[string]api.ResourceThresholds
classifiers []classifier.Classifier[string, api.ResourceThresholds]
}{
{
name: "happy path test",
usage: map[string]v1.ResourceList{
"node1": {
// underutilized on cpu and memory.
v1.ResourceCPU: resource.MustParse("10"),
v1.ResourceMemory: resource.MustParse("10"),
},
"node2": {
// overutilized on cpu and memory.
v1.ResourceCPU: resource.MustParse("90"),
v1.ResourceMemory: resource.MustParse("90"),
},
"node3": {
// properly utilized on cpu and memory.
v1.ResourceCPU: resource.MustParse("50"),
v1.ResourceMemory: resource.MustParse("50"),
},
"node4": {
// underutilized on cpu and overutilized on memory.
v1.ResourceCPU: resource.MustParse("10"),
v1.ResourceMemory: resource.MustParse("90"),
},
},
totals: normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4"},
v1.ResourceList{
v1.ResourceCPU: resource.MustParse("100"),
v1.ResourceMemory: resource.MustParse("100"),
},
),
thresholds: normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4"},
[]api.ResourceThresholds{
{v1.ResourceCPU: 20, v1.ResourceMemory: 20},
{v1.ResourceCPU: 80, v1.ResourceMemory: 80},
},
),
expected: []map[string]api.ResourceThresholds{
{
"node1": {v1.ResourceCPU: 10, v1.ResourceMemory: 10},
},
{
"node2": {v1.ResourceCPU: 90, v1.ResourceMemory: 90},
},
},
classifiers: []classifier.Classifier[string, api.ResourceThresholds]{
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(usage - limit)
},
),
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(limit - usage)
},
),
},
},
{
name: "three thresholds",
usage: map[string]v1.ResourceList{
"node1": {
// match for the first classifier.
v1.ResourceCPU: resource.MustParse("10"),
v1.ResourceMemory: resource.MustParse("10"),
},
"node2": {
// match for the third classifier.
v1.ResourceCPU: resource.MustParse("90"),
v1.ResourceMemory: resource.MustParse("90"),
},
"node3": {
// match fo the second classifier.
v1.ResourceCPU: resource.MustParse("40"),
v1.ResourceMemory: resource.MustParse("40"),
},
"node4": {
// matches no classifier.
v1.ResourceCPU: resource.MustParse("10"),
v1.ResourceMemory: resource.MustParse("90"),
},
"node5": {
// match for the first classifier.
v1.ResourceCPU: resource.MustParse("11"),
v1.ResourceMemory: resource.MustParse("18"),
},
},
totals: normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
v1.ResourceList{
v1.ResourceCPU: resource.MustParse("100"),
v1.ResourceMemory: resource.MustParse("100"),
},
),
thresholds: normalizer.Replicate(
[]string{"node1", "node2", "node3", "node4", "node5"},
[]api.ResourceThresholds{
{v1.ResourceCPU: 20, v1.ResourceMemory: 20},
{v1.ResourceCPU: 50, v1.ResourceMemory: 50},
{v1.ResourceCPU: 80, v1.ResourceMemory: 80},
},
),
expected: []map[string]api.ResourceThresholds{
{
"node1": {v1.ResourceCPU: 10, v1.ResourceMemory: 10},
"node5": {v1.ResourceCPU: 11, v1.ResourceMemory: 18},
},
{
"node3": {v1.ResourceCPU: 40, v1.ResourceMemory: 40},
},
{
"node2": {v1.ResourceCPU: 90, v1.ResourceMemory: 90},
},
},
classifiers: []classifier.Classifier[string, api.ResourceThresholds]{
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(usage - limit)
},
),
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(usage - limit)
},
),
classifier.ForMap[string, v1.ResourceName, api.Percentage, api.ResourceThresholds](
func(usage, limit api.Percentage) int {
return int(limit - usage)
},
),
},
},
} {
t.Run(tt.name, func(t *testing.T) {
pct := normalizer.Normalize(tt.usage, tt.totals, ResourceListUsageNormalizer)
res := classifier.Classify(pct, tt.thresholds, tt.classifiers...)
if !reflect.DeepEqual(res, tt.expected) {
t.Fatalf("unexpected result: %v, expecting: %v", res, tt.expected)
}
})
}
}