Kubernetes bare metal en HA, sans se mentir
Sur un cloud, la haute disponibilité de Kubernetes s’achète: le control plane est managé, le load balancer est un objet de l’API, le stockage est une classe qu’on référence. Sur du bare metal, chacune de ces trois commodités redevient une chose qu’il faut monter soi-même et c’est là que la plupart des tutoriels empilent sept composants pour en faire tenir trois. Ce billet monte un cluster HA avec le minimum défendable: un control plane qui survit à la perte d’un nœud, une VIP d’API sans load balancer externe, un réseau qui porte aussi le LoadBalancer et un stockage répliqué sur les disques des nœuds. Trois pièces qui portent les trois points de défaillance et pas une de plus parce qu’un cluster qu’on ne comprend pas n’est pas disponible, il est en sursis.
Les trois choses qui ne doivent pas être uniques
La HA n’est pas une qualité du cluster, c’est l’absence de point de défaillance unique sur trois axes précis et chacun a sa mécanique propre. Le premier est le control plane: l’API server et l’etcd qui tient l’état, qui doivent tolérer la perte d’un nœud sans que le cluster devienne illisible. Le deuxième est l’entrée: l’adresse par laquelle on parle à l’API et celle par laquelle le trafic atteint les Services, qui ne doivent pas pendre à une seule machine. Le troisième est le stockage: un PersistentVolume qui survit à la mort du nœud qui le portait. Tout le reste, les charges applicatives, se répare tout seul dès que ces trois-là tiennent, parce que c’est précisément le travail que Kubernetes fait bien.
Le control plane: impair et vide de charge
etcd est un magasin à quorum, donc le nombre de nœuds de control plane se raisonne comme un vote: il faut une majorité vivante pour écrire. Trois nœuds tolèrent la perte d’un, cinq en tolèrent deux et les nombres pairs sont un piège, parce que quatre nœuds tolèrent toujours une seule panne tout en offrant une machine de plus à défaillir et à corrompre. On monte donc trois control planes pour un cluster normal, cinq quand l’enjeu justifie de survivre à deux pertes simultanées, jamais deux ni quatre. Et on n’y planifie aucune charge applicative: le control plane détient les clés du cluster et une app vulnérable co-localisée avec l’etcd est un chemin direct vers l’état entier.
C’est là que k0s
gagne sa place. C’est un binaire unique qui embarque tout le control plane et son etcd, se déclare en quelques lignes et se pilote en multi-nœuds par k0sctl depuis un simple fichier d’inventaire. Le repli neutre existe si on le préfère, un kubeadm vanilla sur Debian avec etcd empilé et toute l’architecture ci-dessous s’y transpose terme à terme; k0s ne fait qu’enlever le travail d’intendance que kubeadm laisse sur les épaules.
La VIP de l’API, sans load balancer externe
Un cluster à trois control planes a trois adresses d’API et il faut une seule adresse stable devant elles, sinon le kubeconfig et les kubelets pointent sur une machine qui peut mourir.
Le réflexe est de monter un HAProxy
et un keepalived à côté, soit deux démons de plus à installer, configurer et surveiller pour une fonction que le cluster peut porter lui-même.
k0s l’embarque: le Control Plane Load Balancing (CPLB) fait tourner un keepalived en VRRP entre les control planes et tient une IP virtuelle qui flotte vers un nœud vivant.
# k0s.yaml, sur chaque control plane
spec:
network:
controlPlaneLoadBalancing:
enabled: true
type: Keepalived
keepalived:
vrrpInstances:
- virtualIPs: ["192.0.2.10/24"]
authPass: "un-secret-de-cluster"
C’est le premier arbitrage KISS de ce billet: la VIP de l’API est une pièce du cluster, pas une infra à côté. Le 192.0.2.10 devient l’adresse de l’API dans tous les kubeconfig et pour tous les workers et il survit à la perte du control plane qui le portait.
Le CPLB se double du Node-Local Load Balancing, qui fait que chaque worker répartit lui-même ses appels d’API sur les control planes vivants; les deux sont faits pour cohabiter et ensemble ils retirent le dernier prétexte à un load balancer externe.
Le réseau: Cilium et un composant de moins
Pour le CNI, le débat est tranché depuis un moment: Cilium l’a emporté sur le terrain eBPF, avec les NetworkPolicy et le L7, le remplacement de kube-proxy, le BGP et l’observabilité Hubble dans un seul projet. Calico reste un choix sérieux et plus classique, surtout si le BGP natif est déjà votre monde, mais pour un cluster neuf c’est Cilium qui est l’état de l’art.
Sur k0s, on débranche le CNI intégré et kube-proxy pour laisser la place:
# k0s.yaml
spec:
network:
provider: custom
podCIDR: "10.244.0.0/16"
serviceCIDR: "10.96.0.0/12"
kubeProxy:
disabled: true
Puis Cilium prend le relais de kube-proxy en eBPF, en pointant explicitement sur la VIP du CPLB parce qu’un worker doit joindre l’API avant que le réseau des pods n’existe:
helm install cilium cilium/cilium -n kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=192.0.2.10 \
--set k8sServicePort=6443 \
--set ipam.mode=kubernetes
Le LoadBalancer des Services, sans MetalLB
Hors cloud, un Service type: LoadBalancer reste en pending pour toujours, parce que personne n’attribue ni n’annonce l’IP externe. Le réflexe, encore, est d’ajouter MetalLB. Et c’est l’arbitrage où ta question “que dirait le KISS” a une réponse nette: puisque Cilium tient déjà le réseau, lui faire aussi le LoadBalancer évite un deuxième projet qui fait le même travail.
Cilium sépare proprement les deux moitiés du problème. L’attribution d’IP est le LB-IPAM, un pool déclaré en CRD:
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata: { name: pool-lan }
spec:
blocks:
- start: "192.0.2.200"
stop: "192.0.2.250"
L’annonce, elle, dépend de votre réseau et c’est le seul vrai choix à faire. Sur un LAN plat, les L2 Announcements suffisent: Cilium répond à l’ARP pour les IP de LoadBalancer, un nœud élu par lease porte le trafic de chaque Service et aucun routeur n’a besoin de savoir quoi que ce soit.
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata: { name: l2-lan }
spec:
loadBalancerIPs: true
interfaces: ["^eth[0-9]+"]
Sur un réseau routé, on préfère le BGP: le CiliumBGPClusterConfig appaire les nœuds avec vos routeurs et annonce les IP de Service comme des routes, ce qui répartit mieux et ne dépend pas d’un unique nœud élu.
Deux remarques honnêtes: les L2 Announcements sont encore marqués beta et exigent le remplacement de kube-proxy, qu’on a de toute façon activé et leur élection de leader bavarde avec l’API, donc sur un gros cluster on relève les limites de débit du client Cilium. MetalLB redevient pertinent, lui, uniquement si on reste sur un CNI qui ne fait pas le LoadBalancer, typiquement du Calico pur; dès que Cilium est là, c’est une pièce qu’on s’épargne.
Le stockage: Rook-Ceph sur les disques des nœuds
Un PersistentVolume en HA veut dire que la donnée est répliquée hors du nœud qui la sert et sur bare metal la réponse mûre est Ceph, orchestré dans le cluster par Rook . Les contraintes de Ceph dictent la même arithmétique que l’etcd: il veut trois moniteurs pour un quorum, un domaine de défaillance à l’hôte et une réplication en trois copies pour survivre à la perte d’un nœud sans perdre ni bloquer la donnée. Concrètement, trois nœuds de stockage au minimum, chacun avec au moins un disque brut, non partitionné et sans système de fichiers, parce que Ceph veut le périphérique entier pour y poser ses OSD.
apiVersion: ceph.rook.io/v1
kind: CephBlockPool
metadata: { name: replicapool, namespace: rook-ceph }
spec:
failureDomain: host
replicated: { size: 3 }
Le reste est la marche habituelle: l’opérateur Rook, une CephCluster qui déclare les nœuds et leurs disques, puis une StorageClass adossée au pool pour que les PVC se servent tout seuls.
Sur un cluster déjà bâti sur Proxmox et Ceph, l’alternative sensée est de consommer le Ceph externe existant par le CSI plutôt que d’en monter un second dans Kubernetes, ce qui sépare proprement le stockage du compute; mais pour un cluster qui doit tenir seul, Rook-Ceph est la réponse complète.
Les nombres qui comptent
Trois revient partout dans ce billet et ce n’est pas un hasard: trois control planes pour un quorum etcd qui tolère une perte, trois moniteurs Ceph pour la même raison, trois copies de la donnée pour survivre à un nœud mort. Cinq est la marche au-dessus, quand survivre à deux pannes simultanées justifie la machine et la latence de consensus en plus. Les nombres pairs n’achètent rien: quatre control planes tolèrent toujours une seule panne, pour une surface d’attaque et un coût de consensus de plus. Et les workers, eux, sont libres: ils ne tiennent que ce qu’on y planifie, pas l’état, donc on en met autant que la charge le demande.
Où atterrir
Trois control planes vides de charge, cinq si l’enjeu le mérite, jamais un nombre pair, parce que l’etcd vote et qu’une majorité doit survivre. La VIP de l’API par le CPLB de k0s, pour que l’adresse de l’API soit une pièce du cluster et non un HAProxy à côté. Cilium en CNI avec le remplacement de kube-proxy et le LoadBalancer des Services porté par le même Cilium, en L2 sur un LAN plat ou en BGP sur un réseau routé, pour n’avoir qu’un composant réseau au lieu de deux. Rook-Ceph sur trois nœuds à disques bruts, domaine de défaillance à l’hôte et trois copies, pour que la donnée survive au nœud. Le fil conducteur, c’est que chaque point de défaillance unique est couvert par exactement une pièce, choisie parce qu’elle en remplace deux. L’état de l’art, sur bare metal, ce n’est pas le plus de composants, c’est le moins qui tienne vraiment.
[edit: 2026] L’implémentation Ansible de tout ceci: les rôles k0s et microk8s .