k3s graceful shutdown im Cluster Betrieb – oder wie keine Pods mehr in der Schwebe hängen bleiben

Um einen eigenen Playground zu haben, steht bei mir nun schon seit einer Weile ein Proxmox Cluster, bestehend aus mehreren Intel NUCs der meine lokalen Services hosted. Unter anderem läuft darauf auch ein k3s cluster mit mehreren Nodes.

Da Kubernetes / k8s eigentlich ein “selbstheilendes” System ist, bin ich bisher davon ausgegangen, dass man ohne Probleme einzelne Nodes aus dem Cluster nehmen kann, ohne dass man sonderlich lange Downtimes hat. Das ist auch korrekt, wenn man Services mit mehreren Instanzen hat, wird jedoch schwer, wenn Services nur aus einzelnen Instanzen bestehen. Mir ist bewusst, dass es dann natürlich kein HA (High Availability) Betrieb ist, aber das ist bei mir lokal auch nicht notwendig. Für mich ist nur wichtig, dass wenn einer der NUCs mal aussteigen sollte, dass meine Services sich selbst heilen und dann auf einer anderen Node weiter laufen.

Beim experimentieren mit diesen Anforderungen habe ich dann schnell gelernt, dass k8s bzw. k3s sich nicht so verhält wie von mir erwartet. Das gewollte Verhalten ist nämlich, dass wenn eine Node für den Cluster nicht erreichbar ist, erstmal für 5 Minuten nichts passiert. Die Hoffnung des Systems ist, dass es sich nur um einen kurzen Restart handelt oder aber z.b. Upgrades gemacht werden. Da k8s ja eine Orchestrierung ist, ist es möglich, dass nur der k8s Service selbst heruntergefahren / neu gestartet wird, die Container/Pods aber einfach weiter laufen.

Bis hierhin macht das für mich auch Sinn, aber das Verhalten wird komisch, wenn man die Node mittels “reboot” neu startet oder per “shutdown” herunterfährt. K8s/k3s verhält sich an dieser Stelle nämlich genauso. Obwohl es wissen sollte, dass ein Shutdown ansteht und somit die Pods auch gestoppt werden, teilt es dies dem restlichen Cluster nicht mit. Und somit wartet der restliche Cluster 5 Minuten, ehe er irgendwas macht.

Wenn die Node als nach 5 Minuten nicht wieder da ist, dann fangen die anderen Nodes an, die Pods zu verschieben bzw. neue Instanzen hochzufahren. Das ist erstmal eine gute Idee, aber im k8s Kontext sind die “alten” Instanzen noch da. Und hier wird es tricky, sobald man Container hat, die z.B. Volumes verwenden. In meinem Fall sind es Volumes innerhalb eines Ceph Clusters, aber das gleiche wird auch bei anderen Formen von Volumes passieren: der Claim auf diese Volumes erlaubt nur einen einzelnen Zugriff, und keinen Zugriff von mehreren Pods gleichzeitig. In meinem speziellen Fall blockiert also der Controller, der die Ceph Volumes bereit stellt, dass der neue Pod hochfahren kann, da er nicht weiß, was mit dem “alten” Pod ist. So lange also die eben heruntergefahrene k3s Node nicht wieder für den Cluster erreichbar ist, verharrt der Pod in diesem Zustand. Das zum Thema selbstheilend.

Ich habe das Problem für mich nun so gelöst, dass ich auf jeder k3s Node einen Cleanup Service neben dem eigentlichen k3s Service laufen lasse. Dieser Service startet NACH dem k3s Service, wird aber VOR der Beendigung des k3s Services auch ordentlich beendet.

Der Service sieht so aus (%H ist der Hostname, meine Nodes heißen jeweils wie ihr Hostname):

[Unit]
Description=k3s node drain on shutdown
After=k3s.service
Before=shutdown.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/k3s-enable-node.sh %H
RemainAfterExit=true
ExecStop=/usr/local/bin/k3s-drain-node.sh %H
[Install]
WantedBy=multi-user.target

Die beiden Scripte haben diesen Inhalt:

k3s-enable-node.sh

#!/bin/bash
kubectl uncordon $1

k3s-drain-node.sh

#!/bin/bash
node_name=$1
kubectl drain $node_name
sleep 5
set +e
kubectl get pods --all-namespaces --field-selector spec.nodeName=$node_name | tail +2 | grep -v "csi-rbdplugin" | grep -v "svclb-traefik" | awk '{print $2 " --namespace=" $1}' | xargs -n 2 kubectl delete pod -o name
sleep 20

Die sleep Befehle habe ich zur Sicherheit drin, weil kubectl Commands ja nicht immer zu 100% synchron ablaufen. Kann sein, dass das übertrieben ist. Das “set +e” ist drin, weil das Stoppen einzelner Pods evtl Fehler wirft und in diesem Fall das ganze Script failen würde. Da ich hier aber nur versuche noch zu retten was zu retten ist, ignoriere ich die Fehler und versuche so viele Pods wie möglich zu verschieben.

Wenn ich nun einen shutdown oder reboot command auf einer meiner k3s Nodes ausführe, dann wird zuerst das k3s-drain-node.sh Script ausgeführt. Es markiert die k3s Node, dass auf dieser keine neuen Pods mehr gestartet werden dürfen und auch kein Processing mehr passiert. Anschließend lasse ich mir eine Liste mit allen Pods, die auf dieser Node laufen ausgeben, formatierte die etwas um, ignoriere einzelne Pods die ohnehin fest an diese Node gepinnt sind und nirgendwo anders laufen und lösche dann alle Pods, die übrig bleiben. Da die aktuelle Node keine neuen Pods mehr starten darf, werden diese ordnungsgemäß beendet und auf den anderen Nodes im Cluster neu gestartet. Erst wenn das erledigt ist fährt sich die eigentliche Node herunter. Somit habe ich natürlich eine kleine Downtime, aber innerhalb kürzester Zeit ist der jeweilige Pod ja dann wieder erreichbar und ich habe keine permanenten Ausfälle mehr. Ohne diese Scripts würde es also mindestens 5 Minuten dauern, ehe der Pod woanders hin verschoben werden würde, und selbst dann ist aufgrund der Volume Problematik nicht sicher, ob er überhaupt neu gestartet werden kann.