Kubernetes descheduler: what it moves and when to use it

It evicts pods that no longer belong on their node. It never picks the next node, and it never removes one.

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

Kubernetes descheduler: it evicts a pod from a busy node to a light one by requests, and no node is deleted

Short answer. The Kubernetes descheduler evicts pods from nodes where they no longer belong, so the scheduler can place their replacements better. Most strategies target pods that break a placement rule, keep restarting or have failed, and the descheduler never schedules a pod or deletes a node.

The Kubernetes descheduler evicts pods so the scheduler can place them again

Picture three identical nodes after one fails. Its pods restart on the other two, and its replacement joins almost empty. Requests on node-a, node-b and node-c sit at 70%, 45% and 10% of each node’s allocatable resources.

Nothing moves them back. Kubernetes picks a node for each new pod once, and Amazon’s EKS documentation puts it plainly: “The Kubernetes scheduler never relocates a pod that is already running.”

Placement drifts in quieter ways too:

  • a node gains a taint
  • a node loses a label
  • an affinity rule stops matching

When the descheduler, a SIG Scheduling project, finds pods like these, it evicts them. Choosing where they go next isn’t its job. The pod’s controller creates a replacement, and the default scheduler places it by the same rules as the first time.

Every eviction goes through the Eviction API. So a PodDisruptionBudget, or PDB, that allows no more disruptions keeps the pod where it is. Removing a node that ends up empty is your autoscaler’s job.

The descheduler evicts, and two other parts finish the move Three boxes left to right. The first, highlighted, reads Evict the pod, by the Descheduler. A solid arrow labelled controller creates a replacement leads to the second, Place the replacement, by the Default scheduler. A dashed arrow labelled only if a node is left empty leads to the third, Remove the empty node, by your autoscaler. From the first box an arrow labelled PDB allows no more disruptions leads down to a small box reading the pod stays. The descheduler evicts, and two other parts finish the move Evict the podDeschedulerPlace thereplacementDefault schedulerRemove theempty nodeYour autoscalercontroller createsa replacementonly if a nodeis left emptyPDB allows no more disruptionsthe pod stays
The descheduler owns only the eviction. The pod’s controller and the default scheduler place the replacement. Your autoscaler removes a node only if one is left empty.

Under the default settings, these pods are never evicted:

  • pods with the system-cluster-critical or system-node-critical priority class
  • pods that no ReplicaSet, StatefulSet, Job or ReplicationController owns, because nothing would recreate them
  • DaemonSet pods
  • pods with local storage, which in the descheduler’s code means an emptyDir or hostPath volume

Pods are evicted even if they use a Persistent Volume Claim (PVC), unless you protect them.

Ten strategies, and only two of them look at how full a node is

The descheduler calls its strategies plugins. Deschedule plugins judge pods one at a time. Balance plugins judge a group of pods against how it was meant to be spread.

StrategyKindWhat it evicts
RemoveDuplicates
Balance
Extra pods of one ReplicaSet, StatefulSet or Job on the same node
LowNodeUtilization
Balance
Pods on busy nodes, so they land on lightly used ones
HighNodeUtilization
Balance
Pods on lightly used nodes, so they pack onto fewer
RemovePodsViolatingTopologySpreadConstraint
Balance
The fewest pods needed to bring each constraint back within maxSkew
RemovePodsViolatingInterPodAntiAffinity
Deschedule
Pods that break an anti-affinity rule
RemovePodsViolatingNodeAffinity
Deschedule
Pods whose node no longer matches their node affinity
RemovePodsViolatingNodeTaints
Deschedule
Pods that no longer tolerate their node’s NoSchedule taints
RemovePodsHavingTooManyRestarts
Deschedule
Pods that reach a restart count
PodLifeTime
Deschedule
Pods matched by age, state, condition or exit code
RemoveFailedPods
Deschedule
Pods in the Failed phase

LowNodeUtilization spreads pods, and HighNodeUtilization packs them

Both utilization strategies measure a node by what its pods request, as a percentage of the node’s allocatable CPU, memory and pod capacity.

Actual use doesn’t count by default, so kubectl top can tell a different story. LowNodeUtilization can read real usage from Metrics Server or Prometheus instead. HighNodeUtilization can’t yet.

LowNodeUtilization fills lightly used nodes

Descheduler LowNodeUtilization takes two maps, thresholds and targetThresholds. A node under thresholds for every resource is underutilized. A node over targetThresholds for any one resource is overutilized.

The strategy evicts pods from the overutilized nodes and expects their replacements to land on the underutilized ones. It does nothing when either group is empty.

When it reads usage from Prometheus, it evicts at most one pod from each overutilized node per cycle, unless evictionLimits.node allows more.

HighNodeUtilization empties them

HighNodeUtilization takes only thresholds. Pods on underutilized nodes are evicted so they’re recreated on busier ones. The project describes the strategy as a way to trigger scale-down when you run node autoscaling.

The same three nodes under each strategy

The Helm chart’s default policy turns on LowNodeUtilization, with thresholds of 20% and targetThresholds of 50%. If your goal is fewer nodes, change that before you install.

HighNodeUtilization at the 20% thresholds in the example below evicts node-c’s pods instead. MostAllocated then prefers the busier node-a, which has room (70% + 10% = 80%).

Three nodes can become two. Removing node-c reduces the pool’s machine cost by a third.

LowNodeUtilization keeps node-c, and HighNodeUtilization empties it Two panels on one shared scale from 0 to 100% of each node's allocatable, each with the same three columns filled to their pods' requests: node-a 70%, node-b 45% and node-c 10%. Left, LowNodeUtilization, chart defaults: dashed lines at thresholds 20% and targetThresholds 50%, and an arrow labelled evicts from node-a to node-c. Below it, node-c fills up and stays. Right, HighNodeUtilization + MostAllocated: a dashed line at thresholds 20%, an arrow labelled evicts from node-c to a dashed 10% segment on top of node-a, which reaches 80%, and node-c's 10% drawn as a dashed outline labelled empty. Below it, 3 nodes become 2: your autoscaler removes node-c. Illustration: three identical nodes. By default, both read pod requests, not live usage. LowNodeUtilization keeps node-c, and HighNodeUtilization empties it LowNodeUtilization, chart defaults100%0%thresholds 20%targetThresholds 50%evictsnode-a70%node-b45%node-c10%HighNodeUtilization + MostAllocatedthresholds 20%evicts80%emptynode-a70%node-b45%node-c10%node-c fills up and stays3 nodes become 2:your autoscaler removes node-cpod requests, as a share of each node's allocatableIllustration: three identical nodes. By default, both read pod requests, not live usage.
Illustration on three identical nodes. Under the chart’s defaults, LowNodeUtilization sends node-a’s pods toward node-c. HighNodeUtilization with MostAllocated moves node-c’s 10% onto node-a and leaves node-c empty.

How a Kubernetes descheduler policy decides what to evict

Each policy is a DeschedulerPolicy object, still at API version descheduler/v1alpha2. You pass it to the binary with --policy-config-file.

Its top level holds limits that apply to every eviction, all unlimited when unset:

  • maxNoOfPodsToEvictPerNode
  • maxNoOfPodsToEvictPerNamespace
  • maxNoOfPodsToEvictTotal (per cycle)

Below them sit one or more profiles. Each has a pluginConfig list of each plugin’s arguments and a plugins block that enables strategies under deschedule or balance.

The DefaultEvictor plugin filters pods for every strategy, and three of its arguments do most of the safety work:

  • Node fit: with nodeFit on (it’s off by default), a pod is evicted only if another node could take it. That node must match the pod’s node selector and node affinity, and the pod must tolerate its taints. It also needs free capacity for the pod’s requests and can’t be unschedulable, and pod anti-affinity must allow the move.
  • Few replicas: minReplicas spares pods whose owner runs fewer replicas than its value.
  • Protections: podProtections, new in v0.34, switches built-in protections off under defaultDisabled. It adds others under extraEnabled, such as PodsWithPVC or PodsWithoutPDB. Older releases such as v0.33 set the same protections with booleans like ignorePvcPods.

From v0.34, a pod can ask to stay with the descheduler.alpha.kubernetes.io/prefer-no-eviction annotation. Each strategy decides whether to respect it. It binds for all of them only when DefaultEvictor sets noEvictionPolicy to Mandatory.

At --v=4 or higher, the descheduler logs why each skipped pod wasn’t evictable.

Install the descheduler Helm chart in dry-run mode first

The chart comes from the project itself, and its version tracks the descheduler’s. As a Job, a CronJob or a Deployment, the descheduler runs in kube-system as a critical pod, so it isn’t evicted itself.

By default the chart runs a CronJob every 2 minutes, with a profile that enables seven strategies:

  • Balance: RemoveDuplicates, RemovePodsViolatingTopologySpreadConstraint and LowNodeUtilization.
  • Deschedule: RemovePodsHavingTooManyRestarts (at 100 restarts), RemovePodsViolatingNodeTaints, RemovePodsViolatingNodeAffinity and RemovePodsViolatingInterPodAntiAffinity.

It also changes two protections. Pods with a PVC are protected, and pods with local storage are not. So with the chart’s defaults, a pod with an emptyDir or hostPath volume can be evicted.

A Kubernetes descheduler example: pack nodes for scale-down

  1. Write a values file. It replaces the default profile with HighNodeUtilization alone and runs the descheduler as a Deployment every 5 minutes. It also turns on dry-run mode, which previews the evictions without making them:
# pack-values.yaml
kind: Deployment
deschedulingInterval: 5m
cmdOptions:
  v: 3
  dry-run: true
deschedulerPolicy:
  profiles:
    - name: pack
      pluginConfig:
        - name: DefaultEvictor
          args:
            nodeFit: true
        - name: HighNodeUtilization
          args:
            thresholds:
              cpu: 20
              memory: 20
      plugins:
        balance:
          enabled:
            - HighNodeUtilization
  1. Install the chart version whose minor matches your cluster’s, such as 0.33.0 for Kubernetes 1.33. The project’s compatibility matrix pairs each release since v0.18 with one Kubernetes minor version: the current v0.37, of October 2026, with 1.37. It also says a release is tested only against the three latest minor versions.
helm repo add descheduler https://kubernetes-sigs.github.io/descheduler/
helm install descheduler descheduler/descheduler --namespace kube-system --version <chart-version> -f pack-values.yaml
  1. Read the descheduler’s logs until the evictions it reports are the ones you expect.
  2. Set MostAllocated scoring, as below.
  3. Check that every app it would touch has a PDB and a second replica. A real eviction restarts the pod elsewhere.
  4. Set dry-run to false and run helm upgrade to make those evictions real.

Both clouds now let you set MostAllocated:

  • EKS: advanced control plane configuration sets kube-scheduler’s nodeResourcesFit.scoringStrategy to MostAllocated on Kubernetes 1.31 or later.
  • AKS: scheduler profiles on Kubernetes 1.33 or later set NodeResourcesFit to MostAllocated. A profile applies to pods that name it in schedulerName, or to the whole cluster under default-scheduler. You turn them on with --enable-upstream-kubescheduler-user-configuration.
  • Azure CLI 2.76.0 or later
  • the aks-preview CLI extension version 18.0.0b27 or later
  • the UserDefinedSchedulerConfigurationPreview feature flag

Packing has a cost the EKS documentation spells out. When a densely packed node becomes unhealthy or its instance is retired, more pods are affected at once.

Kubernetes descheduler alternatives: your autoscaler may already remove the node

If the aim is to reduce cloud cost, the eviction is the small part. A node costs money until something deletes it, and that’s your autoscaler’s job. Check what it already does before you add a second component that evicts pods on its own schedule.

  • Cluster Autoscaler: by default, it checks every 10 seconds for nodes it could remove. A node qualifies when its summed CPU and memory requests are under 50% of allocatable and its pods can all move. A node unneeded for 10 minutes is drained and removed, one non-empty node at a time.
  • Karpenter: consolidation with consolidationPolicy: WhenEmptyOrUnderutilized, the default, removes or replaces any node it can to reduce cost. WhenEmpty removes only empty nodes. On AKS, node auto-provisioning (NAP) is based on Karpenter.
  • Scheduler scoring: MostAllocated alone packs new pods only. Running pods stay where they are until something evicts or restarts them.

The descheduler earns its place where the autoscalers don’t look:

  • a pod left on a node whose taints or labels changed
  • replicas bunched onto one node after an outage
  • a topology spread that drifted
  • a pod restarting on a node that can’t attach its volume

By default, the scheduler, the descheduler and the Cluster Autoscaler all judge a node by requests, not use. A node whose pods request 60% of its CPU but use 15% looks busy to all three, so none of them empties it.

Requests at 60% make the node look busy, though its pods use 15% One horizontal bar for a node's CPU on a scale from 0 to 100% of allocatable, with ticks at 0, 25, 50, 75 and 100%. Requests fill it from 0 to 60%, and a thinner use bar inside them runs from 0 to 15%. The rest of the bar is an empty outline. Two dashed lines cross it, one at 20% labelled descheduler thresholds 20% (example), one at 50% labelled Cluster Autoscaler 50%. Beside the bar: requests over both lines: neither empties the node. Requests at 60% make the node look busy, though its pods use 15% descheduler thresholds 20% (example)Cluster Autoscaler 50%Node CPUuse 15%requests 60%requests over both lines:neither empties the node0%25%50%75%100%
Illustration of the example node. By default, the descheduler and the Cluster Autoscaler read requests. A node whose pods request 60% of its CPU but use 15% sits over both lines and stays.

Requests that match real use come first and node removal second. That’s the order the Kubernetes cost optimization guide follows. Kubernetes bin packing explains why placement alone never empties a node, even with the scheduler set to pack.

How Coral empties lightly used nodes

Coral optimizes Kubernetes on AKS and EKS. Its Evictor does the node-emptying part of this job inside your cluster.

Despite the name, it’s a part of Coral, not the descheduler’s DefaultEvictor plugin. The Evictor:

  • moves pods off lightly used nodes and leaves your autoscaler to remove each empty node
  • drains only in node pools whose setting allows it, which is off by default
  • never deletes a machine and never overrides a PDB
  • has a “Dry run” that lists the nodes it would empty in the Event log, without moving any pod

Node optimization handles the rest. Before it drains a node, Coral checks on the live cluster that every pod fits on another Ready node. The check takes in affinity, taints, spread, ports and zones.

Coral cordons and drains one node at a time. Where a Deployment or Argo Rollout qualifies, Coral first starts a replacement on another node. It waits until that copy is Ready before the old pod goes.

Other pods are evicted within their PDB, so a single-replica app among them restarts briefly.

Coral waits for the time set in “Wait before removing empty nodes”, then hands the empty node to your autoscaler. It deletes the machine itself only in fixed-size pools that nothing else manages. Pools that Karpenter or NAP already consolidate are left to them.

Once you switch on “Automatically optimize”, Coral also rebalances CPU and memory requests to real use. Nodes then stop looking fuller than they are.

On our own AKS development cluster, Coral moved the apps off one 4-CPU worker and removed it in under 10 minutes. That reduced the cluster’s machine cost by $147.46 a month. Apps that ran a single copy restarted once on another machine.

The case study has the full run.

Sources: Kubernetes SIGs, “Descheduler for Kubernetes” (opens in a new tab) (README and source code, release-1.37, with release-1.33 and the v0.34.0 release notes for what changed in v0.34); Kubernetes SIGs, “Descheduler for Kubernetes” (opens in a new tab) Helm chart (README and values.yaml, release-1.37); AWS, “Advanced Kubernetes control plane configuration” (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); Kubernetes Autoscaler, “Cluster Autoscaler FAQ” (opens in a new tab); Karpenter, “Disruption” (opens in a new tab); Microsoft Learn, “Overview of node auto-provisioning (NAP) in 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