Tres saltos de Talos y cuatro minors de Kubernetes en un solo domingo
Read in English →El clúster de mi homelab llevaba meses quieto en Talos 1.11.0 y Kubernetes 1.34.0. Siete nodos en Proxmox, tres control planes, cuatro workers, todo manejado desde git. Sabía que estaba atrasado. Lo que no sabía era cómo se hacía un upgrade en mi propio lab, porque nunca había hecho uno desde la instalación.
El domingo arrancó con una pregunta boba: ¿cómo actualizo esto sin romperlo? Terminó con todos los nodos en Talos 1.14.2 y Kubernetes 1.37.1, cero alertas, y cuatro bugs en mis scripts que me tocó arreglar sobre la marcha. Te cuento cómo fue.
La regla que decidí seguir
Talos publica una ventana de compatibilidad. Talos 1.12 soporta Kubernetes hasta 1.35, la 1.13 hasta 1.36, la 1.14 hasta 1.37. Entonces el orden tiene que ser: primero Talos, un minor a la vez, y después Kubernetes dentro de la ventana que permita el Talos nuevo. Sin saltarse nada. Snapshot fresco de etcd antes de cada salto de Talos.
Eso significaba siete upgrades separados en un día:
- Talos 1.11.0 a 1.12.12, luego 1.13.11, luego 1.14.2
- Kubernetes 1.34.0 a 1.34.12, 1.35.9, 1.36.5 y por último 1.37.1
Cada uno como un PR en el repo de infra, para que Renovate me abra el siguiente en el futuro en vez de darme cuenta seis meses tarde.
Dos scripts
Escribí dos scripts para manejar esto. El primero, upgrade-talos.sh, hace un upgrade rolling del sistema operativo al tag del instalador que esté en git: aplica la config renderizada, hace drain del nodo, corre talosctl upgrade --wait y verifica que el nodo quede Ready y etcd sano. Primero los control planes, después los workers.
El segundo, upgrade-k8s.sh, envuelve talosctl upgrade-k8s con un dry run y un paso extra que te explico más abajo, porque me mordió.
Y había un tercero que ya tenía, drift.sh, que hace un apply en dry-run contra cada nodo y muestra el diff entre lo que git renderizaría y lo que el nodo está corriendo de verdad. Ese resultó ser la herramienta más importante del día. Después de cada salto lo corrí hasta que dijera siete veces “in sync”.
Lo que se rompió
El generador era más nuevo que los nodos
Primer error. Rendericé las configs con --talos-version 1.12 cuando los nodos todavía corrían 1.11. El generador nuevo sacaba llaves como grubUseUKICmdline que los nodos viejos rechazan, y además cambiaba al formato multi-documento que no acepta mis parches JSON.
El arreglo fue separar dos cosas que yo venía tratando como una sola. La versión del esquema de config es el Talos más viejo que corre en el clúster. El tag del instalador es el destino. Renderizas para lo que tienes, instalas lo que quieres.
upgrade-k8s me pisó la config de CoreDNS
Después del primer paso de Kubernetes, el DNS se portaba distinto. Resulta que talosctl upgrade-k8s reescribe el Corefile de CoreDNS con su propia plantilla. El mío lo maneja Flux y tiene una política de forward secuencial que puse después de un postmortem por una caída de la WAN. El upgrade lo reemplazó sin avisar.
Ahora el script vuelve a reconciliar la Kustomization de Flux que es dueña de CoreDNS, verifica que policy sequential esté de vuelta en el Corefile y reinicia CoreDNS. También apagué las flags --with-docs y --with-examples, que vienen en true por defecto e instalaban cosas que nunca pedí.
El cliente era más viejo que el clúster
Mi portátil tenía talosctl 1.12.1. Cuando intenté el paso de Kubernetes contra nodos en 1.13 se negó: “compatibility with version 1.13.11 is not supported”. Brew upgrade a 1.14.2, y de paso fijé esa versión en el CI. Regla sencilla que no tenía interiorizada: el cliente tiene que ser por lo menos tan nuevo como el clúster.
Catorce PodDisruptionBudgets dijeron que no
En talos-xoq-mmc el drain se quedó colgado. Catorce PDBs con cero disrupciones permitidas, casi todos de mis bases de datos en CloudNativePG. talosctl upgrade hizo timeout y dejó el nodo en cordon.
Para un homelab de una sola réplica esto es el PDB haciendo su trabajo en una situación donde no puede ayudar. El arreglo fue drenar los workers primero con kubectl drain --disable-eviction --force. Postgres igual recibe un apagado limpio, y como los volúmenes son local-path, los pods vuelven al mismo nodo de todas formas. Después un uncordon explícito, porque el script estaba confiando en que el upgrade lo hiciera.
Borré el webhook de Kyverno en la mitad de un drain
Este fue el que me asustó. En talos-5ip-j4x el drain desalojó el admission controller de Kyverno. Kyverno corre con un webhook fail-closed, o sea que cuando el controller no está, el API server rechaza todo create y delete de pods hasta que vuelva. Incluyendo los deletes que el mismo drain estaba intentando hacer. Gitea y Nexus se cayeron porque sus volúmenes viven en ese nodo.
El arreglo es el orden. Cordon, desalojar kyverno-admission-controller primero, esperar a que el endpoint de kyverno-svc esté poblado en otro nodo, y solo entonces drenar el resto.
El problema de la tarde
Con todo actualizado saltó una alerta: NodeSystemSaturation en talos-wmv-ufg. Load entre 7 y 13 en una VM de 2 vCPU, memoria al 74 por ciento, y el host de Proxmox debajo haciendo swap.
Lo primero que pensé fue un pod desbocado. Miré. El pod más grande del nodo usaba como 0,06 cores. No había ningún glotón.
La causa real fueron los drains. Durante el upgrade rolling ese nodo fue varias veces el único worker sin cordon, así que todos los pods desalojados cayeron ahí. Treinta y nueve pods, veinticinco de ellos en una ventana de once minutos. Mientras tanto talos-5ip-j4x estaba al 10 por ciento de CPU. Kubernetes programa una vez y nunca rebalancea solo.
Lo arreglé a mano primero. Cordon, borrar 18 pods stateless que habían llegado ese día, uncordon. El load pasó de 13,5 a menos de 1 en cinco minutos.
Después el arreglo permanente: kube-descheduler como CronJob cada 15 minutos, con la estrategia LowNodeUtilization sobre uso real desde metrics-server, no sobre requests. Ojo con esa diferencia. Por requests el nodo se veía al 85 por ciento y ningún otro nodo se veía desocupado, así que un descheduler basado en requests no habría hecho nada. Por CPU real era evidente.
La primera corrida me enseñó una cosa más. Desalojó una réplica duplicada de esta misma página, y el scheduler la mandó de vuelta al mismo nodo, porque el deployment traía una afinidad preferida hacia ese host desde hace mucho. Un loop de desalojar y reprogramar. La reemplacé por una anti-afinidad de pods por hostname, y limité el descheduler a hacer cumplir solo las afinidades requeridas, porque otras cinco apps todavía cargan esa misma preferencia vieja.
Dónde terminó
Siete de siete nodos en Talos 1.14.2 y Kubernetes 1.37.1. El script de drift en verde siete veces. etcd desfragmentado de 262 MB a 128 MB. CNPG once de once sanos. Cero alertas, cero PRs abiertos.
Lo que queda pendiente: Talos 1.14 genera configs de máquina multi-documento, y cada documento choca con mis parches v1alpha1 viejos. Los nodos corren bien con el formato legacy, así que fijé el esquema en 1.13.11 y dejé la migración anotada como deuda. Ese es el próximo domingo.