Guide · EKS Operations

PodDisruptionBudgets for MCP Servers on EKS

Without a PodDisruptionBudget, a routine EKS node drain — triggered by a node group upgrade, a cluster autoscaler scale-down, or a simple kubectl drain — will evict all your MCP server pods simultaneously, dropping every in-flight tool call with a hard connection reset.

TL;DR

Create a PodDisruptionBudget with minAvailable: 1 (or maxUnavailable: 1) whose matchLabels align exactly with your Deployment's pod labels. Set terminationGracePeriodSeconds: 60 on the pod spec and add a preStop lifecycle hook to drain active connections before the SIGTERM lands. This combination gives node drains and EKS managed node group upgrades a safe path to evict pods one at a time while in-flight tool calls finish cleanly. Never set minAvailable to 100% of replicas — it will block all voluntary disruptions forever.

What PodDisruptionBudgets actually do (and don't do)

A PodDisruptionBudget (PDB) is a Kubernetes policy object that constrains voluntary disruptions — situations where the cluster itself decides to evict a pod. The Eviction API (used by kubectl drain, the cluster autoscaler, and EKS managed node group rollovers) checks the PDB before evicting any pod. If eviction would push the number of available pods below minAvailable (or above maxUnavailable), the API returns HTTP 429 and the drain retries later.

PDBs do not protect against involuntary disruptions: if a node loses power, the AWS hypervisor reclaims the instance, or the kernel OOM-killer fires, your pods die regardless of any PDB. For those scenarios, you need multiple replicas spread across availability zones — which a PDB alone cannot enforce (see node affinity and topology spread).

Key resource fields:

Check how many evictions are currently allowed with:

kubectl get pdb mcp-server-pdb -n prod -o wide
# NAME             MIN-AVAILABLE   MAX-UNAVAILABLE   ALLOWED-DISRUPTIONS   AGE
# mcp-server-pdb   1               N/A               1                     3d

The ALLOWED-DISRUPTIONS column reflects status.disruptionsAllowed. If this reads 0 when you expect a non-zero value, check whether replicas are healthy and whether the selector is correct.

PDB and Deployment YAML

The selector in the PDB must match the labels your Deployment stamps onto pods. The safest pattern is to define a dedicated label like app: mcp-server and reference it in both objects. Here is a complete example for a two-replica MCP server deployment:

# pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: mcp-server-pdb
  namespace: prod
spec:
  minAvailable: 1          # at least 1 pod must stay up during voluntary disruption
  selector:
    matchLabels:
      app: mcp-server      # must match .spec.template.metadata.labels in Deployment
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
  namespace: prod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server    # PDB matchLabels targets this
    spec:
      terminationGracePeriodSeconds: 60   # time to finish in-flight tool calls
      containers:
        - name: mcp-server
          image: your-registry/mcp-server:latest
          ports:
            - name: http
              containerPort: 3000
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]  # see preStop section below
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0   # never take a pod down during rollout without a replacement

With minAvailable: 1 and 2 replicas, the drain can evict one pod and then must wait for it to finish and the replacement to become ready before evicting the second. This is the right default for most MCP server deployments.

If you use a percentage instead of an integer, Kubernetes rounds down. minAvailable: "50%" on 3 replicas = 1 pod must remain (floor(1.5) = 1), which allows 2 pods to be evicted concurrently. For small replica counts, explicit integers are safer and more predictable.

Graceful shutdown: terminationGracePeriodSeconds and preStop hooks

The PDB controls when a pod gets evicted. terminationGracePeriodSeconds and the preStop lifecycle hook control how cleanly the pod exits after eviction is permitted.

The shutdown sequence when kubelet evicts a pod:

  1. kubelet executes the preStop hook (if defined) and waits for it to complete.
  2. kubelet sends SIGTERM to PID 1 in the container.
  3. The process has until terminationGracePeriodSeconds expires (counting from step 1, not step 2) to exit cleanly.
  4. If the process is still running when the grace period expires, kubelet sends SIGKILL.

For MCP servers, "clean exit" means: stop accepting new connections, finish all in-flight JSON-RPC tool calls, flush any buffered logs, and close database connection pools. Thirty to sixty seconds is typically enough for tool calls that hit external APIs; set it higher only if your tools have genuinely long-running operations.

The preStop hook runs before SIGTERM. A common pattern is a short sleep to give the load balancer time to remove the pod from its target group before the server stops accepting connections — without this, the load balancer may still route new requests to a pod that has already received SIGTERM and is closing its listener:

# More complete preStop hook for an MCP server behind an ALB or NLB
lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - |
          # Signal the app to stop accepting new connections
          kill -SIGUSR1 1
          # Wait for the load balancer to deregister this target (ALB deregistration
          # delay is 30s by default; match or exceed that here)
          sleep 30

If your MCP server process does not handle SIGUSR1, use the HTTP variant to call a drain endpoint instead:

lifecycle:
  preStop:
    httpGet:
      path: /admin/drain
      port: 3000

Make sure terminationGracePeriodSeconds is greater than the preStop sleep plus your worst-case tool call duration. A common mistake is setting a 30s grace period with a 30s preStop sleep, leaving zero seconds for actual in-flight requests to complete before SIGKILL fires.

Check the effective shutdown sequence with:

kubectl describe pod <pod-name> -n prod
# Look for:
#   Terminating      True    <timestamp>
# and the "Events" section showing preStop, SIGTERM, SIGKILL timings

How EKS uses PDBs during managed node group upgrades

EKS managed node group rollovers (triggered by a Kubernetes version upgrade, AMI update, or launch template change) drain each old node before replacing it. The upgrade process calls the Eviction API in exactly the same way as kubectl drain — your PDB applies here without any extra configuration.

If the PDB is violated — for example, all replicas are on the node being drained — the upgrade will block and eventually time out (the default is 15 minutes per node). You will see an event like:

Cannot evict pod as it would violate the pod's disruption budget.
Eviction of pod prod/mcp-server-7f4b9c-xk9np is not allowed,
0 of 1 disruptions are allowed by PDB mcp-server-pdb

The fix is always one of: spread replicas across nodes (topology spread constraints or anti-affinity rules), or temporarily lower minAvailable before draining manually. Never reach for kubectl drain --force on production nodes — it bypasses the PDB entirely and evicts pods with SIGKILL regardless of in-flight requests.

The cluster autoscaler also uses the Eviction API when it decides to scale down an underutilized node. If your MCP server's PDB would be violated by removing the last pod from a node, the autoscaler will skip that node and leave it running (it logs pod-disruption-budget-related as the reason). This is the correct behavior — but if it causes nodes to never scale down, check whether your replica count and PDB settings together always allow at least one eviction.

Quick reference: PDB behavior per disruption type:

Disruption type PDB enforced? Notes
kubectl drain Yes (Eviction API) --force bypasses PDB — never use in production
EKS managed node group upgrade Yes Upgrade blocks if PDB is violated; times out after 15 min/node
Cluster autoscaler scale-down Yes Skips the node if eviction would violate PDB
Deployment rolling update No (separate mechanism) Governed by maxUnavailable/maxSurge on the Deployment
Node hardware failure No Involuntary disruption — PDB does not apply
OOM kill / process crash No Involuntary disruption — PDB does not apply

PDB and Deployment rolling update interaction

PDBs and Deployment rollingUpdate settings operate at different layers. The PDB governs Eviction API calls; maxUnavailable/maxSurge governs how the Deployment controller replaces pods during a rollout. They do not directly interact — but they can compound in surprising ways.

Example: you have 2 replicas, minAvailable: 1 in the PDB, maxUnavailable: 1 in the rolling update, and a node drain fires simultaneously with a rollout. The rollout may have already taken one pod down (within its maxUnavailable budget), leaving only 1 pod available. Now the drain tries to evict that remaining pod — and the PDB blocks it because dropping it would leave 0 available. The drain stalls until the rollout completes and brings the replacement pod to Ready.

The safest combination for MCP servers during a rollout is maxUnavailable: 0 and maxSurge: 1 on the Deployment. This means rollouts temporarily overprovision by one pod rather than underprovision — and the PDB never has to block a concurrent drain because the available pod count never drops below minAvailable.

Failure modes reference

Failure mode Symptom Cause Fix
PDB blocks node drain indefinitely kubectl drain hangs; EKS upgrade stalls at "draining node" All replicas landed on the same node, so evicting any pod would violate minAvailable Add pod anti-affinity or topology spread constraints to ensure replicas are distributed; never set minAvailable equal to total replicas
kubectl drain --force bypasses PDB silently Pods evicted immediately; in-flight requests dropped; no warning in application logs Operator used --force flag to unblock a stalled drain Never use --force in production; instead fix the root cause (misscheduled replicas, PDB set too high)
Mismatched label selector PDB exists but ALLOWED-DISRUPTIONS shows N/A; drain ignores it spec.selector.matchLabels does not match pod labels — e.g., PDB targets app: mcp but pods carry app: mcp-server Run kubectl get pods -n prod --show-labels and compare with PDB selector; fix the mismatch
All replicas on the same node Node drain evicts one pod, PDB blocks the rest; or entire deployment is lost on node failure Scheduler placed all pods on one node due to resource availability or missing anti-affinity Add topologySpreadConstraints or podAntiAffinity with topologyKey: kubernetes.io/hostname
terminationGracePeriodSeconds too short In-flight tool calls return connection reset errors; logs show SIGKILL before request completion Grace period expires while tool calls are still running; kubelet sends SIGKILL Set terminationGracePeriodSeconds to preStop duration + p99 tool call latency + 10s buffer; typically 60–90s for MCP servers
preStop sleep exceeds grace period Pod receives SIGKILL before or immediately after SIGTERM; in-flight requests still dropped preStop hook sleeps longer than terminationGracePeriodSeconds allows Ensure terminationGracePeriodSeconds > preStop duration + max request duration; the grace period clock starts at preStop, not at SIGTERM
minAvailable: 100% of replicas All voluntary disruptions blocked permanently; node upgrades never complete PDB set to match total replica count — no eviction is ever allowed Set minAvailable to total replicas minus 1, or use maxUnavailable: 1 instead