In this post, we’ll explore how Kubernetes PodDisruptionBudgets (PDBs) help maintain service availability by controlling how many Pods can be disrupted at once, with a hands-on Lab14 walkthrough using a 3-node Minikube cluster and complete source code.
This series of stories shows how to use Kubernetes in the Spring ecosystem. We work with a Spring Boot API and Minikube to have a lightweight and fast development environment similar to production.
- Lab1 (Spring Boot/K8S): Deploy Spring Boot application on Kubernetes
- Lab2 (Spring Boot/K8S): Kubernetes health probes with Spring Boot
- Lab3 (Spring Boot/K8S): Mastering ConfigMaps in Kubernetes
- Lab4 (Spring Boot/K8S): Using Kubernetes Secrets in Spring Boot
- Lab5 (Spring Boot/K8S): Understanding Kubernetes Resources Management
- Lab6 (Spring Boot/K8S): Persistent Volumes in Kubernetes
- Lab7 (Spring Boot/K8S): Spring Batch on Kubernetes — Jobs and CronJobs
- Lab8 (Spring Boot/K8S): Deploy a Spring Boot application on Kubernetes using Helm Chart
- Lab9 (Spring Boot/K8S): Understanding Kubernetes DaemonSet
- Lab10 (Spring Boot/K8S): Frontend deployment with Angular 19 on Kubernetes
- Lab11 (Spring Boot/K8S): Auto-Scaling Spring Boot Applications with Kubernetes HPA
- Lab12 (Spring Boot/K8S): Exposing Spring Boot Applications with Kubernetes Ingress
- Lab13 (Spring Boot/K8S): Zero-Downtime Deployments with Kubernetes Rolling Updates
- 👉 Lab14 (Spring Boot/K8S): Ensuring Application Availability with PodDisruptionBudgets
Lab13 made sure a bad deploy can’t take your service down — maxSurge, maxUnavailable, and a readiness probe kept traffic flowing even when a broken rollout got stuck. This lab covers a different kind of disruption entirely: what happens to your pods when someone else — a cluster administrator, a node upgrade, the Cluster Autoscaler — needs to take a node down for maintenance, out from under a perfectly healthy Deployment. PodDisruptionBudget (PDB) is the object that lets your workload tell the cluster “you can take pods from me, but never more than this many at once” — and it’s the last piece needed before this series can call itself production-oriented.
This lab deploys three replicas of a Spring Boot service spread across three Minikube nodes, attaches a PodDisruptionBudget with minAvailable: 2, drains a node and watches the eviction succeed safely, then forces a second pod unhealthy and watches a second drain get flatly rejected — because honoring it would drop available replicas below the budget.
Concept Overview
Voluntary vs. involuntary disruption — the distinction PDB actually protects
Kubernetes draws a sharp line the docs are precise about, and it matters for what follows:
- Involuntary disruptions — a node crashes, the kernel panics, a pod gets OOM-killed, hardware fails. Nothing in Kubernetes prevents these; you defend against them with replicas, anti-affinity, and multi-zone spread, not with a PDB.
- Voluntary disruptions — something chooses to remove a running, healthy pod: a cluster admin draining a node for an OS patch, the Cluster Autoscaler consolidating underused nodes, a descheduler rebalancing the cluster. These are the disruptions a
PodDisruptionBudgetactually governs.
PodDisruptionBudget does exactly one thing: it caps how many pods matching its selector can be evicted at the same time through voluntary disruption. It does not prevent disruption forever — a node still eventually gets drained — it just prevents too many replicas going down simultaneously.
The mechanism is the Eviction API, and that scoping is the whole story
kubectl drain doesn’t delete pods directly. For each pod on the node, it calls the Eviction API (POST /api/v1/namespaces/{ns}/pods/{name}/eviction) — a dedicated subresource that exists specifically so the API server can consult any matching PodDisruptionBudget before honoring the request. If evicting the pod would drop the number of currently-healthy replicas below minAvailable (or above maxUnavailable, if that’s the field used instead), the API server rejects the request outright with 429 Too Many Requests. kubectl drain retries with backoff until the budget allows it or the operation times out.
This is also, precisely, why PDB does not protect against everything that removes a pod:
kubectl delete poddeletes directly — no Eviction API call, no PDB check.- A Deployment’s
RollingUpdate(Lab13) replaces pods by having the ReplicaSet controller scale old and new ReplicaSets directly — also not the Eviction API, also not PDB-governed. - HPA (Lab11) scaling down reduces
spec.replicason the Deployment, which again triggers direct ReplicaSet-managed deletion, not eviction.
Only tools that explicitly call the Eviction API — kubectl drain, the Cluster Autoscaler, a descheduler — are ever blocked by a PodDisruptionBudget. This isn’t a gap in the design; it’s the intended scope. PDB protects against a cluster operator’s disruptive action colliding with your own availability requirements — it was never meant to referee your own Deployment’s rollout or your own HPA’s scaling decisions, both of which you already control directly.
minAvailable vs. maxUnavailable
A PodDisruptionBudgetSpec accepts exactly one of the two (setting both is rejected by the API server):
minAvailable— an absolute number or percentage of replicas that must stay healthy, always. This lab usesminAvailable: 2on 3 replicas.maxUnavailable— the inverse framing: how many can be down at once. Equivalent expressive power, different mental model — some teams find “at most N unavailable” clearer when replica counts vary dynamically (e.g., alongside HPA).
Either way, the API server computes disruptionsAllowed continuously and exposes it on the object’s status — kubectl get pdb shows it directly, and it’s the number that actually decides whether the next kubectl drain succeeds or waits.
What We’re Building
minikube (3 nodes)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ node-1 │ │ node-2 │ │ node-3 │
│ pdb-demo-a │ │ pdb-demo-b │ │ pdb-demo-c │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└──────────────────┴──────────────────┘
PodDisruptionBudget
minAvailable: 2
kubectl drain node-1
→ Eviction API checks PDB: 3 healthy, evicting 1 leaves 2 (== minAvailable) → ALLOWED
→ pod reschedules to node-2 or node-3, becomes Ready again
curl chaos endpoint on pdb-demo-b → forces it NotReady (2 healthy now)
kubectl drain node-3
→ Eviction API checks PDB: 2 healthy, evicting 1 leaves 1 (< minAvailable) → REJECTED (429)
→ kubectl drain retries with backoff until the budget allows it
Implementation
Step 1 — A pod/node reporting endpoint
package com.bootlabs.pdbdemo.controller;
import com.bootlabs.pdbdemo.dto.StatusResponse;
import com.bootlabs.pdbdemo.health.ChaosState;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class StatusController {
private final String podName;
private final String nodeName;
private final ChaosState chaosState;
public StatusController(ChaosState chaosState) {
this.chaosState = chaosState;
this.podName = System.getenv().getOrDefault("POD_NAME", "local");
this.nodeName = System.getenv().getOrDefault("NODE_NAME", "local");
}
@GetMapping("/api/status")
public StatusResponse status() {
return new StatusResponse(podName, nodeName, !chaosState.isUnready());
}
}
NODE_NAME comes from the Downward API’s spec.nodeName field (Step 5) — alongside the POD_NAME pattern from earlier labs, this lets curl confirm exactly which node answered, which matters for a lab about node-level disruption in a way it didn’t for the earlier ones.
Step 2 — A readiness toggle for reproducible chaos
Real node-drain timing is a race: which node a rescheduled pod lands on, how fast it becomes ready, whether the scheduler happens to co-locate two replicas — none of it is fully deterministic in a live demo. Instead of relying on that, one pod’s readiness can be forced down on command:
package com.bootlabs.pdbdemo.health;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicBoolean;
// Shared in-memory toggle, flipped by the chaos endpoints and read by
// ChaosHealthIndicator, so this one pod's readiness can be forced down
// on demand without redeploying — used to make PDB's eviction blocking
// reproducible on command instead of racing real node-drain timing.
@Component
public class ChaosState {
private final AtomicBoolean unready = new AtomicBoolean(false);
public void markUnready() {
unready.set(true);
}
public void markReady() {
unready.set(false);
}
public boolean isUnready() {
return unready.get();
}
}
package com.bootlabs.pdbdemo.health;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
// Contributes to the "readiness" health group (see application.yml). Flipping
// ChaosState makes this one pod fail its readiness probe on demand, which is
// exactly the signal PodDisruptionBudget uses to count "currently healthy"
// replicas against minAvailable.
@Component
public class ChaosHealthIndicator implements HealthIndicator {
private final ChaosState chaosState;
public ChaosHealthIndicator(ChaosState chaosState) {
this.chaosState = chaosState;
}
@Override
public Health health() {
if (chaosState.isUnready()) {
return Health.down().withDetail("reason", "chaos toggle forced this pod unready").build();
}
return Health.up().build();
}
}
Same wiring pattern as Lab13’s simulated-dependency indicator: a custom HealthIndicator only affects /actuator/health/readiness if it’s explicitly added to the readiness health group (Step 3). This is also the exact mechanism that ties PDB back to Lab2 — disruptionsAllowed is computed from Ready pod count, and readiness is entirely the application’s own signal to give.
package com.bootlabs.pdbdemo.controller;
import com.bootlabs.pdbdemo.health.ChaosState;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
// Demo-only chaos endpoints, deliberately unauthenticated for lab
// simplicity — never ship an unauthenticated state-mutating endpoint
// like this in a real service.
@RestController
public class ChaosController {
private final ChaosState chaosState;
public ChaosController(ChaosState chaosState) {
this.chaosState = chaosState;
}
@PostMapping("/api/chaos/unready")
public Map<String, Object> makeUnready() {
chaosState.markUnready();
return Map.of("chaos", "unready");
}
@PostMapping("/api/chaos/recover")
public Map<String, Object> recover() {
chaosState.markReady();
return Map.of("chaos", "ready");
}
}
Step 3 — Wiring the chaos indicator into the readiness group
server:
port: 8080
spring:
application:
name: pdb-demo
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
group:
readiness:
include: readinessState,chaos
chaos is ChaosHealthIndicator‘s contribution key, derived from the bean’s class name. Skip the group.readiness.include line and the chaos toggle would still show up on /actuator/health while having zero effect on /actuator/health/readiness — the same silent trap called out in Lab13, worth repeating here because it’s exactly as easy to hit twice.
Step 4 — Dockerfile
FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Step 5 — Deployment with soft anti-affinity across nodes
apiVersion: apps/v1
kind: Deployment
metadata:
name: pdb-demo
labels:
app: pdb-demo
spec:
replicas: 3
selector:
matchLabels:
app: pdb-demo
template:
metadata:
labels:
app: pdb-demo
spec:
# Soft anti-affinity: the scheduler prefers one pod per node, so a
# 3-replica/3-node cluster spreads one-to-one — but it's still allowed
# to double up if a node is cordoned, which is exactly what we want
# when a drained pod needs to reschedule elsewhere.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: pdb-demo
topologyKey: kubernetes.io/hostname
containers:
- name: pdb-demo
image: pdb-demo:1.0.0
imagePullPolicy: Never
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 2
periodSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 20
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: pdb-demo-service
spec:
type: ClusterIP
selector:
app: pdb-demo
ports:
- port: 80
targetPort: 8080
preferredDuringSchedulingIgnoredDuringExecution (soft, not required) is a deliberate choice: a required anti-affinity would refuse to schedule a rescheduled pod anywhere once a node is cordoned and two pods must temporarily share a node — which is exactly the situation a real drain creates. Soft anti-affinity keeps the “usually one-per-node” property under normal operation without turning a routine drain into a scheduling deadlock.
Step 6 — The PodDisruptionBudget itself
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: pdb-demo-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: pdb-demo
selector has to match the Deployment’s pod labels exactly. Get it wrong — a typo, a label that doesn’t exist on any pod — and the PDB applies to zero pods, silently. kubectl get pdb won’t show an error; it’ll just show 0 under EXPECTED-PODS, which looks correct enough at a glance to go unnoticed until the drain it was supposed to protect against goes through unopposed.
Step 7 — Multi-node Minikube and image distribution
minikube start --nodes=3 --cpus=2 --memory=4096
Minikube’s --nodes=3 gives one control-plane node (untainted, schedulable like the others) plus two worker nodes — enough to demonstrate real cross-node rescheduling. Building the image only reaches one node’s Docker daemon, even with minikube docker-env — for a multi-node cluster, the image has to be explicitly propagated:
eval $(minikube docker-env)
docker build -t pdb-demo:1.0.0 ./pdb-demo
minikube image load pdb-demo:1.0.0
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/pdb.yaml
kubectl rollout status deployment/pdb-demo
kubectl get pods -l app=pdb-demo -o wide
NAME READY STATUS NODE
pdb-demo-7d9c4f5b6d-a1b2c 1/1 Running pdb-demo-m02
pdb-demo-7d9c4f5b6d-d3e4f 1/1 Running pdb-demo-m03
pdb-demo-7d9c4f5b6d-g5h6i 1/1 Running pdb-demo
kubectl get pdb pdb-demo-pdb
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
pdb-demo-pdb 2 N/A 1 30s
ALLOWED DISRUPTIONS: 1 is the number that matters — 3 healthy replicas, minAvailable: 2, so exactly one can be taken right now.
Step 8 — Draining a node: the budget allows it
NODE=$(kubectl get pod -l app=pdb-demo -o jsonpath='{.items[0].spec.nodeName}')
kubectl drain "$NODE" --ignore-daemonsets --delete-emptydir-data --timeout=90s
node/pdb-demo-m02 cordoned
evicting pod default/pdb-demo-7d9c4f5b6d-a1b2c
pod/pdb-demo-7d9c4f5b6d-a1b2c evicted
node/pdb-demo-m02 drained
--ignore-daemonsets skips any DaemonSet-managed pods on the node (they’re expected to run on every node regardless of drain state — evicting them isn’t meaningful the way it is for a Deployment’s replicas); --delete-emptydir-data is required whenever a pod uses an emptyDir volume, which kubectl drain otherwise refuses to touch since that data doesn’t survive the pod anyway. Neither flag has anything to do with PDB — they’re just prerequisites for drain to proceed at all.
kubectl get pods -l app=pdb-demo -o wide -w
The evicted pod’s replacement schedules onto one of the two remaining nodes, passes its readiness probe, and — once 3/3 are Ready again — ALLOWED DISRUPTIONS on the PDB returns to 1.
kubectl uncordon "$NODE"
Step 9 — Forcing a second pod unhealthy, then trying to drain again
This is the scenario that actually proves the budget is enforced, not just present:
POD=$(kubectl get pods -l app=pdb-demo -o jsonpath='{.items[1].metadata.name}')
kubectl port-forward "pod/$POD" 8081:8080 &
curl -X POST http://localhost:8081/api/chaos/unready
kubectl get pdb pdb-demo-pdb

Two healthy replicas, minAvailable: 2 — ALLOWED DISRUPTIONS is now 0. Attempting to drain a node holding a different, still-healthy pod:
OTHER_NODE=$(kubectl get pod -l app=pdb-demo -o jsonpath='{.items[2].spec.nodeName}')
kubectl drain "$OTHER_NODE" --ignore-daemonsets --delete-emptydir-data --timeout=30s
node/pdb-demo-m03 cordoned
evicting pod default/pdb-demo-7d9c4f5b6d-g5h6i
error when evicting pods/"pdb-demo-7d9c4f5b6d-g5h6i" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error when evicting pods/"pdb-demo-7d9c4f5b6d-g5h6i" -n "default" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error: unable to drain node "pdb-demo-m03" due to error:Cannot evict pod as it would violate the pod's disruption budget., continuing command...
There are pending nodes to be drained:
pdb-demo-m03
error: Cannot evict pod as it would violate the pod's disruption budget.
kubectl drain retries automatically until its own --timeout elapses — the node stays cordoned (no new pods scheduled there), but the running pod on it is never forcibly removed, because doing so would take healthy replicas from 2 to 1. Recover the chaos pod and the drain that was blocking on the budget succeeds on its very next retry, without re-running the command:
curl -X POST http://localhost:8081/api/chaos/recover
pod/pdb-demo-7d9c4f5b6d-g5h6i evicted
node/pdb-demo-m03 drained
kubectl uncordon "$OTHER_NODE"
Testing
Verifying zero dropped requests through a permitted drain
Same technique as Lab13 — a request loop running concurrently with the drain:
kubectl port-forward svc/pdb-demo-service 8080:80 &
while true; do
code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/status)
echo "$(date +%T) $code"
sleep 0.5
done
Every line should read 200 throughout Step 8’s drain — the PDB-permitted eviction never drops the Service below minAvailable Ready pods, so there’s no window where the Service has too few endpoints to answer.
Unit tests
ChaosHealthIndicator is a plain unit test, no Spring context:
package com.bootlabs.pdbdemo.health;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.Status;
import static org.assertj.core.api.Assertions.assertThat;
class ChaosHealthIndicatorTest {
@Test
void reportsDownAfterMarkUnready() {
ChaosState chaosState = new ChaosState();
chaosState.markUnready();
ChaosHealthIndicator indicator = new ChaosHealthIndicator(chaosState);
Health health = indicator.health();
assertThat(health.getStatus()).isEqualTo(Status.DOWN);
}
}
ChaosController gets a @WebMvcTest slice. ChaosState is a plain @Component, not a controller, so it isn’t picked up by @WebMvcTest‘s restricted scanning by default — @Import(ChaosState.class) pulls the real bean into the sliced context instead of mocking it, since the test asserts actual state mutation, not just a method call:
package com.bootlabs.pdbdemo.controller;
import com.bootlabs.pdbdemo.health.ChaosState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ChaosController.class)
@Import(ChaosState.class)
class ChaosControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ChaosState chaosState;
@AfterEach
void resetChaosState() {
// The Spring context — and this singleton bean — is cached and reused
// across test methods in this class, so state must be reset explicitly
// or an earlier test's toggle leaks into a later one.
chaosState.markReady();
}
@Test
void unreadyEndpointFlipsChaosState() throws Exception {
mockMvc.perform(post("/api/chaos/unready"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.chaos").value("unready"));
assertThat(chaosState.isUnready()).isTrue();
}
}
The @AfterEach reset is not incidental — Spring’s test context caching means the same ChaosState singleton is reused across every test method in this class, so a test that leaves it unready silently poisons whichever test runs next.
Edge cases worth adding beyond what’s shown here: draining two nodes truly simultaneously (kubectl drain nodeA & kubectl drain nodeB in parallel) with minAvailable set such that only one can succeed, and confirming the PDB’s status.currentHealthy field updates within one readiness-probe interval of a pod’s health changing — the whole mechanism depends on that being fast enough to matter operationally.
Performance Considerations
minAvailable too close to replica count creates a drain that never completes. With replicas: 3 and minAvailable: 3, disruptionsAllowed is permanently 0 — no voluntary disruption of any kind can ever proceed, including a routine node OS patch or a Cluster Autoscaler trying to consolidate nodes. This isn’t a hypothetical edge case; it’s a common production incident pattern discovered mid-maintenance-window, the worst possible time to discover it.
A cordoned-but-not-yet-drained node still counts toward scheduling decisions elsewhere. kubectl cordon (which drain performs first) marks a node unschedulable but doesn’t move anything off it — capacity planning for a rolling maintenance window across many nodes has to account for the reduced schedulable capacity during the whole operation, not just the moment each individual pod actually moves.
PDB evaluation adds negligible per-eviction latency, but retries add real wall-clock time to maintenance windows. Each blocked eviction attempt from kubectl drain waits and retries with backoff; a maintenance script draining many nodes in sequence, each one initially blocked by a PDB until a previous node’s pods finish rescheduling, can turn a plan that “should” take minutes into one that takes much longer — budget for that when scheduling a maintenance window, not just for the sum of individual drain operations run against an idle cluster.
Anti-affinity and PDB solve different halves of the same problem — neither is sufficient alone. A PDB with minAvailable: 2 still permits all 3 replicas to be co-located on a single node if nothing spreads them — draining that one node then legitimately takes all 3 pods down at once, and the PDB never even gets consulted for the other two if the Eviction API is asked to evict the whole node’s pods as a single unavoidable unit at the node level (an involuntary disruption, if the node itself fails, is not something PDB governs at all). Anti-affinity or topology spread constraints are what make the PDB’s protection meaningful across node failures, not just node drains.
Common Pitfalls
minAvailableequal to (or effectively equal to) replica count. Blocks every future voluntary disruption permanently — no node drain, no Cluster Autoscaler scale-down, no descheduler rebalance can ever proceed. Leave real headroom:minAvailableshould be meaningfully less than desired replicas, not just numerically less.- A single-replica Deployment with a PDB set to
minAvailable: 1. Mathematically identical to the pitfall above at a smaller scale — the one pod can never be evicted voluntarily, ever. Either accept that a single-replica workload has no meaningful PDB protection to offer, or don’t attach one at all. - Assuming PDB governs Deployment rollouts or HPA scale-down. It doesn’t — both operate through direct pod deletion, not the Eviction API, and are entirely unaffected by any
PodDisruptionBudgetin the cluster. Confusing the two leads to false confidence: a service can have “protected” availability during node maintenance while still being fully exposed to a careless rolling update if Lab13’smaxUnavailable/readiness-probe discipline isn’t also in place. - A selector that matches zero pods. No error, no warning —
kubectl get pdbjust shows0EXPECTED-PODS, silently providing no protection at all. Verify the PDB’s selector against the Deployment’s actual pod labels explicitly after applying, don’t assume a copy-pasted selector is correct. - Treating PDB as a substitute for anti-affinity or multi-zone spread. A PDB only limits simultaneous voluntary disruption; it does nothing for a node that crashes outright, and it does nothing to prevent all replicas from landing on the same node in the first place if nothing else discourages that placement.
- Never testing the PDB against a real drain before it matters. The first
kubectl draina team runs against a PDB-protected Deployment should not be during an actual, time-boxed cluster upgrade. Run it once, deliberately, against a non-production cluster — exactly as this lab does — before trusting it under real operational pressure. - Forgetting
--ignore-daemonsets/--delete-emptydir-dataondrainand misreading the resulting error as a PDB problem. Neither flag is PDB-related, but their absence produces adrainfailure that looks superficially similar to a disruption-budget rejection if you’re not reading the error message closely.
Key Takeaways
PodDisruptionBudgetgoverns the Eviction API only —kubectl drain, the Cluster Autoscaler, a descheduler — and has no effect on Deployment rolling updates or HPA scale-down, which both delete pods directly.minAvailableset too close to (or equal to) replica count doesn’t add safety margin — it permanently blocks all future voluntary disruption, a failure mode that surfaces at the worst possible time: mid-maintenance.- A blocked
kubectl drainisn’t a bug — it’s the PDB working exactly as configured, retrying with backoff until the budget allows the eviction or the operator intervenes (fix capacity, wait for a pod to recover, or override).
The complete source code is available on GitHub.
Support me through GitHub Sponsors.
Thank you for Reading !! See you in the next post.
References
- Kubernetes Documentation — Disruptions
- Kubernetes Documentation — Specifying a Disruption Budget for your Application
- Kubernetes Documentation — Safely Drain a Node
- Kubernetes Documentation — Assigning Pods to Nodes (affinity/anti-affinity)
- Kubernetes Documentation — Pod Topology Spread Constraints
- Cluster Autoscaler FAQ — How does scale-down work?
- Minikube Documentation — Multi-Node Clusters
- Spring Boot Documentation — Kubernetes Probes