Contexte : la mise en place de GitOps sur le cluster est racontée dans GitOps avec ArgoCD, le stockage dans Stockage bloc distribué avec Longhorn.
La contrainte : tout éteindre, ne rien perdre
Mi-septembre 2026, le rack du homelab doit changer de place. Pas un nœud à redémarrer : les six. Trois masters Raspberry Pi 4 (8 Go), deux workers Pi 5 (16 Go), un petit worker Pi 4 (2 Go), 15 volumes Longhorn répliqués entre eux, et 32 Applications ArgoCD dont le métier est précisément d’empêcher que quoi que ce soit bouge. Le NAS, à côté, porte MinIO et les sauvegardes ; c’est lui qu’on rallumera en premier.
Le cadrage est volontairement étroit : mêmes machines, même box, même switch, réservations DHCP conservées. Les IP ne doivent pas changer. Le problème, c’est qu’un cluster k3s n’est pas pensé pour être éteint en entier. etcd veut un quorum, Longhorn veut des répliques joignables, ArgoCD veut que le cluster ressemble à git. Débrancher tout ça proprement demande un ordre. Le rallumer en demande un autre — et c’est celui-là que le runbook avait sous-estimé.
Au programme : le runbook écrit le 17 septembre, son exécution réelle le 20 (60 minutes de relance, quatre surprises), puis sa réexécution le 26 (10 minutes).
La sortie de secours : des sauvegardes vraiment restaurées
Avant de toucher à un seul replica, une règle : une sauvegarde jamais restaurée reste une hypothèse. Le 17 septembre, la sortie de secours est donc préparée, puis testée :
- un backup Longhorn complet forcé vers MinIO sur le NAS, lancé à partir du CronJob hebdomadaire ;
- un snapshot etcd copié sur le NAS ;
- des dumps frais des bases, dont le PostgreSQL de Gitea, intégrité gzip vérifiée ;
- une table MAC ↔ IP ↔ nœud, au cas où une réservation DHCP ferait des siennes ;
- le miroir GitHub du dépôt GitOps, vérifié au même SHA que Gitea.
Les tests ensuite : le dump PostgreSQL de Gitea restauré dans une base temporaire — 39 dépôts retrouvés — et un volume Longhorn restauré depuis MinIO en 25 secondes. Le runbook lui-même existe en copie lisible hors du cluster : pendant la coupure, Gitea est éteint, et un runbook qui n’est consultable que dans Gitea perd beaucoup de son intérêt.
Un détail compte plus qu’il n’en a l’air : le snapshot etcd est pris avant le moindre scale-down. C’est lui qui décrit l’état « tout tourne ». Un snapshot pris après décrirait un cluster où toutes les apps sont à 0 replica ; le restaurer donnerait un cluster parfaitement sain qui ne sert strictement rien.
L’extinction : neutraliser GitOps avant de le contredire
Le piège du selfHeal
Le réflexe naïf : kubectl scale --replicas=0 partout, puis shutdown. Sur un cluster GitOps avec selfHeal, ça ne tient pas trois minutes. ArgoCD constate qu’un Deployment déclare 1 replica dans git et en a 0 dans le cluster, y voit une dérive, et remonte tout. C’est exactement son travail.
Plutôt que de modifier 32 Applications, ou pire git, on coupe le contrôleur :
K="sudo k3s kubectl"
$K -n argocd scale statefulset argocd-application-controller --replicas=0
Plus de contrôleur, plus de réconciliation. Le repo-server et l’interface restent en place, mais plus personne ne compare rien. Pour un moment, le cluster redevient un Kubernetes ordinaire qui obéit à kubectl.
Mémoriser les CronJobs, puis les suspendre
Avant même de couper ArgoCD, les CronJobs actifs sont listés dans un fichier, puis suspendus. Un backup qui se déclenche au milieu de l’extinction, c’est un volume qui se rattache au pire moment. La liste servira à la relance — on verra pourquoi elle est indispensable.
$K get cronjobs -A -o json | python3 -c "
import sys, json
for c in json.load(sys.stdin)['items']:
if not c['spec'].get('suspend'):
print(c['metadata']['namespace'] + '/' + c['metadata']['name'])" > cronjobs-actifs.txt
while IFS=/ read ns name; do
$K -n "$ns" patch cronjob "$name" -p '{"spec":{"suspend":true}}' </dev/null
done < cronjobs-actifs.txt
Les apps, puis les bases, puis le tunnel
L’ordre de scale-down suit les dépendances à l’envers :
- les namespaces applicatifs, puis le monitoring (Grafana, collecteurs, alerting) ;
- les applications adossées à une base, Gitea compris ;
- 45 secondes de pause, pour laisser les apps se terminer proprement ;
- les bases : PostgreSQL, MySQL, la base de séries temporelles ;
- le tunnel Cloudflare, en tout dernier — tant qu’il tourne, on garde un œil distant sur le cluster.
Les bases en dernier, parce qu’une app qui perd sa base en plein vol écrit des erreurs, retente, parfois laisse un état local bancal. Une base qu’on arrête alors que plus personne ne lui parle, c’est un non-événement.
La gate : 15 volumes detached, pas 14
Aucun shutdown tant qu’un seul volume Longhorn est attaché. Un volume attaché, c’est un engine qui peut écrire ; éteindre le nœud sous lui, c’est s’offrir une reconstruction de réplique au retour, dans le meilleur des cas.
watch -n5 "sudo k3s kubectl -n longhorn-system get volumes.longhorn.io \
-o custom-columns=N:.metadata.name,S:.status.state,R:.status.robustness | grep -v detached"
La liste doit se vider. Si un volume reste attaché, on cherche le pod qui le tient et on le réduit à 0. Les guillemets doubles et la commande écrite en entier ne sont pas un caprice : on y revient plus bas.
Les nœuds : workers d’abord, master principal en dernier
Les shutdown partent du PC, en SSH direct sur chaque nœud — pas en rebond via le premier master, qui doit tomber en dernier et couperait sinon la branche sur laquelle on est assis. Les trois workers d’abord, puis les masters, 20 secondes entre chaque, le premier master en dernier. On attend que les LED disque s’éteignent avant de débrancher quoi que ce soit : les SSD en USB-SATA sont le composant le plus fragile du rack.
Le déménagement physique n’a rien de glorieux : photos du câblage, étiquettes par nœud sur les câbles et les alimentations, et une vérification de l’alimentation au nouvel emplacement. Un Pi 5 veut 5 V / 5 A ; une sous-tension ne produit pas de message d’erreur, elle produit de la corruption silencieuse.
La relance : l’ordre inverse, et ce qu’il n’avait pas prévu
Le 20 septembre, 15 h 05 : le NAS est rallumé. 15 h 09 : les trois masters sont Ready et etcd a son quorum.
sudo k3s kubectl get nodes
sudo k3s kubectl get --raw '/readyz?verbose' | grep etcd
# [+]etcd ok
# [+]etcd-readiness ok
Les masters d’abord, parce que sans quorum etcd rien d’autre n’a de sens : les kubelets des workers frapperaient une API qui ne répond pas. Les workers suivent, Ready à 15 h 27. À 15 h 29, Longhorn confirme 15 volumes sur 15 detached, aucun faulted. Le plus dur semble fait. Le runbook prévoit alors une seule étape : remettre le contrôleur ArgoCD à 1 et laisser git reconstruire le cluster.
Le vert final arrivera à 16 h 05. Entre les deux, quatre surprises.
Surprise n° 1 : le NAS ne relance pas ses conteneurs
Avant même le cluster, le NAS. Ses conteneurs Docker sont en restart: unless-stopped, et ils avaient été arrêtés à la main par docker stop avant l’extinction. Or unless-stopped veut dire très exactement « redémarre toujours, sauf si quelqu’un m’a arrêté » — et Docker s’en souvient à travers un reboot. Résultat : un NAS allumé, et pas un seul conteneur. MinIO compris.
Le correctif : docker start des bases d’abord, puis du reste. 38 conteneurs sur 38, à la main, en une quarantaine de secondes. Rien de grave, mais à savoir : un docker stop « par précaution » est une décision qui survit au redémarrage.
Surprise n° 2 : ArgoCD a besoin de Gitea, que seul ArgoCD sait relancer
Le contrôleur repart, et ne réconcilie rien. Logique, a posteriori : ArgoCD lit ses manifests dans Gitea, et Gitea est à 0 replica — c’est justement ArgoCD qui devait le remonter. Dépendance circulaire parfaite.
La sortie est manuelle, et sans risque : remonter la base puis Gitea à la main, dans l’état exact que décrit git (1 replica chacun). Quand le contrôleur reprend la main, il ne trouve aucun écart à corriger.
$K -n gitea scale deploy postgres --replicas=1 # attendre Ready (~20 s)
$K -n gitea scale deploy gitea --replicas=1 # attendre Ready (~30 s)
$K -n argocd scale statefulset argocd-application-controller --replicas=1
C’est la leçon la plus structurelle des quatre : sur un cluster qui héberge son propre git, le chemin de démarrage à froid n’est pas le chemin nominal. Il faut le connaître avant d’en avoir besoin.
Surprise n° 3 : un repo-server fantôme
Même avec Gitea en ligne, rien ne se synchronise. Le coupable : un pod argocd-repo-server resté en Unknown depuis l’extinction. Le ReplicaSet le croyait toujours vivant et n’en recréait donc pas d’autre ; or sans repo-server, le contrôleur ne peut générer aucun manifest, donc aucune comparaison. Dix minutes de blocage avant de comprendre.
$K get pods -A | grep Unknown
$K -n argocd delete pod <nom-du-fantôme> --force --grace-period=0
Une fois le fantôme supprimé, le ReplicaSet en recrée un vrai et la réconciliation démarre. Dans le runbook corrigé, cette chasse aux Unknown passe avant l’amorçage de Gitea : on nettoie les restes de l’extinction avant de compter sur quoi que ce soit.
Surprise n° 4 : selfHeal ne désuspend pas les CronJobs
Les Applications repassent au vert une à une. Mais aucun CronJob ne repart. Le runbook initial affirmait que selfHeal s’en chargerait. Faux.
Les manifests des CronJobs, dans git, ne déclarent pas le champ suspend du tout. ArgoCD compare ce que git déclare ; un champ absent n’est pas une valeur déclarée, donc le suspend: true posé à la main dans le cluster ne produit aucun diff. Pour selfHeal, il n’y a rien à corriger.
C’est là que le fichier cronjobs-actifs.txt prend toute sa valeur : on réactive exactement ce qui tournait, ni plus — un CronJob volontairement suspendu avant la coupure le reste —, ni moins.
while IFS=/ read ns name; do
$K -n "$ns" patch cronjob "$name" -p '{"spec":{"suspend":false}}' </dev/null
done < cronjobs-actifs.txt
16 h 05 : 32 Applications sur 32 Synced et Healthy, tunnel en ligne, CronJobs réactivés.
Le bonus : trois jetons expirés que le cache cachait
Le reschedule de tous les pods a débusqué un problème sans aucun rapport avec le déménagement : trois secrets d’accès au registre d’images portaient un jeton expiré. Personne ne l’avait vu, parce que les images étaient en cache sur les nœuds. Tant qu’un pod redémarre là où son image est déjà présente, aucun pull n’a lieu, et aucun jeton n’est vérifié. Les trois secrets ont été rescellés avec un jeton valide. Depuis, toute opération qui replace les pods est suivie d’un test d’authentification sur chaque secret de pull.
Le déroulé réel : 60 minutes, puis 10
| Heure (20.09) | Étape |
|---|---|
| 15:05 | NAS rallumé (conteneurs relancés à la main) |
| 15:09 | 3 masters Ready, etcd ok |
| 15:27 | workers Ready |
| 15:29 | 15/15 volumes detached, 0 faulted |
| 16:05 | 32/32 Applications, tunnel, CronJobs réactivés |
Zéro perte de données, IP identiques, une heure de relance pour une estimation de 30 à 45 minutes. Les surprises expliquent une bonne partie de l’écart — le fantôme à lui seul en a coûté dix.
Le 26 septembre, rebelote : nouvel arrêt complet du cluster, pendant lequel le disque d’un worker a été remplacé (c’est une autre histoire, qui aura son propre article). Cette fois, le runbook corrigé est appliqué tel quel. Relance complète en 10 minutes.
Les deux bugs shell que personne n’avait vus
En corrigeant le runbook le 26, deux bugs de shell sont apparus, du genre qui passe toutes les relectures.
Le premier tient à deux guillemets :
K=sudo k3s kubectl # K vaut "sudo" pour la seule commande "k3s kubectl"
K="sudo k3s kubectl" # ce qu'on voulait
Sans guillemets, le shell lit VAR=valeur commande : une affectation qui ne vaut que pour l’environnement de la commande qui suit. K ne reçoit que sudo, et k3s kubectl s’exécute dans la foulée. Tous les $K … suivants partent alors dans le vide.
Le second est plus vicieux. La gate des volumes était écrite watch -n5 '$K …'. watch exécute sa chaîne dans un nouveau shell, où $K — une simple variable, pas une variable d’environnement exportée — n’existe pas. La commande ne pouvait donc rien vérifier. Une gate qui ne vérifie rien est pire qu’une absence de gate : on lui fait confiance. Correctif : guillemets doubles et commande écrite en entier, comme plus haut.
Le runbook, version réutilisable
Ce qui se transpose à n’importe quel cluster GitOps qu’on doit éteindre en entier :
- Restaurer, pas seulement sauvegarder. Un dump restauré dans une base temporaire, un volume restauré depuis l’objet. Et une copie du runbook lisible hors du cluster.
- Snapshot etcd avant tout scale-down, pour capturer l’état « tout tourne ».
- Mémoriser puis suspendre les CronJobs actifs. La liste est la seule source de vérité à la relance.
- Contrôleur ArgoCD à 0, sinon selfHeal annule les scale-down en trois minutes environ.
- Apps, puis bases, puis tunnel.
- Gate : tous les volumes detached. Une gate qu’on a vue échouer au moins une fois.
- Workers, puis masters, master principal en dernier, depuis une machine hors du cluster.
- Relance : masters, quorum etcd (
/readyz?verbose), puis workers. - Chasser les pods
Unknownavant de compter sur la réconciliation. - Amorcer Gitea à la main (base, puis app) avant de rendre la main au contrôleur.
- Contrôleur à 1, puis dé-suspension des CronJobs mémorisés.
- Hors cluster, vérifier les politiques de redémarrage :
unless-stoppedplus un arrêt manuel, c’est un service qui ne revient pas.
Les limites, honnêtement : tout cela suppose des IP inchangées, garanties ici par les réservations DHCP. Avec un changement d’adressage, etcd aurait été une tout autre histoire. Et un runbook n’est validé qu’exécuté : celui du 17 septembre était relu, testé sur ses sauvegardes, et contenait quand même deux bugs et une étape fausse.
Le rack a changé de place. Le cluster, lui, ne s’en est pas aperçu — à quatre détails près.