| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3 |
| Applies to | Any host with kubectl configured; Kubernetes 1.31+ for stable unhealthyPodEvictionPolicy support |
| Cert prep | CKA |
| Lab environment | Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm. This walkthrough uses two schedulable nodes (k8s-cp and worker01); the control-plane NoSchedule taint is removed for the lab so replicas can land on both nodes during drain tests, then restored. |
| Privilege | Normal user for kubectl when kubeconfig is available |
| Kubernetes permissions | Create, update, get, watch, and delete Namespaces, Deployments, Pods, and PodDisruptionBudgets; exec into Pods; patch Node taints; and create Pod evictions through kubectl drain. |
| Scope | Voluntary vs involuntary disruptions, policy/v1 selectors, minAvailable and maxUnavailable (including percentages), PDB status fields, kubectl drain interaction, readiness and healthy Pods, unhealthyPodEvictionPolicy, and how PDB relates to Deployment rollouts. Does not cover Deployment strategy design, drain flag encyclopedia, priority and preemption, cluster autoscaler, or application-level failover. |
| Related guides | Upgrade Kubernetes cluster version Kubernetes Pods and Pod Lifecycle |
When you cordon a node for maintenance, a PodDisruptionBudget (PDB) tells the cluster how many replicated Pods must stay available during that voluntary work. In this guide you will build a three-replica Deployment, attach a PDB, and run real kubectl drain commands with both minAvailable and maxUnavailable so you can see when eviction is allowed and when drain has to wait.
This guide covers voluntary disruptions only. Those are the evictions kubectl drain sends through the eviction API. Keep these boundaries in mind:
- A PDB does not stop involuntary outages such as node crashes
- A PDB does not replace Deployment rolling-update settings (
maxUnavailable/maxSurgeon the Deployment strategy) - For drain flags and cordon behavior, see kubectl drain, cordon, and uncordon
- For rollout strategy fields, see Deployments and rolling updates
Voluntary vs involuntary disruptions
Kubernetes treats two kinds of disruption differently:
| Voluntary | Involuntary |
|---|---|
| Node drain | Hardware failure |
| Cluster upgrade draining nodes | Kernel crash |
| Administrative eviction | Network partition |
| Planned node maintenance | Sudden node loss |
A PDB limits voluntary evictions. That matters in practice because:
- Voluntary — drain and the eviction API must respect your budget; Kubernetes can delay or retry the eviction
- Involuntary — node failure, kernel panic, or network loss can still remove Pods; the API cannot pause a dead node the way it can delay a drain
Involuntary events still affect PDB status. They lower the observed healthy count even though the eviction API never blocked them.
Prepare a three-replica Deployment
Start with a small nginx Deployment and a readiness probe. Three replicas matter here: with minAvailable: 2 you can evict one Pod voluntarily while two stay healthy.
On my two-node lab I remove the control-plane NoSchedule taint first so Pods can schedule on both nodes during drain tests. Restore that taint in Clean up when you finish:
kubectl taint nodes k8s-cp node-role.kubernetes.io/control-plane:NoSchedule-node/k8s-cp untaintedCreate the namespace:
kubectl create ns pdb-demonamespace/pdb-demo createdApply the Deployment and a PDB that keeps at least two Pods available. The Pod template uses a lab-specific label and a topology spread constraint so three replicas land as two Pods on one node and one Pod on the other:
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: pdb-demo
spec:
replicas: 3
selector:
matchLabels:
app: web
pdb-demo: "true"
template:
metadata:
labels:
app: web
pdb-demo: "true"
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
pdb-demo: "true"
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 2
periodSeconds: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
namespace: pdb-demo
spec:
minAvailable: 2
selector:
matchLabels:
app: web
pdb-demo: "true"
EOFdeployment.apps/web created
poddisruptionbudget.policy/web-pdb createdWait for the rollout, then confirm Pods landed on more than one node:
kubectl -n pdb-demo rollout status deploy/web --timeout=90sdeployment "web" successfully rolled outkubectl -n pdb-demo get pods -o wide -l 'app=web,pdb-demo=true'NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web-6569b64977-5vpcg 1/1 Running 0 8s 192.168.5.56 worker01 <none> <none>
web-6569b64977-f4bmc 1/1 Running 0 8s 192.168.5.55 worker01 <none> <none>
web-6569b64977-x2jnz 1/1 Running 0 8s 192.168.62.162 k8s-cp <none> <none>Topology spread places two Pods on one node and one on the other. Either node can receive the pair, so calculate the drain target from the live placement before you run the first drain test:
DRAIN_NODE=$(
kubectl -n pdb-demo get pods \
-l 'app=web,pdb-demo=true' \
-o custom-columns='NODE:.spec.nodeName' \
--no-headers |
sort |
uniq -c |
sort -nr |
awk 'NR == 1 {print $2}'
)
echo "$DRAIN_NODE"worker01On my lab that node was worker01. When you drain $DRAIN_NODE, two protected Pods sit on the same node and the PDB has to pace evictions.
How PDB selects Pods
Your PDB only protects Pods its selector matches. Use the same labels on the Deployment Pod template (app: web and pdb-demo: "true" here). Confirm the match count with the same label query the controller uses:
kubectl -n pdb-demo get pods -l 'app=web,pdb-demo=true' --no-headers | wc -l3Three Pods match, so the budget is watching the Deployment you just created.
In policy/v1, a missing selector and an empty selector are not the same:
| Selector shape | What it matches |
|---|---|
Selector omitted (Selector: <unset>) |
No Pods |
Empty selector (selector: {}) |
Every Pod in the namespace |
That distinction trips people up in production:
- Selector omitted — the budget matches nothing;
ALLOWED DISRUPTIONSstays at zero and Events may report no matching Pods - Empty
selector: {}— the budget counts every Pod in the namespace, including unrelated workloads
Always set explicit matchLabels (or matchExpressions) for the workload you intend to protect.
Protect with minAvailable
The PDB already uses:
minAvailable: 2With three healthy replicas and minAvailable: 2, Kubernetes allows one voluntary eviction at a time. Two Pods stay healthy while maintenance moves a third.
That pattern works well for a small replicated frontend:
- You keep a floor of capacity during drain
- Maintenance can still move forward one Pod at a time
Inspect the budget:
kubectl -n pdb-demo get pdbNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
web-pdb 2 N/A 1 25sALLOWED DISRUPTIONS is 1 here because:
- Current healthy — 3 Ready Pods selected by the PDB
- Desired healthy — 2 (from
minAvailable: 2) - Room for eviction — 3 − 2 = 1 voluntary disruption allowed right now
Protect with maxUnavailable
Pick only one field per PDB: minAvailable or maxUnavailable, not both. Delete the current budget and recreate it with maxUnavailable: 1:
kubectl -n pdb-demo delete pdb web-pdbpoddisruptionbudget.policy "web-pdb" deleted from pdb-demo namespacekubectl apply -f - <<'EOF'
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
namespace: pdb-demo
spec:
maxUnavailable: 1
selector:
matchLabels:
app: web
pdb-demo: "true"
EOFpoddisruptionbudget.policy/web-pdb createdkubectl -n pdb-demo get pdbNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
web-pdb N/A 1 1 2sWith three healthy Pods, maxUnavailable: 1 and minAvailable: 2 give you the same practical limit: only one Pod may be unavailable from a voluntary eviction at a time. The difference shows up when you scale:
- Integer
maxUnavailable: 1— always permits exactly one unavailable Pod, no matter how many replicas the Deployment runs - Percentage
maxUnavailable— the permitted unavailable count grows with the desired replica count
Percentage budgets and rounding
After the integer examples, try a percentage budget. Kubernetes rounds up when it converts a percentage to a Pod count. On my lab, three Pods with maxUnavailable: 34% allowed two disruptions:
kubectl apply -f - <<'EOF'
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
namespace: pdb-demo
spec:
maxUnavailable: 34%
selector:
matchLabels:
app: web
pdb-demo: "true"
EOFpoddisruptionbudget.policy/web-pdb configuredkubectl -n pdb-demo get pdb web-pdbNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
web-pdb N/A 34% 2 1s34% of three Pods rounds up to two, so this budget is looser than the integer maxUnavailable: 1. Put minAvailable: 2 back on web-pdb for the rest of this guide. You can also keep maxUnavailable: 1; both allow one disruption while all three Pods are Ready:
kubectl apply -f - <<'EOF'
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
namespace: pdb-demo
spec:
minAvailable: 2
selector:
matchLabels:
app: web
pdb-demo: "true"
EOFpoddisruptionbudget.policy/web-pdb configuredInspect PDB status
kubectl get pdb gives you the quick columns. kubectl describe pdb shows the full controller status. Run describe when you need to see why disruptions allowed changed:
kubectl -n pdb-demo describe pdb web-pdbName: web-pdb
Namespace: pdb-demo
Min available: 2
Selector: app=web,pdb-demo=true
Status:
Allowed disruptions: 1
Current: 3
Desired: 2
Total: 3
Events: <none>Read the status fields together:
- Current healthy — Ready Pods selected by the PDB
- Desired healthy — how many must stay available (
minAvailable, or total minusmaxUnavailable) - Expected Pods (Total) — Pods the budget is counting
- Disruptions allowed — how many voluntary evictions may proceed right now
When disruptions allowed is 0, drain and other eviction API callers wait or retry until the healthy count recovers.
Test PDB with kubectl drain
Drain calls the eviction API, so it respects your PDB. Confirm all three replicas are 1/1 Ready before you drain. If one Pod is NotReady, disruptions allowed can stay at zero and drain will retry indefinitely.
Drain $DRAIN_NODE, which holds two protected Pods. For real maintenance you still need the DaemonSet and emptyDir flags shown below. Flag details are in kubectl drain, cordon, and uncordon.
kubectl drain "$DRAIN_NODE" --ignore-daemonsets --delete-emptydir-datanode/worker01 cordoned
Warning: ignoring DaemonSet-managed Pods: calico-system/calico-node-j9m4s, calico-system/csi-node-driver-hczmm, kube-system/kube-proxy-g4df2
evicting pod pdb-demo/web-6569b64977-5vpcg
evicting pod pdb-demo/web-6569b64977-f4bmc
error when evicting pods/"web-6569b64977-f4bmc" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod kube-system/metrics-server-5b58578978-wmrjc
evicting pod local-path-storage/local-path-provisioner-6d484fd799-c79sh
pod/web-6569b64977-5vpcg evicted
evicting pod pdb-demo/web-6569b64977-f4bmc
pod/web-6569b64977-f4bmc evicted
pod/local-path-provisioner-6d484fd799-c79sh evicted
pod/metrics-server-5b58578978-wmrjc evicted
node/worker01 drainedWatch what drain does under minAvailable: 2:
- It evicts one protected Pod first
- The second eviction is rejected until a replacement becomes Ready
- After the replacement is Ready, drain evicts the second Pod and finishes
A PDB limits simultaneous unavailability, not how many Pods drain can eventually remove. Drain keeps retrying rejected evictions until every targeted Pod is gone.
While drain runs, ALLOWED DISRUPTIONS can move:
1before the first eviction0while a replacement is still starting1again after that replacement becomes Ready
The value you see immediately after drain depends on whether the final replacement is Ready yet:
kubectl -n pdb-demo get pdb web-pdbNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
web-pdb 2 N/A 1 41sUncordon when the node should accept work again:
kubectl uncordon "$DRAIN_NODE"node/worker01 uncordonedIf you try another eviction while ALLOWED DISRUPTIONS is still 0, drain prints Cannot evict pod as it would violate the pod's disruption budget and retries. That wait is the PDB doing its job. The next protected Pod leaves only after enough peers are healthy again.
Understand readiness and healthy Pods
PDB math cares about the Ready condition, not just Running or the replica count on the Deployment. If a Pod is Running but NotReady, currentHealthy drops and disruptions allowed can hit zero even when three Pod objects still exist.
To show that, recreate the Deployment so each container creates /tmp/ready and the readiness probe requires that file:
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: pdb-demo
spec:
replicas: 3
selector:
matchLabels:
app: web
pdb-demo: "true"
template:
metadata:
labels:
app: web
pdb-demo: "true"
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
pdb-demo: "true"
containers:
- name: nginx
image: nginx:1.27-alpine
ports:
- containerPort: 80
command: ["/bin/sh","-c"]
args: ["touch /tmp/ready && nginx -g 'daemon off;'"]
readinessProbe:
exec:
command: ["cat","/tmp/ready"]
initialDelaySeconds: 1
periodSeconds: 2
EOFWait for the new rollout, then confirm placement and remove the ready file on two Pods so only one stays Ready. Reuse $DRAIN_NODE from the earlier placement step, or recalculate it with the same command if you opened a new shell:
kubectl -n pdb-demo rollout status deploy/web --timeout=90sdeployment "web" successfully rolled outmapfile -t DRAIN_NODE_PODS < <(
kubectl -n pdb-demo get pods \
-l 'app=web,pdb-demo=true' \
--field-selector "spec.nodeName=${DRAIN_NODE}" \
-o custom-columns='NAME:.metadata.name' \
--no-headers
)
OTHER_POD=$(
kubectl -n pdb-demo get pods \
-l 'app=web,pdb-demo=true' \
-o custom-columns='NAME:.metadata.name,NODE:.spec.nodeName' \
--no-headers |
awk -v node="$DRAIN_NODE" '$2 != node {print $1; exit}'
)
if (( ${#DRAIN_NODE_PODS[@]} < 2 )) || [[ -z "$OTHER_POD" ]]; then
echo "This demonstration requires two web Pods on ${DRAIN_NODE} and one on the other node."
kubectl -n pdb-demo get pods -l 'app=web,pdb-demo=true' -o wide
exit 1
fi
UNHEALTHY_DRAIN_NODE_POD=${DRAIN_NODE_PODS[0]}
HEALTHY_DRAIN_NODE_POD=${DRAIN_NODE_PODS[1]}
kubectl -n pdb-demo exec "$UNHEALTHY_DRAIN_NODE_POD" -- rm -f /tmp/ready
kubectl -n pdb-demo exec "$OTHER_POD" -- rm -f /tmp/readyThe exec commands exit with no output when the file is removed. Here is what each variable represents:
$UNHEALTHY_DRAIN_NODE_POD— first Pod on$DRAIN_NODE; you remove its ready file so it becomes NotReady$OTHER_POD— the Pod on the other node; you remove its ready file too$HEALTHY_DRAIN_NODE_POD— second Pod on$DRAIN_NODE; you leave this one Ready
After a few probe periods:
kubectl -n pdb-demo get pods -o wide -l 'app=web,pdb-demo=true'NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web-75bf8b9856-24fj2 1/1 Running 0 15s 192.168.5.53 worker01 <none> <none>
web-75bf8b9856-8zxml 0/1 Running 0 15s 192.168.5.54 worker01 <none> <none>
web-75bf8b9856-kfx84 0/1 Running 0 15s 192.168.62.160 k8s-cp <none> <none>kubectl -n pdb-demo get pdbNAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
web-pdb 2 N/A 0 104sYou still have three Pod objects, but only one is Ready. Current healthy is one and desired healthy is two, so disruptions allowed is zero. That is why replica count alone is not enough when you troubleshoot drain.
Handle unhealthy Pods during drain
By default, unhealthyPodEvictionPolicy behaves like IfHealthyBudget. When the application is already below desired healthy, Running-but-NotReady Pods cannot be evicted either. That protects a struggling app, but it can also stall node drain.
You now have one Ready and two NotReady Pods under minAvailable: 2. Drain retries both the healthy and unhealthy Pods on $DRAIN_NODE. For this experiment, limit drain to the lab Pods only:
--pod-selector='app=web,pdb-demo=true'— the lab-specific selector limits this drain demonstration to Pods carrying bothapp=webandpdb-demo=trueon the selected node; the flag is not namespace-scoped, which is why the additional label is important- This is not a complete node-maintenance drain; in production you would drain the whole node
kubectl drain "$DRAIN_NODE" --ignore-daemonsets --delete-emptydir-data --pod-selector='app=web,pdb-demo=true'node/worker01 cordoned
evicting pod pdb-demo/web-75bf8b9856-8zxml
evicting pod pdb-demo/web-75bf8b9856-24fj2
error when evicting pods/"web-75bf8b9856-24fj2" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error when evicting pods/"web-75bf8b9856-8zxml" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.Both Pods are blocked. Press Ctrl+C after you confirm that both evictions are being retried. Drain leaves the node cordoned, so uncordon before you continue:
kubectl uncordon "$DRAIN_NODE"Patch the PDB so unhealthy Pods can be evicted even when the healthy budget is already exhausted:
kubectl -n pdb-demo patch pdb web-pdb --type=merge -p '{"spec":{"unhealthyPodEvictionPolicy":"AlwaysAllow"}}'poddisruptionbudget.policy/web-pdb patchedDrain again with the same selector:
kubectl drain "$DRAIN_NODE" --ignore-daemonsets --delete-emptydir-data --pod-selector='app=web,pdb-demo=true'node/worker01 cordoned
evicting pod pdb-demo/web-75bf8b9856-8zxml
evicting pod pdb-demo/web-75bf8b9856-24fj2
error when evicting pods/"web-75bf8b9856-24fj2" -n "pdb-demo" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/web-75bf8b9856-8zxml evictedUnder AlwaysAllow, the NotReady Pod ($UNHEALTHY_DRAIN_NODE_POD) is evicted. The healthy Pod ($HEALTHY_DRAIN_NODE_POD) is still rejected because disruptions allowed is zero.
That is the trade-off you are choosing:
- Maintenance can clear stuck NotReady Pods
- Ready replicas still stay protected while the app is below desired healthy
After the NotReady Pod is evicted and the Ready Pod continues to be rejected, press Ctrl+C to stop the demonstration, then uncordon the node.
Uncordon when finished:
kubectl uncordon "$DRAIN_NODE"PDB and Deployment rolling updates
Pods that become unavailable during a rollout change the observed PDB status the same way a drain does. That does not mean the Deployment controller asks the PDB before deleting Pods for its own rolling update.
Keep these roles separate:
- Deployment strategy (
maxUnavailable,maxSurge) — controls how the controller replaces Pods during a rollout; see Deployments and rolling updates - PodDisruptionBudget — controls voluntary eviction paths such as drain and direct eviction API calls
Workload controllers such as Deployment and StatefulSet are not blocked by a PDB during their built-in rolling-update deletes. Use the PDB for maintenance and eviction API callers, not as a substitute for rollout settings.
Common PDB mistakes
If a budget seems to do nothing, or drain never finishes, check whether you hit one of these patterns:
- Single-replica workload with
minAvailable: 1— zero voluntary disruptions allowed; drain waits forever on that Pod - Selector matches no Pods — Allowed disruptions stays zero and Events show no matching Pods
- Selector matches unrelated Pods — empty
selector: {}inpolicy/v1matches the whole namespace - Treating PDB as protection from node failure — involuntary loss still happens
- Replica count too small for the maintenance you plan — three replicas with
minAvailable: 3blocks every voluntary eviction - NotReady Pods leave zero disruptions allowed — fix readiness before blaming drain
- Multiple PDBs select the same Pod — the Eviction API does not support eviction when a Pod is covered by more than one PDB. Avoid overlapping selectors except during a short transition between budgets.
- Expecting PDB to control a Deployment rollout — set the Deployment strategy instead
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot evict pod as it would violate the pod's disruption budget |
Disruptions allowed is zero | Wait for Ready replacements, raise replicas, or temporarily loosen the budget |
| Drain never completes | Budget requires zero voluntary disruptions, or unhealthy Pods blocked under IfHealthyBudget |
Check kubectl describe pdb; consider AlwaysAllow for stuck NotReady Pods |
PDB shows ALLOWED DISRUPTIONS 0 with replicas present |
NotReady or mismatched selector | Compare kubectl get pods -l … with PDB selector; fix probes |
| Empty selector matches unexpected Pods | policy/v1 empty selector matches all Pods in the namespace |
Set explicit matchLabels for one workload |
Both minAvailable and maxUnavailable rejected |
Fields are mutually exclusive | Keep only one field on the PDB |
Clean up
When you finish the lab, delete the demo namespace:
kubectl delete ns pdb-demonamespace "pdb-demo" deletedRestore the control-plane NoSchedule taint if you removed it for the lab:
kubectl taint nodes k8s-cp node-role.kubernetes.io/control-plane:NoSchedulenode/k8s-cp taintedWhat's Next
- Kubernetes Static Pods and Mirror Pods
- Kubernetes Volumes with Practical Examples
- Kubernetes PersistentVolume and PVC with Examples
References
- Specifying a Disruption Budget for your Application
- Disruptions
- PodDisruptionBudget API (policy/v1)
- kubectl drain
- API-initiated Eviction
Summary
A PodDisruptionBudget is the availability contract you set for voluntary evictions. With a three-replica Deployment, minAvailable: 2 or maxUnavailable: 1 leaves one disruption allowed while every selected Pod is Ready. Drain uses that headroom to evict protected Pods one at a time and wait for each replacement to become Ready before evicting the next.
The status columns matter more than the YAML alone. Read them together:
- Current healthy and desired healthy — tell you whether the app is above the floor right now
- Disruptions allowed — tells you whether drain or another eviction API caller can proceed
- NotReady replicas — lower current healthy even when the Pod object count looks fine
The default IfHealthyBudget policy can leave drain stuck on unhealthy Pods until you adopt AlwaysAllow or restore readiness.
Use PDBs alongside Deployment rollout settings, not instead of them. Restore any lab taints when you are done. Next, practice full drain and cordon workflows, or walk a controlled cluster upgrade where PDBs routinely decide how fast nodes can be emptied.

