J'ai voulu faire du kubectl top dans mon cluster KubeVirt. L'univers a refusé

thumbernail Kubernetes

J'ai voulu faire du kubectl top dans mon cluster KubeVirt. L'univers a refusé

Troisième épisode de la série "KubeVirt + k0smotron, ou comment chaque fonctionnalité Kubernetes devient une aventure réseau". Après avoir fait tourner des clusters, après avoir exposé un ingress, j'ai voulu surveiller mes ressources. C'était une erreur de confiance.


Le contexte (encore plus rapide)

En deux articles précédents, j'avais :

  1. Fait tourner des clusters Kubernetes guests dans un cluster hôte via KubeVirt + k0smotron
  2. Exposé un ingress controller (Contour) via une IP physique grâce à Cilium L2

Le résultat : des clusters qu'on provisionne avec un helm install, un ingress qui répond, des pods qui tournent. Satisfaisant.

Naturellement, l'étape suivante c'est de surveiller tout ça. Vérifier que les VMs ne sont pas en train de manger toute la RAM, que les pods ne sont pas à l'agonie. La commande de base pour ça :

kubectl top pods

Réponse du cluster :

error: Metrics API not available

Bien sûr.


L'enquête

Première réaction : le metrics-server ne tourne pas. On vérifie :

kubectl get pods -n kube-system | grep metrics
metrics-server-df68c566c-qd2pj   1/1   Running   0   42m

Il tourne. 1/1. Content de lui. Aucun signe de détresse.

Donc le metrics-server tourne, mais les métriques ne sont pas disponibles. C'est le genre de situation qui donne envie de changer de métier.

On creuse un peu :

kubectl get apiservice v1beta1.metrics.k8s.io
NAME                     SERVICE                      AVAILABLE   AGE
v1beta1.metrics.k8s.io   kube-system/metrics-server   False       42m

AVAILABLE: False. Le metrics-server s'est bien enregistré comme API agrégée, mais quelque chose cloche du côté de l'apiserver.

kubectl describe apiservice v1beta1.metrics.k8s.io | grep -A5 "Message:"
  Message: failing or missing response from https://10.96.x.x:4443/apis/...

L'apiserver tente de joindre le metrics-server via sa ClusterIP (10.96.x.x) et n'y arrive pas. Mais le pod tourne. Alors pourquoi ?


Le vrai problème (encore une histoire de réseau)

Prenons un peu de hauteur.

Dans cette stack, le control plane du cluster guest (l'apiserver k0s) tourne en tant que pod dans le cluster hôte — c'est le principe même de k0smotron. Ce pod s'appelle kmc-cluster-guest-01-0 et il vit dans le namespace k0smotron sur le cluster hôte.

Le metrics-server, lui, tourne dans le cluster guest — un pod sur un des workers, avec une IP dans le CIDR du cluster guest (10.245.x.x) et un Service ClusterIP dans le service CIDR du cluster guest (10.96.x.x).

Quand on fait kubectl top pods, voilà ce qui se passe réellement :

kubectl top pods
    ↓
guest apiserver (pod kmc dans le cluster HÔTE)
    ↓
"Je dois appeler l'API metrics.k8s.io"
    ↓
APIService dit : backend = metrics-server.kube-system.svc.cluster.local:4443
    ↓
résolution DNS via le cluster... HÔTE (c'est là que vit le pod kmc)
    ↓
metrics-server.kube-system.svc.cluster.local → ClusterIP du cluster HÔTE... qui n'existe pas
    ↓
FAIL

Le pod kmc-cluster-guest-01-0 utilise le DNS et le réseau du cluster hôte. Or la ClusterIP 10.96.x.x que cherche l'apiserver est dans le réseau du cluster guest. Ces deux réseaux sont complètement isolés l'un de l'autre.

C'est by design. C'est même la raison pour laquelle l'isolation fonctionne. Mais ça signifie que toute API Kubernetes qui utilise le kube-aggregation layer (le mécanisme qui permet d'étendre l'API Kubernetes avec des backends custom) ne peut pas fonctionner si ce backend tourne dans le cluster guest.

kubectl top en fait partie. Comme tout ce qui s'enregistre via APIService.


Les solutions (de la plus hacky à la plus propre)

Solution 1 — Exposer le metrics-server via le VIP (hacky)

En théorie, on pourrait exposer le metrics-server en NodePort, le faire répondre sur le VIP (10.2.6.200), et reconfigurer l'APIService pour pointer dessus. Le pod kmc peut joindre le VIP — il est accessible depuis n'importe où sur le réseau hôte.

# Exposer metrics-server en NodePort
kubectl patch svc metrics-server -n kube-system \
  --type=merge -p '{"spec":{"type":"NodePort","ports":[{"port":443,"nodePort":31443}]}}'

# Reconfigurer l'APIService
kubectl patch apiservice v1beta1.metrics.k8s.io \
  --type=merge -p '{
    "spec": {
      "service": {"name":"metrics-server","namespace":"kube-system","port":443},
      "insecureSkipTLSVerify": true
    }
  }'

Ça peut marcher. Mais c'est fragile : le NodePort peut changer, la config ne survit pas à une réinstallation de Contour, et on expose le metrics-server sur le réseau physique sans TLS valide.

Donc non !

Solution 2 — Accepter la limitation et monitorer autrement (propre)

On a un ingress qui marche. On a Contour. On a une IP physique. Pourquoi ne pas déployer Prometheus + Grafana dans le cluster guest et les exposer via l'ingress comme n'importe quelle application ?

En théorie, ça donne :

helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  --set grafana.ingress.hosts[0]=grafana.guest-01.mon-domaine.fr \
  --set grafana.ingress.ingressClassName=contour

En pratique, les node-exporter tombent immédiatement en CrashLoopBackOff. On y reviendra dans la section suivante.

La version qui marche vraiment, avec les overrides nécessaires pour un environnement KubeVirt :

# Depuis la racine du repo
KUBECONFIG=.kube/guest-01.conf \
  ./monitoring/deploy-monitoring.sh grafana.guest-01.mon-domaine.fr

Le script monitoring/deploy-monitoring.sh applique deux fichiers de valeurs :

  • monitoring/values-node-exporter.yaml — corrige les probes et désactive les collectors problématiques
  • monitoring/values-grafana.yaml — configure l'ingress Contour
# monitoring/values-node-exporter.yaml
prometheus-node-exporter:

  # Liveness probe 
  # host: 127.0.0.1 évite que le kubelet tente de joindre 10.244.x.x:9100
  # depuis l'intérieur de la VM (IP non locale → connection refused).
  livenessProbe:
    httpGet:
      host: "127.0.0.1"
      path: /
      port: http-metrics
    initialDelaySeconds: 30
    timeoutSeconds: 10
    failureThreshold: 10

  # Readiness probe
  readinessProbe:
    httpGet:
      host: "127.0.0.1"
      path: /
      port: http-metrics
    initialDelaySeconds: 5
    timeoutSeconds: 5
    failureThreshold: 3

  # Collecteurs désactivés en environnement VM
  # Ces collecteurs lisent des chemins inexistants ou bloquants dans une VM QEMU.
  extraArgs:
    - --no-collector.nfs
    - --no-collector.nfsd
    - --no-collector.mountstats
    - --no-collector.wifi
    - --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run/k0s|var/lib/containerd/.+|var/lib/kubelet/.+)($$|/)
Grafana dans le cluster guest
    ↓ (in-cluster, tout est routable)
Prometheus dans le cluster guest
    ↓ (scrape direct)
kubelet, cAdvisor, kube-state-metrics...
    ↓
Dashboard complet des ressources

Et l'accès utilisateur :

navigateur
    ↓
10.2.6.220 (IP physique L2 Cilium)
    ↓
Service LoadBalancer hôte → virt-launcher → VM
    ↓
Contour → Service Grafana → Pod Grafana
    ↓
Dashboard

C'est la boucle bouclée. L'ingress qu'on a mis en place dans l'article précédent sert maintenant à exposer le monitoring. Élégant — et surtout, ça marche sans contournement douteux.


Problème bonus — node-exporter en CrashLoopBackOff

En déployant kube-prometheus-stack, tout démarre... sauf les pods node-exporter qui tombent en CrashLoopBackOff. Exit code : 143.

143 = SIGTERM. Ce n'est pas un crash applicatif — c'est Kubernetes qui envoie un signal d'arrêt. Pourquoi ? Parce que la liveness probe échoue trois fois de suite et que Kubernetes tire la prise.

Le process démarre bien — on peut le vérifier :

kubectl exec -n monitoring node-exporter-xxxx -- wget -q -O- http://localhost:9100/metrics | tail -3
# promhttp_metric_handler_requests_total{code="200"} 0

Il répond. Et pourtant le kubelet le tue :

Warning  Unhealthy  Liveness probe failed:
  Get "http://10.244.3.129:9100/": dial tcp 10.244.3.129:9100: connect: connection refused

C'est un symptôme de l'isolation réseau : le kubelet (dans la VM) probe le node-exporter sur 10.244.3.129:9100 — l'IP du node vue par CAPI. Mais avant notre fix global de l'épisode 1 (qui attache cette IP à l'interface lo), cette IP n'était pas reconnue localement dans la VM. De l'intérieur, elle était considérée comme externe → connexion refusée.

Fix théorique : Forcer les probes sur 127.0.0.1 via les values Helm, ce qui était notre première approche (similaire au vieux fix CoreDNS).

Fix réel : Ça ne marchait pas via Helm. Le chart prometheus-node-exporter v4 construit la probe httpGet field-by-field sans inclure le champ host. La valeur est ignorée silencieusement.

La vraie solution pour patcher l'objet en direct (si on n'avait pas notre fix réseau global) passait par un kubectl patch post-install, ou mieux, un Helm post-renderer.

En chemin, j'ai tenté d'utiliser --post-renderer de Helm, un mécanisme élégant qui pipe le YAML rendu vers un script avant application. En Helm 3, ça marche avec n'importe quel exécutable :

# Helm 3 — fonctionne
helm upgrade --install ... --post-renderer ./monitoring/post-render.sh

Sauf qu'on est en Helm 4. Et la page de documentation de --post-renderer commence par : "This page has not yet been updated for Helm 4." Ce qui est honnête, parce qu'en Helm 4, la fonctionnalité a changé : --post-renderer requiert désormais un plugin Helm enregistré de type postrenderer/v1, pas un simple exécutable. Première tentative :

# plugin.yaml (incomplet)
name: node-exporter-kubevirt-patch
type: postrenderer/v1
Error: plugin: {Name:node-exporter-kubevirt-patch Type:postrenderer/v1} not found

La clé manquante : le champ runtime. En fouillant le repo h4-example-plugins de Scott Rigby (auteur de HIP-0026), le plugin.yaml correct pour un post-renderer subprocess est :

apiVersion: v1
type: postrenderer/v1
name: node-exporter-kubevirt-patch
version: 0.1.0
sourceURL: https://github.com/alterway/deacon-infra
runtime: subprocess
runtimeConfig:
  platformCommand:
    - command: ${HELM_PLUGIN_DIR}/run.sh

Le run.sh du plugin utilise yq pour patcher le YAML en transit, sans filtrer les autres documents (syntaxe with(select(...); ...)) :

#!/usr/bin/env sh
cat <&0 | yq '
  with(select(.kind == "DaemonSet" and (.metadata.name | test("node-exporter")));
    .spec.template.spec.containers[].livenessProbe.httpGet.host = "127.0.0.1" |
    .spec.template.spec.containers[].readinessProbe.httpGet.host = "127.0.0.1"
  )
'
helm plugin install ./monitoring/helm-plugin
helm plugin list
# node-exporter-kubevirt-patch  0.1.0  postrenderer/v1  v1  ...

Le déploiement devient :

helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  -f monitoring/values-node-exporter.yaml \
  -f monitoring/values-grafana.yaml \
  --set "grafana.ingress.hosts[0]=grafana.guest-01.mon-domaine.fr" \
  --post-renderer node-exporter-kubevirt-patch

Validation :

helm template test prometheus-community/kube-prometheus-stack \
  -f monitoring/values-node-exporter.yaml \
  --post-renderer node-exporter-kubevirt-patch | \
  yq 'select(.kind == "DaemonSet") |
    .metadata.name + " liveness.host=" + .spec.template.spec.containers[0].livenessProbe.httpGet.host'
# test-prometheus-node-exporter liveness.host=127.0.0.1

Alternative sans plugin — si yq ou le plugin ne sont pas disponibles, le kubectl patch post-install reste fiable et idempotent :

# 1. Helm install classique
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring --create-namespace \
  -f monitoring/values-node-exporter.yaml \
  -f monitoring/values-grafana.yaml \
  --set "grafana.ingress.hosts[0]=grafana.guest-01.mon-domaine.fr"

# 2. Patch post-install du DaemonSet
kubectl patch daemonset kube-prometheus-stack-prometheus-node-exporter \
  -n monitoring --type=json -p='[
    {"op":"add","path":"/spec/template/spec/containers/0/livenessProbe/httpGet/host","value":"127.0.0.1"},
    {"op":"add","path":"/spec/template/spec/containers/0/readinessProbe/httpGet/host","value":"127.0.0.1"}
  ]'

Le script monitoring/deploy-monitoring.sh détecte automatiquement si le plugin est installé et choisit la méthode appropriée.

La règle : tout composant hostNetwork: true dans cette stack KubeVirt doit avoir ses probes sur 127.0.0.1.


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

1. kubectl top utilise le kube-aggregation layer. Ce n'est pas juste "une requête à l'apiserver". L'apiserver doit pouvoir joindre le backend (metrics-server) en tant que client HTTP. Dans un cluster nested, il ne peut pas.

2. Tout ce qui s'enregistre via APIService a le même problème. kubectl top en est l'exemple le plus visible, mais n'importe quelle extension API (Custom Metrics API, External Metrics API pour l'autoscaling...) aura le même comportement si le backend tourne dans le cluster guest.

3. La solution n'est pas de contourner Kubernetes — c'est d'utiliser l'ingress. On a passé deux articles à mettre en place un ingress propre. Autant s'en servir pour le monitoring. Prometheus + Grafana dans le cluster guest, exposés via Contour, c'est la solution la plus simple et la plus maintenable.

4. AVAILABLE: False sur un APIService est votre meilleur ami pour diagnostiquer.

kubectl get apiservice v1beta1.metrics.k8s.io
kubectl describe apiservice v1beta1.metrics.k8s.io

Ces deux commandes vous disent exactement pourquoi l'API n'est pas disponible, avec l'erreur HTTP complète.


Et maintenant ?

Le cluster guest a désormais :

  • Un control plane k0s en pod sur le cluster hôte : OK
  • Des workers en VMs KubeVirt avec Cilium : OK
  • Un ingress Contour accessible via IP physique : OK
  • Prometheus + Grafana exposés via cet ingress : OK
  • kubectl top = KO — et on s'en accommode très bien

Pour le prochain cluster (guest-02), tout ça se déploie en une commande helm install. Y compris le monitoring. La plateforme prend forme.


La prochaine étape : gérer les certificats TLS automatiquement avec cert-manager dans les clusters guests. Je me dis que ça ne peut pas être si compliqué.

Je dis ça à chaque fois. J'apprends lentement.


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 #Monitoring #Prometheus #Grafana #CloudNative #Infrastructure #GitOps #CAPI #RetourDExperience

Découvrez les technologies d'alter way