Kubernetes 1.32 to 1.37 in one day, five hops, without the kubespray that built the cluster
An end-of-life bare-metal cluster, a lost kubespray inventory and binaries outside any package manager. How five kubeadm hops went in one day, and the traps no plan listed.
A bare-metal cluster at Optimacros was on Kubernetes 1.32.8, a branch already past end of life. The plan was to take it to 1.37 — five minor hops, because kubeadm never skips a minor:
1.32.8 → 1.33.13 → 1.34.12 → 1.35.9 → 1.36.5 → 1.37.1
What we were working with
The cluster had been built with kubespray, and the inventory was gone. The binaries lived in /usr/local/bin, not owned by any package, with no Kubernetes apt repository configured — so apt install kubeadm=… was not an option; every hop meant swapping binaries downloaded from the official release site. Three stacked masters, four large workers, an external etcd under systemd, Calico from kubespray’s manifests, kube-proxy in ipvs mode.
The workers held 124 local-path volumes for test environments with no backup. A drain would not move them anywhere, so the workers were upgraded in place, without draining. And the alerting delivered to a null receiver, so the whole operation ran with eyes on dashboards, not on pages — a decision written down before starting, not discovered later.
Prepare so that only one thing changes at a time
The day before, the cluster got:
- a deprecated-API audit with
plutoagainst 1.37 — zero hits in live objects and in all 17 Helm releases; - an etcd defragmentation: the database file was 743 MB with 73 MB of data — 90% fragmentation, never defragmented since install. One member at a time, leader last, under a second each: 82 MB;
- an etcd snapshot, verified on a copy, plus config backups from all seven nodes and an export of cluster objects;
- an OS release upgrade as a separate window, so that the Kubernetes hops would be the only variable.
The rule for components: upgrade each one before the hop its current version does not support, and re-check the compatibility matrices before every hop, because they change.
The hops
Each hop followed the same script: new kubeadm and kubelet with checksums verified, kubeadm upgrade apply on the first master, upgrade node on the other two, then workers one by one — cordon, upgrade, kubelet, Ready, uncordon, about ten seconds each. Acceptance after every hop: nodes, etcd, kube-system, storage, Argo CD, the database operator, Gateway routes, a policy-engine smoke test and a fresh-pod-to-fresh-pod network test with a timer.
Hops one and two took about ten minutes each. All five were done the same day.
Traps that were not in any plan
- Calico from kubespray needed upstream CRDs and ClusterRoles, not just new images — kubespray’s RBAC was a strict subset. And the new version looped on an XDP program it could not remove, which made fresh pods unreachable for up to 42 seconds until XDP was switched off.
--apiserver-countwas removed in kube-apiserver 1.35. kubespray puts it into the kubeadm config, so it had to be deleted beforeupgrade apply. Checking flags against the new image’s--helpbefore each hop caught it.- Longhorn 1.11 requires Kubernetes 1.34 or newer, while 1.10 only supports up to 1.33. So Longhorn had to go up right after the 1.34 hop, strictly one minor at a time.
- CoreDNS is outside kubeadm in kubespray clusters — the addon phase is disabled — so it stays on its old version unless updated by hand.
- The kubelet reads kubespray’s own config file, not the one
kubeadm upgrade nodewrites.clusterDNShas to be checked in the right file after every hop. - Argo CD caches the cluster version. After each hop its application controller kept rendering Helm with the old
--kube-version, and a chart that required 1.34+ would not render. A restart of the controller after every hop fixed it. - etcd 3.6 is a one-way door: the minimum starting point is 3.5.32, a v2-store check must pass on every member, and once the last member is on 3.6 the only way back is a snapshot.
- On a separate front, moving ingress to Envoy Gateway exposed a kube-proxy
strictARPdefault that made every node answer ARP for LoadBalancer IPs.
The gate we lifted on purpose
The plan had a hard gate before 1.37: Longhorn and the policy engine had to list 1.37 in their support matrices. On the day, they did not. We lifted the gate deliberately and recorded why: every out-of-matrix component used only stable APIs and passed a functional smoke test on 1.37 — a PVC write, webhook and generate rules, routes, fresh-pod networking. Jumping to a brand-new Calico x.0 release that restructures its DaemonSet looked like a bigger risk than running the previous one slightly outside its matrix.
Takeaways
- Without the original installer, kubeadm by hand is fine — but write the procedure once and run it identically on every hop.
- Upgrade components before the hop that breaks them, and re-read the matrices every time.
- Defragment and snapshot etcd before you start; it is cheap and it was 90% fragmented.
- Check removed flags against the new binary before
upgrade apply. - Restart anything that caches the cluster version — Argo CD in particular.
- A gate can be lifted, but only with a written reason and a functional test, never silently.