<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Illia Bilous</title>
    <description>The latest articles on DEV Community by Illia Bilous (@illiabilous).</description>
    <link>https://dev.to/illiabilous</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4209555%2F8e52a686-8e70-44df-8e19-efd51f1641e7.JPG</url>
      <title>DEV Community: Illia Bilous</title>
      <link>https://dev.to/illiabilous</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/illiabilous"/>
    <language>en</language>
    <item>
      <title>kubectl drain exits 0. Your database sits Pending.</title>
      <dc:creator>Illia Bilous</dc:creator>
      <pubDate>Wed, 28 Oct 2026 21:21:41 +0000</pubDate>
      <link>https://dev.to/illiabilous/kubectl-drain-exits-0-your-database-sits-pending-4o6n</link>
      <guid>https://dev.to/illiabilous/kubectl-drain-exits-0-your-database-sits-pending-4o6n</guid>
      <description>&lt;p&gt;&lt;code&gt;kubectl drain&lt;/code&gt; has one job: evict the pods on a node, respecting disruption budgets. When every eviction is accepted, it exits 0. It does not check that the evicted pods have anywhere to go. That is the scheduler's problem, and the scheduler only gets asked after the pod is already gone.&lt;/p&gt;

&lt;p&gt;Most of the time that is fine. Two cases where it isn't come up on almost every production cluster: a pod whose disk lives in one zone, and a pod that holds a port on the host. Both drain cleanly, and in both the replacement pod never starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 1: a zonal disk, and a zone with no room
&lt;/h2&gt;

&lt;p&gt;A pod's PersistentVolume can carry a required &lt;code&gt;nodeAffinity&lt;/code&gt;. The scheduler's VolumeBinding filter only places the pod on a node whose labels satisfy it. For a cloud disk the affinity names a zone, because the disk physically lives there.&lt;/p&gt;

&lt;p&gt;The part that is easy to miss: CSI drivers write that zone in &lt;strong&gt;their own label key&lt;/strong&gt;, not in &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Driver&lt;/th&gt;
&lt;th&gt;Key on the PV and on the node&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Azure Disk&lt;/td&gt;
&lt;td&gt;&lt;code&gt;topology.disk.csi.azure.com/zone&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon EBS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;topology.ebs.csi.aws.com/zone&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCE Persistent Disk&lt;/td&gt;
&lt;td&gt;&lt;code&gt;topology.gke.io/zone&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a script that looks for &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt; on your PVs finds nothing and concludes the disks are not pinned. They are.&lt;/p&gt;

&lt;p&gt;Now drain a node in that zone when the other nodes in the zone can't take the pod (a taint, a selector, no room). Nodes in other zones have room, but the disk can't follow. The drain exits 0, and the pod sits Pending.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproduce it on kind
&lt;/h3&gt;

&lt;p&gt;Four nodes: a control plane, two workers in zone &lt;code&gt;za&lt;/code&gt; (one tainted for another team), one worker in zone &lt;code&gt;zb&lt;/code&gt;. The PV's affinity uses the Azure Disk key, the way a real Azure disk would.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; drain-demo &lt;span class="nt"&gt;--config&lt;/span&gt; - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes: [{role: control-plane}, {role: worker}, {role: worker}, {role: worker}]
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;n &lt;span class="k"&gt;in &lt;/span&gt;worker:za worker2:za worker3:zb&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;kubectl label node drain-demo-&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="p"&gt;%%&lt;/span&gt;:&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    topology.kubernetes.io/zone&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="p"&gt;##*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt; topology.disk.csi.azure.com/zone&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="p"&gt;##*&lt;/span&gt;:&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;kubectl taint node drain-demo-worker &lt;span class="nv"&gt;workload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db:NoSchedule
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a PV pinned to &lt;code&gt;za&lt;/code&gt; in the driver's key, and a one-replica StatefulSet that claims it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;pv-db&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ReadWriteOnce&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/tmp/pv-db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;DirectoryOrCreate&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;claimRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data-db-0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;topology.disk.csi.azure.com/zone&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;In&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;za&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The StatefulSet is a &lt;code&gt;pause&lt;/code&gt; container with a &lt;code&gt;volumeClaimTemplates&lt;/code&gt; entry named &lt;code&gt;data&lt;/code&gt; in namespace &lt;code&gt;data&lt;/code&gt;; full manifest at the end.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;db-0&lt;/code&gt; lands on &lt;code&gt;worker2&lt;/code&gt;, the only untainted node in &lt;code&gt;za&lt;/code&gt;. Drain it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl drain drain-demo-worker2 --ignore-daemonsets --delete-emptydir-data
...
$ echo $?
0
$ kubectl -n data get pod db-0
NAME   READY   STATUS    RESTARTS   AGE
db-0   0/1     Pending   0          20s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the scheduler's reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0/4 nodes are available: 1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) were unschedulable, 2 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/4 nodes are available: 4 Preemption is not helpful for scheduling.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;worker3&lt;/code&gt; has room, but it's in &lt;code&gt;zb&lt;/code&gt;. &lt;code&gt;worker&lt;/code&gt; is in &lt;code&gt;za&lt;/code&gt;, but it's tainted. Nothing else is left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case 2: a host port, held on every other node
&lt;/h2&gt;

&lt;p&gt;An ingress controller or a node-local agent is often run as a Deployment with &lt;code&gt;hostPort&lt;/code&gt; (or &lt;code&gt;hostNetwork: true&lt;/code&gt;), one replica per node. The scheduler's NodePorts filter won't put two pods holding the same host port on one node.&lt;/p&gt;

&lt;p&gt;One detail makes this easy to miss: with &lt;code&gt;hostNetwork: true&lt;/code&gt;, every &lt;code&gt;containerPort&lt;/code&gt; you declare is a host port. The API server fills in &lt;code&gt;hostPort&lt;/code&gt; for you.&lt;/p&gt;

&lt;p&gt;Drain one of those nodes, and the evicted replica needs a node where the port is free. If every other eligible node already runs a replica, there isn't one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n edge get pods -o wide --no-headers | awk '{print $1,$3,$7}'
ingress-5bdbf6d67b-6gxqd Running drain-demo-worker3
ingress-5bdbf6d67b-8tdnz Running drain-demo-worker2

$ kubectl drain drain-demo-worker3 --ignore-daemonsets --delete-emptydir-data
...
$ echo $?
0
$ kubectl -n edge get pods
NAME                       READY   STATUS    RESTARTS   AGE
ingress-5bdbf6d67b-8tdnz   1/1     Running   0          22s
ingress-5bdbf6d67b-tjkvq   0/1     Pending   0          21s

0/4 nodes are available: 1 node(s) didn't have free ports for the requested pod ports, 1 node(s) were unschedulable, 2 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/4 nodes are available: 1 No preemption victims found for incoming pod, 3 Preemption is not helpful for scheduling.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checking before you drain
&lt;/h2&gt;

&lt;p&gt;Both checks are mechanical. Before draining node &lt;code&gt;N&lt;/code&gt;, for each pod on it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Volumes.&lt;/strong&gt; For each PVC the pod mounts, read the bound PV's &lt;code&gt;spec.nodeAffinity.required&lt;/code&gt;. Find the nodes, other than &lt;code&gt;N&lt;/code&gt;, whose labels satisfy it. Match on whatever key the PV uses, not just &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host ports.&lt;/strong&gt; Collect the pod's host ports (&lt;code&gt;hostPort&lt;/code&gt;, or every &lt;code&gt;containerPort&lt;/code&gt; when &lt;code&gt;hostNetwork: true&lt;/code&gt;). Drop every node where a running pod already holds one of them, by address, protocol and number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rest.&lt;/strong&gt; Drop the nodes the pod's nodeSelector, required node affinity and tolerations rule out, and the cordoned ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If no node is left, the pod has nowhere to go, and the drain will still exit 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure, and a bug of my own
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://shoalcheck.com" rel="noopener noreferrer"&gt;Shoalcheck&lt;/a&gt;, a read-only tool that answers this before you drain (&lt;code&gt;shoalcheck preflight drain &amp;lt;node&amp;gt;&lt;/code&gt;, free on every plan). It got both cases wrong until this week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Until v0.1.2 it read a volume's zone only from &lt;code&gt;topology.kubernetes.io/zone&lt;/code&gt;, so a disk pinned in a CSI driver's key looked unpinned.&lt;/li&gt;
&lt;li&gt;Until v0.1.3 it didn't read host ports at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the cluster above, the old versions answered that nothing they check stops the drain. The new ones answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ shoalcheck preflight drain drain-demo-worker3       # v0.1.2
✓ drain-demo-worker3: nothing this model checks stops the drain
$ echo $?
0

$ shoalcheck preflight drain drain-demo-worker3       # v0.1.3
✗ drain-demo-worker3 cannot be evacuated: 1 thing stops the drain
1. Losing node drain-demo-worker3 leaves 1 pod with no node this model accepts
   If node drain-demo-worker3 were unavailable, 1 pod running on it would have no remaining node in this reading that satisfies the constraints this model checks.
   · Snapshot taken at 2026-10-28T12:30:28Z. Each resource kind is listed independently, so the node, pod and budget views can differ by moments; re-verify against the live cluster before acting.
   · edge/ingress-5bdbf6d67b-6gxqd requests 10m cpu and 16Mi memory and host port 8080/TCP; no surviving node in this reading satisfies the modelled constraints
   · modelled here: …
   · not modelled: …
$ echo $?
2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The checks are still a subset of the scheduler. The tool says so in every answer, with the list of what it does not model: preemption, autoscaling, inter-pod affinity, topology spread, and more. It can tell you a drain will strand something. It never tells you a drain is safe.&lt;/p&gt;

&lt;p&gt;The drain check is one of the things in this 74-second walkthrough, filmed on the real product (a kind test cluster, every frame a capture):&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/eyYNvSTE65E" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Full manifest
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;pv-db&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ReadWriteOnce&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/tmp/pv-db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;DirectoryOrCreate&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;claimRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data-db-0&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;nodeAffinity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodeSelectorTerms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matchExpressions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;topology.disk.csi.azure.com/zone&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;In&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;za&lt;/span&gt;&lt;span class="pi"&gt;]}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;StatefulSet&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.k8s.io/pause:3.10&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;10m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;16Mi&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;/data&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
  &lt;span class="na"&gt;volumeClaimTemplates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ReadWriteOnce&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1Gi&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;edge&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;edge&lt;/span&gt;&lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxy&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.k8s.io/pause:3.10&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt;&lt;span class="nv"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;8080&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;10m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;16Mi&lt;/span&gt;&lt;span class="pi"&gt;}}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>storage</category>
    </item>
    <item>
      <title>Kubernetes upgrade preflight checklist for AKS, EKS and GKE</title>
      <dc:creator>Illia Bilous</dc:creator>
      <pubDate>Mon, 26 Oct 2026 21:48:16 +0000</pubDate>
      <link>https://dev.to/illiabilous/kubernetes-upgrade-preflight-checklist-for-aks-eks-and-gke-17g8</link>
      <guid>https://dev.to/illiabilous/kubernetes-upgrade-preflight-checklist-for-aks-eks-and-gke-17g8</guid>
      <description>&lt;p&gt;Managed Kubernetes makes the control-plane upgrade a button. The button does not tell you what will break in the workloads, and it does not tell you that the node-pool roll will stall at the first node with a bad PodDisruptionBudget. This is the checklist to run on a cluster (call it prod-eu-1) before you press it. The first four sections apply everywhere; the last covers what differs on AKS, EKS and GKE.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Read the release notes and pick a target
&lt;/h2&gt;

&lt;p&gt;Upgrade one minor version at a time. Kubernetes does not support skipping minors on the control plane, and the managed providers enforce this in different ways. Read the upstream changelog for the target version, specifically the "Urgent upgrade notes" and "Deprecation" sections, and your provider's own release notes for that version.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. APIs removed in the target version
&lt;/h2&gt;

&lt;p&gt;Kubernetes removes beta API versions on a published schedule. The authoritative list is the upstream deprecated API migration guide at kubernetes.io. Some well-known removals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1.22: &lt;code&gt;extensions/v1beta1&lt;/code&gt; and &lt;code&gt;networking.k8s.io/v1beta1&lt;/code&gt; Ingress, &lt;code&gt;apiextensions.k8s.io/v1beta1&lt;/code&gt; CRDs, &lt;code&gt;admissionregistration.k8s.io/v1beta1&lt;/code&gt; webhooks.&lt;/li&gt;
&lt;li&gt;1.25: &lt;code&gt;batch/v1beta1&lt;/code&gt; CronJob, &lt;code&gt;policy/v1beta1&lt;/code&gt; PodDisruptionBudget and PodSecurityPolicy, &lt;code&gt;autoscaling/v2beta1&lt;/code&gt; HorizontalPodAutoscaler, &lt;code&gt;discovery.k8s.io/v1beta1&lt;/code&gt; EndpointSlice.&lt;/li&gt;
&lt;li&gt;1.26: &lt;code&gt;autoscaling/v2beta2&lt;/code&gt; HorizontalPodAutoscaler, &lt;code&gt;flowcontrol.apiserver.k8s.io/v1beta1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;1.27: &lt;code&gt;storage.k8s.io/v1beta1&lt;/code&gt; CSIStorageCapacity.&lt;/li&gt;
&lt;li&gt;1.29: &lt;code&gt;flowcontrol.apiserver.k8s.io/v1beta2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;1.32: &lt;code&gt;flowcontrol.apiserver.k8s.io/v1beta3&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The list is illustrative; check the guide for your exact target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find what still uses a removed API
&lt;/h3&gt;

&lt;p&gt;There are three places a removed API can hide, and none of them alone is sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live requests.&lt;/strong&gt; The API server counts calls to deprecated APIs. This is the best signal that something is actively using one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt; /metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;apiserver_requested_deprecated_apis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each series carries &lt;code&gt;group&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;, &lt;code&gt;resource&lt;/code&gt; and &lt;code&gt;removed_release&lt;/code&gt; labels. A non-zero series with &lt;code&gt;removed_release="1.32"&lt;/code&gt; means a client called that API recently. Metrics reset on API server restart and on managed services you may not be able to scrape &lt;code&gt;/metrics&lt;/code&gt; at all, so treat an empty result as "not seen", not "not used".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the cluster still serves.&lt;/strong&gt; Discovery lists every group/version the server answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl api-versions | &lt;span class="nb"&gt;sort
&lt;/span&gt;kubectl api-resources &lt;span class="nt"&gt;--verbs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;list &lt;span class="nt"&gt;-o&lt;/span&gt; name | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A beta group/version in this list that your target removes is the thing to chase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is stored and what is applied.&lt;/strong&gt; Objects are stored in one version and served in others, so &lt;code&gt;kubectl get&lt;/code&gt; returns the preferred version regardless of how it was written. Manifests are where the old version lives: grep Helm charts, Kustomize overlays, Terraform and CI templates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEn&lt;/span&gt; &lt;span class="s2"&gt;"apiVersion: (extensions/v1beta1|policy/v1beta1|batch/v1beta1|autoscaling/v2beta1|autoscaling/v2beta2)"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
helm template my-release ./chart | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"^apiVersion:"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check what Helm has recorded: a release whose stored manifest uses a removed API can fail to upgrade even when the live objects are fine. The &lt;code&gt;helm mapkubeapis&lt;/code&gt; plugin exists for that.&lt;/p&gt;

&lt;p&gt;Finally, check admission webhooks and CRD conversion webhooks. A webhook registered with a removed &lt;code&gt;admissionregistration&lt;/code&gt; version, or a CRD that declares a conversion webhook whose Service has no ready endpoints, will cause API errors that look unrelated to the upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. PDBs that will stall node-pool upgrades
&lt;/h2&gt;

&lt;p&gt;The control plane upgrade is quick. The node-pool upgrade is where clusters get stuck. Every provider's node upgrade drains each node, and drain respects PodDisruptionBudgets. A budget with zero allowed disruptions pins its pod, and the node waits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pdb &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl get pdb &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] | select(.status.disruptionsAllowed == 0)
  | "\(.metadata.namespace)/\(.metadata.name)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three causes cover most cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Deployment with one replica and &lt;code&gt;minAvailable: 1&lt;/code&gt; (or &lt;code&gt;maxUnavailable: 0&lt;/code&gt;). Allowed disruptions is always zero.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;minAvailable&lt;/code&gt; equal to the replica count.&lt;/li&gt;
&lt;li&gt;A workload that is already unhealthy, so the budget has nothing to give.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A roll with surge nodes does not fix this. Surge adds capacity so pods have somewhere to go; it does not change the budget. The pod still cannot be evicted. Providers handle the stall differently: some time out and fail the upgrade, some force the eviction after a window. See section 5, and read your provider's current documentation for the exact timeout.&lt;/p&gt;

&lt;p&gt;Beyond budgets, a node roll also trips on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pods with no controller, which &lt;code&gt;kubectl drain&lt;/code&gt; refuses without &lt;code&gt;--force&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;pods with &lt;code&gt;emptyDir&lt;/code&gt; data;&lt;/li&gt;
&lt;li&gt;pods that cannot reschedule: node selectors, required anti-affinity, taints, zonal volumes, or no spare capacity once a node is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same checks apply as for a single drain; &lt;a href="https://dev.to/illiabilous/kubectl-drain-stuck-why-drains-hang-on-poddisruptionbudgets-and-how-to-check-before-you-start-2p38"&gt;why kubectl drain hangs&lt;/a&gt; covers each. The difference for an upgrade is that you need the answer for every node in the pool, not one.&lt;/p&gt;

&lt;p&gt;Also confirm the pool can absorb a surge node: VM quota for the family and free IP addresses in the subnet. A surge that cannot get either fails for reasons unrelated to Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Version skew
&lt;/h2&gt;

&lt;p&gt;The upstream version skew policy sets the limits. Verify the current numbers against it, since they have changed over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The kubelet may not be newer than the API server. It may be older, by up to three minor versions on recent releases (it was two on older ones).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; is supported within one minor version of the API server, in either direction.&lt;/li&gt;
&lt;li&gt;Controller-manager, scheduler and cloud-controller-manager must not be newer than the API server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On managed services the control plane moves first, so the practical rule is: after the control plane upgrade, the nodes will be behind, and you want that gap to stay inside the policy while you work through the pools. If your nodes are already two minors behind, upgrading the control plane another minor puts them at three, and then you have no room. Upgrade node pools soon after the control plane rather than letting them lag.&lt;/p&gt;

&lt;p&gt;Check the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NODE:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion'&lt;/span&gt;
kubectl version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clients matter too: CI runners with an old &lt;code&gt;kubectl&lt;/code&gt;, and operators built against client libraries that only speak a removed API version, break at the API server upgrade, not the node upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add-ons
&lt;/h2&gt;

&lt;p&gt;Add-ons are what actually break. Each one has its own compatibility matrix with Kubernetes versions, and most are not upgraded automatically.&lt;/p&gt;

&lt;p&gt;Make a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy,ds &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NS:.metadata.namespace,NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, for each one, look up whether the target Kubernetes version is supported by the add-on version you run. Typical entries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CNI plugin and CSI drivers (storage drivers are the ones that hurt most when they are wrong).&lt;/li&gt;
&lt;li&gt;CoreDNS and kube-proxy, where the provider manages versions.&lt;/li&gt;
&lt;li&gt;Cluster autoscaler (its minor version should match the cluster's) and ingress controllers.&lt;/li&gt;
&lt;li&gt;cert-manager, service meshes, policy engines and any operator that ships CRDs.&lt;/li&gt;
&lt;li&gt;Anything with a webhook. A webhook with &lt;code&gt;failurePolicy: Fail&lt;/code&gt; and no backing pod can block all writes of the resource it covers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. What to check per provider
&lt;/h2&gt;

&lt;p&gt;These are the managed-service specifics that have been stable for a while. Confirm details against the current docs, because defaults and limits get changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  EKS
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade insights.&lt;/strong&gt; EKS runs checks for deprecated API usage and other issues and reports them per cluster. In the console or with &lt;code&gt;aws eks list-insights --cluster-name prod-eu-1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add-ons.&lt;/strong&gt; Managed add-ons (VPC CNI, CoreDNS, kube-proxy, EBS CSI) have versions per Kubernetes release: &lt;code&gt;aws eks describe-addon-versions --kubernetes-version &amp;lt;target&amp;gt; --addon-name &amp;lt;name&amp;gt;&lt;/code&gt;. Self-managed ones are your job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order.&lt;/strong&gt; Control plane first, then add-ons, then node groups. A control plane upgrade cannot be rolled back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed node groups.&lt;/strong&gt; The update config has &lt;code&gt;maxUnavailable&lt;/code&gt; (default one node at a time) or a percentage; raising it speeds up the roll but concentrates the disruption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AKS
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Available versions.&lt;/strong&gt; &lt;code&gt;az aks get-upgrades -g &amp;lt;rg&amp;gt; -n prod-eu-1 -o table&lt;/code&gt;. AKS does not let you skip minor versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node surge.&lt;/strong&gt; Each node pool has a max surge setting, with a default of a percentage of the pool; &lt;code&gt;az aks nodepool update --max-surge 33%&lt;/code&gt; changes it. Surge nodes count against your VM quota and subnet addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drain behaviour.&lt;/strong&gt; AKS drains nodes during upgrade and honours PDBs; a drain that cannot finish within the node drain timeout fails the upgrade. Check the current timeout in the docs and set it deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  GKE
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Release channels and maintenance windows.&lt;/strong&gt; On a release channel the control plane upgrades on Google's schedule within your window; set exclusions for periods you cannot afford a change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deprecation insights.&lt;/strong&gt; GKE reports usage of deprecated APIs for upcoming versions in the console, and can pause automatic upgrades when it sees usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surge upgrades.&lt;/strong&gt; Node pools use &lt;code&gt;maxSurge&lt;/code&gt; and &lt;code&gt;maxUnavailable&lt;/code&gt;; the defaults favour one surge node and no unavailable nodes. Blue-green is an alternative for pools that need to roll back quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDBs.&lt;/strong&gt; GKE honours PodDisruptionBudgets during node upgrades for a bounded time, after which it proceeds. Do not count on that: a budget that waits an hour then gets bypassed is not a design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Target version chosen, one minor step, release notes read.&lt;/li&gt;
&lt;li&gt;Removed APIs: metric checked, discovery checked, manifests and Helm releases grepped, webhooks checked.&lt;/li&gt;
&lt;li&gt;PDBs: none with zero allowed disruptions on a workload you are about to roll.&lt;/li&gt;
&lt;li&gt;Drain blockers: unmanaged pods, emptyDir, unschedulable replacements, per node.&lt;/li&gt;
&lt;li&gt;Quota and IP headroom for surge.&lt;/li&gt;
&lt;li&gt;Skew: kubelet gap, kubectl, CI clients.&lt;/li&gt;
&lt;li&gt;Add-on compatibility for the target version, with an upgrade order.&lt;/li&gt;
&lt;li&gt;Backups and a rollback plan: the control plane usually cannot be downgraded, so the plan is "fix forward" or "restore a new cluster".&lt;/li&gt;
&lt;li&gt;Staging cluster upgraded first, on the same version path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A tool for part of it
&lt;/h2&gt;

&lt;p&gt;Shoalcheck is a read-only Kubernetes audit tool, a CLI and a desktop app for macOS, Windows and Linux, and it installs nothing in the cluster. &lt;code&gt;shoalcheck preflight upgrade &amp;lt;version&amp;gt;&lt;/code&gt; answers one question: what stops the way to that version. It compares the group/versions the cluster still serves with a removal schedule taken from the upstream deprecation guide, and it reports the node pools that cannot roll because a drain stops on them. That covers sections 1 and 2 above, not skew or add-ons. Preflight is free on every plan: &lt;a href="https://shoalcheck.com" rel="noopener noreferrer"&gt;shoalcheck.com&lt;/a&gt;, downloads on &lt;a href="https://github.com/Shoalcheck/shoalcheck/releases" rel="noopener noreferrer"&gt;GitHub releases&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Most failed managed upgrades are not Kubernetes bugs. They are a removed API that nobody grepped for, a PDB that can never be satisfied, a node pool that cannot get surge capacity, or an add-on three versions behind. Each of those is visible in the cluster before the upgrade starts.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>aks</category>
    </item>
    <item>
      <title>kubectl drain stuck? Why drains hang on PodDisruptionBudgets, and how to check before you start</title>
      <dc:creator>Illia Bilous</dc:creator>
      <pubDate>Mon, 26 Oct 2026 21:47:29 +0000</pubDate>
      <link>https://dev.to/illiabilous/kubectl-drain-stuck-why-drains-hang-on-poddisruptionbudgets-and-how-to-check-before-you-start-2p38</link>
      <guid>https://dev.to/illiabilous/kubectl-drain-stuck-why-drains-hang-on-poddisruptionbudgets-and-how-to-check-before-you-start-2p38</guid>
      <description>&lt;p&gt;You cordon a node, run &lt;code&gt;kubectl drain&lt;/code&gt;, and the terminal prints the same line every five seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error when evicting pods/"api-7d9c6b8f5-x2k4q" -n payments (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is broken. Drain is doing what it was told: asking the API server to evict pods one at a time and waiting. A drain hangs for a small number of reasons, and almost all of them can be read from the cluster before you start. This post goes through them with commands you can run against any cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What drain actually does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kubectl drain &amp;lt;node&amp;gt;&lt;/code&gt; first cordons the node (marks it unschedulable), then evicts each pod through the Eviction API. Eviction is not delete. The API server checks the pod's PodDisruptionBudget (PDB) and refuses with HTTP 429 if the eviction would take the workload below its budget. kubectl keeps retrying, which is the "hang".&lt;/p&gt;

&lt;p&gt;Drain also refuses to start on some pods, and it can succeed on eviction while the pod then has nowhere to run. Those are three different failure classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Eviction is refused (PDB).&lt;/li&gt;
&lt;li&gt;Drain refuses the pod up front (unmanaged pods, local storage, DaemonSets).&lt;/li&gt;
&lt;li&gt;Eviction works, but the replacement pod stays &lt;code&gt;Pending&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. PDB math
&lt;/h2&gt;

&lt;p&gt;List every budget and look at one column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pdb &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMESPACE   NAME   MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
payments    api    1               N/A               0                     41d
payments    web    N/A             1                 1                     41d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ALLOWED DISRUPTIONS&lt;/code&gt; is the number of pods the Eviction API will let go right now. If it is &lt;code&gt;0&lt;/code&gt; for a workload that has a pod on your node, that pod cannot be evicted and the drain waits.&lt;/p&gt;

&lt;p&gt;To get just the blocking budgets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pdb &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] | select(.status.disruptionsAllowed == 0)
  | "\(.metadata.namespace)/\(.metadata.name)  healthy=\(.status.currentHealthy)/\(.status.desiredHealthy) expected=\(.status.expectedPods)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  minAvailable vs maxUnavailable
&lt;/h3&gt;

&lt;p&gt;Both express the same idea from opposite sides.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;minAvailable: N&lt;/code&gt; means at least N pods must stay healthy. Allowed disruptions = healthy pods minus N.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxUnavailable: N&lt;/code&gt; means at most N pods may be unavailable. Allowed disruptions = N minus pods already unavailable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Percentages are rounded up. With 10 replicas, &lt;code&gt;minAvailable: 80%&lt;/code&gt; allows 2 disruptions; &lt;code&gt;maxUnavailable: 25%&lt;/code&gt; allows 3 (2.5 rounds up).&lt;/p&gt;

&lt;p&gt;The two behave differently when replicas change. &lt;code&gt;minAvailable: 3&lt;/code&gt; on a 3-replica Deployment allows zero disruptions forever; &lt;code&gt;maxUnavailable: 1&lt;/code&gt; keeps allowing one disruption at any size. If you want a budget that scales with the workload, prefer &lt;code&gt;maxUnavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A budget also counts only healthy pods. If one of three replicas is already crash-looping and the budget is &lt;code&gt;minAvailable: 2&lt;/code&gt;, allowed disruptions is &lt;code&gt;0&lt;/code&gt; even though no one touched anything. A PDB can set &lt;code&gt;unhealthyPodEvictionPolicy: AlwaysAllow&lt;/code&gt; (beta since 1.27, GA in 1.31) so that pods which are not ready can be evicted regardless of the budget; check your cluster version before relying on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The single-replica Deployment with a PDB
&lt;/h3&gt;

&lt;p&gt;This is the most common self-inflicted drain block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;report-worker&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;payments&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;report-worker&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;report-worker&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;registry.example/report-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;1.4&lt;/span&gt; &lt;span class="pi"&gt;}]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;policy/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PodDisruptionBudget&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;report-worker&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;payments&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;minAvailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;report-worker&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One replica, one required. Allowed disruptions is &lt;code&gt;0&lt;/code&gt;, always. &lt;code&gt;maxUnavailable: 0&lt;/code&gt; has the same effect. Every drain that touches the node hosting this pod will hang until someone deletes the budget or scales up.&lt;/p&gt;

&lt;p&gt;The honest options are: run at least two replicas, set &lt;code&gt;maxUnavailable: 1&lt;/code&gt; and accept a brief gap, or drop the PDB for a workload that cannot meet it anyway. A PDB that can never be satisfied protects nothing and blocks every node upgrade.&lt;/p&gt;

&lt;p&gt;Also check the selector. A PDB whose selector matches no pods shows &lt;code&gt;expectedPods: 0&lt;/code&gt;, and a selector that matches pods from several controllers produces unspecified results; the API documentation says to keep one PDB per workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pods that drain refuses to evict
&lt;/h2&gt;

&lt;p&gt;Before any eviction, &lt;code&gt;kubectl drain&lt;/code&gt; checks each pod and aborts with a message if it finds one of these. These are flags you have to choose deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pods without a controller
&lt;/h3&gt;

&lt;p&gt;A bare pod (no ReplicaSet, StatefulSet, Job or similar owner) will not be recreated anywhere. Drain refuses unless you pass &lt;code&gt;--force&lt;/code&gt;, and then the pod is simply gone.&lt;/p&gt;

&lt;p&gt;Find them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] | select((.metadata.ownerReferences // []) | length == 0)
  | "\(.spec.nodeName)\t\(.metadata.namespace)/\(.metadata.name)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static (mirror) pods show up here too; those are owned by the node and drain ignores them. Anything else on the list is a pod you need to decide about: recreate it as a Deployment, or accept losing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  emptyDir and local storage
&lt;/h3&gt;

&lt;p&gt;Pods with an &lt;code&gt;emptyDir&lt;/code&gt; volume lose that data on eviction. Drain refuses unless you pass &lt;code&gt;--delete-emptydir-data&lt;/code&gt; (older versions called it &lt;code&gt;--delete-local-data&lt;/code&gt;). That flag is a decision, not a fix. If the emptyDir holds a cache, fine. If it holds the only copy of something, it is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] | select(any(.spec.volumes[]?; has("emptyDir")))
  | "\(.spec.nodeName)\t\(.metadata.namespace)/\(.metadata.name)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A different local-storage trap: a PersistentVolume with node affinity (local PVs, or zonal disks) pins the pod to a node or zone. The pod can be evicted, but the replacement will not schedule anywhere else. That belongs in section 3.&lt;/p&gt;

&lt;h3&gt;
  
  
  DaemonSets
&lt;/h3&gt;

&lt;p&gt;DaemonSet pods are recreated on the same node immediately, so evicting them is pointless. Drain refuses until you pass &lt;code&gt;--ignore-daemonsets&lt;/code&gt;, which skips them. This is the one flag nearly everyone passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl drain prod-eu-1-node-a &lt;span class="nt"&gt;--ignore-daemonsets&lt;/span&gt; &lt;span class="nt"&gt;--delete-emptydir-data&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put &lt;code&gt;--timeout&lt;/code&gt; on every drain you script. Without it, a PDB block waits forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Pods that cannot reschedule
&lt;/h2&gt;

&lt;p&gt;Eviction succeeds, the drain moves on, and the replacement pod sits in &lt;code&gt;Pending&lt;/code&gt;. For a single-replica workload that is an outage; for a multi-replica one it is lost capacity. Check this before you drain, because the scheduler will not tell you until it is too late.&lt;/p&gt;

&lt;p&gt;Look at what is already pending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase&lt;span class="o"&gt;=&lt;/span&gt;Pending
kubectl describe pod &lt;span class="nt"&gt;-n&lt;/span&gt; payments &amp;lt;pod&amp;gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/Events:/,$p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The event reads like &lt;code&gt;0/6 nodes are available: 2 node(s) didn't match Pod's node affinity/selector, 3 Insufficient cpu, 1 node(s) had untolerated taint&lt;/code&gt;. Each clause is a reason, and you can test for each one ahead of time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node selectors and affinity.&lt;/strong&gt; List what the pods on your node require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt; spec.nodeName&lt;span class="o"&gt;=&lt;/span&gt;prod-eu-1-node-a &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'
  .items[] | "\(.metadata.namespace)/\(.metadata.name)\t\(.spec.nodeSelector // {})\t\(.spec.affinity != null)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a pod selects &lt;code&gt;disktype=ssd&lt;/code&gt; and this is the only node with that label, there is nowhere for it to go. The same applies to required pod anti-affinity: a pod that must not share a node with its siblings needs one free node per replica.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Taints.&lt;/strong&gt; Compare node taints to the tolerations of the pods you are about to move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NODE:.metadata.name,TAINTS:.spec.taints[*].key'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Capacity.&lt;/strong&gt; Look at what the remaining nodes can absorb:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe nodes | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A6&lt;/span&gt; &lt;span class="s2"&gt;"Allocated resources"&lt;/span&gt;
kubectl top nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests, not usage, decide scheduling. A node that looks idle in &lt;code&gt;top&lt;/code&gt; can be full in &lt;code&gt;Allocated resources&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zones and volumes.&lt;/strong&gt; A pod bound to a zonal volume can only restart in that zone. If the zone has no spare capacity, it stays &lt;code&gt;Pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of this replaces a real drain in a staging cluster, and the scheduler has more inputs than any manual check covers (topology spread constraints, priorities, extended resources). But the checks above catch the usual causes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-drain checklist
&lt;/h2&gt;

&lt;p&gt;Run these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;kubectl get pdb -A&lt;/code&gt; and look for &lt;code&gt;ALLOWED DISRUPTIONS&lt;/code&gt; of &lt;code&gt;0&lt;/code&gt; on workloads with pods on the node.&lt;/li&gt;
&lt;li&gt;List unmanaged pods and emptyDir pods on the node, and decide on &lt;code&gt;--force&lt;/code&gt; and &lt;code&gt;--delete-emptydir-data&lt;/code&gt; knowingly.&lt;/li&gt;
&lt;li&gt;For each workload on the node, confirm it has a place to go: labels, taints, free requests, zone.&lt;/li&gt;
&lt;li&gt;Cordon first (&lt;code&gt;kubectl cordon&lt;/code&gt;), then drain with &lt;code&gt;--ignore-daemonsets --timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it hangs, read the retry message. It names the pod and the namespace.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing this by hand for one node is fine. Doing it for every node in a pool before an upgrade is tedious and easy to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing the same check with a tool
&lt;/h2&gt;

&lt;p&gt;Shoalcheck is a read-only Kubernetes audit tool (CLI and desktop app) that installs nothing in the cluster. &lt;code&gt;shoalcheck preflight drain &amp;lt;node&amp;gt;&lt;/code&gt; answers this one question: whether that node can be evacuated, and if not, which object stops it (a budget at zero, a pod nothing recreates, a volume pinned to the node, work with nowhere to go). It exits 0 when nothing stops the drain, 2 when something does, and 1 when RBAC refused some reads and the answer is partial. Preflight is free on every plan: &lt;a href="https://shoalcheck.com" rel="noopener noreferrer"&gt;shoalcheck.com&lt;/a&gt;, downloads on &lt;a href="https://github.com/Shoalcheck/shoalcheck/releases" rel="noopener noreferrer"&gt;GitHub releases&lt;/a&gt;. Its model checks less than the scheduler does, so treat a clear result as "nothing found", not a guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A drain hangs because an eviction is refused or a replacement cannot start. A PDB with &lt;code&gt;ALLOWED DISRUPTIONS&lt;/code&gt; of &lt;code&gt;0&lt;/code&gt; is the usual cause, and the single-replica Deployment with &lt;code&gt;minAvailable: 1&lt;/code&gt; is the usual way to create one. Bare pods, emptyDir data and DaemonSets are things drain makes you acknowledge with flags. Unschedulable replacements are the quietest failure, so look at selectors, taints, capacity and zones before you cordon, not after.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>sre</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
