Kubernetes bin packing: fewer nodes for the same pods

The scheduler scores nodes by requests, spreads pods by default and never moves a running one. What changes that.

Published 1 November 2026 · By Rupesh, Guest author · SDE II · Published by Zoorik

Kubernetes bin packing: spread pods hold every node, but packed pods leave a node empty that can be removed

Short answer. Kubernetes bin packing places pods on as few nodes as their requests allow, so spare nodes can be removed. The default kube-scheduler spreads pods and MostAllocated makes it pack, but either way it counts requests, not use, and never moves a running pod.

A Kubernetes bin packing example: requests matter more than spread or pack

Take three nodes with 4 CPU each for pods and six pods that arrive one at a time, each requesting 1 CPU. Count only CPU and the NodeResourcesFit score.

The default, LeastAllocated, sends each pod to the node with the least requested so far, so the pods land two per node. Every node is 50% requested, and none is empty.

MostAllocated does the opposite. Four pods fill node-a, two go to node-b, and node-c stays empty, so an autoscaler can remove it.

Even when LeastAllocated spreads them, each node would be 15% requested (600m ÷ 4,000m). That is low enough for the Cluster Autoscaler to remove two of the three, one at a time, if the pods can move.

The request change did more than the strategy change.

Packing empties one node, requests that match use empty two Three rows of the same three nodes, node-a, node-b, node-c, each an outline of 4 CPU for pods, with each pod a block as wide as its CPU request. Row 1, LeastAllocated with 1 CPU requests: two pods on every node, each node 2 of 4 CPU, no node empty. Row 2, MostAllocated with 1 CPU requests: node-a 4 of 4 CPU, node-b 2 of 4 CPU, node-c empty, drawn dashed as a node that can be removed, 1 node empty. Row 3, MostAllocated with 300m requests: all six pods on node-a, 1.8 of 4 CPU, node-b and node-c empty and dashed, 2 nodes empty. Packing empties one node, requests that match use empty two one pod, as wide as its CPU requestnode, 4 CPU for podsempty, can be removednode-anode-bnode-cLeastAllocated1 CPU requestsno node empty2 of 4 CPU2 of 4 CPU2 of 4 CPUMostAllocated1 CPU requests1 node empty4 of 4 CPU2 of 4 CPU0 of 4 CPUMostAllocated300m requests2 nodes empty1.8 of 4 CPU0 of 4 CPU0 of 4 CPU
Illustration: CPU only, NodeResourcesFit score only.

What is bin packing in Kubernetes: a node score built on requests

Bin packing is the classic problem of fitting items of different sizes into as few fixed-size bins as possible. In a cluster the bins are nodes and the items are pods, sized by their CPU and memory requests. Microsoft’s Azure Kubernetes Service (AKS) guide calls it raising pod density on nodes instead of spreading pods across a pool.

The kube-scheduler places each new pod once, in two phases:

  • Filtering removes every node that can’t take the pod. NodeResourcesFit checks that the node has room for everything the pod requests. Other plugins check taints, affinity, ports and volumes.
  • Scoring ranks the nodes left, and the pod goes to the one with the highest weighted sum of plugin scores. Kubernetes automated bin packing happens here, for every pod, without you naming a node.

Both phases read requests. Karpenter’s docs say it directly: “kube-scheduler scores nodes based on pod resource requests”.

A node whose pods request 3.5 of its 4 CPU looks almost full whether they use 3.5 CPU or 0.2. No scoring strategy closes that gap between requests and use, which is Kubernetes overprovisioning.

Both nodes look almost full: 3.5 of 4 CPU requested Two nodes, Node 1 and Node 2, each an outline of 4 CPU for pods on one shared scale from 0 to 4 CPU. In both rows a teal requested bar runs to 3.5 CPU, and one highlighted bracket with a dashed line through both rows reads requested 3.5 CPU in both. Inside each requested bar a thinner violet bar shows use: 3.5 CPU on Node 1 and 0.2 CPU on Node 2. Both nodes look almost full: 3.5 of 4 CPU requested requestedusednode, 4 CPU for podsrequested 3.5 CPU in bothNode 1used3.5 CPUNode 2used0.2 CPU01234CPU
Illustration: the scheduler scores both nodes on the 3.5 CPU their pods request, whatever they use.

A pool rarely packs to 100% either, because a pod can’t be split across nodes. A node with 4 CPU for pods takes two pods that request 1.5 CPU each. That strands the last 1 CPU, which no pod of that size can use.

The filter checks every resource too. A node with spare CPU and no spare memory is full for a pod that needs memory. See allocatable versus capacity for how much of a node pods get.

Spread or pack is a scoring strategy on the default scheduler

There’s no separate Kubernetes bin packing scheduler to install. Packing is a scoring strategy of the default scheduler’s NodeResourcesFit plugin, set in the kubescheduler.config.k8s.io/v1 configuration, stable since Kubernetes 1.25. The plugin offers three:

StrategyWhich node it favoursWhere you can set it
LeastAllocated (the default)
The one with the most CPU and memory not yet requested, so pods spread
Every cluster runs it unless you change it
MostAllocated
The one with the most already requested, so pods pack
Self-managed clusters, AKS scheduler profiles, EKS control plane configuration
RequestedToCapacityRatio
Whatever a curve you define gives for each utilization level
Self-managed clusters and AKS scheduler profiles; EKS accepts only the other two

RequestedToCapacityRatio maps utilization to a score through a shape. A curve from score 0 at 0% utilization up to score 10 at 100% packs, and the reverse spreads.

Microsoft’s recommended production profile for AKS uses a curve of its own. Its score reaches the top of 10 at 50% utilization and holds it to 85%. After that it drops to 5 at 90% and 0 at 100%, so nodes fill without running to the edge.

The NodeResourcesFit score is one of several, and spread scores push replicas onto different nodes. When a pod sets no spread constraints of its own, the scheduler applies built-in defaults. Only pods of a Service, ReplicaSet, StatefulSet or ReplicationController get them.

The defaults are a maxSkew of 3 across kubernetes.io/hostname and 5 across topology.kubernetes.io/zone, both ScheduleAnyway. Microsoft’s AKS bin-packing profiles disable PodTopologySpread because it “can override the weighted score from NodeResourcesFit if left enabled by default.”

To drop only the defaults, set defaultConstraints: [] with defaultingType: List on the plugin, as the Kubernetes docs show.

Why packing at placement never empties a node

A node that is 20% requested keeps its pods until something evicts them. Changing the strategy “affects only future scheduling”, as the Karpenter docs note, and running pods are not moved.

So a cluster drifts. As Deployments scale in and jobs finish, nodes are left part-full. MostAllocated only steers the next pods to the fuller ones.

Emptying a node takes a separate component, and the common ones measure nodes by requests too:

  • The Cluster Autoscaler counts a node as unneeded when its pods request under 50% of its allocatable CPU and memory. That 50% is the default --scale-down-utilization-threshold of 0.5. Every pod must also be able to run elsewhere. The autoscaler removes the node after 10 minutes unneeded, and removes non-empty nodes one at a time. Why a node stays in service covers the usual blockers.
  • Karpenter simulates a tight bin packing of pending pods to choose which nodes to launch, and its instance type math “only uses requests”. The scheduler then places the pods, and when it spreads them, Karpenter’s nodes “can end up under-packed”. Consolidation then churns, which is why Karpenter recommends MostAllocated.

So a node whose pods request 60% of its CPU and use 10% counts as 60% full to both. That sets the order for bin packing: requests that match real use first, then packing, then removal.

The Kubernetes cost optimization guide takes a cluster through the same order: requests first, then nodes. The descheduler guide covers another way to move running pods.

Tight packing trades burst room for fewer nodes

Packing leaves less unrequested room on each node, and some workloads live in that room. Take a pod whose limits sit above its requests. Karpenter’s docs warn it may be unable to burst into more memory, risking an out-of-memory (OOM) kill.

It may also be CPU-throttled sooner than under the default spread. More pods share each node too, so one node failing takes more of them down.

Three habits help:

  • Set requests from measured peaks, so packing by requests is close to packing by use.
  • Give replicated workloads a PodDisruptionBudget and the spread rules they need, in a scheduler profile that keeps PodTopologySpread on.
  • On AKS, score nearly full nodes down with a RequestedToCapacityRatio curve, as Microsoft’s does at 90%.

How to turn on bin packing on AKS and EKS

On a self-managed cluster, set type: MostAllocated under the NodeResourcesFit plugin’s scoringStrategy in a KubeSchedulerConfiguration file and pass it with kube-scheduler --config.

On a managed cluster the provider runs the kube-scheduler, so each service exposes the setting its own way. Test it on a non-production cluster first.

EKS: advanced control plane configuration

On Kubernetes 1.31 or later, Amazon Elastic Kubernetes Service (EKS) lets you set nodeResourcesFit.scoringStrategy to LeastAllocated or MostAllocated. Resource weights go from 1 to 100. EKS exposes no other scheduler parameter, so the spread defaults stay.

  1. Set the strategy:
aws eks update-cluster-config \
  --name <cluster-name> \
  --kube-scheduler-config '{"nodeResourcesFit":{"scoringStrategy":{"type":"MostAllocated"}}}'
  1. Wait for EKS’s rolling update of the control plane, which takes several minutes:
aws eks wait cluster-active --name <cluster-name>

AKS: scheduler profiles

AKS clusters on Kubernetes 1.33 or later take scheduler profiles through a SchedulerConfiguration resource named upstream. In October 2026 Microsoft’s setup guide, “Configure scheduler profiles”, asks for Azure CLI 2.90.0 or later. It asks for no extension or feature flag.

Microsoft’s bin-packing guide still lists the preview prerequisites:

  • Azure CLI 2.76.0 or later
  • aks-preview extension 18.0.0b27 or later
  • UserDefinedSchedulerConfigurationPreview feature flag
  1. Turn the feature on and check that its custom resource definition exists:
az aks update \
  --resource-group <resource-group> \
  --name <cluster-name> \
  --enable-upstream-kubescheduler-user-configuration

kubectl get crd schedulerconfigurations.aks.azure.com
  1. Apply a profile with kubectl apply -f. Microsoft’s MostAllocated profile weights CPU eight times above memory and disables PodTopologySpread:
apiVersion: aks.azure.com/v1alpha1
kind: SchedulerConfiguration
metadata:
  name: upstream
spec:
  rawConfig: |
    apiVersion: kubescheduler.config.k8s.io/v1
    kind: KubeSchedulerConfiguration
    profiles:
      - schedulerName: cpu-binpack-scheduler-mA
        plugins:
          multiPoint:
            enabled:
              - name: NodeResourcesFit
            disabled:
              - name: PodTopologySpread
        pluginConfig:
          - name: NodeResourcesFit
            args:
              apiVersion: kubescheduler.config.k8s.io/v1
              kind: NodeResourcesFitArgs
              scoringStrategy:
                type: MostAllocated
                resources:
                  - name: cpu
                    weight: 8
                  - name: memory
                    weight: 1
  1. Set spec.schedulerName: cpu-binpack-scheduler-mA in the pod template of each workload that should use it. Pods without that field keep the default scheduler.

AKS doesn’t support plugins that target its aks-system scheduler, or a profile with that name.

How Coral helps empty spare nodes

A scoring strategy places new pods. It doesn’t make requests match use or empty the part-full nodes you already run. Coral optimizes Kubernetes on AKS and EKS clusters running Kubernetes 1.30 or later, and works on both halves.

  • Requests. Coral rebalances each container’s CPU and memory requests to what it uses, with headroom on top. Pod sizing starts when you switch on “Automatically optimize” for a policy or a workload.
  • The Evictor. Coral’s Evictor moves pods off lightly used nodes and leaves each empty node for your autoscaler. It drains only in pools whose setting allows it, which is off by default. Even then, it never overrides a PodDisruptionBudget.
  • Node optimization. Coral works out from pod requests how many nodes each pool needs, and every pool keeps at least one node. Before each drain it checks on the live cluster that every pod fits elsewhere, with its affinity, taints, spread, ports and zones. Nodes drain one at a time, and a drain that can’t finish in time puts the node back into service.
  • Removal. Coral hands each emptied node to your Cluster Autoscaler, AKS node auto-provisioning (NAP) or Karpenter, whose own timers decide when it goes. It deletes machines itself only in fixed-size pools that nothing else manages. Pools that Karpenter or NAP already consolidates are left to them.

When a pod has to move

For pods of a Deployment or an Argo Rollout, Coral first tries a warm handover:

  1. Coral scales the owner up by one.
  2. It waits until the new pod is Ready on another node.
  3. Then it evicts the old one.

No process state moves, and open connections to the old pod end.

Warm handover isn’t used for these, among others:

  • StatefulSets
  • the Recreate strategy
  • ReadWriteOnce volumes
  • an app scaled by a Horizontal Pod Autoscaler, during consolidation

Those pods are evicted within their PodDisruptionBudget, and an app that runs a single copy restarts briefly. A pod annotated karpenter.sh/do-not-disrupt: "true" is never moved, and its node is kept.

The guide’s section on consolidation lists the steps and the wait before removal. The FAQ explains how consolidation avoids scheduling failures later.

What Coral did on our own cluster

On our own AKS development cluster, on 6 October 2026, Coral worked out that the apps would fit on fewer machines. It moved them and removed one of the two 4-CPU workers on its own in under 10 minutes. That is $147.46 a month less at Azure pay-as-you-go list prices.

Apps that run a single copy restarted once on another machine. The full run is in the case study.

Sources: Kubernetes, “Resource Bin Packing” (opens in a new tab); Kubernetes, “Scheduler Configuration” (opens in a new tab); Kubernetes, “Pod Topology Spread Constraints” (opens in a new tab); Kubernetes Cluster Autoscaler, “Frequently Asked Questions” (opens in a new tab); Karpenter, “Scheduling” (opens in a new tab); Amazon EKS User Guide, “Configure advanced Kubernetes control plane parameters” (opens in a new tab); Microsoft Learn, “Configure scheduler profiles on Azure Kubernetes Service (AKS)” (opens in a new tab); Microsoft Learn, “Bin Pack Nodes with scheduler profiles on Azure Kubernetes Service (AKS)” (opens in a new tab).

Find your own number

One line in your cloud console. One PDF report with your estimated saving at list price.

Estimate your saving

Talk to the builders

Thirty minutes with the people who build Coral.

Book a demo (opens in a new tab)

Or write to founders@zoorik.com