📋 Upgrade Fundamentals
Kubernetes follows a strict N±1 minor version skew policy. Upgrading one minor version at a time (e.g. 1.28 → 1.29) is the only supported path. Skipping versions (1.28 → 1.30) is unsupported and risks etcd schema incompatibilities and API breakage.
Version Skew Policy
| Component | Allowed skew vs kube-apiserver | Notes |
|---|---|---|
| kube-apiserver | N/A — upgraded first | All other components are ±1 of the apiserver version |
| kube-controller-manager | ≤ N (same or one minor behind) | Upgraded after apiserver on same node |
| kube-scheduler | ≤ N | Same node as controller-manager |
| kubelet | N-2 to N (2 minor behind is ok) | Allows rolling worker upgrades over time |
| kube-proxy | N-2 to N | Same node as kubelet |
| kubectl | N-1 to N+1 | Client can be one minor ahead or behind |
Upgrade Order
Check deprecated APIs, take etcd snapshot, verify cluster health, read release notes for removed APIs
One member at a time. If co-located, kubeadm handles this automatically
kubeadm upgrade apply — upgrades apiserver, controller-manager, scheduler, etcd (if stacked)
kubeadm upgrade node — one at a time, verify health between each
Drain → upgrade packages → uncordon. Roll one node (or batch) at a time
CoreDNS, kube-proxy, CNI, metrics-server, cert-manager — check compatibility matrix
🔍 Pre-Upgrade Checks & Control-Plane Upgrade
Pre-Upgrade Checks
# 1. Check current cluster version
kubectl version --short
kubectl get nodes -o wide
# 2. Scan for deprecated/removed API usage
# Install pluto (Fairwinds)
pluto detect-all-in-cluster --target-versions k8s=v1.29.0
# COMPONENT KIND VERSION DEPRECATED REMOVED
# my-ingress Ingress networking.k8s.io/v1beta1 true true ← must fix!
# 3. Check all nodes are Ready and no pending evictions
kubectl get nodes
kubectl get pods -A --field-selector=status.phase=Pending
# 4. Verify etcd health
etcdctl endpoint health --cluster ...
etcdctl endpoint status --cluster --write-out=table
# 5. Take etcd snapshot BEFORE upgrading
etcdctl snapshot save /backup/pre-upgrade-$(date +%Y%m%d).db ...
# 6. Check available upgrade path
kubeadm upgrade plan
# COMPONENT CURRENT TARGET
# kube-apiserver v1.28.5 v1.29.3
# kube-controller-manager v1.28.5 v1.29.3
# kube-scheduler v1.28.5 v1.29.3
# etcd 3.5.9 3.5.10
flowcontrol.apiserver.k8s.io/v1beta2. Any resources using removed APIs must be migrated before upgrading or the resource becomes inaccessible.
Upgrade First Control-Plane Node
# On the first control-plane node:
# 1. Upgrade kubeadm package
apt-mark unhold kubeadm
apt-get install -y kubeadm=1.29.3-1.1
apt-mark hold kubeadm
# 2. Verify new version
kubeadm version
# 3. Dry-run first to check for issues
kubeadm upgrade plan v1.29.3
# 4. Apply the upgrade
kubeadm upgrade apply v1.29.3
# [upgrade/successful] SUCCESS! Your cluster was upgraded to "v1.29.3".
# 5. Upgrade kubelet and kubectl on this node
apt-mark unhold kubelet kubectl
apt-get install -y kubelet=1.29.3-1.1 kubectl=1.29.3-1.1
apt-mark hold kubelet kubectl
# 6. Restart kubelet
systemctl daemon-reload
systemctl restart kubelet
# 7. Verify this control-plane node
kubectl get nodes
# NAME STATUS ROLES VERSION
# cp-1 Ready control-plane v1.29.3 ← upgraded
# cp-2 Ready control-plane v1.28.5 ← not yet
# cp-3 Ready control-plane v1.28.5 ← not yet
Upgrade Additional Control-Plane Nodes
# On each additional control-plane node (cp-2, cp-3):
# Same package upgrade, but use 'kubeadm upgrade node' (not apply)
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.29.3-1.1
kubeadm upgrade node # ← different command for non-first CP nodes
apt-mark unhold kubelet kubectl
apt-get install -y kubelet=1.29.3-1.1 kubectl=1.29.3-1.1
systemctl daemon-reload && systemctl restart kubelet
# After all CP nodes: verify
kubectl get nodes
# NAME STATUS ROLES VERSION
# cp-1 Ready control-plane v1.29.3
# cp-2 Ready control-plane v1.29.3
# cp-3 Ready control-plane v1.29.3
kubectl get nodes and etcdctl endpoint health before proceeding to the next. Don't rush — a broken second CP node while the third is still old can trigger quorum loss.
🔧 Worker Node Upgrade Strategies
In-Place Rolling
Drain, upgrade packages, restart kubelet, uncordon. One node (or batch) at a time. Requires capacity headroom for evicted pods.
Blue-Green Node Pool
Add new nodes (new version), cordon old nodes, let pods migrate naturally, then delete old nodes. Zero disruption, more expensive short-term.
Surge Upgrade
Add N new nodes before removing N old nodes. Used by managed Kubernetes (EKS managed node groups, GKE node pools). Cloud-provider automated.
Cluster Autoscaler Integration
Pause CA during upgrade to prevent it scaling old nodes back up. Resume after all old nodes are drained.
In-Place Worker Node Upgrade (kubeadm)
# For each worker node (repeat for each):
# 1. Cordon the node (stop new pods being scheduled here)
kubectl cordon worker-1
# 2. Drain (evict all pods gracefully)
kubectl drain worker-1 \
--ignore-daemonsets \
--delete-emptydir-data \
--grace-period=60 \
--timeout=300s
# Wait for drain to complete — check no pods remain except DaemonSets
kubectl get pods -o wide --field-selector spec.nodeName=worker-1
# 3. SSH to the node and upgrade packages
ssh worker-1
apt-mark unhold kubeadm kubelet kubectl
apt-get install -y \
kubeadm=1.29.3-1.1 \
kubelet=1.29.3-1.1 \
kubectl=1.29.3-1.1
kubeadm upgrade node
systemctl daemon-reload
systemctl restart kubelet
# 4. Back on control plane — uncordon
kubectl uncordon worker-1
# 5. Verify node is Ready and new version
kubectl get node worker-1
# NAME STATUS ROLES VERSION
# worker-1 Ready <none> v1.29.3
# 6. Repeat for next worker node
Blue-Green Node Pool (Cloud)
# AWS EKS / GKE / AKS approach:
# 1. Create new node group with target version
eksctl create nodegroup \
--cluster my-cluster \
--name workers-v129 \
--kubernetes-version 1.29 \
--nodes 5
# 2. Cordon all old nodes (prevent new scheduling)
kubectl cordon -l eks.amazonaws.com/nodegroup=workers-v128
# 3. Drain old nodes one at a time
for node in $(kubectl get nodes -l eks.amazonaws.com/nodegroup=workers-v128 -o name); do
kubectl drain $node --ignore-daemonsets --delete-emptydir-data --force
done
# 4. Delete old node group after pods migrated
eksctl delete nodegroup --cluster my-cluster --name workers-v128
kubectl drain respects PDBs — if a PDB prevents eviction, drain will block. Ensure PDBs allow at least one pod to be unavailable, or drain will time out. Use --disable-eviction only as a last resort in emergencies.
Handling Stuck Drains
# Check why drain is stuck
kubectl get pdb -A
kubectl get pods -o wide --field-selector spec.nodeName=worker-1
# PDB is blocking — check which PDB
kubectl describe pdb my-app-pdb
# Disruptions Allowed: 0 ← all replicas on one node, PDB prevents eviction
# Fix: scale up the deployment first so PDB allows disruption
kubectl scale deployment my-app --replicas=3
# Or temporarily patch the PDB (use with caution)
kubectl patch pdb my-app-pdb -p '{"spec":{"minAvailable":0}}'
# ... drain completes ...
kubectl patch pdb my-app-pdb -p '{"spec":{"minAvailable":1}}'
⏪ Rollback & Add-on Upgrades
Control-Plane Rollback
Kubernetes does not support downgrading the control plane via kubeadm. The only rollback path is restoring from the pre-upgrade etcd snapshot to a fresh cluster running the old version.
# Rollback procedure (last resort):
# 1. Stop kube-apiserver on all control-plane nodes
# 2. Restore etcd from pre-upgrade snapshot (see lesson 83)
# 3. Reinstall old kubeadm/kubelet/kubectl packages
# 4. Re-run kubeadm init with old version config
# This is why pre-upgrade etcd snapshots are MANDATORY.
# Without a snapshot, rollback is impossible.
kubeadm upgrade apply. Store it off-cluster.
Add-on Compatibility Matrix
| Add-on | How to upgrade | Compatibility check |
|---|---|---|
| CoreDNS | kubeadm upgrades automatically; or kubectl edit deployment coredns -n kube-system | Check kubeadm release notes for bundled version |
| kube-proxy | kubeadm upgrades DaemonSet image automatically | Must match cluster minor version |
| CNI (Cilium/Calico) | helm upgrade cilium cilium/cilium --version X.Y.Z | Check CNI's Kubernetes version support matrix |
| metrics-server | helm upgrade metrics-server metrics-server/metrics-server | K8s version in chart requirements |
| cert-manager | helm upgrade cert-manager jetstack/cert-manager | Check supported K8s versions in cert-manager docs |
| Ingress NGINX | helm upgrade ingress-nginx ingress-nginx/ingress-nginx | Check IngressClass API version compatibility |
| cluster-autoscaler | Image tag must match cluster minor version exactly | cluster-autoscaler:v1.29.x for K8s 1.29 |
Upgrade Automation with Managed Kubernetes
# EKS — upgrade control plane then node groups
aws eks update-cluster-version \
--name my-cluster \
--kubernetes-version 1.29
# Wait for control plane upgrade
aws eks wait cluster-active --name my-cluster
# Upgrade managed node group
aws eks update-nodegroup-version \
--cluster-name my-cluster \
--nodegroup-name workers \
--kubernetes-version 1.29
# GKE — single command upgrades control plane + node pools
gcloud container clusters upgrade my-cluster \
--master --cluster-version 1.29
gcloud container clusters upgrade my-cluster \
--node-pool default-pool \
--cluster-version 1.29
Upgrade in Non-Prod First
Always upgrade dev → staging → production. Test your workloads and add-ons against the new version before touching production.
Read Release Notes
Check the CHANGELOG for every minor version between current and target. Removed APIs and behaviour changes are listed explicitly.
Maintenance Window
Even "zero-downtime" upgrades cause brief API unavailability during etcd compaction and apiserver restart. Schedule during low-traffic periods.
Test Rollback Procedure
Practice restoring from an etcd snapshot in staging before you need to in production. A rollback you've never tested is not a rollback plan.
📝 Knowledge Check
kubectl drain worker-3, the command hangs for 10 minutes. What is the most common cause?kubectl drain honours PDBs. If a PDB requires minAvailable: 1 and all replicas are on worker-3, no pod can be evicted. The fix is to scale up the deployment so replicas exist on other nodes before draining, or temporarily relax the PDB.