---
title: "Pod Disruption Budgets: The Floor Kubernetes Won't Let You Fall Below"
description: "What a PodDisruptionBudget actually does, why it quietly runs your cloud bill, and the one status field that tells you whether yours is real or decorative."
canonical: "https://zainahmed.net/blog/pod-disruption-budgets-the-floor-kubernetes-wont-let-you-fall-below"
published: "2026-08-07"
---

# Pod Disruption Budgets: The Floor Kubernetes Won't Let You Fall Below

> Author: Zain Ahmed
> Published: 2026-08-07
> Read Time: 19 min read
> Category: Kubernetes
> Tags: Kubernetes, SRE, Cloud Architecture, FinOps
> Canonical URL: https://zainahmed.net/blog/pod-disruption-budgets-the-floor-kubernetes-wont-let-you-fall-below

## Excerpt
What a PodDisruptionBudget actually does, why it quietly runs your cloud bill, and the one status field that tells you whether yours is real or decorative.

---

<!-- Data Classification: Public -->

<div className="my-8 not-prose">
  <card className="border-primary/30 bg-primary/5 ring-1 ring-primary/10">
    <cardheader>
      <cardtitle className="text-primary font-bold">If You Only Read One Sentence</cardtitle>
    </cardheader>
    <cardcontent>
      A Pod Disruption Budget is a safety rule that tells Kubernetes: no matter what maintenance you are doing, always keep enough copies of my app running that users never notice anything breaking.
    </cardcontent>
  </card>
</div>

That's the whole idea, and a kid could work with that sentence. The rest of this article is what happens once you actually run one in production, where the gap between "I created a PDB" and "my app is protected" is exactly one status field wide.

## The Afternoon I Went Looking for One Problem and Found a Different One

Enabling Workload Identity on GKE means flipping `GKE_METADATA` on the node pool. Google's own docs carry a warning about that: the change takes effect immediately and might cause disruptions. Concretely, it recreates nodes.

A PodDisruptionBudget is how you tell Kubernetes the floor. It says: whatever you're doing, keep at least this much of my service alive. Voluntary evictions, the kind a node drain causes, respect it. Without one, a drain takes whatever it wants.

My public-facing API had no budget. The background workers all had one, and had for months. So did a couple of third-party deployments I hadn't even written myself. The only service taking live user traffic was the one running unprotected.

What caught me is that this was never really about the migration. The pool ran with `autoUpgrade: true`, which means the cloud provider recreates those nodes on its own schedule whether anyone touches them or not. Unprotected evictions had been happening for as long as the deployment existed. I didn't create the risk. I needed it gone for one afternoon, went looking, and found it had been sitting there the whole time.

So I stopped treating it as mine. The easy move is to make the budget step four of a migration runbook and move on. It went out as its own pull request instead. A production availability gap that predates your project by months isn't a step in your project, and burying it inside one makes it invisible to everyone who wasn't in that migration.

It also killed a line item. I had a dedicated node pool costed out as insurance: a separate pool would let me enable Workload Identity without cutting identity over for everything at once. But most of that risk sat in one unprotected deployment. Once it had a budget, updating the existing pool in a change window was defensible, and the insurance stopped being worth buying.

Then the part I keep coming back to. Once the budget deployed, I checked its status instead of assuming it worked. `currentHealthy` was non-zero, which proved the selector actually matched running pods. Get the selector wrong and a PDB reports `currentHealthy: 0` and blocks nothing at all. That's worse than having no budget, because now you have the same exposure plus a green check in your runbook and a line in your pull request claiming the workload is protected. Checking that one field is the difference between having a PDB and thinking you have one.

Everything below is the mechanical explanation of why that check matters, and how far the same three lines of YAML reach once autoscalers, Spot capacity, and stateful quorum systems get involved.

## Why a Restaurant Manager Already Understands This

You don't need to know Kubernetes internals, or even Linux, to get the core idea. Picture a busy restaurant on a Friday night with five servers on the floor.

If one of them gets food poisoning and has to leave mid-shift, nobody planned that. The restaurant reacts: fewer tables covered, slower service, maybe the manager jumps behind the register. That's an **involuntary disruption**. Nobody chose it and nothing prevents it.

If the manager instead decides business is slow and sends two servers home early to save on labor, that was a decision. Someone weighed a tradeoff and pulled the trigger. That's a **voluntary disruption**.

A PodDisruptionBudget is the rule painted on the wall behind the manager's desk: you can send people home to save money, but there must always be at least three servers on the floor. The rule has zero opinion about the food-poisoning scenario. It only governs choices someone gets to make.

In a Kubernetes cluster, involuntary disruptions look like a hardware failure, a kernel panic, a hypervisor crash, or a network partition taking a node down. Nothing stops those, and a PDB has no jurisdiction over them, though they still count against the budget once they happen.

Voluntary disruptions are the ones a human or a piece of automation initiates on purpose: running `kubectl drain` to patch a node, an autoscaler consolidating underused capacity, or a cloud provider recreating nodes for something like a Workload Identity flag flip. Those are exactly what a PDB constrains.

One thing worth knowing early: deleting a Deployment outright, or deleting a pod directly, skips the budget entirely. A PDB only intercepts disruptions that go through the Eviction API. That distinction becomes important later.

## What Kubernetes Actually Does When You Ask for an Eviction

If you only know Docker, here's the minimum vocabulary. A pod is one or more containers scheduled together on the same node. A Deployment or StatefulSet is the controller that keeps a target number of those pods running. When something needs to move pods off a node, it doesn't just delete them. It calls a specific API.

Running `kubectl drain node-1` doesn't delete pods on `node-1` directly. It sends a POST request to each pod's `eviction` subresource. That request is functionally similar to a policy-controlled DELETE, except the API server checks it against any PodDisruptionBudget matching that pod first.

Two outcomes:

- If evicting the pod would still leave enough healthy pods to satisfy the budget, the API server returns `200 OK` and the pod terminates gracefully, honoring `terminationGracePeriodSeconds`.
- If it would violate the budget, the server returns `429 Too Many Requests`, with a body like `"Cannot evict pod as it would violate the pod's disruption budget."`

`kubectl drain` doesn't give up on a `429`. It backs off and retries, sometimes for a long time, until a replacement pod comes up healthy elsewhere and the budget has room again. This is why a drain can hang for what feels like forever without anyone doing anything wrong. Kubernetes is just refusing to hand you more than you asked to protect.

## minAvailable, maxUnavailable, and the Rounding That Surprises People

A PDB in the `policy/v1` API has a selector and exactly one of two fields:

- `minAvailable`: how many pods must survive an eviction, either as an integer or a percentage of the desired replica count.
- `maxUnavailable`: how many pods are allowed to be down at once, same format.

You cannot set both on one object. Pick the one that matches how you think about the workload: `minAvailable` if you're protecting a floor, `maxUnavailable` if you're capping how much can move at once.

Percentages don't divide evenly, so Kubernetes has to round, and it rounds up in both directions. With 7 replicas and `minAvailable: "50%"`, that's 3.5 pods, rounded up to 4 required healthy. With the same 7 replicas and `maxUnavailable: "30%"`, that's 2.1, rounded up to 3 allowed unavailable. Notice that means a `maxUnavailable` percentage can let through slightly more disruption than the raw percentage implies, because the rounding always favors letting one more pod go rather than one less.

<datachart type="bar" title="Ceiling Rounding on a 7 Replica Deployment" data='[{"name":"minAvailable 50% of 7","raw":3.5,"enforced":4},{"name":"maxUnavailable 30% of 7","raw":2.1,"enforced":3}]' config='{"raw":{"label":"Raw percentage math","color":"var(--chart-2)"},"enforced":{"label":"What Kubernetes enforces","color":"var(--chart-1)"}}'></datachart>

There's a sharper edge case worth knowing: if a workload has exactly one desired replica and you set `maxUnavailable` as a percentage, that single pod is still allowed to be disrupted, which means effective unavailability of 100% for that pod. A percentage doesn't protect a fleet of one.

<div className="my-8 not-prose">
  <card className="border-amber-500/40 bg-amber-500/5 ring-1 ring-amber-500/10">
    <cardheader>
      <cardtitle className="text-amber-600 dark:text-amber-400 font-bold">The Setting That Blocks Everything</cardtitle>
    </cardheader>
    <cardcontent>
      Set <code>maxUnavailable: 0</code> or <code>minAvailable</code> equal to the replica count, and you've asked for zero voluntary evictions. That's allowed. It also means a node drain touching one of those pods will never complete. This isn't a bug. It's exactly what you asked for.
    </cardcontent>
  </card>
</div>

## Manifests You Can Actually Ship

### A Stateless API or Frontend

For horizontally scalable services, think in terms of how much capacity you can afford to lose at once.

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web-api-pdb
  namespace: production
spec:
  maxUnavailable: 2
  selector:
    matchLabels:
      app: web-api
```

With 6 replicas, at most 2 go down during any single voluntary disruption, leaving 4 handling traffic. If the same Deployment scales with an HPA, a percentage keeps the protection proportional instead of fixed:

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web-api-pdb-pct
  namespace: production
spec:
  minAvailable: "80%"
  selector:
    matchLabels:
      app: web-api
```

### A Quorum-Based Stateful System

ZooKeeper, Kafka controllers, etcd, anything voting on writes cares about staying above quorum, not about a percentage. For an N-node cluster, quorum is `floor(N/2) + 1`. A 5-node cluster needs 3; it can lose 2 and keep writing.

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: kafka-brokers-pdb
  namespace: data-plane
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: kafka-broker
```

The official docs actually recommend `maxUnavailable: 1` for this exact reason: it stays correct even if the cluster later scales from 3 nodes to 5 or 7, where a fixed `minAvailable` number would need updating every time.

### The Trap: A PDB on a Single Replica

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: single-replica-trap
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: lonely-service
```

If the Deployment behind this only runs one replica, you've written "never evict this pod, ever." Node drains touching it will hang, and so will any autoscaler consolidation. If a workload genuinely can't tolerate downtime, the fix is more replicas, not a stricter PDB on a fleet of one.

## Where This Gets Genuinely Tricky

### The Problem unhealthyPodEvictionPolicy Was Built to Fix

Here's a deadlock that used to be common. Say an app needs `minAvailable: 2` out of 3 replicas, but two of those replicas are stuck in `CrashLoopBackOff` from a bad deploy. Only one pod is actually healthy.

Try to drain the node with the healthy pod, and the PDB correctly blocks it, since that would drop healthy count to zero. Try to drain a node with one of the *broken* pods instead, and older Kubernetes blocked that too, because the app was already below its target and the controller wouldn't allow any further disruption of any kind, healthy or not. Every node drain touching that app stalls, including the ones just trying to clear out pods that were already dead weight.

Kubernetes 1.26 added `.spec.unhealthyPodEvictionPolicy` to give you a way out, and it went stable in 1.31, enabled by default. Two settings:

- `IfHealthyBudget` (the default): a running-but-not-ready pod can only be evicted if the app is already at or above its healthy target.
- `AlwaysAllow`: running-but-not-ready pods can be evicted regardless of budget status, while healthy pods stay protected.

The official docs now recommend setting `AlwaysAllow` on most PDBs specifically so a misbehaving app can't hold a node drain hostage:

```yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  maxUnavailable: 1
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels:
      app: web-api
```

### The disruptedPods Map and the Race Condition Behind It

When the API server approves an eviction, the pod doesn't vanish instantly. It has to run through graceful termination, which can take up to `terminationGracePeriodSeconds`. If a second eviction request for a sibling pod lands during that window, the disruption controller needs to know the first pod is already spoken for, or it might approve a second eviction that actually violates the budget.

The fix is a status field called `disruptedPods`: a map of pod name to the timestamp the API server approved its eviction. A pod stays in that map from approval until the controller sees it actually get marked for deletion, or until a timeout clears it automatically. In healthy operation this map is empty almost all the time. If you inspect a PDB's status and find it persistently populated with old timestamps, that's a sign pods aren't terminating cleanly, maybe a stuck finalizer or a preStop hook that never returns, and the controller is correctly refusing to count those pods as gone yet.

### Empty Selectors Mean Different Things in Different API Versions

`selector: {}` is not a no-op, and the meaning changed between API versions. Under the deprecated `policy/v1beta1`, an empty selector matched zero pods, so the PDB was harmless. Under the current `policy/v1`, an empty selector matches every pod in the namespace. Ship that by accident with `maxUnavailable: 0` and you've frozen every voluntary disruption for the whole namespace, silently, until someone notices maintenance isn't happening.

### Two PDBs, One Pod, No Clean Answer

You can technically create two PodDisruptionBudgets with selectors that both match the same pod. The official docs are direct about the consequence: "The eviction API will disallow eviction of any pod covered by multiple PDBs, so most users will want to avoid overlapping selectors." In practice teams find this the hard way, usually as a warning event on the pod naming both PDBs and picking one arbitrarily for status purposes, which is its own signal that the selectors need to be split apart. The one legitimate reason to run overlapping PDBs briefly is migrating a workload from an old budget object to a new one.

### When There Is No Scale Subresource to Read

`maxUnavailable` and percentage math both depend on Kubernetes knowing the workload's total desired replica count, which it reads from the owning controller's `/scale` subresource. Deployments, ReplicaSets, and StatefulSets all expose one. Some custom resources don't.

KubeVirt is the clearest real example: a `VirtualMachineInstance` is a singleton, not a scaled set, so it has no `/scale` subresource to query. KubeVirt works around this by generating a dedicated PodDisruptionBudget per VMI that has an eviction strategy configured, guaranteeing the VM gets live-migrated rather than shut off during a drain. The general rule the official docs give for any CRD in this position: skip `maxUnavailable` and percentages entirely, and use an integer `minAvailable` instead, since that only requires counting currently healthy pods and doesn't need a scale lookup at all.

## The Autoscaler Collision Nobody Warns You About

This is the part that actually moves cloud spend, and it hinges on one asymmetry most people never notice: not every scale-down path goes through the Eviction API.

| Disruption Path | Mechanism | Respects the PDB? |
| --- | --- | --- |
| `kubectl drain` (patching, upgrades) | Eviction API | Yes |
| Cluster Autoscaler / Karpenter consolidation | Eviction API | Yes |
| Managed node pool upgrades (GKE, EKS) | Eviction API | Yes, up to a platform ceiling |
| HPA scale-down | Patches `.spec.replicas` directly | No |
| `kubectl delete pod` | Direct deletion | No |
| Deleting the Deployment | Direct deletion | No |
| Hardware failure, kernel panic, network partition | Involuntary | No |

**The HorizontalPodAutoscaler bypasses PDBs entirely.** When an HPA decides to scale down, it patches `.spec.replicas` on the Deployment directly. The Deployment controller then deletes the excess pods itself. No eviction request, no PDB check. If your HPA's scale-down policy is aggressive, say dropping from 20 replicas to 2 in one step, your carefully tuned PDB has nothing to say about it, because the HPA never asked permission. The actual defense here is `scaleDown.stabilizationWindowSeconds` and scale-down policies on the HPA itself, not the PDB.

**Karpenter and the Cluster Autoscaler do the opposite.** Both go through the Eviction API on purpose, specifically so they respect PDBs. When either one is deciding whether a node is safe to consolidate or remove, it checks whether every pod on that node can be evicted without violating a budget. One pod with zero disruptions allowed is enough to make the autoscaler skip that node and try a different one.

This is the part with real financial consequences. A PDB set too strictly, `maxUnavailable: 0` on a small deployment, or `minAvailable` equal to replica count, doesn't just block a manual drain. It blocks the autoscaler from ever consolidating that node, which means an underused instance you're paying for keeps running indefinitely. Too loose, and a burst of Spot interruptions or an aggressive consolidation pass can wipe out most of a service's capacity at once.

Karpenter also has its own escape hatch that sits alongside the PDB: the `karpenter.sh/do-not-disrupt` pod annotation, which functions like a single-pod blocking PDB that's always active. It's meant for long-running batch jobs or stateful workloads where mid-execution disruption causes real data loss, not general availability protection. AWS's own EKS Auto Mode documentation is blunt about the cost side of this: a node hosting an annotated pod is exempt from consolidation entirely, its unused capacity stays unused, and if the annotation spreads across many pods, cluster-wide consolidation drops and the bill goes up. It's a tool for genuine can't-restart workloads, not a default.

Managed upgrades hit the same wall, with a ceiling that differs by platform. On EKS, a managed node group update that can't satisfy a PDB, three pods needed on a three-node cluster, one node draining at a time, retries the eviction for roughly 15 minutes and then fails with `PodEvictionFailure`. GKE bends the other way: a node upgrade respects the budget for up to an hour per node, then force-drains and proceeds anyway. That one-hour ceiling applies to the `autoUpgrade` recreations from the story at the top, too. The budget buys ordered, paced evictions, not immunity. On a managed platform, a PDB is a rate limiter with a platform-enforced ceiling, not an indefinite block, and neither behavior is the platform malfunctioning. It's the budget doing exactly what it was configured to do, bounded by how long the platform is willing to wait.

## Watching It Instead of Trusting It

The metrics that matter here come from `kube-state-metrics`, and the one worth alerting on is `kube_poddisruptionbudget_status_pod_disruptions_allowed`. When it sits at zero for an extended stretch, that's not a crisis in itself, but it does mean every voluntary disruption for that workload, manual or automated, is currently blocked.

```bash
kubectl get pdb -A -o wide
kubectl get pdb web-api-pdb -n production -o jsonpath='Current: {.status.currentHealthy}, Desired: {.status.desiredHealthy}, Allowed: {.status.disruptionsAllowed}'
```

Those three fields, `currentHealthy`, `desiredHealthy`, and `disruptionsAllowed`, are the whole diagnosis. If `currentHealthy` is stuck at zero while pods are visibly running, the selector doesn't match anything real, and you have exactly the failure mode from the story above: a PDB that looks correct in the manifest and does nothing in the cluster.

## Why This Belongs on a CTO's Radar, Not Just an SRE's Runbook

Strip away the YAML and a PDB is a decision about risk tolerance, written down in a place a machine can enforce it. Every automated node upgrade, every autoscaler pass, every Spot interruption is a moment where infrastructure is going to make a call about your availability whether anyone's watching or not. A PDB is the only mechanism that puts a floor under that call.

The financial angle is straightforward. Spot and preemptible capacity can run 50 to 90 percent cheaper than on-demand, but it's interruptible by design. Running production workloads there safely requires exactly the guarantee a PDB provides: enough replicas survive any single wave of interruptions to keep serving traffic while replacements spin up. Get that wrong in the strict direction and you're paying full price for capacity that never scales down. Get it wrong in the loose direction and a routine consolidation event turns into a page.

<datachart type="bar" title="Relative Hourly Compute Cost" data='[{"name":"On-demand","cost":100},{"name":"Spot, upper bound","cost":50},{"name":"Spot, lower bound","cost":10}]' config='{"cost":{"label":"Cost relative to on-demand (%)","color":"var(--chart-1)"}}'></datachart>

None of this requires a specific cloud. It's a `policy/v1` object in open-source Kubernetes, and it works the same whether the node underneath it is a GKE autoupgrade, an EKS Karpenter consolidation, or a bare-metal cluster running its own drain scripts. The three managed platforms differ in how aggressively their autoscalers move, not in whether they respect the budget.

<div className="my-8 not-prose">
  <card className="border-primary/30 bg-primary/5 ring-1 ring-primary/10">
    <cardheader>
      <cardtitle className="text-primary font-bold">If You Only Check One Field</cardtitle>
    </cardheader>
    <cardcontent>
      Whether you wrote the PDB or approved the project it shipped in, ask one question in review: has anyone actually checked <code>currentHealthy</code>? A PDB that reports zero matched pods gives you the same exposure as no PDB at all, plus false confidence in a document that says otherwise.
    </cardcontent>
  </card>
</div>

## What I Want to Build Next for This Post

Three interactive pieces belong alongside this article. They aren't built yet, so here's the spec, partly as a public to-do list and partly because the specs themselves teach the mechanics.

### The PDB Calculator

A `PDBCalculator` widget: a replica count slider from 1 to 50, a mode toggle between `minAvailable` and `maxUnavailable`, and a value input that accepts either an integer or a percentage string. It applies the same ceiling rounding as the real API and renders a row of pod icons: one color for required healthy, one for eligible to disrupt, one for forbidden. When the configuration works out to `minAvailable` equal to replicas or `maxUnavailable: 0` on a small fleet, it shows a plain warning that this will block drains and autoscaler scale-downs. Not a scary red banner, just the fact.

### The Drain Simulator

A `PDBSimulator` widget: two node cards side by side, with buttons for "Drain Node A" and "Simulate crash on Node A." Drain animates pods moving one at a time from A to B, checking the budget before each move and stopping with a message quoting the real error text ("Cannot evict pod as it would violate the pod's disruption budget") when the next move would go over. Crash skips the check entirely and grays out the node, with a short note that involuntary disruptions never ask the PDB anything.

### The Disruption Matrix

A `DisruptionMatrix` grid for the business-facing part of the piece: disruption strictness on one axis, cost efficiency on the other, with a handful of labeled scenarios (no PDB and all on-demand, moderate PDBs with Spot on stateless tiers, an over-strict PDB blocking consolidation). This one is built for someone deciding whether to fund a Spot migration, not for someone debugging a drain. The disruption table earlier in this post is the static version of it.

### The Terminal Walkthrough

The video version of this post runs in a multiplexed terminal, real panes, no slides. One pane watching `kubectl get pods -w`, one with the PDB manifest open in an editor, one tailing application logs. Show a drain against a Deployment with no PDB, timed so two pods die at once and the error rate visibly ticks up. Apply a PDB with `maxUnavailable: 1` and `unhealthyPodEvictionPolicy: AlwaysAllow`, run `kubectl describe pdb`, and read `currentHealthy`, `desiredHealthy`, and `disruptionsAllowed` out loud. Drain again and show the difference: one pod moves, the rest hold. Then break it on purpose. Scale to one replica, set `minAvailable: 1`, drain, and let the eviction error sit on screen long enough for it to land. Close by pointing at the same three status fields and saying the thing worth remembering: the manifest is not the proof, the status is.

---

## Related Endpoints & Navigation
- **All Articles**: [https://zainahmed.net/blog](https://zainahmed.net/blog)
- **Author Profile**: [https://zainahmed.net/about](https://zainahmed.net/about)
- **Machine-Readable LLM Manifest**: [https://zainahmed.net/llms.txt](https://zainahmed.net/llms.txt)
- **RSS Feed**: [https://zainahmed.net/feed.xml](https://zainahmed.net/feed.xml)
