Engineering10 min read
Tetris in d dimensions: how we rightsize Kubernetes nodes
Node rightsizing is vector bin packing. How CostGraph sizes pods from real usage, checks every placement against the scheduler, and packs with geometric heuristics instead of first fit decreasing.
By Emmanuel Bakare
"All models are wrong, but some are useful." - George E. P. Box
TLDR, Node rightsizing is vector bin packing. If you sort pods by a single "size" number (first fit decreasing), you leave capacity on the table whenever some workloads lean on CPU and others lean on memory. We size pods from real usage, check every placement against the scheduler's hard rules, and pack with the geometric heuristics from Panigrahy et al. (2011). The packing is greedy, so treat the savings it reports as a floor rather than a ceiling.
Sooner or later someone holding a cloud bill asks the platform team, "do we really need 40 nodes?" The usual answer is "probably not, but I can't tell you which ones to delete without a whiteboard." This post is that whiteboard, with buttons.
I'll walk through how CostGraph answers the question, what a node gives you, how big a pod is, why the obvious sorting approach falls short, and what we run instead.
What is a node, really?
A node is the bin, but the bin is smaller than the instance you pay for. The kubelet holds back CPU and memory for itself and the OS, keeps an eviction buffer, and every DaemonSet (your CNI, log shipper, node exporter) takes a slice of each node before an application pod is scheduled.
So a node's capacity is not "4 vCPU, 16 GiB". After reservations and DaemonSets it is closer to "3.4 vCPU, 12.9 GiB, 58 pods, 0 GPUs, 90 GiB of ephemeral disk". CPU, memory, pod count, GPUs, and disk all limit the same placement. A pod fits only when it fits within every limit. On EKS, a small instance can run out of IP addresses before it runs out of CPU.
A pod is a vector
Formally, this is the -dimensional Vector Bin Packing problem. Each pod is a vector of its demand divided by node capacity, and we want the fewest nodes such that no node overflows in any dimension,
The problem is NP-hard for every , and for it is APX-hard, which means no polynomial-time algorithm can get arbitrarily close to the optimum unless . At cluster scale, we use heuristics. The interesting question is which ones.
NOTE, In the demos I draw a node as a square, with CPU along the bottom and memory up the side. Pods are rectangles stacked corner to corner along the diagonal. A node is valid exactly when the staircase stays inside the square, because the widths add up to CPU used and the heights add up to memory used. A flat staircase means memory is going unused; a tall one means CPU is.
How big is a pod?
Before packing anything we need each pod's vector, and the request in the manifest is often wrong. Someone set it once, during an incident, two years ago. So the size we use depends on the question we're asking,
- Can today's nodes be consolidated? Each pod is sized at the larger of its request and its 7-day P95 usage, per dimension. We never assume a pod is smaller than the scheduler already thinks it is.
- What if workloads were rightsized too? We swap in the rightsized request, which comes from usage percentiles and how bursty the workload is.
The rightsized request is the P95 divided by a target utilisation . The target depends on volatility, measured by the coefficient of variation (standard deviation over mean). Steady workloads can run hot, while spiky ones need room to spike,
Two more steps happen before we trust the numbers. The 7-day and 30-day windows are merged by taking the larger of each statistic, so a monthly batch job doesn't disappear just because last week was quiet. And raw samples go through an interquartile filter that drops anything outside , so one bad scrape doesn't buy you a bigger node.
NOTE, A request is a threshold, a fixed line drawn over a signal that rarely stays still. Click diurnal above and the same workload swings by hundreds of millicores between day and night. Deciding where that line goes is the same problem as deciding where an alert threshold goes, and I covered it in depth (percentiles, deterministic vs non-deterministic systems, sinusoidal daily load, and measuring how well a band fits) in Exploring the Math of Thresholds. The rule used here, P95 over a target utilisation, is a percentile plus a buffer, the kind of threshold that article builds up to, with the volatility class deciding how big the buffer is.
Why sorting by size is not enough
The classic answer to one-dimensional bin packing is First Fit Decreasing (FFD), which sorts items from biggest to smallest and drops each one into the first bin it fits. In one dimension it is guaranteed to use at most bins, where is the optimal packing, the fewest bins any arrangement could possibly use. If the best packing needs 9 nodes, FFD never uses more than 12. Computing at cluster scale is the NP-hard part. It is the yardstick heuristics are measured against, while FFD still works well in practice. The trouble starts when you have to decide what "biggest" means for a vector.
Panigrahy, Talwar, Uyeda and Wieder (Microsoft Research, 2011) give a simple example. Write each pod's size as (CPU, memory), each a fraction of one node. Half the pods are CPU-heavy at and half are memory-heavy at . The optimal packing puts two of each kind in a node and fills it exactly,
But any FFD variant gives every pod of the same type the same weight, so it places all pods of one type next to each other. Three CPU-heavy pods fill the CPU and leave half the memory unused, and a fourth pod of either kind no longer fits,
Four nodes instead of three is a third more hardware for the same pods. With more dimensions, FFD can make increasingly inefficient choices. This is common in practice. A CPU-bound API next to a memory-hungry cache describes a normal cluster.
The fix is to turn the loop around. FFD is item-centric, picking the next pod and then looking for a node. The geometric heuristics are bin-centric, opening a node and then asking which pod best fills the space that is left.
The heuristics in the paper
Panigrahy, Talwar, Uyeda, and Wieder compare a family of scoring rules. Let be a weight for each dimension, so scarce resources count more, and the remaining capacity of the open node,
Dot Product prefers pods that point in the same direction as the space they're filling. L2 prefers pods whose shape is closest to that space. The paper's main finding is that both do as well as FFD when the dimensions are positively correlated, and better when they are negatively correlated. You can check that yourself,
Any single draw can go either way, so the panel also shows the average of 50 draws. Across 200 seeded runs of 80 pods with CPU and memory fully anti-correlated, Dot Product needed 24.5 nodes and FFDAvgSum needed 26.2. Against the better FFD variant, the difference is usually 2 to 4 percent and largest around -0.7. At +1 every pod has the same shape, the problem collapses to one dimension, and all four tie. That matches the paper's result, a few percent in general and more on the hardest distributions.
NOTE, The lower bound is the minimum number of nodes required by the most heavily used dimension, . falls between that bound and the best heuristic.
What we run in production
This is how the pipeline turns measurements into a plan. Usage becomes statistics, statistics become requests, and requests become vectors. Before a vector can count as a placement, it must pass the scheduler's rules.
What Kubernetes will allow
A placement that fits on resources but that Kubernetes would refuse produces a savings number that disappears the moment someone acts on it. So every candidate placement is checked against the hard constraints the scheduler enforces,
- resources against allocatable, in every dimension, including pod count and GPUs
NoScheduleandNoExecutetaints without a matching tolerationnodeSelectorand required node affinity- required pod affinity and anti-affinity
- topology spread with
DoNotScheduleand itsmaxSkew - host ports, and the zone a persistent volume is pinned to
Some pods cannot move, including pods on local persistent volumes, static pods, bare pods with no controller, running Jobs, system-node-critical pods, and anything behind a PodDisruptionBudget that allows zero disruptions. A node holding one of those cannot be drained, and we report why instead of quietly skipping it.
Consolidation, which nodes can be emptied?
The first engine never adds new hardware. It asks how many of today's nodes could be emptied into the rest. It checks the emptiest and most expensive candidates first, then moves their pods one by one. Each pod goes to the node where it best fills the remaining capacity across all dimensions,
for node in sort(nodes, by: packed_fraction asc, cost desc):
if any pod on node is pinned: report blocked(reason); continue
snapshot()
for pod in sort(node.pods, by: L2(demand / capacity) desc):
target = argmax over other nodes that fit on every dimension
and pass the feasibility check, sum_d (demand_d / cap_d) * (residual_d / cap_d)
if no target: rollback(); report blocked(reason); next node
residual[target] -= pod.demand
mark node drained; nodes that received pods stay put
Here is a five-node cluster going through it. Web pods have a required anti-affinity, so each one needs its own node, and Postgres uses a local volume.
Turn headroom up to 10% and the second drain fails. That's why our default node-level headroom is zero, rightsized requests already include a buffer from the utilisation target, so adding node headroom on top would count the buffer twice and hide real savings. Untick anti-affinity and web-2 has more places to go. The plan changes (a different node drains) but the count stays at two. Greedy plans depend on the order they explore things in, which is another reason to treat the number as a floor.
NOTE, Every blocked node also gets a "what if". We relax the rule that blocked it (anti-affinity, affinity, spread, or a missing controller) and run the pack again, so the report can say "this node would free up if
webused preferred anti-affinity" instead of just "blocked".
Node pools, which instance types should exist?
The second engine starts from an empty pool and opens nodes from the price catalogue. The question changes from "fewest nodes" to "cheapest nodes", and the nodes are no longer all the same size. For each candidate instance type it tries packing the pod plus every pod that doesn't yet fit an open node, and scores the result,
The dominant resource is GPU if any pod needs one, otherwise whichever of CPU or memory is scarcer for the pool. Counting what gets packed matters because a 64-core machine has the lowest price per core in the catalogue and is still the worst possible choice for three pods.
| type | $/mo | list $/core | packed | $/core packed |
|---|---|---|---|---|
| 4 vCPU | $93 | $23.25 | 4000m | $23.25 |
| 2 vCPU | $62 | $31.00 | 2000m | $31.00 |
| 64 vCPU | $992 | $15.50 | 5000m | $198.40 |
At 43 pods the big machine finally wins, because it is now full enough to be cheap. In between you get mixed pools naturally, since each new node can be a different type. Candidates come straight from the price catalogue, filtered by region and zone, architecture, current generation, a minimum size of the largest pod plus DaemonSet overhead, and matching GPU model. Spot capacity is only offered when every pod in the pool tolerates it.
What this does not do (yet)
Greedy heuristics are fast and easy to explain, but they are not optimal. The current limits,
- No exact solver. The paper found LP-based approaches too slow beyond a few hundred items, and that matches our experience. The savings we report are what a greedy pass can prove, so the real number is usually higher.
- Soft constraints are ignored. Preferred affinity,
ScheduleAnywayspread, priorities and NUMA don't count against a placement, and the report says so. - Usage-based sizing covers CPU and memory only. GPU and ephemeral storage are sized from requests.
- No randomised search. The paper's Bubble Search variant saves a little more at many times the runtime. It isn't worth it yet.
Summary
Most of node rightsizing isn't the packing algorithm. It's getting the pod sizes right, getting the node capacity right, and never counting a placement the scheduler would reject. But the algorithm matters once your workloads stop looking alike, and the fix from 2011 is small, instead of asking "which pod is biggest?", ask "which pod fits the space left?"
The paper is short and worth reading, Heuristics for Vector Bin Packing (Panigrahy, Talwar, Uyeda, Wieder, 2011).
On our own cluster
Because we dog food internally, or as we often say, CostGraph runs CostGraph, it is only fair to share our own findings. We found six nodes in our production cluster, one of which could go, saving $104.71 a month.
The list underneath is the useful part. One node can be drained, with 18 pods moving onto the others. The other three stay, and each one says why. One hosts a pod whose required node affinity matches no other node, and two are short on memory. It's the same multi-dimension problem from the start of this post. The cluster requests 28% of its CPU but 62% of its memory, so memory runs out first while most of the CPU remains unused.
NOTE, The demos on this page are better than spaghetti, but pasta is pasta :-P
If you want to see what this looks like on your own cluster, get started with CostGraph or book a demo.