Upgrading Talos and Kubernetes on Genesis¶
How to move the cluster to a new Talos and Kubernetes version, and the traps that are not obvious
from the HCL. The mechanics live in terraform/proxmox/hyper-cluster/k8s/talos/
(upgrade-talos.tf, upgrade-k8s.tf, talos-cluster.tf).
Version rules¶
Talos only tests migration between adjacent minor versions, so go through the latest patch of each intermediate minor. Each Talos minor supports only the six Kubernetes minors counting back from its own default, so a Kubernetes minor is unreachable until Talos has been raised first. The order is always Talos, then Kubernetes, one minor at a time.
| Talos | default Kubernetes |
|---|---|
| 1.11 | 1.34 |
| 1.12 | 1.35 |
| 1.13 | 1.36 |
talosctl must be at least as new as the version it installs. Check with
talosctl version --client and run brew upgrade talosctl before starting.
Version sources: https://github.com/siderolabs/talos/releases and https://kubernetes.io/releases/.
Four version variables¶
talos_version and kubernetes_version are targets: what the nodes should run. They drive the
installer image passed to talosctl upgrade and the --to of talosctl upgrade-k8s.
talos_config_contract and kubernetes_config_contract are what machine configuration is
generated against. They lag the targets during an upgrade, because the config apply runs before the
upgrade steps and older nodes reject config generated for a newer contract. Terraform cannot order
it the other way round: machine_configuration_apply feeds machine_bootstrap, which feeds
cluster_kubeconfig, which the upgrade steps depend on.
talos_secrets_contract is separate again and pins talos_machine_secrets.
Raise kubernetes_config_contract right after upgrade-k8s¶
Machine configs render the control plane images and the kubelet at kubernetes_config_contract.
Once upgrade-k8s has moved the cluster, a contract left behind means the next apply that touches
machine config pushes the old version back and downgrades the control plane. Raise
kubernetes_config_contract to kubernetes_version immediately after upgrade-k8s finishes, and
before any other apply in this directory. talos-cluster.tf has a
check "kubernetes_config_contract_current" that warns on every plan while the two differ. The
warning is expected during phase 1 and nowhere else.
talos_config_contract stays behind on purpose; raising it is tracked in docs/backlog/README.md. Talos
accepts an older-contract config, so this is drift, not a fault.
Traps¶
Lowering talos_secrets_contract destroys the cluster. The provider replaces
talos_machine_secrets when its version decreases, and that regenerates the cluster CA, etcd
certificates and service account keys. It is decoupled from talos_version so reverting a failed
upgrade is safe, and it carries prevent_destroy so a replacing plan fails. semver.Compare reads
v1.11 as v1.11.0, which is lower than v1.11.6: pin the exact value already in state, and only
ever raise it.
machine.install.image is pinned in config_patches to the image factory schematic and
talos_version. Left unset, the provider fills it from the Talos version the provider was built
against, which drifts on every provider bump and points at the plain installer without the
schematic's extensions. Because it references the shared schematic ID, changing the extension list
rewrites install.image on all six nodes, and a one-off image on a single node is reverted to the
fleet image at its next install.
Older nodes reject a newer config contract. Raising talos_config_contract from 1.11 to 1.12 adds
machine.install.grubUseUKICmdline, and a 1.11 node refuses the whole document with
unknown keys found during decoding. The 1.12 contract also emits a HostnameConfig document that
conflicts with the static machine.network.hostname in config_patches, and these GRUB-booted
nodes need grubUseUKICmdline pinned to false. Both are part of the docs/backlog/README.md entry.
Never point upgrade-k8s at the VIP. It patches each node's machine config in turn, and patching
the node that holds the VIP re-elects it onto another control plane. talosctl keeps the dead socket
open and blocks forever. Nothing reboots during upgrade-k8s, so upgrade-k8s.tf addresses the
first control plane directly, and so does the health gate after it. The gates in
upgrade-talos.tf are the opposite case: nodes reboot there, so they address the VIP.
A hang looks like elapsed time climbing while CPU time stays flat. Terraform's log lags, so check what the nodes report:
ps -o pid,etime,time,command -p <talosctl pid>
talosctl -e 10.3.10.30 -n <node ip> get machineconfig v1alpha1 -o yaml | grep -o 'kubelet:v[0-9.]*'
To recover, kill the hung talosctl, not Terraform. The non-zero exit fails the provisioner,
Terraform writes state and releases the lock, completed node upgrades stay in state, and
null_resource.upgrade_kubernetes is tainted so the next apply reruns only that step. Killing
Terraform instead risks a stale lock on the state blob.
Every provisioner runs under set -euo pipefail with an explicit bash interpreter and ends on a
talosctl health gate, never a sleep: local-exec takes the exit code of the last command, so a
trailing sleep reports a failed upgrade as success and the next node starts. Under set -e,
write conditionals as if ... then ... fi; [ cond ] && cmd exits the script when the condition
is false.
--preserve on talosctl upgrade is still functional. It is deprecated in Talos 1.13 with removal
slated for 1.18; do not drop it from upgrade-talos.tf before then.
Dry-run the generated config¶
This catches contract rejections and installer drift without touching the cluster:
terraform plan -out=/tmp/p.tfplan
terraform show -json /tmp/p.tfplan | jq -r '
.resource_changes[]
| select(.address|test("machine_configuration_apply.worker.\"genesis-worker-03\""))
| .change.after.machine_configuration' > /tmp/cfg.yaml
talosctl -e 10.3.10.30 -n 10.3.10.36 apply-config --mode=auto --dry-run --file /tmp/cfg.yaml
It prints the config diff and whether Talos would reboot the node. Do it for a worker and a control
plane (machine_configuration_apply.controlplane."genesis-ctrl-01" on 10.3.10.31): they generate
different configs and only the control plane carries the VIP.
Before you start¶
Keep these outside the repo, in a chmod 700 directory.
./convert-secrets.sh > machine-secrets.yaml && chmod 600 machine-secrets.yaml
talosctl -e 10.3.10.30 -n 10.3.10.31 etcd snapshot etcd-pre-upgrade.db
machine-secrets.yaml is the cluster identity. With it the cluster can be rebuilt even if the
Terraform state is lost; copy it into Bitwarden. Take a fresh etcd snapshot even though the nightly
one exists. Database dumps and the rest of the backup layers are in
../backups/README.md. PV data needs no extra backup: proxmox-local
volumes and Synology NFS are not on the node disks, which are the only thing an upgrade writes.
If a control plane VM is ever restored from a backup, do not let the stale member rejoin a live
quorum. Wipe EPHEMERAL so it joins fresh, or do a full recovery from the snapshot
(../backups/restore.md).
Procedure¶
Run every step from terraform/proxmox/hyper-cluster/k8s/talos.
Baseline¶
With versions unchanged and both upgrade flags false, run a plan so provider churn is separated
from the upgrade. No proxmox_virtual_environment_vm may show must be replaced (that wipes Talos
and etcd), and there must be no prevent_destroy error on the secrets.
Phase 1: move the nodes¶
Raise only the targets in terraform.tfvars; leave both contracts where they are:
talos_version = "<new talos version>"
kubernetes_version = "<new kubernetes version>"
enable_talos_upgrade = true
enable_kubernetes_upgrade = true
Plan, dry-run the generated config, then apply. The only machine config change is
machine.install.image, which the nodes accept without a reboot. Terraform then upgrades ctrl-01
through worker-03 one at a time, each gated on talosctl health, and runs upgrade-k8s. Only one
node is down at a time; etcd keeps quorum on two of three members and kubectl blips while the VIP
moves.
Phase 2: raise the Kubernetes contract¶
As soon as upgrade-k8s has finished, before any other apply:
kubernetes_config_contract = "<new kubernetes version>"
enable_talos_upgrade = false
enable_kubernetes_upgrade = false
Plan, dry-run a worker and a control plane again, and apply. The check warning is gone and the
null_resource upgrade steps leave the graph until the next upgrade.
Verify¶
A full run takes about half an hour. upgrade-k8s rolls kube-apiserver, kube-controller-manager
and kube-scheduler on each control plane, then the kubelet on every node, so an old kubelet next to
a new API server is normal while it runs. If it seems stuck on
config version mismatch, check the API server pods rather than Terraform's log:
kubectl get pods -n kube-system -l k8s-app=kube-apiserver \
-o custom-columns='NODE:.spec.nodeName,READY:.status.containerStatuses[0].ready,IMAGE:.spec.containers[0].image'
When it is done:
kubectl get nodes -o custom-columns='NAME:.metadata.name,TALOS:.status.nodeInfo.osImage,KUBELET:.status.nodeInfo.kubeletVersion'
kubectl version -o json | jq -r '.serverVersion.gitVersion'
talosctl -e 10.3.10.30 -n 10.3.10.30 health --server=true
talosctl -e 10.3.10.30 -n 10.3.10.31,10.3.10.32,10.3.10.33 etcd members
kubectl get applications -n argocd -o custom-columns='SYNC:.status.sync.status,HEALTH:.status.health.status' --no-headers | sort | uniq -c
All three etcd members must report the same membership, with the same IDs as before the upgrade
and no LEARNER entries.
Open items¶
Tracked in docs/backlog/README.md: raising talos_config_contract, and the next upgrade.