<?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>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>
