J'ai voulu exposer mes clusters KubeVirt sur Internet. J'aurais dû m'en douter

thumbernail Kubernetes

J'ai voulu exposer mes clusters KubeVirt sur Internet. J'aurais dû m'en douter

Suite de "J'ai voulu faire du Kubernetes dans Kubernetes" — ou comment exposer un ingress controller dans un cluster-dans-un-cluster m'a appris tout ce que je ne voulais pas savoir sur iptables DNAT, les tunnels VXLAN, et les labels Kubernetes.


Le contexte (rapide, promis)

Dans le premier article, j'avais réussi à faire tourner des clusters Kubernetes guests dans un cluster hôte, via KubeVirt + k0smotron. Les workers en VMs, le control plane en pods, le tout orchestré par CAPI. Propre sur le papier. Chaotique dans la réalité réseau.

À la fin, on avait des clusters qui tournaient, des workers qui rejoignaient, kubectl exec qui fonctionnait. Victoire déclarée, champagne (mental) débouché.

Sauf qu'un cluster sans ingress, c'est un peu comme un restaurant sans porte d'entrée. Le cuisinier est là, les tables aussi, mais personne ne peut entrer.

Deuxième épisode, donc.


L'idée (toujours naïve)

L'objectif : déployer automatiquement un ingress controller dans chaque cluster guest au bootstrap, et l'exposer via une IP physique sur le réseau du datacenter.

J'avais choisi Contour (basé sur Envoy Proxy) pour ses performances et sa conformité Gateway API. Le plan semblait simple :

  1. Contour se déploie dans le cluster guest
  2. Son Service passe en NodePort (ports fixes)
  3. Un Service LoadBalancer côté cluster hôte forward le trafic vers les VMs
  4. Cilium fait de la L2 announcement (ARP) pour l'IP physique
  5. curl http://10.2.6.220/ → ça marche

Cinq étapes. Combien de fois est-ce que j'ai pensé "cinq étapes, c'est raisonnable" avant que ça tourne mal ?


L'architecture finale (celle qui marche)

Avant de parler des problèmes, voilà l'architecture finale. C'est quand même une belle architecture :

Internet / datacenter
    ↓
10.2.6.220 (IP physique, annoncée par ARP via Cilium L2 Announcement)
    ↓
Service LoadBalancer — cluster HÔTE, namespace k0smotron
(Cilium IPAM alloue l'IP depuis le pool 10.2.6.220/29)
    ↓
Pods virt-launcher (les "boîtes" qui contiennent les VMs workers)
(sélectionnés par label CAPI : cluster.x-k8s.io/cluster-name=cluster-guest-01)
    ↓
KubeVirt DNAT iptables (dans le network namespace de chaque virt-launcher)
port 30080 → 10.0.2.2:30080 (IP interne QEMU de la VM)
    ↓
NodePort 30080 dans la VM → Service Envoy → Pod applicatif

Quatre couches de NAT. C'est beau comme une poupée russe qui fait des iptables.

Et le déploiement de Contour ? Automatique, via le manifest watcher de k0s : au démarrage du cluster guest, k0s applique un Job qui fait kubectl apply -f https://projectcontour.io/quickstart/contour.yaml et patche le Service résultant en NodePort fixe. Zéro intervention manuelle.


Problème 1 — Les pods Envoy sont à 1/2. Pourquoi ?

Contour déploie Envoy comme DaemonSet avec deux containers : le proxy principal et un shutdown-manager pour le graceful drain. Un pod 2/2 = tout va bien. Un pod 1/2 = quelque chose cloche.

J'avais 3 pods Envoy sur 3 nœuds : un 2/2, deux 1/2.

Pattern suspect. Le pod 2/2 tournait sur le même nœud que les pods Contour. Les pods 1/2 étaient sur des nœuds différents.

Cause réelle : Envoy se connecte à Contour via le protocole xDS (gRPC) pour récupérer sa configuration. Si la connexion xDS échoue, Envoy démarre mais sans configuration — y compris sans son health check endpoint sur le port 8002. La readiness probe échoue → container NotReady → pod 1/2.

Et pourquoi xDS échouait-il pour les pods cross-nœuds ? Parce que le trafic inter-nœuds était silencieusement cassé.

Le CNI Cilium dans le cluster guest utilise des tunnels VXLAN pour router les paquets entre nœuds. Il encapsule les paquets dans des datagrammes UDP et les envoie à l'IP du nœud destination.

Dans notre cas, les "nœuds" sont des VMs derrière du masquerade QEMU. Leur IP depuis l'extérieur, c'est l'IP du pod virt-launcher (10.244.x.x). Cilium le sait (le token-patcher patche les objets CiliumNode). Il envoie donc ses paquets VXLAN vers 10.244.x.x.

Ces paquets arrivent au virt-launcher pod. Et là, KubeVirt vérifie : ce port UDP est-il déclaré dans la spec VMI ? S'il ne l'est pas, le paquet est dropé en silence.

Fix 1 : Déclarer le port VXLAN dans la spec VMI masquerade.

# workers.yaml — spec.domain.devices.interfaces[0].ports
- name: vxlan
  port: 8473   # tunnel-port dans cilium-config (pas 8472 — vérifiez !)
  protocol: UDP

Détail crucial : le port VXLAN configuré dans notre Cilium est 8473, pas 8472 (le défaut historique).

Fix 2 : Corriger l'IP de destination avec iptables (le fameux double-NAT évoqué dans l'épisode 1). Car même en ouvrant le port 8473, KubeVirt traduit l'IP de destination en 10.0.2.2. La règle iptables DNAT injectée au démarrage par notre token-patcher est ce qui permet enfin au paquet d'arriver intact jusqu'à Cilium.


Problème 2 — Le token-patcher ne peut plus SSH dans les VMs

Le token-patcher doit SSH dans chaque VM pour injecter --node-ip=10.244.x.x dans le service k0s. Sans ça, le kubelet s'enregistre avec 10.0.2.2 comme InternalIP — et on retombe dans tous les problèmes décrits dans le premier article.

Après l'ajout du port VXLAN, les logs montraient :

WARN SSH nodeip patch cluster-guest-01-...: Unable to connect to port 22

Même cause, même coupable : port 22 (SSH) non déclaré dans la spec VMI masquerade. Le token-patcher tente de se connecter à 10.244.x.x:22 — le virt-launcher reçoit le paquet TCP mais n'a pas de règle DNAT pour le port 22 → connexion refusée.

On a aussi eu une surprise sur le type de clé SSH :

WARN SSH key parse error: encountered EC key, expected OPENSSH key

CAPI génère des clés ECDSA pour les VMs. Le code du token-patcher ne gérait que RSA et Ed25519. Deux lignes de paramiko plus tard, problème résolu.

La liste complète des ports à déclarer — et pourquoi chacun existe :

Port Proto Pourquoi
22 TCP SSH → token-patcher patche --node-ip dans les VMs
10250 TCP Kubelet → kubectl logs/exec depuis l'apiserver
8473 UDP VXLAN Cilium → trafic inter-pods cross-nœud
30080 TCP NodePort HTTP ingress controller
30443 TCP NodePort HTTPS ingress controller

Chacun a sa propre histoire. Chacun a causé une panne silencieuse avant d'être découvert.


Problème 3 — Le rolling update ne se déclenche pas

Chaque fois qu'on ajoute un port dans la spec VMI, les VMs existantes doivent être recréées pour que la nouvelle spec s'applique.

Dans CAPI, la ressource qui décrit la VM s'appelle KubevirtMachineTemplate. Quand Helm met à jour ce template in-place, CAPI ne déclenche aucun rolling update — les VMs existantes sont nées avec l'ancienne spec et ignorent les changements.

CAPI déclenche un rolling update uniquement quand le nom du template change dans le MachineDeployment.

Solution : faire dépendre le nom du template d'un hash des paramètres d'infrastructure. Quand les paramètres changent, le hash change, Helm crée un nouveau template, le MachineDeployment détecte le nouveau nom, et lance un rolling update.

cluster-guest-01-worker-masq-a3f7c2b1  ← ancien (ports 10250 seulement)
cluster-guest-01-worker-masq-d8f01a2d  ← après ajout port 22 + 8473
cluster-guest-01-worker-masq-3c91e7f2  ← après templateRevision: 2

Et pour les changements hardcodés dans le chart (qui n'affectent pas les values) : une valeur workers.templateRevision que l'on incrémente manuellement pour forcer un nouveau hash. GitOps-compatible, déclaratif, traçable.


Problème 4 — Le Service LoadBalancer ne sélectionne aucun pod

  • IP allouée : OK
  • ARP qui répond : OK
  • curl http://10.2.6.220/ → connexion refusée : KO.
kubectl get endpoints cluster-guest-01-ingress-lb -n k0smotron
# cluster-guest-01-ingress-lb   <none>

Endpoints vides. Le Service ne sélectionne aucun pod virt-launcher.

Le selector du Service était :

selector:
  kubevirt-cluster: cluster-guest-01   # ← défini dans virtualMachineTemplate.metadata.labels

Ce label était bien défini dans le KubevirtMachineTemplate. Sauf que KubeVirt ne propage pas virtualMachineTemplate.metadata.labels aux pods virt-launcher. Les pods ont leurs propres labels — ceux injectés par KubeVirt lui-même et par CAPI.

En regardant les vrais labels des pods virt-launcher :

cluster.x-k8s.io/cluster-name=cluster-guest-01   ← CAPI l'ajoute automatiquement
cluster.x-k8s.io/role=worker
kubevirt.io=virt-launcher
kubevirt.io/vm=cluster-guest-01-pool-worker-01-xxxx
...

La solution était là depuis le début :

selector:
  cluster.x-k8s.io/cluster-name: cluster-guest-01
  cluster.x-k8s.io/role: worker

Un helm upgrade, les endpoints se remplissent :

curl -H "Host: hello.guest-01.local" http://10.2.6.220/
hello from guest-01

Ce que j'aurais voulu savoir avant (épisode 2)

1. Chaque port entrant dans une VM KubeVirt masquerade doit être déclaré explicitement. SSH (22), kubelet (10250), VXLAN (8473), NodePorts applicatifs. Sans déclaration, pas de règle DNAT iptables, paquet dropé en silence. Faites-en une checklist dès le départ.

2. Le port VXLAN de Cilium n'est pas forcément 8472. Lisez tunnel-port dans le ConfigMap cilium-config du cluster guest avant de déclarer quoi que ce soit.

3. Les labels virtualMachineTemplate.metadata.labels ne vont pas sur les pods virt-launcher. Utilisez les labels CAPI (cluster.x-k8s.io/cluster-name, cluster.x-k8s.io/role) — présents automatiquement, gérés par CAPI, fiables.

4. Changer un KubevirtMachineTemplate ne recycle pas les VMs. Utilisez un hash dans le nom du template. Ajoutez un templateRevision dans vos values pour les changements hardcodés dans le chart.

5. Le "ça marche sur un nœud, pas sur les autres" est un signal Cilium. Quand un pod fonctionne et que ses homologues sur d'autres nœuds échouent silencieusement, regardez les tunnels VXLAN et les CiliumNode. C'est presque toujours un problème d'IP de tunnel — ou de port non déclaré.


Et maintenant ?

helm upgrade guest-01 charts/kubevirt-cluster \
  -f instances/guest-01/values.yaml \
  --set ingress.enabled=true \
  --set ingress.loadBalancerIP=10.2.6.220 \
  -n k0smotron

Dix minutes plus tard : Contour déployé, Envoy 2/2 partout, IP physique opérationnelle, premier curl qui répond. Pour un deuxième cluster (guest-02) : changer le nom et l'IP, tout le reste se déploie identiquement.

C'est ce qu'on appelle l'industrialisation — quand les nuits blanches du premier cluster deviennent de la documentation pour les suivants.


La prochaine étape : automatiser la rotation des certificats TLS dans le cluster guest. Je me dis que ça ne peut pas être si compliqué.

Je dis ça à chaque fois.


Ce sujet vous intéresse ? Clusters multi-tenant, KubeVirt, plateformes Kubernetes as a Service, automatisation de bootstrap ? N'hésitez pas à me contacter directement.

Hervé Leclerc — Alterway


#Kubernetes #KubeVirt #Ingress #Contour #Cilium #CloudNative #Infrastructure #GitOps #CAPI #RetourDExperience

Découvrez les technologies d'alter way