0087. The Kubernetes upgrade runs in three stages: control-plane components, then kube-proxy, then kubelets
Status: Accepted · Date: 2026-10-27 · Design section: Upgrades
Decided: the kubernetes executor visits the machines in four stages, recorded in status.kubernetes.stage: (1) APIServers — each control plane, one at a time, gets kube-apiserver at the target with controller-manager, scheduler, kubelet and kube-proxy pinned to the version its node runs; (2) ControlPlaneComponents — each control plane gets controller-manager and scheduler at the target; (3) KubeProxy — each control plane gets kube-proxy at the target, then the bootstrap manifests are synced and the DaemonSet's rollout awaited; (4) Kubelets — control planes, then workers within maxUnavailable, get the full rendered configuration, which is the cluster's desired state; pkg/render takes the pins as a system-layer input (SystemInputs.Staged) that only the executor sets, nothing is written to spec, status.machines is cleared at each stage change, an operation with no recorded stage starts at APIServers, and the plan names the stages and warns (does not refuse) about a minor upgrade of several control planes behind an endpoint of type node
Amended (hardware, v1.36.5 to v1.37.1): four stages, not three. The first stage below, ControlPlanes, is now two: APIServers (kube-apiserver on every control plane, one at a time) then ControlPlaneComponents (controller-manager and scheduler, one control plane at a time). See "Amendment: apiservers first" at the end; where this text says three stages and ControlPlanes, read that.
Context
ADR 0057 applied a node's control-plane images and its kubelet in one apply, one node at a time. On a minor upgrade the first node's kubelet and kube-proxy were the new minor while the other control planes' API servers were still the old one, which breaks the Kubernetes version-skew policy (a kubelet may not be newer than the API server it talks to). On hardware, v1.36.5 to v1.37.1 behind a single-node endpoint left the upgraded node's components unready and the executor paused after 20 minutes. talosctl upgrade-k8s (Talos v1.14.3, pkg/cluster/kubernetes/talos_managed.go, Upgrade) orders it differently: the three static-pod components on every control plane, then kube-proxy, then the kubelets, then the manifest sync.
Decision
Stages. The backbone (ADR 0026) is unchanged in what it guarantees (lock, pause on failure, maxUnavailable, hold annotation, order label, per-machine status). For this kind kubernetesBatch picks the machines for the current stage and, when a stage has none left, moves to the next one and clears status.machines in the same status write, so a restarted controller resumes from the stage and the machines recorded in it.
| Stage | Machines | Rendered configuration | Verified |
|---|---|---|---|
ControlPlanes | control planes, one at a time | components at the target; kubelet and kube-proxy pinned to the node's kubelet version | node Ready, three static pods Ready on the target |
KubeProxy | control planes, one at a time | components and kube-proxy at the target; kubelet pinned | the same, then SyncBootstrapManifests from this node and the kube-proxy DaemonSet rolled out (skipped when kube-proxy is replaced) |
Kubelets | control planes one at a time, then workers within maxUnavailable | the cluster's desired state (no pin) | kubelet on the target, node Ready, static pods on the target |
So at no step is any kubelet newer than any API server, and the node's final configuration is what TalosMachineReconciler renders, so it sees no drift afterwards. A patch upgrade takes the same path (one code path; the intermediate states are harmless). A held control plane is excluded from every stage.
The intermediate render. render.SystemInputs.Staged (KubeletVersion, KubeProxyVersion) is applied below the patch layers with machinery's own stdpatches.WithKubeletImage and WithKubeProxyImage, so the contract-dependent form (the KubeletConfig and KubeProxyConfig documents from v1.14, the legacy fields before) is machinery's choice, and a patch that sets an image itself still wins. A worker gets no kube-proxy pin (it has no such document). The pinned version is not stored: until the Kubelets stage the kubelet of a node has not been touched, so the executor reads it from the node's Node.status.nodeInfo.kubeletVersion on every call and gets the same answer.
Everything is still re-derived. In each stage a call compares the node's configuration hash with the render for that stage: different means pre-checks, dry run, pre-pull of the changed images, apply; equal means only verify. The images that change are the stage's own, so the kubelet image is pulled in the Kubelets stage only. The skew, Talos pairing, Ready and CNI hand-over checks run at the start of every stage's apply.
kube-proxy and the manifest sync move to the middle stage. ADR 0057 synced from the last control plane. Now the sync runs in the KubeProxy stage, from each control plane once it has the target kube-proxy image (server-side apply is idempotent, so repeating it is harmless); the manifests are generated by the node being asked, which is why each control plane is first given the image. upgrade-k8s syncs after the kubelets; Ichor does it before them because a kube-proxy newer than a kubelet is inside the skew policy and the rollout is verified before any kubelet moves. The mode is read with render.KubeProxyMode, so a switch recorded in status.cni.kubeProxy is honoured.
Plan. PlanOperation returns the three stages in stages and the CLI prints them ("no kubelet is ever newer than an API server"). For a minor upgrade of a cluster with more than one approved control plane whose spec.endpoint.type is node, the plan carries a warning: the endpoint is a single apiserver, so the whole cluster loses the API while that node's apiserver is replaced; use a virtual IP or a load balancer. It is not a refusal. Only a move known to stay within one minor is silent; an unknown target (the spec already says it) warns.
Consequences
- Each control plane receives up to three applies instead of one, and each is an apply without a reboot; the wall-clock time of an upgrade grows by the second and third control-plane pass. Workers still get one.
TalosOperation.status.kubernetes.stageis a new status field. The UI shows the plan's stages with its existing "passes" wording (written for the CA rotation) and does not show the current stage; a follow-up.- A single control plane behind any endpoint is staged too: its apiserver restarts first, the kubelet later, with the API unreachable only while the apiserver restarts. That is the case that failed on hardware; this change has not been run there.
- With an endpoint of type
nodeand several control planes, the API stays unreachable for clients using that node's address while its apiserver restarts, in every stage that restarts it; staging does not change that, which is why the plan warns. - Not verified against a real cluster. Tests use fakes and envtest: they assert the apply order and the rendered versions of each step, that no kubelet is newer than any apiserver after any apply, resume by a fresh reconciler on every pass, and pause on a failed apply in each stage.
Amendment: apiservers first
What happened. On hardware (3 control planes and 1 worker, Talos v1.14.3, v1.36.5 to v1.37.1) the ControlPlanes stage moved the first control plane's apiserver, controller-manager and scheduler together. The v1.37.1 apiserver and controller-manager became Ready; the v1.37.1 scheduler never did (readiness 500 for 20 minutes): failed to list *v1.DeviceTaintRule: the server could not find the requested resource. The v1.37 scheduler needs resource.k8s.io/v1 DeviceTaintRule, which only v1.37 apiservers serve, and the other two apiservers were still v1.36.5. The executor waited for all three pods and paused after 20 minutes.
Why the scheduler saw old apiservers. Talos writes the controller-manager and scheduler kubeconfigs with the secrets controller's LocalEndpoint (internal/app/machined/pkg/controllers/secrets/root.go:118-128, kubernetes.go:136,157): with KubePrism enabled, which is the default, that is https://127.0.0.1:7445, a local load balancer over every apiserver in the cluster; without it, https://localhost:<apiserver port>, the node's own apiserver. So a new scheduler or controller-manager may talk to any apiserver, not only the one next to it, and cannot be assumed to find the APIs it needs until every apiserver is new.
What talosctl upgrade-k8s does (Talos v1.14.3, pkg/cluster/kubernetes/talos_managed.go): Upgrade (line 150) loops over the services kube-apiserver, kube-controller-manager, kube-scheduler in that order, and for each service upgradeStaticPod (line 221) visits every control plane node in turn and waits for that node's pod before the next. So all apiservers move first, then all controller-managers, then all schedulers; then kube-proxy on every control plane (line 156), the kubelets (line 160), and the manifest sync (line 168). Ichor already follows that for kube-proxy and the kubelets; it now follows it for the apiserver.
Decision. The first stage is split:
| Stage | Machines | Rendered configuration | Verified |
|---|---|---|---|
APIServers | control planes, one at a time | apiserver at the target; controller-manager, scheduler, kubelet and kube-proxy pinned to the version the node runs | node Ready, that node's kube-apiserver Ready on the target |
ControlPlaneComponents | control planes, one at a time | apiserver, controller-manager and scheduler at the target; kubelet and kube-proxy pinned | node Ready, three static pods Ready on the target |
KubeProxy | unchanged | unchanged | unchanged |
Kubelets | unchanged | unchanged | unchanged |
So at no step is a controller-manager, scheduler or kubelet newer than any apiserver. Controller-manager and scheduler are done together per control plane (upgrade-k8s does all controller-managers, then all schedulers); the stricter rule that matters, no component newer than any apiserver, holds either way, and this keeps the number of applies per control plane at four.
Render. render.StagedKubernetes gains ControllerManagerVersion and SchedulerVersion, applied with machinery's WithKubeControllerManagerImage and WithKubeSchedulerImage below the patch layers like the other pins (none on a worker). The pin is the node's Node.status.nodeInfo.kubeletVersion, read on every call: a node's controller-manager and scheduler run the version its kubelet does until their stage, and the kubelet is untouched until the last one, so the answer is the same on every call and nothing is stored.
Resume. status.kubernetes.stage is one of the four stages; every call compares the node's configuration hash with the render for the current stage, so a restarted controller either applies or only verifies.
Plan and UI. The plan lists four stages; the UI shows "Stage N of 4".
Not verified on hardware. Tests (fakes and envtest) assert the apply order APIServers cp1 cp2 cp3, ControlPlaneComponents cp1 cp2 cp3, KubeProxy, Kubelets, and after every apply that no controller-manager or scheduler is newer than any apiserver and no kubelet is newer than any apiserver.
No compatibility with pre-release stage layouts: Ichor had no release, so the ControlPlanes stage and the unstaged-operation rule are gone; an operation with no recorded stage starts at APIServers.