Lesson 66 of 72 · 31 min read · Updated

Cluster Maintenance — Cordon, Drain, Certificates and Upgrades

Sooner or later every node in a cluster has to be taken out of service. The operating system needs a security patch, the disk is full, the VM is moved to a new host, or Kubernetes itself must be upgraded. Day-2 operations like these are where clusters lose availability. Usually the cause is not a bug but a routine command run in the wrong order.

This lesson is about doing that maintenance without your users noticing. You build a small .NET 10 inventory API, run it on a disposable three-node kind cluster, and keep a load generator firing requests at it the whole time. You drain nodes, watch a node go NotReady, renew the control plane certificates, and finally perform a real Kubernetes upgrade from v1.35.8 to v1.36.4 with kubeadm. The headline result: a naive single-replica Deployment lost requests on every drain, while the prepared one answered 4,243 of 4,243 requests during the complete cluster upgrade.

We will cover the following topics:

  1. The node maintenance workflow: cordon, drain and uncordon
  2. What kubectl drain really does, its important flags, and why it uses the Eviction API instead of deleting Pods
  3. How PodDisruptionBudgets, replicas, topology spread and readiness together make a drain invisible to clients
  4. What happens when a node fails instead of being drained: NotReady, the node.kubernetes.io/unreachable taint and tolerationSeconds
  5. Checking and renewing kubeadm certificates
  6. The version skew policy and the order in which components must be upgraded
  7. A complete kubeadm upgrade: upgrade plan, upgrade apply, kubelet upgrades and upgrade node
  8. How managed clusters (AKS, EKS, GKE) handle the same work with surge upgrades

Technical Requirements

  1. A disposable kind cluster created with the kind CLI, not Docker Desktop's built-in cluster used in most other lessons of this series. Draining nodes, stopping a kubelet, restarting the control plane and upgrading Kubernetes would disrupt every workload you run on your everyday cluster, and Docker Desktop manages its own Kubernetes version. A throw-away cluster costs a minute to create and can be deleted afterwards. The examples use kind v0.33.0 with the images kindest/node:v1.35.8 and kindest/node:v1.36.4. The lesson Setting Up a Local Multi-Node Cluster with kind and Docker Desktop explains the kind CLI in detail.
  2. Docker with about 3 CPUs and 4 GB of memory free for a three-node cluster.
  3. kubectl (the examples use v1.36.1) and the .NET 10 SDK to build the sample API.
  4. The lesson PriorityClass, Preemption and PodDisruptionBudgets introduces PodDisruptionBudgets. This lesson uses them as one tool among several and does not re-explain every field.

Core Concepts

Voluntary and Involuntary Disruptions

Kubernetes distinguishes two kinds of trouble for a Pod:

KindExamplesCan Kubernetes coordinate it?
Voluntary disruptionkubectl drain, node pool upgrades, cluster autoscaler scale-down, deleting a DeploymentYes. Tools that use the Eviction API ask first and respect PodDisruptionBudgets.
Involuntary disruptionHardware failure, kernel panic, a VM deleted by mistake, network partition, node out of memoryNo. Pods simply disappear. Only spare replicas on other nodes help.

Maintenance is the voluntary kind, and that is good news: you decide when it happens, so you can make sure the application is ready for it. Everything in this lesson either prepares an application for voluntary disruptions or limits the damage of involuntary ones.

Cordon, Drain and Uncordon

A maintenance window for one node always follows the same three steps:

StepCommandWhat changes
Cordonkubectl cordon <node>Sets spec.unschedulable: true. The scheduler places no new Pods on the node. Running Pods are not touched. The node shows Ready,SchedulingDisabled.
Drainkubectl drain <node> --ignore-daemonsets --delete-emptydir-dataCordons the node if needed, then evicts every Pod on it and waits until they are gone.
Uncordonkubectl uncordon <node>Clears unschedulable. New Pods may land on the node again.

Uncordoning does not move anything back. Pods that were evicted stay where the scheduler put them, so after a series of drains the load is often uneven. A kubectl rollout restart of the affected Deployments spreads them again, and the Descheduler project can do it automatically.

What kubectl drain Really Does

kubectl drain is a client-side loop, not a single API call. It lists the Pods on the node, filters them, and then for each Pod sends an Eviction to the API server. An eviction is a request to delete a Pod that the API server is allowed to refuse. It refuses with HTTP 429 Too Many Requests when the eviction would violate a PodDisruptionBudget, and drain then retries every 5 seconds until it succeeds or its --timeout runs out.

Before evicting anything, drain refuses to continue when it finds Pods it cannot handle safely. Each refusal has its own flag:

Pod on the nodeDefault behaviourFlagWhy the default is cautious
Managed by a DaemonSetDrain stops--ignore-daemonsetsThe DaemonSet controller would immediately recreate the Pod on the same node. Skipping them is almost always what you want.
Uses an emptyDir volumeDrain stops--delete-emptydir-dataThe data in the emptyDir is lost when the Pod leaves the node.
Has no controller (a bare Pod)Drain stops--forceNothing will recreate the Pod elsewhere. It is gone for good.
Static Pod (mirror Pod)IgnorednoneThe kubelet owns it through a manifest file. Drain cannot remove it.

A few more flags matter in practice:

  1. --timeout=5m limits the whole drain. Without it a drain blocked by a budget waits forever.
  2. --pod-selector app=inventory-api evicts only matching Pods. Useful to move one application without draining everything.
  3. --grace-period=N overrides each Pod's terminationGracePeriodSeconds. Leave it at the default so applications can shut down cleanly.
  4. --dry-run=server shows what would happen, including the refusals, without changing anything.
  5. --disable-eviction deletes Pods instead of evicting them. This bypasses every PodDisruptionBudget. It exists for clusters where the Eviction API is unavailable or for emergencies, and it should never be part of a normal runbook.

Why Eviction and Not Delete

Deleting a Pod and evicting it end the same way, with a graceful termination, but only an eviction checks PodDisruptionBudgets first. That is why every well-behaved tool that removes Pods for maintenance, drain, the cluster autoscaler and managed node pool upgrades, uses evictions. A budget does not protect you from someone running kubectl delete pod in a loop. The lesson PriorityClass, Preemption and PodDisruptionBudgets compares the two paths in detail.

Making a Drain Invisible to Clients

A PodDisruptionBudget on its own does not prevent errors. Four things have to work together:

IngredientWhat it prevents
More than one replicaWith one replica, every eviction is a full outage until the replacement is ready.
Topology spread or anti-affinityReplicas on different nodes, so one drain cannot take them all at once.
PodDisruptionBudgetDrain evicts replicas one at a time and waits until the replacement counts as healthy.
Readiness probe plus graceful shutdownThe replacement receives traffic only when it can answer, and the old Pod leaves the Service endpoints before it stops. The lesson Running ASP.NET Core on Kubernetes — Health Checks, Graceful Shutdown and Configuration covers the .NET side in depth.

The Step-by-Step section measures the difference: the same API with and without these ingredients, under the same drain.

When a Node Fails Instead of Being Drained

Drain is planned. A crashed node is not, and Kubernetes reacts on a timer:

Time after the kubelet stops reportingWhat happens
0 sThe kubelet stops renewing its Lease in kube-node-lease.
about 50 sThe node lifecycle controller in kube-controller-manager marks the node Ready=Unknown (NotReady), adds the taints node.kubernetes.io/unreachable:NoSchedule and node.kubernetes.io/unreachable:NoExecute, and marks its Pods not ready, which removes them from Service endpoints.
tolerationSeconds laterPods whose toleration for the NoExecute taint has expired are evicted. Their controllers create replacements on healthy nodes.

Unless a Pod declares its own, the DefaultTolerationSeconds admission plugin gives it tolerations for node.kubernetes.io/not-ready and node.kubernetes.io/unreachable with tolerationSeconds: 300: five minutes during which replicas on a dead node still count. A fast-starting stateless API is better served by about 30 seconds; a stateful workload that is expensive to move after a short network blip by the default or more.

The evicted Pods stay in Terminating until the node comes back or someone force-deletes them, because only the kubelet can confirm that the containers have really stopped.

kubeadm Certificates

A kubeadm cluster runs on certificates: the API server's serving certificate, the client certificates the control plane components use to talk to each other and to etcd, and the certificates embedded in admin.conf, controller-manager.conf and scheduler.conf. kubeadm issues these leaf certificates for one year. The certificate authorities (ca, etcd-ca, front-proxy-ca) are valid for ten years.

When a leaf certificate expires, the control plane stops working: components can no longer authenticate to the API server or to etcd. Three facts keep that from happening:

  1. kubeadm certs check-expiration lists every certificate with its expiry date.
  2. kubeadm upgrade apply and kubeadm upgrade node renew all certificates on the node they run on. A cluster that is upgraded at least once a year never reaches expiry.
  3. kubeadm certs renew all renews them by hand. Afterwards the control plane static Pods must be restarted, because some components read their certificates and kubeconfig files only at startup.

Kubelet client certificates are separate. With rotateCertificates: true in the kubelet configuration, the default in kubeadm clusters, each kubelet requests a new client certificate from the API server before the old one expires.

Version Skew Policy

Kubernetes does not upgrade atomically. During an upgrade, old and new components run side by side, and the version skew policy defines which combinations are supported:

ComponentAllowed relative to kube-apiserver
kube-apiserver (HA control plane)Instances may differ by at most one minor version during the upgrade
kube-controller-manager, kube-scheduler, cloud-controller-managerSame minor as the API server or one older. Never newer.
kubelet, kube-proxyUp to three minors older than the API server. Never newer.
kubectlOne minor newer or older than the API server

Two rules follow from the table and shape every upgrade:

  1. Upgrade the control plane first, then the nodes. A kubelet must never be newer than the API server it talks to.
  2. Upgrade one minor version at a time. kubeadm refuses to skip from 1.34 to 1.36. To go two minors, upgrade the control plane twice. The kubelets may follow later, because they tolerate up to three minors of skew.

The kubeadm Upgrade Flow

PhaseWhereCommands
1. PrepareYour laptopRead the release notes of the target minor, check for removed APIs, take an etcd snapshot (see the next lesson, Backing Up and Restoring etcd)
2. Upgrade kubeadmFirst control plane nodeInstall the new kubeadm binary or package
3. PlanFirst control plane nodekubeadm upgrade plan checks cluster health and shows the target versions
4. ApplyFirst control plane nodekubeadm upgrade apply v1.36.4 upgrades etcd, the API server, controller manager and scheduler one by one, then CoreDNS and kube-proxy, and renews certificates
5. Other control plane nodesEach additional control plane nodekubeadm upgrade node
6. KubeletsEvery node, one at a timekubectl drain, install the new kubelet and kubectl, systemctl daemon-reload && systemctl restart kubelet, kubectl uncordon. Worker nodes run kubeadm upgrade node first to update their kubelet configuration.

On a real cluster the binaries come from the Kubernetes package repositories (pkgs.k8s.io), one repository per minor version. kind nodes have no package manager setup for Kubernetes, so the Step-by-Step example copies the binaries out of the newer kindest/node image instead. Every kubeadm command is the same as on a production node.

Managed Clusters

On AKS, EKS and GKE the provider upgrades the control plane, and node pool upgrades do the node work: they add surge nodes with the new version (AKS and GKE maxSurge, EKS managed node groups maxUnavailable), then cordon, drain and delete old nodes. The drains use the Eviction API, so your budgets are respected, and a budget that never allows a disruption stalls the upgrade until the provider times out or forces it, each by its own rules. The skew policy still applies. The preparation in the Step-by-Step section is exactly what makes these upgrades uneventful too.

Hands-On: Kubernetes Commands

See which nodes are cordoned

A cordoned node shows SchedulingDisabled in its status.

kubectl get nodes -o custom-columns=NAME:.metadata.name,UNSCHEDULABLE:.spec.unschedulable

Preview a drain without changing anything

--dry-run=server runs every check, including PodDisruptionBudgets, and prints what would be evicted.

kubectl drain ops-lab-worker2 --ignore-daemonsets --delete-emptydir-data --dry-run=server

Drain one application only

Moves just the Pods that match the selector off the node.

kubectl drain ops-lab-worker2 --ignore-daemonsets --pod-selector app=inventory-api --timeout=2m

Check whether a budget currently allows a disruption

ALLOWED DISRUPTIONS of 0 means every eviction will be refused.

kubectl get pdb -A
kubectl -n shop get pdb inventory-api -o jsonpath='{.status.disruptionsAllowed}{"\n"}'

Check certificate expiry on a kubeadm control plane

On a real node run it with sudo. On kind, docker exec into the control plane container.

docker exec ops-lab-control-plane kubeadm certs check-expiration

Show the kubelet version of every node

kubectl get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion

Step-by-Step Example

The Scenario

A small online shop runs an inventory API. Operations has three jobs this week: patch the worker nodes, renew the control plane certificates before they expire, and upgrade the cluster from Kubernetes 1.35 to 1.36. The business has one requirement: the inventory API must keep answering during all of it.

You will:

  1. Build the API and a disposable cluster on Kubernetes v1.35.8.
  2. Drain a node under load with a naive Deployment and count the failures.
  3. Prepare the Deployment for maintenance and repeat the drain.
  4. See how drain refuses unsafe Pods and how a budget can block it.
  5. Simulate a node failure and shorten the time to recovery.
  6. Renew the certificates.
  7. Upgrade the whole cluster to v1.36.4 while a load generator keeps counting.

Step 1 — The Inventory API

Create the project:

dotnet new web -n InventoryApi -o app --framework net10.0

Replace Program.cs. The API reports which Pod and node answered, and its readiness endpoint starts failing the moment the host begins to shut down, so Kubernetes stops routing traffic to a Pod that is about to stop.

var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));
var app = builder.Build();

var lifetime = app.Lifetime;
var pod = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
var node = Environment.GetEnvironmentVariable("NODE_NAME") ?? "unknown";

// Readiness fails as soon as shutdown starts, so the Pod leaves the Service endpoints.
app.MapGet("/healthz/live", () => Results.Ok("alive"));
app.MapGet("/healthz/ready", () =>
lifetime.ApplicationStopping.IsCancellationRequested
? Results.StatusCode(503)
: Results.Ok("ready"));

app.MapGet("/api/stock/{sku}", (string sku) => new
{
sku,
quantity = Random.Shared.Next(0, 500),
pod,
node
});

lifetime.ApplicationStopping.Register(() => Console.WriteLine($"{DateTime.UtcNow:O} SIGTERM received on {pod}, draining"));
lifetime.ApplicationStopped.Register(() => Console.WriteLine($"{DateTime.UtcNow:O} stopped cleanly"));

app.Run();

The Dockerfile uses a chiseled runtime image, which runs as a non-root user and contains no shell:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY InventoryApi.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
USER app
ENTRYPOINT ["dotnet", "InventoryApi.dll"]

Build it. A quick docker run -p 18080:8080 inventory-api:1.0 answers curl localhost:18080/api/stock/SKU-1 with {"sku":"SKU-1","quantity":361,"pod":"c947a5ac56e4","node":"unknown"}.

docker build -t inventory-api:1.0 app

Step 2 — A Disposable Cluster on v1.35.8

The cluster starts one minor version behind, so it can be upgraded at the end. Pinning the image digest makes the setup reproducible. Save this as ops-lab.yaml:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: ops-lab
nodes:
- role: control-plane
image: kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0
- role: worker
image: kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0
- role: worker
image: kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0

Create it with its own kubeconfig file, so your normal kubectl context is never touched, and load the image into the nodes:

kind create cluster --config ops-lab.yaml --kubeconfig ./kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kind load docker-image inventory-api:1.0 --name ops-lab
kubectl get nodes
NAME STATUS ROLES AGE VERSION
ops-lab-control-plane Ready control-plane 56s v1.35.8
ops-lab-worker Ready <none> 47s v1.35.8
ops-lab-worker2 Ready <none> 47s v1.35.8

Step 3 — The Naive Deployment and a Load Generator

Start the way many Deployments start: one replica, no probes, no budget. Save as 1-naive.yaml:

apiVersion: v1
kind: Namespace
metadata:
name: shop
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: inventory-api
namespace: shop
spec:
replicas: 1
selector:
matchLabels:
app: inventory-api
template:
metadata:
labels:
app: inventory-api
spec:
containers:
- name: api
image: inventory-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
---
apiVersion: v1
kind: Service
metadata:
name: inventory-api
namespace: shop
spec:
selector:
app: inventory-api
ports:
- port: 80
targetPort: 8080

The load generator sends about eight requests per second and prints the HTTP status of each. A status of 000 means no answer within one second. It runs on the control plane node, so draining the workers never moves it. Save as loadgen.yaml:

apiVersion: v1
kind: Pod
metadata:
name: loadgen
namespace: shop
spec:
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: curl
image: curlimages/curl:8.17.0
command: ["sh", "-c"]
args:
- |
while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 http://inventory-api.shop/api/stock/SKU-42)
echo "$(date +%T) $code"
sleep 0.1
done
kubectl apply -f 1-naive.yaml
kubectl -n shop rollout status deploy/inventory-api
kubectl apply -f loadgen.yaml
kubectl -n shop get pods -o wide
NAME READY STATUS RESTARTS AGE IP NODE
inventory-api-976695ff7-585cv 1/1 Running 0 22s 10.244.1.2 ops-lab-worker2
loadgen 1/1 Running 0 23s 10.244.0.6 ops-lab-control-plane

Step 4 — Drain the Naive Deployment

Remember how many lines the load generator has written, drain the node that hosts the API, and count the results of everything after that point:

N=$(kubectl -n shop logs loadgen | wc -l)
kubectl drain ops-lab-worker2 --ignore-daemonsets --delete-emptydir-data
sleep 15
kubectl -n shop logs loadgen | tail -n +$((N+1)) | awk '{print $2}' | sort | uniq -c
node/ops-lab-worker2 cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/kindnet-24ztr, kube-system/kube-proxy-f779w
evicting pod shop/inventory-api-976695ff7-585cv
pod/inventory-api-976695ff7-585cv evicted
node/ops-lab-worker2 drained
3 000
142 200

Three requests got no answer. The drain itself took about two seconds, which is the problem: the only replica was gone before its replacement on the other worker could answer. On a busy production API those three seconds are hundreds of failed requests. An earlier run of the same drain on ops-lab-worker lost two requests with a two-second client timeout, so the exact number varies, but it is never zero.

Bring the node back:

kubectl uncordon ops-lab-worker2

Step 5 — Prepare the Deployment for Maintenance

Save as 2-ready-for-maintenance.yaml. It adds three replicas spread across nodes, readiness and liveness probes, a preStop sleep that gives the endpoint removal time to reach every node before the app stops, and a PodDisruptionBudget that keeps at least two replicas available:

apiVersion: apps/v1
kind: Deployment
metadata:
name: inventory-api
namespace: shop
spec:
replicas: 3
selector:
matchLabels:
app: inventory-api
template:
metadata:
labels:
app: inventory-api
spec:
terminationGracePeriodSeconds: 30
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: inventory-api
containers:
- name: api
image: inventory-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
periodSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
periodSeconds: 10
lifecycle:
preStop:
sleep:
seconds: 5
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: inventory-api
namespace: shop
spec:
minAvailable: 2
selector:
matchLabels:
app: inventory-api

whenUnsatisfiable: ScheduleAnyway is deliberate. With DoNotSchedule, a drain of one of only two workers would leave replacements Pending, because they could not satisfy the spread, and the budget would block the drain forever.

kubectl apply -f 2-ready-for-maintenance.yaml
kubectl -n shop rollout status deploy/inventory-api
kubectl -n shop get pods -l app=inventory-api -o wide
kubectl -n shop get pdb
NAME READY STATUS RESTARTS AGE IP NODE
inventory-api-5c8b79fcdd-4z6f2 1/1 Running 0 1s 10.244.2.5 ops-lab-worker
inventory-api-5c8b79fcdd-p5g84 1/1 Running 0 3s 10.244.1.4 ops-lab-worker2
inventory-api-5c8b79fcdd-tz6dj 1/1 Running 0 2s 10.244.1.5 ops-lab-worker2

NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
inventory-api 2 N/A 1 4s

Step 6 — Drain Again, With the Budget

ops-lab-worker2 hosts two of the three replicas. Drain it under the same load:

N=$(kubectl -n shop logs loadgen | wc -l)
time kubectl drain ops-lab-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=5m
sleep 15
kubectl -n shop logs loadgen | tail -n +$((N+1)) | awk '{print $2}' | sort | uniq -c
node/ops-lab-worker2 cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/kindnet-24ztr, kube-system/kube-proxy-f779w
evicting pod shop/inventory-api-5c8b79fcdd-tz6dj
evicting pod shop/inventory-api-5c8b79fcdd-p5g84
error when evicting pods/"inventory-api-5c8b79fcdd-tz6dj" -n "shop" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod shop/inventory-api-5c8b79fcdd-tz6dj
pod/inventory-api-5c8b79fcdd-p5g84 evicted
pod/inventory-api-5c8b79fcdd-tz6dj evicted
node/ops-lab-worker2 drained
266 200

The output tells the whole story. Drain asked for both Pods at once. The API server allowed one eviction and refused the second with the budget message. Drain retried five seconds later, by which time the replacement for the first Pod was ready, and the second eviction went through. According to time, the drain took 12.6 seconds instead of 2, and all 266 requests succeeded.

After the drain, all three replicas run on ops-lab-worker, the only schedulable worker. Uncordon the node and spread them again:

kubectl uncordon ops-lab-worker2
kubectl -n shop rollout restart deploy/inventory-api
kubectl -n shop get pods -l app=inventory-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
inventory-api-d66fff7fd-bxpmm 1/1 Running 0 2s 10.244.2.8 ops-lab-worker
inventory-api-d66fff7fd-t5jd2 1/1 Running 0 5s 10.244.1.7 ops-lab-worker2
inventory-api-d66fff7fd-tpvdm 1/1 Running 0 5s 10.244.1.6 ops-lab-worker2

(The three old Pods were still Terminating at that moment and are left out.)

Step 7 — When Drain Refuses or Gets Stuck

Drain stops on Pods it cannot move safely. Start a bare Pod with an emptyDir volume on ops-lab-worker2, the kind of debugging Pod people forget about:

apiVersion: v1
kind: Pod
metadata:
name: scratch
namespace: shop
spec:
nodeName: ops-lab-worker2
containers:
- name: shell
image: curlimages/curl:8.17.0
command: ["sleep", "infinity"]
volumeMounts:
- name: tmp
mountPath: /cache
volumes:
- name: tmp
emptyDir: {}

A server-side dry run shows each refusal in turn:

kubectl drain ops-lab-worker2 --dry-run=server
node/ops-lab-worker2 cordoned (server dry run)
error: unable to drain node "ops-lab-worker2" due to error: [cannot delete DaemonSet-managed Pods (use --ignore-daemonsets to ignore): kube-system/kindnet-24ztr, kube-system/kube-proxy-f779w, cannot delete Pods with local storage (use --delete-emptydir-data to override): shop/scratch], continuing command...
kubectl drain ops-lab-worker2 --ignore-daemonsets --delete-emptydir-data --dry-run=server
node/ops-lab-worker2 cordoned (server dry run)
error: unable to drain node "ops-lab-worker2" due to error: cannot delete Pods that declare no controller (use --force to override): shop/scratch, continuing command...

With --force the bare Pod would be deleted and never come back. The selector variant moves only the API and leaves everything else alone:

kubectl drain ops-lab-worker2 --ignore-daemonsets --pod-selector app=inventory-api --dry-run=server
node/ops-lab-worker2 cordoned (server dry run)
evicting pod shop/inventory-api-d66fff7fd-tpvdm (server dry run)
evicting pod shop/inventory-api-d66fff7fd-t5jd2 (server dry run)
node/ops-lab-worker2 drained (server dry run)

Delete the scratch Pod with kubectl -n shop delete pod scratch.

Now the opposite problem: a budget that never allows a disruption. Raise minAvailable to the replica count and drain the node that hosts one replica, with a timeout:

kubectl -n shop patch pdb inventory-api --type merge -p '{"spec":{"minAvailable":3}}'
kubectl -n shop get pdb
kubectl drain ops-lab-worker --ignore-daemonsets --delete-emptydir-data --timeout=20s; echo "exit=$?"
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
inventory-api 3 N/A 0 60s

node/ops-lab-worker cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/kindnet-8489f, kube-system/kube-proxy-wtxhk
evicting pod shop/inventory-api-d66fff7fd-bxpmm
error when evicting pods/"inventory-api-d66fff7fd-bxpmm" -n "shop" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
There are pending pods in node "ops-lab-worker" when an error occurred: error when evicting pods/"inventory-api-d66fff7fd-bxpmm" -n "shop": global timeout reached: 20s
error: unable to drain node "ops-lab-worker" due to error: error when evicting pods/"inventory-api-d66fff7fd-bxpmm" -n "shop": global timeout reached: 20s, continuing command...
exit=1

Without --timeout this drain would retry forever. Note that the node stays cordoned after the failed drain. Uncordon it, or fix the budget and drain again. The Eviction API shows the same decision as an HTTP status. This is the request drain sends for every Pod:

cat > eviction.json <<EOF
{"apiVersion":"policy/v1","kind":"Eviction","metadata":{"name":"<pod-name>","namespace":"shop"}}
EOF
kubectl create --raw /api/v1/namespaces/shop/pods/<pod-name>/eviction -f eviction.json
Error from server (TooManyRequests): Cannot evict pod as it would violate the pod's disruption budget.

With minAvailable back at 2, the same request succeeds with {"kind":"Status","apiVersion":"v1","metadata":{},"status":"Success","code":201}. Restore the budget and the node:

kubectl -n shop patch pdb inventory-api --type merge -p '{"spec":{"minAvailable":2}}'
kubectl uncordon ops-lab-worker

Step 8 — A Node Fails

Look at the tolerations every Pod received by default:

kubectl -n shop get pod <pod> -o jsonpath='{range .spec.tolerations[*]}{.key} {.effect} {.tolerationSeconds}{"\n"}{end}'
node.kubernetes.io/not-ready NoExecute 300
node.kubernetes.io/unreachable NoExecute 300

Five minutes is long for an API that starts in a second. Shorten it to 30 seconds with a patch. Save as 3-tolerations-patch.yaml:

spec:
template:
spec:
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 30
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 30
kubectl -n shop patch deploy inventory-api --patch-file 3-tolerations-patch.yaml
kubectl -n shop rollout status deploy/inventory-api

Simulate a failed node by stopping its kubelet. The kind node is a container, so docker exec reaches it. Then watch the node and the Pods every ten seconds:

docker exec ops-lab-worker2 systemctl stop kubelet
kubectl get node ops-lab-worker2 -w

Condensed from the run (local time):

15:07:01 kubelet stopped on ops-lab-worker2
15:07:41 ops-lab-worker2 Ready 2 API Pods Running on ops-lab-worker2
15:07:51 ops-lab-worker2 NotReady 2 API Pods still listed as Running
15:08:22 ops-lab-worker2 NotReady 2 Pods Terminating, 2 new Pods Running on ops-lab-worker
15:09:02 ops-lab-worker2 NotReady the 2 old Pods still Terminating

The node turned NotReady after roughly 50 seconds, the replicas were replaced about 30 seconds after that, as the shortened toleration demands. The old Pods stay Terminating because no kubelet can confirm their end. The node carries both unreachable taints and explains itself:

kubectl get node ops-lab-worker2 -o jsonpath='{range .spec.taints[*]}{.key}:{.effect}{"\n"}{end}'
kubectl get node ops-lab-worker2 -o jsonpath='{range .status.conditions[?(@.type=="Ready")]}{.status} {.reason} {.message}{"\n"}{end}'
node.kubernetes.io/unreachable:NoSchedule
node.kubernetes.io/unreachable:NoExecute
Unknown NodeStatusUnknown Kubelet stopped posting node status.

The load generator logged 1,165 successful requests and no failures during this experiment. That is partly luck of the simulation: stopping only the kubelet left the API containers running, so they kept answering until Kubernetes moved traffic away. When a machine loses power, requests routed to its Pods fail until the node is marked NotReady and the endpoints are updated. Spare replicas on other nodes keep that window survivable.

Start the kubelet again. The node returns, the taints disappear, and the stuck Pods are cleaned up:

docker exec ops-lab-worker2 systemctl start kubelet
kubectl get nodes
NAME STATUS ROLES AGE VERSION
ops-lab-control-plane Ready control-plane 8m35s v1.35.8
ops-lab-worker Ready <none> 8m26s v1.35.8
ops-lab-worker2 Ready <none> 8m26s v1.35.8

Step 9 — Check and Renew Certificates

docker exec ops-lab-control-plane kubeadm certs check-expiration
CERTIFICATE EXPIRES RESIDUAL TIME CERTIFICATE AUTHORITY EXTERNALLY MANAGED
admin.conf Oct 21, 2027 12:01 UTC 364d ca no
apiserver Oct 21, 2027 12:01 UTC 364d ca no
apiserver-etcd-client Oct 21, 2027 12:01 UTC 364d etcd-ca no
...
super-admin.conf Oct 21, 2027 12:01 UTC 364d ca no

CERTIFICATE AUTHORITY EXPIRES RESIDUAL TIME CERTIFICATE AUTHORITY EXTERNALLY MANAGED
ca Oct 18, 2036 12:01 UTC 9y no
etcd-ca Oct 18, 2036 12:01 UTC 9y no
front-proxy-ca Oct 18, 2036 12:01 UTC 9y no

Renew one certificate first and compare serial numbers on disk and on the wire:

docker exec ops-lab-control-plane openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial
docker exec ops-lab-control-plane kubeadm certs renew apiserver
docker exec ops-lab-control-plane openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial
PORT=$(docker port ops-lab-control-plane 6443/tcp | cut -d: -f2)
echo | openssl s_client -connect 127.0.0.1:$PORT 2>/dev/null | openssl x509 -noout -serial
serial=0CAD817FD2E1C73C
certificate for serving the Kubernetes API renewed
serial=434AC18F535EA602
serial=434AC18F535EA602

The running API server already serves the new certificate, because it watches its serving certificate files and reloads them. That is not true for everything: the client certificates inside controller-manager.conf and scheduler.conf, and etcd's own certificates, are read at startup. So after renew all, kubeadm asks for a restart of the control plane:

docker exec ops-lab-control-plane kubeadm certs renew all
certificate embedded in the kubeconfig file for the scheduler manager to use renewed
certificate embedded in the kubeconfig file for the super-admin renewed

Done renewing certificates. You must restart the kube-apiserver, kube-controller-manager, kube-scheduler and etcd, so that they can use the new certificates.

Static Pods cannot be restarted with kubectl delete: deleting the mirror Pod only makes the kubelet create the mirror object again, the containers keep running. The kubelet restarts them when their manifest leaves and returns to /etc/kubernetes/manifests:

docker exec ops-lab-control-plane sh -c '
for c in kube-apiserver kube-controller-manager kube-scheduler etcd; do
mv /etc/kubernetes/manifests/$c.yaml /tmp/
done
sleep 20
mv /tmp/kube-*.yaml /tmp/etcd.yaml /etc/kubernetes/manifests/'
kubectl -n kube-system get pods -l tier=control-plane
NAME READY STATUS RESTARTS AGE
etcd-ops-lab-control-plane 1/1 Running 1 10m
kube-apiserver-ops-lab-control-plane 1/1 Running 1 10m
kube-controller-manager-ops-lab-control-plane 1/1 Running 1 (60s ago) 10m
kube-scheduler-ops-lab-control-plane 1/1 Running 1 10m

On a single control plane node this means about 20 seconds without an API server. Running workloads are not affected, but deployments and scaling pause. With three control plane nodes, renew and restart them one at a time.

admin.conf was renewed as well. Kubeconfig files that were copied from it earlier, such as the one kind wrote for you, keep working until their own copy of the certificate expires. Copy the new file again before that.

The kubelets take care of their own client certificates:

docker exec ops-lab-worker grep rotateCertificates /var/lib/kubelet/config.yaml
rotateCertificates: true

Step 10 — Plan the Upgrade to v1.36.4

The cluster runs v1.35.8. The target is v1.36.4, exactly one minor version newer. On a real node you would install the new kubeadm package from the v1.36 repository on pkgs.k8s.io. Here the binaries come from the kindest/node:v1.36.4 image, which contains exactly the files the packages would install:

mkdir -p bin-v1.36.4
docker create --name kind-bins kindest/node:v1.36.4
for b in kubeadm kubelet kubectl; do docker cp kind-bins:/usr/bin/$b bin-v1.36.4/$b; done
docker rm kind-bins

To prove that the whole upgrade is invisible to clients, replace the in-cluster load generator, which lives on the control plane node that is about to be drained, with one outside the cluster. A NodePort Service makes the API reachable from a container on the kind Docker network. Save as 4-nodeport.yaml:

apiVersion: v1
kind: Service
metadata:
name: inventory-api-np
namespace: shop
spec:
type: NodePort
selector:
app: inventory-api
ports:
- port: 80
targetPort: 8080
nodePort: 30080
kubectl apply -f 4-nodeport.yaml
kubectl -n shop delete pod loadgen
docker run -d --name ops-lab-loadgen --network kind curlimages/curl:8.17.0 sh -c \
'while true; do code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 http://ops-lab-worker:30080/api/stock/SKU-42); echo "$(date +%T) $code"; sleep 0.1; done'

Install the new kubeadm on the control plane and ask it for a plan:

docker cp bin-v1.36.4/kubeadm ops-lab-control-plane:/usr/bin/kubeadm
docker exec ops-lab-control-plane kubeadm version -o short
docker exec ops-lab-control-plane kubeadm upgrade plan
v1.36.4
[preflight] Running pre-flight checks.
[upgrade] Running cluster health checks
[upgrade] Fetching available versions to upgrade to
[upgrade/versions] Cluster version: 1.35.8
[upgrade/versions] kubeadm version: v1.36.4
[upgrade/versions] Target version: v1.36.5
[upgrade/versions] Latest version in the v1.35 series: v1.35.9

...
Upgrade to the latest stable version:

COMPONENT NODE CURRENT TARGET
kube-apiserver ops-lab-control-plane v1.35.8 v1.36.5
kube-controller-manager ops-lab-control-plane v1.35.8 v1.36.5
kube-scheduler ops-lab-control-plane v1.35.8 v1.36.5
kube-proxy 1.35.8 v1.36.5
CoreDNS v1.13.1 v1.14.2
etcd ops-lab-control-plane 3.6.6-0 3.6.8-0

You can now apply the upgrade by executing the following command:

kubeadm upgrade apply v1.36.5

Note: Before you can perform this upgrade, you have to update kubeadm to v1.36.5.

The plan checks cluster health, looks up the newest patch releases online, and offers two paths: the newest patch of the current minor (v1.35.9) or the newest stable release (v1.36.5). For each path it also prints a second table, left out above, with what apply will not do for you: the kubelet on every node. kubeadm can always upgrade to its own version, so the next step targets v1.36.4, the version of the binaries at hand.

Step 11 — Upgrade the Control Plane

time docker exec ops-lab-control-plane kubeadm upgrade apply v1.36.4 --yes

Condensed output:

[upgrade/preflight] You have chosen to upgrade the cluster version to "v1.36.4"
[upgrade/versions] Cluster version: v1.35.8
[upgrade/versions] kubeadm version: v1.36.4
[upgrade/control-plane] Upgrading your static Pod-hosted control plane to version "v1.36.4" (timeout: 5m0s)...
[upgrade/staticpods] Preparing for "etcd" upgrade
[upgrade/staticpods] Renewing etcd-server certificate
[upgrade/staticpods] Moving new manifest to "/etc/kubernetes/manifests/etcd.yaml" and backing up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2026-10-21-12-14-32/etcd.yaml"
[upgrade/staticpods] Component "etcd" upgraded successfully!
[upgrade/staticpods] Component "kube-apiserver" upgraded successfully!
[upgrade/staticpods] Component "kube-controller-manager" upgraded successfully!
[upgrade/staticpods] Component "kube-scheduler" upgraded successfully!
[upgrade/control-plane] The control plane instance for this node was successfully upgraded!
[upgrade/kubelet-config] The kubelet configuration for this node was successfully upgraded!
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

[upgrade] SUCCESS! A control plane node of your cluster was upgraded to "v1.36.4".

[upgrade] Now please proceed with upgrading the rest of the nodes by following the right order.

time reported 3 minutes 36 seconds, most of it spent pulling the new images. kubeadm replaced the static Pod manifests one component at a time, kept backups of the old ones under /etc/kubernetes/tmp, renewed every certificate on the way, and rolled out new CoreDNS and kube-proxy versions. The API server now reports the new version, while every kubelet still runs the old one, which the skew policy allows:

kubectl version
kubectl get nodes
kubectl -n kube-system get pods -o 'custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image'
Client Version: v1.36.1
Kustomize Version: v5.8.1
Server Version: v1.36.4

NAME STATUS ROLES AGE VERSION
ops-lab-control-plane Ready control-plane 16m v1.35.8
ops-lab-worker Ready <none> 16m v1.35.8
ops-lab-worker2 Ready <none> 16m v1.35.8

etcd-ops-lab-control-plane registry.k8s.io/etcd:3.6.8-0
kube-apiserver-ops-lab-control-plane registry.k8s.io/kube-apiserver:v1.36.4
kube-proxy-lt5pr registry.k8s.io/kube-proxy:v1.36.4
kube-proxy-rr4qw registry.k8s.io/kube-proxy:v1.35.8
...

The kube-proxy DaemonSet is still rolling out at this moment. Wait for it before touching the nodes:

kubectl -n kube-system rollout status ds/kube-proxy
kubectl -n kube-system rollout status deploy/coredns

Step 12 — Upgrade the Kubelet on the Control Plane Node

The kubelet on the control plane node is upgraded like any other: drain, replace, restart, uncordon.

kubectl drain ops-lab-control-plane --ignore-daemonsets --delete-emptydir-data
docker cp bin-v1.36.4/kubelet ops-lab-control-plane:/usr/bin/kubelet
docker cp bin-v1.36.4/kubectl ops-lab-control-plane:/usr/bin/kubectl
docker exec ops-lab-control-plane sh -c 'systemctl daemon-reload && systemctl restart kubelet && kubelet --version'
kubectl uncordon ops-lab-control-plane
kubectl get nodes
node/ops-lab-control-plane cordoned
...
node/ops-lab-control-plane drained
Kubernetes v1.36.4
node/ops-lab-control-plane uncordoned
NAME STATUS ROLES AGE VERSION
ops-lab-control-plane Ready control-plane 17m v1.36.4
ops-lab-worker Ready <none> 17m v1.35.8
ops-lab-worker2 Ready <none> 17m v1.35.8

The static Pods of the control plane are not evicted by the drain; they keep running while the kubelet restarts.

Step 13 — Upgrade the Workers One at a Time

Each worker goes through the same loop. kubeadm upgrade node on a worker only updates the kubelet configuration; the binary replacement and restart do the rest.

for N in ops-lab-worker2 ops-lab-worker; do
kubectl drain $N --ignore-daemonsets --delete-emptydir-data --timeout=5m
docker cp bin-v1.36.4/kubeadm $N:/usr/bin/kubeadm
docker exec $N kubeadm upgrade node
docker cp bin-v1.36.4/kubelet $N:/usr/bin/kubelet
docker cp bin-v1.36.4/kubectl $N:/usr/bin/kubectl
docker exec $N sh -c 'systemctl daemon-reload && systemctl restart kubelet'
kubectl wait --for=condition=Ready node/$N --timeout=120s
kubectl uncordon $N
done

Condensed output for the first worker:

node/ops-lab-worker2 cordoned
evicting pod shop/inventory-api-5bf967fc99-ssc9k
evicting pod kube-system/coredns-589f44dc88-cb7kn
evicting pod shop/inventory-api-5bf967fc99-4cmhx
error when evicting pods/"inventory-api-5bf967fc99-ssc9k" -n "shop" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
...
pod/inventory-api-5bf967fc99-ssc9k evicted
node/ops-lab-worker2 drained
[upgrade/preflight] Skipping prepull. Not a control plane node.
[upgrade/control-plane] Skipping phase. Not a control plane node.
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[upgrade/kubelet-config] The kubelet configuration for this node was successfully upgraded!
node/ops-lab-worker2 condition met
node/ops-lab-worker2 uncordoned

The budget again forced the evictions into single file. When the loop finishes, every component runs v1.36.4:

kubectl get nodes -o wide
NAME STATUS VERSION CONTAINER-RUNTIME
ops-lab-control-plane Ready v1.36.4 containerd://2.3.4
ops-lab-worker Ready v1.36.4 containerd://2.3.4
ops-lab-worker2 Ready v1.36.4 containerd://2.3.4

Step 14 — Count the Requests

The external load generator ran from before kubeadm upgrade plan until after the last uncordon:

docker logs ops-lab-loadgen | awk '{print $2}' | sort | uniq -c
4243 200

Every one of the 4,243 requests sent between 12:13:26 and 12:20:50 was answered, through an etcd upgrade, three API server restarts, new CoreDNS and kube-proxy versions and three drained nodes. That is the result of the preparation in Step 5, not of luck in the upgrade itself.

Step 15 — Clean Up

docker rm -f ops-lab-loadgen
kind delete cluster --name ops-lab --kubeconfig ./kubeconfig
Deleting cluster "ops-lab" ...
Deleted nodes: ["ops-lab-control-plane" "ops-lab-worker" "ops-lab-worker2"]

Summary

  1. Node maintenance is always cordon, drain, work, uncordon. Uncordoning does not move Pods back; use kubectl rollout restart or the Descheduler to rebalance.
  2. kubectl drain evicts Pods through the Eviction API, which respects PodDisruptionBudgets and answers 429 when a budget allows no disruption. kubectl delete pod and --disable-eviction bypass budgets.
  3. Drain refuses DaemonSet Pods, emptyDir Pods and bare Pods until you pass --ignore-daemonsets, --delete-emptydir-data or --force. Use --dry-run=server to preview, --pod-selector to scope and always --timeout to avoid endless waits.
  4. A budget alone does not stop errors. Replicas, topology spread with ScheduleAnyway, readiness and graceful shutdown together turned 3 failed requests on a naive drain into 0 of 4,243 during a full upgrade.
  5. A failed node becomes NotReady after about 50 seconds and gets the node.kubernetes.io/unreachable taints. Pods are evicted after their tolerationSeconds, 300 by default; 30 seconds suits fast-starting stateless APIs.
  6. kubeadm leaf certificates last one year. kubeadm certs check-expiration shows them, kubeadm certs renew all renews them, and every kubeadm upgrade renews them too. Restart the control plane static Pods afterwards by moving their manifests.
  7. Upgrade control plane first, one minor at a time. Kubelets may lag up to three minors but must never be newer than the API server.
  8. The kubeadm flow is: new kubeadm, kubeadm upgrade plan, kubeadm upgrade apply, then per node drain, kubeadm upgrade node, new kubelet, restart, uncordon. Managed clusters automate the same steps with surge nodes, and depend on the same budgets.