Skip to content

Commit

Permalink
feat: calc resources for limited simultaneous rollouts with --max-rol…
Browse files Browse the repository at this point in the history
…louts

Introduces the --max-rollouts param, which limits how many simultaneous rollouts are assumed.

Refs: #16
  • Loading branch information
druppelt committed Sep 16, 2024
1 parent 4174364 commit 1deb674
Show file tree
Hide file tree
Showing 18 changed files with 292 additions and 81 deletions.
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,37 @@ Simple utility to calculate the maximum needed resource quota for deployment(s).
deployment strategy, replicas and all containers into account, see [supported-resources](https://github.com/druppelt/kuota-calc#supported-k8s-resources) for a list of kubernetes resources which are currently supported by kuota-calc.

## Motivation
In shared environments such as kubernetes it is always a good idea to isolate/constrain different workloads to prevent them from infering each other. Kubernetes provides [Resource Quotas](https://kubernetes.io/docs/concepts/policy/resource-quotas/) to limit compute, storage and object resources of namespaces.
In shared environments such as kubernetes it is always a good idea to isolate/constrain different workloads to prevent them from interfering each other. Kubernetes provides [Resource Quotas](https://kubernetes.io/docs/concepts/policy/resource-quotas/) to limit compute, storage and object resources of namespaces.

Calculating the needed compute resources can be a bit challenging (especially with large and complex deployments) because we must respect certain settings/defaults like the deployment strategy, number of replicas and so on. This is where kuota-calc can help you, it calculates the maximum needed resource quota in order to be able to start a deployment of all resources at the same time by respecting deployment strategies, replicas and so on.

## Example
Get a detailed report of all resources, their max required quota and a total.
```bash
$ cat examples/deployment.yaml | kuota-calc -detailed
Version Kind Name Replicas Strategy MaxReplicas CPURequest CPULimit MemoryRequest MemoryLimit
apps/v1 Deployment myapp 10 RollingUpdate 11 2750m 5500m 704Mi 2816Mi
apps/v1 StatefulSet myapp 3 RollingUpdate 3 750m 3 6Gi 12Gi
Version Kind Name Replicas Strategy MaxReplicas CPURequest CPULimit MemoryRequest MemoryLimit
apps/v1 Deployment myapp 10 RollingUpdate 13 3250m 6500m 832Mi 3328Mi
apps/v1 StatefulSet myapp 3 RollingUpdate 3 750m 3 6Gi 12Gi

Table and Total assuming simultaneous rollout of all resources

Total
CPU Request: 3500m
CPU Limit: 8500m
Memory Request: 6848Mi
Memory Limit: 15104Mi
CPU Request: 4
CPU Limit: 9500m
Memory Request: 6976Mi
Memory Limit: 15616Mi
```

To calc usage for deploymentConfigs, deployments and statefulSets deployed in openshift:
For comparison, here the simultaneous rollout is limited to zero resources, so you get the required quotas to just run, but not deploy the applications.
````bash
$ cat examples/deployment.yaml | kuota-calc --max-rollouts=0
CPU Request: 3250m
CPU Limit: 8
Memory Request: 6784Mi
Memory Limit: 14848Mi
````

To calc usage for deploymentConfigs, deployments and statefulSets deployed in an openshift cluster:
```bash
$ oc get dc,sts,deploy -o json | yq -p=json -o=yaml '.items[] | split_doc' | kuota-calc --detailed
Warning: apps.openshift.io/v1 DeploymentConfig is deprecated in v4.14+, unavailable in v4.10000+
Expand All @@ -52,7 +64,7 @@ Pre-compiled statically linked binaries are available on the [releases page](htt

kuota-calc can either be used as a kubectl plugin or invoked directly. If you intend to use kuota-calc as
a kubectl plugin, simply place the binary anywhere in `$PATH` named `kubectl-kuota_calc` with execute permissions.
For further information, see the offical documentation on kubectl plugins [here](https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/).
For further information, see the official documentation on kubectl plugins [here](https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/).

**currently the kubectl plugin is not released for this fork**

Expand All @@ -68,3 +80,7 @@ Currently supported:
- batch/v1 CronJob
- batch/v1 Job
- v1 Pod

## known limitation
- CronJobs: the cron concurrencyPolicy is not considered, a CronJob is treated as a single Pod (#18)
- DaemonSet: neither node count nor UpdateStrategy are considered. Treated as a single Pod. (#21)
46 changes: 21 additions & 25 deletions cmd/kuota-calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (

"github.com/druppelt/kuota-calc/internal/calc"
"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/api/resource"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/cli-runtime/pkg/genericclioptions"
)
Expand All @@ -29,9 +28,10 @@ type KuotaCalcOpts struct {
genericclioptions.IOStreams

// flags
debug bool
detailed bool
version bool
debug bool
detailed bool
version bool
maxRollouts int
// files []string

versionInfo *Version
Expand Down Expand Up @@ -61,6 +61,7 @@ func NewKuotaCalcCmd(version *Version, streams genericclioptions.IOStreams) *cob
cmd.Flags().BoolVar(&opts.debug, "debug", false, "enable debug logging")
cmd.Flags().BoolVar(&opts.detailed, "detailed", false, "enable detailed output")
cmd.Flags().BoolVar(&opts.version, "version", false, "print version and exit")
cmd.Flags().IntVar(&opts.maxRollouts, "max-rollouts", -1, "limit the simultaneous rollout to the n most expensive rollouts per resource")

return cmd
}
Expand Down Expand Up @@ -131,41 +132,36 @@ func (opts *KuotaCalcOpts) printDetailed(usage []*calc.ResourceUsage) {
u.Details.Replicas,
u.Details.Strategy,
u.Details.MaxReplicas,
u.Resources.CPUMin.String(),
u.Resources.CPUMax.String(),
u.Resources.MemoryMin.String(),
u.Resources.MemoryMax.String(),
u.RolloutResources.CPUMin.String(),
u.RolloutResources.CPUMax.String(),
u.RolloutResources.MemoryMin.String(),
u.RolloutResources.MemoryMax.String(),
)
}

if err := w.Flush(); err != nil {
_, _ = fmt.Fprintf(opts.Out, "printing detailed resources to tabwriter failed: %v\n", err)
}

if opts.maxRollouts > -1 {
_, _ = fmt.Fprintf(opts.Out, "\nTable assuming simultaneous rollout of all resources\n")
_, _ = fmt.Fprintf(opts.Out, "Total assuming simultaneous rollout of %d resources\n", opts.maxRollouts)
} else {
_, _ = fmt.Fprintf(opts.Out, "\nTable and Total assuming simultaneous rollout of all resources\n")
}

_, _ = fmt.Fprintf(opts.Out, "\nTotal\n")

opts.printSummary(usage)
}

func (opts *KuotaCalcOpts) printSummary(usage []*calc.ResourceUsage) {
var (
cpuMinUsage resource.Quantity
cpuMaxUsage resource.Quantity
memoryMinUsage resource.Quantity
memoryMaxUsage resource.Quantity
)

for _, u := range usage {
cpuMinUsage.Add(u.Resources.CPUMin)
cpuMaxUsage.Add(u.Resources.CPUMax)
memoryMinUsage.Add(u.Resources.MemoryMin)
memoryMaxUsage.Add(u.Resources.MemoryMax)
}
totalResources := calc.Total(opts.maxRollouts, usage)

_, _ = fmt.Fprintf(opts.Out, "CPU Request: %s\nCPU Limit: %s\nMemory Request: %s\nMemory Limit: %s\n",
cpuMinUsage.String(),
cpuMaxUsage.String(),
memoryMinUsage.String(),
memoryMaxUsage.String(),
totalResources.CPUMin.String(),
totalResources.CPUMax.String(),
totalResources.MemoryMin.String(),
totalResources.MemoryMax.String(),
)
}
89 changes: 87 additions & 2 deletions internal/calc/calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package calc
import (
"errors"
"fmt"
"slices"

openshiftAppsV1 "github.com/openshift/api/apps/v1"
openshiftScheme "github.com/openshift/client-go/apps/clientset/versioned/scheme"
Expand Down Expand Up @@ -45,8 +46,9 @@ func (cErr CalculationError) Unwrap() error {

// ResourceUsage summarizes the usage of compute resources for a k8s resource.
type ResourceUsage struct {
Resources Resources
Details Details
NormalResources Resources
RolloutResources Resources
Details Details
}

// Details contains a few details of a k8s resource, which are needed to generate a detailed resource
Expand Down Expand Up @@ -151,6 +153,89 @@ func maxQuantity(q1, q2 resource.Quantity) resource.Quantity {
return q2
}

// diffQuantities is just higher-lower returned as a new Quantity
func diffQuantities(higher, lower *resource.Quantity) resource.Quantity {
q := higher.DeepCopy()
q.Sub(*lower)

return q
}

// Total calculates the sum of all usages. maxRollout limits how many simultaneous rollouts are assumed.
// Negative maxRollout value -> unlimited rollouts.
func Total(maxRollout int, usage []*ResourceUsage) Resources {
var (
cpuMinUsage resource.Quantity
cpuMaxUsage resource.Quantity
memoryMinUsage resource.Quantity
memoryMaxUsage resource.Quantity
)

if maxRollout <= -1 {
// unlimited simultaneous rollout, just sum all rollout resources
for _, u := range usage {
cpuMinUsage.Add(u.RolloutResources.CPUMin)
cpuMaxUsage.Add(u.RolloutResources.CPUMax)
memoryMinUsage.Add(u.RolloutResources.MemoryMin)
memoryMaxUsage.Add(u.RolloutResources.MemoryMax)
}
} else {
// limited simultaneous rollout
// first sum the normal resources
// then search for the highest diffs between normal and rollout and add the top `opts.maxRollout` to the sums.
for _, u := range usage {
cpuMinUsage.Add(u.NormalResources.CPUMin)
cpuMaxUsage.Add(u.NormalResources.CPUMax)
memoryMinUsage.Add(u.NormalResources.MemoryMin)
memoryMaxUsage.Add(u.NormalResources.MemoryMax)
}

var cpuMinDiffs, cpuMaxDiffs, memoryMinDiffs, memoryMaxDiffs []resource.Quantity

for _, u := range usage {
cpuMinDiffs = append(cpuMinDiffs, diffQuantities(&u.RolloutResources.CPUMin, &u.NormalResources.CPUMin))

cpuMaxDiffs = append(cpuMaxDiffs, diffQuantities(&u.RolloutResources.CPUMax, &u.NormalResources.CPUMax))

memoryMinDiffs = append(memoryMinDiffs, diffQuantities(&u.RolloutResources.MemoryMin, &u.NormalResources.MemoryMin))

memoryMaxDiffs = append(memoryMaxDiffs, diffQuantities(&u.RolloutResources.MemoryMax, &u.NormalResources.MemoryMax))
}

compareQuantityDescending := func(a, b resource.Quantity) int {
return a.Cmp(b) * -1
}

slices.SortFunc(cpuMinDiffs, compareQuantityDescending)
slices.SortFunc(cpuMaxDiffs, compareQuantityDescending)
slices.SortFunc(memoryMinDiffs, compareQuantityDescending)
slices.SortFunc(memoryMaxDiffs, compareQuantityDescending)

for i := 0; i < len(cpuMinDiffs) && i < maxRollout; i++ {
cpuMinUsage.Add(cpuMinDiffs[i])
}

for i := 0; i < len(cpuMaxDiffs) && i < maxRollout; i++ {
cpuMaxUsage.Add(cpuMaxDiffs[i])
}

for i := 0; i < len(memoryMinDiffs) && i < maxRollout; i++ {
memoryMinUsage.Add(memoryMinDiffs[i])
}

for i := 0; i < len(memoryMaxDiffs) && i < maxRollout; i++ {
memoryMaxUsage.Add(memoryMaxDiffs[i])
}
}

return Resources{
CPUMin: cpuMinUsage,
CPUMax: cpuMaxUsage,
MemoryMin: memoryMinUsage,
MemoryMax: memoryMaxUsage,
}
}

// ResourceQuotaFromYaml decodes a single yaml document into a k8s object. Then performs a type assertion
// on the object and calculates the resource needs of it.
// Currently supported:
Expand Down
101 changes: 101 additions & 0 deletions internal/calc/calc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,3 +734,104 @@ func TestResourceQuotaFromYaml(t *testing.T) {
func AssertEqualQuantities(r *require.Assertions, expected resource.Quantity, actual resource.Quantity, name string) {
r.Conditionf(func() bool { return expected.Equal(actual) }, name+" expected: "+expected.String()+" but was: "+actual.String())
}

func TestTotal(t *testing.T) {
var tests = []struct {
name string
maxRollout int
usages []*ResourceUsage
expectedResources Resources
}{
{
name: "two resources, unlimited rollouts",
maxRollout: -1,
usages: []*ResourceUsage{
{
NormalResources: Resources{
CPUMin: resource.MustParse("100m"),
CPUMax: resource.MustParse("200m"),
MemoryMin: resource.MustParse("100Mi"),
MemoryMax: resource.MustParse("200Mi"),
},
RolloutResources: Resources{
CPUMin: resource.MustParse("200m"),
CPUMax: resource.MustParse("400m"),
MemoryMin: resource.MustParse("200Mi"),
MemoryMax: resource.MustParse("400Mi"),
},
},
{
NormalResources: Resources{
CPUMin: resource.MustParse("50m"),
CPUMax: resource.MustParse("100m"),
MemoryMin: resource.MustParse("50Mi"),
MemoryMax: resource.MustParse("100Mi"),
},
RolloutResources: Resources{
CPUMin: resource.MustParse("100m"),
CPUMax: resource.MustParse("200m"),
MemoryMin: resource.MustParse("100Mi"),
MemoryMax: resource.MustParse("200Mi"),
},
},
},
expectedResources: Resources{
CPUMin: resource.MustParse("300m"),
CPUMax: resource.MustParse("600m"),
MemoryMin: resource.MustParse("300Mi"),
MemoryMax: resource.MustParse("600Mi"),
},
},
{
name: "two resources, one rollouts",
maxRollout: 1,
usages: []*ResourceUsage{
{
NormalResources: Resources{
CPUMin: resource.MustParse("100m"),
CPUMax: resource.MustParse("200m"),
MemoryMin: resource.MustParse("100Mi"),
MemoryMax: resource.MustParse("200Mi"),
},
RolloutResources: Resources{
CPUMin: resource.MustParse("200m"),
CPUMax: resource.MustParse("400m"),
MemoryMin: resource.MustParse("200Mi"),
MemoryMax: resource.MustParse("400Mi"),
},
},
{
NormalResources: Resources{
CPUMin: resource.MustParse("50m"),
CPUMax: resource.MustParse("100m"),
MemoryMin: resource.MustParse("50Mi"),
MemoryMax: resource.MustParse("100Mi"),
},
RolloutResources: Resources{
CPUMin: resource.MustParse("100m"),
CPUMax: resource.MustParse("200m"),
MemoryMin: resource.MustParse("100Mi"),
MemoryMax: resource.MustParse("200Mi"),
},
},
},
expectedResources: Resources{
CPUMin: resource.MustParse("250m"),
CPUMax: resource.MustParse("500m"),
MemoryMin: resource.MustParse("250Mi"),
MemoryMax: resource.MustParse("500Mi"),
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := require.New(t)
total := Total(test.maxRollout, test.usages)
AssertEqualQuantities(r, test.expectedResources.CPUMin, total.CPUMin, "cpu request value")
AssertEqualQuantities(r, test.expectedResources.CPUMax, total.CPUMax, "cpu limit value")
AssertEqualQuantities(r, test.expectedResources.MemoryMin, total.MemoryMin, "memory request value")
AssertEqualQuantities(r, test.expectedResources.MemoryMax, total.MemoryMax, "memory limit value")
})
}
}
6 changes: 4 additions & 2 deletions internal/calc/cronjob.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package calc
import batchV1 "k8s.io/api/batch/v1"

func cronjob(cronjob batchV1.CronJob) *ResourceUsage {
podResources := calcPodResources(&cronjob.Spec.JobTemplate.Spec.Template.Spec).MaxResources
podResources := calcPodResources(&cronjob.Spec.JobTemplate.Spec.Template.Spec)

resourceUsage := ResourceUsage{
Resources: podResources,
// TODO should jobs always be considered with their rollout resources?
NormalResources: podResources.Containers,
RolloutResources: podResources.MaxResources,
Details: Details{
Version: cronjob.APIVersion,
Kind: cronjob.Kind,
Expand Down
8 changes: 4 additions & 4 deletions internal/calc/cronjob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ func TestCronJob(t *testing.T) {
r.NoError(err)
r.NotEmpty(usage)

AssertEqualQuantities(r, test.cpuMin, usage.Resources.CPUMin, "cpu request value")
AssertEqualQuantities(r, test.cpuMax, usage.Resources.CPUMax, "cpu limit value")
AssertEqualQuantities(r, test.memoryMin, usage.Resources.MemoryMin, "memory request value")
AssertEqualQuantities(r, test.memoryMax, usage.Resources.MemoryMax, "memory limit value")
AssertEqualQuantities(r, test.cpuMin, usage.RolloutResources.CPUMin, "cpu request value")
AssertEqualQuantities(r, test.cpuMax, usage.RolloutResources.CPUMax, "cpu limit value")
AssertEqualQuantities(r, test.memoryMin, usage.RolloutResources.MemoryMin, "memory request value")
AssertEqualQuantities(r, test.memoryMax, usage.RolloutResources.MemoryMax, "memory limit value")
r.Equalf(test.replicas, usage.Details.Replicas, "replicas")
r.Equalf(test.maxReplicas, usage.Details.MaxReplicas, "maxReplicas")
r.Equalf(test.strategy, usage.Details.Strategy, "strategy")
Expand Down
Loading

0 comments on commit 1deb674

Please sign in to comment.