Quelques semaines après avoir viré GitLab pour Forgejo sur mon Kapsule, je me suis posé la question d’à côté : et le control plane managé lui-même, je l’utilise vraiment ? Spoiler : non. J’ai migré toute mon infra perso (git.z3k.eu, auth.z3k.eu, ce site, ArgoCD, la stack VictoriaMetrics) sur un cluster Hetzner Cloud auto-géré en Talos Linux. Voilà les galères qui m’ont coûté du temps.
Pourquoi quitter Kapsule
Même logique que la migration GitLab → Forgejo : 90% de la valeur d’un Kubernetes managé est inutile quand tu opères seul une infra perso. Tu paies le control plane, tu paies un autoscaler que tu sur-configures pour qu’il scale enfin down, tu paies un load balancer par Service de type LB, et tu hérites d’une version de Kubernetes que tu ne choisis pas vraiment. Pour un opérateur unique avec une stack stable, c’est de l’abstraction qui coûte plus qu’elle ne rend.
L’alternative qui me trottait : des VMs brutes Hetzner Cloud, Talos Linux pour l’OS immuable, quelques helm charts pour le reste. Moins d’abstrait, moins cher, plus contrôlable. Le target FinOps que je me suis donné, c’était à peu près moitié prix par rapport au Kapsule équivalent.
La nouvelle stack
Trois cpx22 en control plane, deux cpx32 en worker, tous sur un réseau privé Hetzner. Talos Linux partout, géré par talosctl et un fichier de config par node, le tout déclaré dans un script bin/new-client-pro.sh qui crache les patches machineconfig et les substrats helmfile. Cilium en CNI avec le kube-proxy replacement en eBPF, Traefik en ingress, hcloud-CCM pour les load balancers, hcloud-CSI pour le storage, cert-manager côté Let’s Encrypt, CNPG pour Postgres.
Le vrai changement c’est le control plane : plus de managé, etcd est sur moi. Mais Talos rend ça quasi-trivial, tout passe par l’API talosctl, pas de SSH, pas de cloud-init, pas de bricolage. Tu décris ton état, tu l’appliques, tu oublies. Et l’OS lui-même est immuable, donc les updates ne ressemblent plus à du apt upgrade les yeux fermés.
Les galères
Le VIP qui ne se claim jamais
Premier déploiement du cluster, Talos boot, les trois CPs montent, et Cilium reste bloqué. Logs des agents :
dial tcp 10.0.1.100:6443: i/o timeout
Le VIP 10.0.1.100 n’était claim par personne. J’avais collé un patch machineconfig avec interface: eth1 parce que c’est ce que je connais des autres providers. Sauf que Hetzner utilise les noms predictable systemd : enp1s0 pour le public, enp7s0 pour le réseau privé. Talos ne hurle pas, il ignore silencieusement la section VIP parce qu’elle pointe sur une interface inexistante.
Deux corrections en parallèle. D’abord, enp7s0 dans le patch, le VIP se claim immédiatement. Ensuite, j’ai basculé le k8sServiceHost de Cilium du VIP vers localhost:7445 — c’est kube-prism, le proxy local que Talos colle sur chaque node vers les apiservers. Plus aucun pod ne dépend du VIP pour atteindre le control plane, chaque node se débrouille en local. Le VIP reste utile pour kubectl depuis l’extérieur, mais en interne il devient optionnel.
Cilium refuse de démarrer sur Talos
Une fois le VIP propre, l’init container clean-cilium-state se vautrait :
unable to apply caps: operation not permitted
Talos est aggressivement verrouillé par défaut, et Cilium a besoin de capabilities explicites (SYS_ADMIN, NET_ADMIN, SYS_MODULE, SYS_RESOURCE…) plus le cgroup autoMount désactivé côté helm. Sans ces overrides dans le values, l’init container n’a pas les droits de toucher au bpffs et il abandonne. Pas un bug Cilium, juste le contrat Talos qui n’a rien à voir avec celui d’un Ubuntu de base.
“Empty reply from server” alors que tout est vert
Cluster up, helm releases déployées, hcloud-CCM avait provisionné un Hetzner Load Balancer pour le Service Traefik. Je tape curl https://git.z3k.eu/ et je me prends :
curl: (52) Empty reply from server
kubectl get pods -n traefik, tout running. Endpoints OK. Le LB répondait sur le port 443 mais sans rien servir. J’ai fini par regarder le LB côté Hetzner :
$ hcloud load-balancer describe lb-z3k
...
Targets:
No targets
Aucun target. Or à ce moment-là j’étais encore en cluster control-plane-only, les workers n’avaient pas fini de joindre. Et Talos colle par défaut le label node.kubernetes.io/exclude-from-external-load-balancers: "" sur ses CPs. hcloud-CCM honore ce label, donc il exclut activement les seuls nodes disponibles. LB sans target, Traefik invisible, curl qui se prend un reset.
Deux fixes possibles : un patch machineconfig pour virer le label (acceptable si tu assumes que tes CPs prennent du trafic), ou attendre que les workers joignent. J’ai pris la deuxième pour rester dans l’esprit Talos, mais le label par défaut m’a coûté deux heures à débugger parce que rien dans la chaîne ne te dit explicitement “le CCM ignore tes nodes”.
helmfile v1 et deux erreurs en cascade
J’en profitais pour passer en helmfile v1. Première erreur au helmfile apply :
error: cannot have multiple top-level sections in the same document
En v1, environments et releases doivent vivre dans deux documents YAML séparés par ---. Je sépare, je relance. Deuxième erreur :
template: helmfile.yaml:42: bad character U+007B '{'
Les directives {{ }} ne sont plus évaluées si le fichier s’appelle helmfile.yaml. Il faut helmfile.yaml.gotmpl. Rename, ça repart. Deux erreurs distinctes du même refactor, c’est rageant sur le moment mais c’est documenté.
SOPS qui chiffre sans matcher la règle
Le script de bootstrap chiffrait le kubeconfig comme ça :
sops -e $workdir/kubeconfig > $state_dir/kubeconfig.enc.yaml
Et le .sops.yaml ciblait path_regex: clients/.+/state/.*\.enc\.yaml$. Résultat : le fichier sortait chiffré avec la clé par défaut, pas celle attendue pour les states. J’étais énervé, j’ai re-lu la doc SOPS : path_regex matche le path d’entrée, pas celui de sortie. Mon input était $workdir/kubeconfig, qui ne matche évidemment pas le regex sur state/.
Fix bête : un cp d’abord vers la destination, puis sops -e -i in-place. SOPS voit le path final, la règle match, la bonne clé est utilisée. Un de ces bugs où l’outil fait exactement ce que tu lui demandes mais pas ce que tu crois lui demander.
external-dns qui réécrit tes records toutes les 30s
Pendant la bascule j’avais besoin d’éditer manuellement quelques records DNS pour pointer sur la nouvelle LB Hetzner. Toutes les 30 secondes, mes changements disparaissaient. L’external-dns du cluster Scaleway tournait toujours en policy: sync et remettait les Ingresses du cluster source comme source de vérité.
La bonne séquence c’est de déployer external-dns sur le nouveau cluster d’abord, avec le même txtOwnerId, scaler à 0 l’ancien, et laisser le nouveau prendre les Ingresses du Hetzner pour réécrire les records via les API OVH et Scaleway DNS. Une fois cette étape passée, plus aucun combat contre soi-même.
Let’s Encrypt qui galère sur l’IPv6
Première émission de certificat via cert-manager HTTP-01, et ça timeout. Let’s Encrypt essaie l’IPv6 en premier quand un AAAA existe. external-dns publiait gentiment l’AAAA de la LB Hetzner, sauf que Traefik n’écoutait qu’en IPv4 sur ce cluster-là. LE tape l’IPv6, personne ne répond, le challenge échoue avant même d’essayer l’IPv4. J’ai supprimé les AAAA en attendant de configurer le bind IPv6 propre côté Traefik.
J’ajoute juste, pour les gens qui viennent du post précédent : oui, le bug MTU 1450 d’act_runner avec dind est revenu mordre dès le premier push, c’est documenté dans le post Forgejo, je ne le re-déroule pas ici.
Ce qui reste cross-cloud pendant la transition
External Secrets Operator pointe encore sur Scaleway Secret Manager. Dépendance cross-cloud assumée, ça marche, je verrai plus tard si je migre vers un Vault local ou si je reste comme ça. La registry container passe sur Forgejo Packages, plus besoin de Harbor. Les zones DNS restent splitées : z3k.eu chez Scaleway DNS, z3k.tech chez OVH, chacune avec son external-dns dédié, les deux maintenant hébergés sur Hetzner.
Le bilan
Cinq nodes Hetzner (3 CP + 2 worker) pour environ 50 €/mois, contre ~80 €/mois sur le Kapsule équivalent. Le gain n’est pas que financier. Plus aucun lock-in managé, je choisis ma version de Kubernetes, mon CNI, et chaque helm release est de moi à moi. En contrepartie j’ai pris la responsabilité d’etcd, des updates OS, des patchs de sécu. Talos rend les deux derniers points indolores, vraiment. Etcd reste l’angle mort, c’est le nouveau truc qu’il faut surveiller, sauvegarder, restaurer si besoin.
Ce que j’en retiens
Talos n’est pas magique mais il porte sa charge : immuable, déclaratif, piloté par API, ça change le rapport à l’OS. Cilium plus kube-prism te sort de la dépendance au VIP, qui est une jolie source de pannes silencieuses dès que ton réseau a un hoquet. Les labels Talos par défaut peuvent te coûter cher quand tu ne les connais pas, le exclude-from-external-load-balancers sur les CPs m’a fait perdre deux heures. Et comme toujours, le bug le plus cher c’est celui que le système ne te signale pas : des records DNS réécrits toutes les 30 secondes, des paquets droppés sans ICMP retour, une section machineconfig ignorée parce qu’elle pointe sur une interface inexistante. C’est là qu’il faut apprendre à regarder.
Points clés à retenir
- ✓ Sur Hetzner, les interfaces réseau ont des noms predictable systemd (enp1s0 / enp7s0). Si ton patch machineconfig Talos référence eth1, la section VIP est ignorée silencieusement et le VIP n'est jamais claim
- ✓ Bascule le k8sServiceHost de Cilium du VIP vers localhost:7445 (kube-prism). Chaque node parle aux apiservers en local, plus aucune dépendance au VIP pour le trafic intra-cluster
- ✓ Talos colle node.kubernetes.io/exclude-from-external-load-balancers sur ses control planes par défaut. hcloud-CCM honore ce label : sans worker joint, ton Hetzner LB a zéro target et tu reçois 'Empty reply from server'
- ✓ SOPS path_regex matche le path d'entrée, pas celui de sortie. Pour qu'une règle ciblant clients/*/state/ s'applique, il faut cp puis sops -e -i in-place, pas une redirection
- ✓ external-dns en policy sync sur l'ancien cluster réécrit tes records DNS toutes les 30s pendant la bascule. Déploie d'abord la nouvelle instance avec le même txtOwnerId, scale l'ancienne à 0, puis laisse la nouvelle reprendre les Ingresses Hetzner comme source de vérité