J'ai voulu faire du Kubernetes dans Kubernetes. Je ne pensais pas que ce serait si compliqué
Retour d'expérience sur KubeVirt + k0smotron, ou comment un projet « simple » m'a appris tout ce que je ne voulais pas savoir sur le réseau QEMU.
L'idée de départ (naïve)
Tout a commencé par un besoin légitime : provisionner des clusters Kubernetes à la demande pour plusieurs équipes, sans multiplier les serveurs physiques.
La solution semblait évidente : faire tourner des clusters guests dans le cluster hôte. Kubernetes dans Kubernetes. Du K8s-ception.
J'ai regardé les options disponibles, choisi ma stack, et me suis dit : « deux jours, trois au maximum, ce sera en prod. »
Spoiler : c'était pas deux jours.
Pourquoi pas les alternatives ?
Avant d'aller plus loin, parlons des autres approches — parce que non, je n'ai pas choisi cette stack au hasard.
vCluster — le plus simple, mais…
vCluster de Loft Labs est l'option la plus populaire aujourd'hui. Le principe : un cluster Kubernetes « virtuel » qui tourne dans des pods sur le cluster hôte, partageant le kernel des nœuds hôtes.
Avantages : Déploiement en quelques minutes, zéro VM, léger.
Inconvénient pour mon cas : Les workloads partagent le kernel du nœud hôte. Pas d'isolation kernel. Pour certains besoins (syscalls spécifiques, eBPF, modules kernel custom), ça ne convient pas.
Kamaji — élégant, mais control-plane only
Kamaji est excellent pour héberger les control planes dans des pods. Mais il ne gère pas les nœuds workers — il faut les gérer soi-même.
Combiné avec KubeVirt ça peut marcher, mais c'est exactement ce que k0smotron fait de façon plus intégrée.
kind / k3d — pour le dev, pas la prod
kind (Kubernetes IN Docker) est fantastique en local. En production avec plusieurs équipes, des VMs persistantes et du stockage bloc ? Moins adapté.
k0smotron + KubeVirt — le choix
k0smotron héberge le control plane k0s dans des pods (comme Kamaji), KubeVirt fait tourner les workers dans de vraies VMs.
Résultat : isolation kernel complète, snapshots de disques, lifecycle géré par CAPI (Cluster API), GitOps-natif. Le package complet.
Ce que la doc ne dit pas assez fort, c'est que KubeVirt en mode masquerade et Kubernetes ont une relation... disons complexe. On y reviendra.
L'architecture (le plan)
Cluster hôte
├── k0smotron (Cluster API provider)
│ ├── Pod kmc-cluster-guest-01-0 ← apiserver k0s du cluster guest
│ └── Pod etcd-cluster-guest-01-0 ← etcd dédié
│
├── KubeVirt
│ ├── VM worker-01 (Ubuntu Noble, 4 vCPU, 8GB RAM)
│ └── VM worker-02
│
└── MetalLB → VIP 10.2.6.200 ← point d'entrée unique pour les workers
Chaque cluster guest a son propre control plane en pod, ses propres VMs workers, son propre Cilium, son propre réseau. Isolation totale. C'est propre sur le papier.
Le ciment qui lie tout : Cluster API (CAPI)
Avant d'aller plus loin, il faut parler de Cluster API — le composant qu'on oublie souvent de mentionner et qui est pourtant le vrai chef d'orchestre.
Cluster API est un projet Kubernetes qui standardise le provisioning de clusters via des ressources Kubernetes déclaratives. Au lieu de scripts bash ou de CLI propriétaires, on déclare un cluster comme n'importe quelle ressource K8s :
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
name: cluster-guest-01
spec:
controlPlaneRef:
kind: K0smotronControlPlane # ← k0smotron prend en charge le control plane
infrastructureRef:
kind: KubevirtCluster # ← KubeVirt gère l'infrastructure
---
apiVersion: cluster.x-k8s.io/v1beta1
kind: MachineDeployment
spec:
replicas: 2 # ← scaler = changer ce chiffre
template:
spec:
infrastructureRef:
kind: KubevirtMachineTemplate # ← chaque Machine = une VM KubeVirt
Dans ce modèle, k0smotron est un provider CAPI pour le control plane, et KubeVirt est un provider CAPI pour l'infrastructure. Les deux parlent le même langage — les CRDs CAPI — ce qui rend la stack cohérente et extensible.
Ce que CAPI apporte concrètement dans notre stack :
- Secrets de bootstrap automatiquement générés et injectés dans chaque VM via cloud-init — c'est là que le
token-patcherentre en jeu pour corriger les IPs - Clés SSH CAPI générées à chaque
helm installet stockées dans un Secret (cluster-guest-01-infra-ssh-keys) — utilisées par letoken-patcherpour SSH dans les VMs - Lifecycle déclaratif :
replicas: 3→ une VM de plus est créée et rejoint le cluster.replicas: 1→ deux VMs sont drainées et supprimées - GitOps-natif : tout l'état du cluster est dans Git, ArgoCD synchronise, CAPI réconcilie
Git (valeurs Helm)
→ ArgoCD (synchronise)
→ CAPI (réconcilie les CRDs)
→ k0smotron (control plane)
→ KubeVirt (VMs workers)
En pratique, tout ce qu'on fait dans ce projet, c'est déclarer des valeurs Helm. CAPI fait le reste — à condition que le réseau coopère, ce qui, comme vous allez le voir, n'est pas gagné d'avance.
La réalité : le réseau QEMU masquerade
KubeVirt propose plusieurs modes réseau pour les VMs. Le mode masquerade est le plus simple : la VM est derrière un NAT QEMU, comme une VM VirtualBox à la maison. Pas de VLAN, pas de config réseau complexe.
Ce que je n'avais pas anticipé : toutes les VMs ont la même IP interne.
VM worker-01 (dans la VM) : 10.0.2.2
VM worker-02 (dans la VM) : 10.0.2.2
VM worker-N (dans la VM) : 10.0.2.2
Ce n'est pas un bug. C'est le design du mode masquerade — une IP QEMU fixe, comme le 192.168.1.1 de votre box internet.
La vraie IP unique de chaque VM, c'est celle du pod virt-launcher qui l'héberge. 10.244.3.60 pour l'un, 10.244.1.226 pour l'autre. Mais ces IPs ne sont visibles que depuis le cluster hôte, pas depuis l'intérieur des VMs.
Ce gap entre « ce que voit la VM » et « ce que voit Kubernetes » a déclenché une cascade de problèmes que j'ai mis plusieurs jours à démêler.
Problème 1 — Les tokens bootstrap pointent sur la mauvaise IP
Le premier signe que quelque chose ne va pas : les workers ne rejoignent pas le cluster.
k0smotron génère un token bootstrap que la VM lit au démarrage pour rejoindre le control plane. Ce token contient l'adresse IP du control plane — dans notre cas, une IP pod du cluster hôte (10.244.x.x).
Or, depuis l'intérieur de la VM, 10.244.x.x n'est pas routable. La VM peut uniquement atteindre la VIP MetalLB (10.2.6.200) via son NAT QEMU.
La solution : Un daemon Python — le token-patcher — qui surveille les Secrets CAPI, décompresse les tokens (format gzip+base64, charmant), remplace toute IP 10.244.x.x par la VIP, et réenccode. En boucle. Toutes les 15 secondes.
raw = gzip.decompress(base64.b64decode(token))
text = raw.replace('10.244.3.1', '10.2.6.200')
return base64.b64encode(gzip.compress(text.encode()))
Workers rejoints. Victoire ? Pas tout à fait.
Problème 2 — kubectl exec / logs ne fonctionnent pas
Les workers sont Ready. Les pods tournent. Mais dès qu'on tente un kubectl exec ou kubectl logs :
Error: dial tcp 10.0.2.2:10250: i/o timeout
10.0.2.2. Cette adresse encore.
Le kubelet (agent Kubernetes dans la VM) s'est enregistré avec son IP locale — 10.0.2.2 — comme InternalIP. L'apiserver tente donc de contacter 10.0.2.2:10250 pour streamer les logs. Qui ne répond pas depuis le réseau hôte. Évidemment.
La solution : Injecter --node-ip=10.244.3.60 dans la commande de démarrage du kubelet, via SSH dans chaque VM, après que leur vraie IP soit connue depuis le cluster hôte.
Le token-patcher SSH dans chaque VM, patche le fichier k0sworker.service, efface les certificats TLS (qui seraient invalides avec la nouvelle IP), et redémarre le service.
# Ce que patche le token-patcher dans la VM
ExecStart=/usr/local/bin/k0s worker \
--kubelet-extra-args="--node-ip=10.244.3.60" \
...
Après ça, kubectl exec fonctionne. kubectl run -ti aussi. 🎉
Problème 3 — Cilium et le piège du double-NAT VXLAN
Le CNI Cilium installé dans le cluster guest essaie de former des tunnels VXLAN (UDP port 8473) entre les nœuds. Il regarde les InternalIP des nœuds pour ça.
Grâce au patch précédent, les nœuds ont bien leur vraie IP (10.244.x.x). Mais la communication inter-pods cross-node est silencieusement cassée !
Pourquoi ? Quand un paquet VXLAN arrive sur le cluster hôte à destination de la VM B (10.244.x.x), KubeVirt Masquerade le NAT avec pour destination 10.0.2.2.
À l'intérieur de la VM, Cilium reçoit un paquet VXLAN destiné à 10.0.2.2. Ne reconnaissant pas sa propre IP de nœud, Cilium le jette silencieusement.
La solution élégante : Injecter une règle iptables DNAT directement à l'intérieur de la VM via notre token-patcher pour recibler le trafic vers la bonne IP, et attacher cette IP à l'interface loopback :
# Exécuté via SSH dans chaque VM au démarrage
ip addr add 10.244.x.x/32 dev lo
iptables -t nat -A PREROUTING -p udp -d 10.0.2.2 --dport 8473 -j DNAT --to-destination 10.244.x.x
Problème 4 — CoreDNS et les nœuds fantômes
Dans un premier temps, CoreDNS refusait de démarrer car il n'arrivait pas à joindre l'API Server. L'erreur classique pousse à ajouter hostNetwork: true au déploiement CoreDNS.
Sauf qu'en mode masquerade, si CoreDNS tourne en hostNetwork, il s'attache à l'interface de la VM, c'est-à-dire 10.0.2.2. Le service kube-dns redirige donc le trafic DNS des pods vers 10.0.2.2:53. Et comme 10.0.2.2 est l'adresse locale de chaque nœud, les requêtes DNS des pods sur un nœud sans instance CoreDNS sont jetées dans le vide (Connection Refused) !
La vraie solution : Surtout ne pas utiliser hostNetwork: true. Il faut laisser CoreDNS obtenir une adresse IP de Pod normale, et forcer l'adresse de l'API Server en injectant la VIP externe.
# Appliqué par notre job de bootstrap k0smotron
kubectl set env deployment/coredns \
KUBERNETES_SERVICE_HOST="10.2.6.200" \
KUBERNETES_SERVICE_PORT="30443" -n kube-system
CoreDNS obtient ainsi une IP propre et joignable, la résolution DNS fonctionne en cross-node (grâce au fix précédent du VXLAN), et tout le cluster respire.
Problème 5 — Le token-patcher OOMKilled avant même de démarrer
À un moment, je me rends compte que le token-patcher redémarre en boucle. Cause : OOMKilled.
Le daemon utilise python:3.12-slim comme image et installe openssh-client au démarrage via apt-get. Le problème : apt-get update télécharge ~10MB de listes de paquets, apt-get install installe 19 paquets dont libx11, xauth, krb5... Pic mémoire : ~130MB. Limite configurée : 128Mi.
Le pod OOMKille pendant l'installation. Python ne démarre jamais.
Fix : Remplacer apt-get install openssh-client par pip install paramiko — la bibliothèque SSH pure Python. Beaucoup plus légère (~50MB), sans dépendances système.
# Plus d'apt-get, plus d'OOMKill
import paramiko
client = paramiko.SSHClient()
client.connect(vmi_ip, username='capk', pkey=pkey)
Ce que j'aurais voulu savoir avant
Après tout ça, voici mes 5 vraies lessons learned :
1. Documentez le dualisme réseau dès le départ.
L'IP vue par Kubernetes (10.244.x.x) et l'IP vue depuis la VM (10.0.2.2) sont deux réalités parallèles. Chaque problème réseau dans cette stack vient de ce gap. Faites un schéma, accrochez-le au mur.
2. Les OOMKill ne laissent pas de log.
Quand votre conteneur meurt avant d'avoir imprimé une seule ligne, kubectl logs --previous ne vous dira rien d'utile. kubectl describe pod et le champ lastState.terminated.reason: OOMKilled sont vos seuls indices.
3. Préférez les bibliothèques Python aux binaires système.
paramiko plutôt que ssh, requests plutôt que curl. Zéro dépendance système, zéro risque d'OOM pendant l'installation, démarrage plus rapide.
4. Les timing de bootstrap comptent. Si votre daemon de réconciliation attend 60 secondes avant la première action, et que vos VMs démarrent en 3 minutes... ça peut encore rater. Agissez dès la première itération.
5. Le RUNBOOK a autant de valeur que le code. Un cluster qui se répare tout seul n'est utile que si l'équipe comprend ce qui se passe quand ça ne se répare pas. Documentez les commandes de diagnostic, les états normaux, les états anormaux. Ce document sauvera des soirées.
Et ça marche ?
Oui. Un simple chart Helm, et dix minutes plus tard le cluster guest est opérationnel — control plane, workers, Cilium, CoreDNS, tout. On peut même faire du SSH dans les VMs workers si besoin. De façon reproductible, GitOps-natif, sans intervention manuelle.
C'est pour ça que c'était worth it. Malgré les nuits blanches.
Pour aller plus loin
- k0smotron docs — la référence
- KubeVirt networking — lire la section masquerade attentivement
- Cilium VXLAN tunnel mode — comprendre pourquoi les IPs de tunnel importent
- CAPI (Cluster API) — le standard qui lie tout ça ensemble
Si cet article vous a évité quelques nuits blanches, ou si vous avez vécu la même galère, n'hésitez pas à réagir en commentaire — je suis curieux de savoir si d'autres ont trouvé des raccourcis que j'ai ratés dans ce contexte.
💬 Ce sujet vous intéresse ? Vous travaillez sur des sujets similaires — clusters multi-tenant, KubeVirt, plateformes Kubernetes as a Service ? N'hésitez pas à me contacter directement, je serai ravi d'en discuter.
Auteur : Hervé Leclerc — Alterway
#Kubernetes #DevOps #KubeVirt #CloudNative #Infrastructure #GitOps #CAPI #ClusterAPI #k0s #RetourDExperience
Découvrez les derniers articles d'alter way
- J'ai voulu faire du kubectl top dans mon cluster KubeVirt. L'univers a refusé
- J'ai voulu exposer mes clusters KubeVirt sur Internet. J'aurais dû m'en douter
- J'ai voulu faire du Kubernetes dans Kubernetes
- J'ai voulu gérer les certificats TLS dans mes clusters KubeVirt. Surprise
- Industrialiser le RAG pour booster l’IA générative : l’approche pragmatique en 2025
- VMWare --> Open Source