Skip to main content

The Kubernetes Descheduler Can Cut Compute Spend 30-50% — Here's the Math on a Real Hetzner Fleet

9 min readDora NodaDora Noda
Share
On this page

Cast AI's 2026 State of Kubernetes Optimization report puts average CPU utilization across clusters at 8%, with 69% of clusters over-provisioning CPU — up from 40% just two years earlier. That's the abstract version of the "bin-packing saves 30-50% on compute" claim you've probably seen in a conference talk slide. It's also useless for deciding anything, because it doesn't tell you what happens to your node count or your bill.

So here's the concrete version. Run the same 60-pod tenant workload two ways on Hetzner CX23 nodes — the shared-vCPU box this site already priced out after 2026's rate hikes — and the difference is 30 nodes at €164.70/month scattered thin, versus 15 nodes at €82.35/month packed tight. Same pods, same total resource requests, half the bill. The rest of this post is that math, plus the PodDisruptionBudget and eviction-cadence configuration a multi-tenant PaaS actually needs before it's safe to turn on.


What the Descheduler Actually Does

The default Kubernetes scheduler only makes placement decisions once, at pod-creation time. It never revisits them. A cluster that's been running for months accumulates placement drift — pods land on whichever node had room then, workloads get deleted and recreated, nodes get added during a traffic spike and never get emptied back out — and nothing in the scheduler's job description includes fixing that after the fact.

That's the gap the descheduler fills. It runs as a separate, periodic job that evicts already-running pods according to a policy, relying on the scheduler to re-place them somewhere better. Two of its strategies matter for bin-packing specifically:

  • LowNodeUtilization finds nodes running below a configured threshold and evicts pods from other, more-utilized nodes, in the hope the scheduler drops the recreated pods onto the underutilized ones — consolidating occupancy rather than spreading it further.
  • HighNodeUtilization works from the other direction: it evicts pods off underutilized nodes directly, deliberately packing what's left onto fewer, fuller nodes so the Cluster Autoscaler (or, for a Cluster-API-managed fleet, CAPI's own MachineDeployment scale-down) can remove the ones left empty. It's designed to pair with the scheduler's MostAllocated bin-packing strategy rather than the default LeastAllocated spread.

One detail that trips people up: both strategies compute "utilization" from pod resource requests measured against node allocatable capacity — not live CPU/memory usage from a metrics server. A node running at 90% actual CPU load but with generous, unused request headroom reads as low-utilization to the descheduler. That's a feature, not a bug — it means bin-packing decisions are deterministic and don't chase noisy real-time metrics — but it also means the descheduler can't fix a workload whose problem is that its requests are already tight. It only fixes the gap between what's requested and what's actually scheduled densely.

And critically: the descheduler evicts. It doesn't delete nodes. Emptying a node is necessary but not sufficient — something else (Cluster Autoscaler, or CAPI's autoscaling annotations on a MachineDeployment) has to notice the empty node and scale the pool down. Skip that piece and you've added eviction churn for zero cost benefit.


The Worked Example: 60 Tenant Pods, Three Consolidation Targets

Here's the fleet: 60 small tenant pods, each requesting 0.3 vCPU and 300Mi of memory — a realistic footprint for a preview environment or a low-traffic tenant app on a multi-tenant PaaS. Total requested: 18 vCPU, 17,578Mi.

The nodes are Hetzner CX23 (2 vCPU / 4GB RAM), priced at €5.49/month post-2026-hike. Reserving a conservative 200m CPU and 512MB for kubelet and system overhead leaves 1.8 vCPU / 3,584Mi allocatable per node.

At any given utilization target, the number of pods a node can hold is capped by whichever resource — CPU or memory — runs out first:

Target utilizationPods/node by CPUPods/node by memoryBinding resourceNodes for 60 podsMonthly cost
35% (scattered)24CPU30€164.70
55% (moderate consolidation)36CPU20€109.80
70% (aggressive consolidation)48CPU15€82.35

CPU is the binding resource at every threshold — memory always has room to spare, because CX23's 2:1 RAM-to-vCPU ratio outpaces this pod shape's memory-to-CPU ratio. That matters: it means the savings below come from real CPU-packing headroom, not from an accidental memory bottleneck papering over a less flattering CPU picture.

Read the table as a sensitivity range rather than one number. Moving from a scattered 35% baseline to a moderate 55% consolidation target cuts the fleet from 30 nodes to 20 — a 33% reduction, €54.90/month back. Pushing to an aggressive 70% target cuts it to 15 nodes — a 50% reduction, €82.35/month back. Both land inside the 30-50% range the industry figure claims, and both come from the same 60-pod fleet — the only variable is how much headroom you're willing to leave for burst.

That headroom question is not cosmetic. A node running at 70% of allocatable CPU by request has 30% left for pods that burst above their base request — noisy-neighbor spikes, a deploy's temporary double-scheduling during a rollout, a tenant's traffic surge. A node running at 35% has more than double that cushion. The 50%-savings number is real, but it's real because it spends headroom that was previously buying safety margin, not waste.


What Turning This On for a Multi-Tenant Fleet Actually Requires

The gap between "the descheduler can do this" and "it's safe to run against tenant workloads" is entirely in the configuration. Four pieces, in order of how badly you'll miss them if skipped.

A DeschedulerPolicy with matched thresholds. The current API version is v1alpha2; a policy pairing LowNodeUtilization (frees up nodes) with HighNodeUtilization (packs remaining pods) needs the packing target used consistently between them:

yaml
apiVersion: descheduler/v1alpha2
kind: DeschedulerPolicy
profiles:
  - name: bin-pack
    pluginConfig:
      - name: HighNodeUtilization
        args:
          thresholds:
            cpu: 70
            memory: 70
      - name: DefaultEvictor
        args:
          nodeFit: true
          evictLocalStoragePods: false
    plugins:
      deschedule:
        enabled:
          - HighNodeUtilization
      filter:
        enabled:
          - DefaultEvictor

nodeFit: true on the DefaultEvictor is what stops the descheduler from evicting a pod it can't actually reschedule anywhere — without it, a pod can get evicted and land right back on the same node, or worse, fail to schedule at all and sit pending. evictLocalStoragePods: false (the safer default) keeps pods with local emptyDir volumes or local PVs off the eviction list entirely — those pods lose their data if rescheduled onto a different node, and the descheduler has no way to know whether that's acceptable for a given tenant's workload.

A PodDisruptionBudget on every tenant workload that can't tolerate simultaneous evictions. The descheduler respects PDBs by default — that's the entire safety mechanism standing between "rebalancing" and "an outage." Without one, a tenant's two-replica Deployment has no floor:

yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: tenant-app-pdb
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: tenant-app

The catch is single-replica workloads: a minAvailable: 1 PDB on a one-replica Deployment blocks every eviction outright, which is correct if downtime is unacceptable but means that workload never participates in consolidation. Decide that per-tenant, not by omission.

Exclude system namespaces and set a conservative cadence. Run the descheduler as a CronJob on a 5-15 minute interval — frequent enough to correct drift, infrequent enough that eviction churn doesn't itself become a source of instability — and exclude kube-system, kube-public, kube-node-lease, and any monitoring/logging namespace from eviction targets explicitly. Those workloads are rarely the ones costing you node count, and evicting them adds control-plane risk for no packing benefit.

Dry-run before live. The descheduler supports a dryRun mode that logs what it would evict without actually evicting anything. Run every new policy change through a dry-run pass against production-shaped data first — the failure mode of a miscalibrated threshold isn't "no savings," it's "an eviction storm against workloads you didn't mean to touch."

One more setting worth knowing about even though it cuts the other way: unhealthyPodEvictionPolicy: AlwaysAllow on a PDB lets the eviction API move already-unhealthy pods even when the budget is otherwise exhausted, so a crash-looping pod doesn't block a node drain indefinitely. It's a drain-time setting, not a bin-packing one — but it's the same PDB object, and worth setting correctly while you're already there.


Where Not to Push the Target Higher

The math above shows a bigger number at a higher consolidation target, which makes 70% (or higher) look like the obviously correct choice. It isn't, for three reasons that don't show up in the node-count table:

Local storage and single-replica workloads sit outside the mechanism entirely. evictLocalStoragePods: false and PDB minAvailable floors mean a meaningful slice of a real tenant fleet — stateful services, dev/preview environments running as single replicas to save cost — never gets touched by bin-packing at all. The 60-pod example above assumes every pod is safely evictable; a real fleet's actual consolidation ratio will be lower once those carve-outs are subtracted.

Burst headroom is a real cost, not a rounding error. The 50%-savings scenario leaves 30% of allocatable CPU as slack. If your tenant workloads regularly spike 2x above their base request — which preview environments and bursty API workloads often do — 70% packing means a spike on one tenant's pod pushes the node into contention with its neighbors' baseline requests, not just its own burst. The moderate 55% target exists precisely because "the biggest number" and "the correct number" aren't the same thing once burst is part of the workload's actual shape.

An eviction landing at the wrong moment is the actual operational risk, not a hypothetical caveat. A tenant's pod getting evicted during a request it's mid-handling shows up to that tenant as a dropped connection, regardless of how well the aggregate node count improved. This is exactly what PDBs and a conservative eviction cadence exist to bound — but bounding a risk isn't eliminating it, and a multi-tenant PaaS should treat the descheduler as a scheduled maintenance operation with blast-radius controls, not a background process that's safe to forget about once configured.

The honest recommendation: start at a moderate target (55% in the example above), verify PDBs are actually in place across tenant workloads before enabling live evictions, and only move toward the more aggressive end once burst behavior for your specific tenant mix is well understood — not because the industry number said 50% was achievable.


Bex.co runs tenant workloads on Cluster API-managed Hetzner fleets, where every node the descheduler frees up and the autoscaler removes is a direct line taken off the monthly bill — not a cost-allocation exercise against a shared hyperscaler invoice. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex