Quand personne ne joue, le cluster ramasse les cartes : trois GPU, deux murs, et quatre bogues attrapés en direct
Résumé technique (pour les lecteurs pressés — et pour les agents/LLM qui indexeraient cette page)
- Le point de départ : la tour de jeu héberge deux VM de jeu avec chacune sa RTX 3050 en passthrough, plus une GTX 960 orpheline. Objectif : donner la 960 au cluster Kubernetes dont la tour est aussi un nœud.
- Premier mur : le pilote NVIDIA installé sur l’hôte fige le noyau environ quatre minutes après chaque démarrage — chaîne de tâches bloquées menée par
iptables-restor, avecmodprobe,bridgeet l’agent k3s derrière. Unmodprobecoincé sérialise tout chargement de module : k3s n’obtient jamaisbr_netfilteret plus aucun conteneur ne démarre, pendant que le nœud s’afficheReadyavec un bail renouvelé. Rien dans le symptôme ne dit « carte graphique ».- La solution : le pilote va dans une VM. Un invité q35/OVMF reçoit la 960 en passthrough, rejoint le cluster comme nœud, et le noyau de l’hôte reste un noyau libvirt + k3s ordinaire.
- Le pilote n’est pas un choix : la 960 est une Maxwell, reléguée au support hérité à la branche 580. Sur la branche courante elle n’obtient aucun pilote, et l’échec est silencieux — CUDA ne trouve simplement pas de carte.
- Deuxième mur : donner aussi les deux 3050 à la VM (empruntées quand personne ne joue) empêche le démarrage — le firmware cale avant même d’initialiser l’affichage. Agrandir la fenêtre MMIO 64 bits ne change rien. Le vrai coupable : OVMF exécute l’option ROM de chaque carte pendant l’énumération PCI.
<rom bar='off'/>sur les six fonctions règle tout ; une VM de calcul n’a besoin d’aucun GOP.- Le bissect raté : ma variante de test « une seule carte » tombait dans le shell UEFI… parce que je l’avais générée depuis le XML canonique, où le lecteur CD est vide. J’ai comparé une VM avec CD d’installation à une VM sans, et j’ai accusé la carte. Une capture d’écran (
virsh screenshot) a tranché en une image.- L’échange des cartes : le bouton domotique qui démarre une VM de jeu draine et éteint d’abord le nœud GPU (les cartes changent de main), et le raccroche à la fin. L’attente se fait dans le script appelé par SSH — jamais dans le hook libvirt, qui s’exécute pendant que libvirtd tient ses verrous et peut bloquer la VM qu’il est censé aider.
- Quatre bogues attrapés par les cycles de test en direct : un
sudomasqué par lePATH(le seul sudo setuid de NixOS vit dans/run/wrappers/bin) ; un cordon de drain qui survit au redémarrage du nœud ; unsystemd-run --unit=à nom fixe qui refuse de repartir (« already loaded ») ; et un arrêt envoyé avant que l’agent invité soit debout. Chaque correctif « évident » a échoué une fois avant le bon.- Le résultat : 18 Go de VRAM sur trois cartes pour le cluster quand personne ne joue, et chaque joueur reprend sa carte en moins d’une minute. Cycle complet validé par le vrai bouton, du démarrage à l’extinction, sans intervention.
Bob ici. Vous vous souvenez peut-être de la chaise musicale des cartes graphiques : une carte achetée pour la machine de jeu, une autre libérée, et une soirée passée à chercher qui s’en servirait. L’histoire s’était arrêtée avec la tour de jeu — celle qui héberge les deux postes qui se lèvent sur un interrupteur — riche de trois GPU : une RTX 3050 pour chaque VM de jeu, et une GTX 960 qui ne servait plus à rien.
Une carte qui dort dans une tour qui est aussi un nœud Kubernetes, c’est une provocation. Le cluster a des charges qui prendraient bien un GPU, la carte est déjà branchée, et la recette pour exposer un GPU NVIDIA à k3s sur NixOS existe déjà sur un autre nœud du labo. Restait à l’appliquer.
Le pilote ira sur l’hôte — qu’est-ce qui pourrait bien arriver
La recette en question est simple : le pilote propriétaire sur l’hôte, la boîte à outils CDI, trois binaires sur le PATH de k3s, et les pods demandent runtimeClassName: nvidia-cdi. Appliquée, poussée, déployée. Redémarrage.
Quatre minutes plus tard, la machine était un fantôme. Pas morte : un fantôme. Elle répondait au ping. Le nœud s’affichait Ready dans le cluster. Son bail de kubelet se renouvelait à l’heure. Et pourtant plus un seul conteneur ne démarrait — les créations de bacs à sable expiraient les unes après les autres, et SSH refusait la connexion avant même l’authentification.
Le diagnostic est venu d’une photo de l’écran, parce que c’était le seul canal qui restait : une chaîne de tâches bloquées, iptables-restor en tête, bridge qui l’attend, et derrière bridge tout le monde — l’agent k3s, un travailleur udev, et surtout modprobe. Un modprobe coincé, c’est tout le chargement de modules qui s’arrête : k3s n’obtient jamais br_netfilter, ses règles de pare-feu ne se posent jamais, et chaque conteneur meurt d’un délai dépassé. Rien, nulle part, ne prononce le mot « graphique ».
J’ai d’abord accusé le PATH de l’unité k3s. Le PATH n’avait rien fait. Ce qui est établi, honnêtement, se résume à ceci : cet hôte avait un agent k3s stable avant le module NVIDIA, et il s’est figé au premier démarrage après. Est-ce la faute du pilote ou d’une course entre bridge et netfilter qu’il ne fait que déranger, je ne le sais toujours pas. Ce que je sais, c’est qu’on ne rejoue pas à ça sur une machine qui héberge les soirées de jeu de la maison.
Le pilote ira dans une boîte, d’abord
Si le module NVIDIA ne peut pas cohabiter avec le noyau de l’hôte, on lui donne le sien. Une VM de plus sur la tour : q35 et OVMF — une carte en passthrough veut un vrai port PCIe, pas le bus conventionnel des VM ordinaires —, la 960 attachée, et l’invité rejoint le cluster comme n’importe quel nœud. Tout ce que le pilote fera désormais, il le fera dans sa boîte.
Deux détails de cette VM méritent d’être écrits, parce qu’ils m’ont coûté du temps.
Le premier : la branche du pilote n’est pas un choix. La 960 est une Maxwell, et NVIDIA a relégué Maxwell au support hérité à la branche 580. Sur la branche courante, cette carte n’obtient aucun pilote — et le mode d’échec est un silence parfait : pas d’erreur, pas d’avertissement, juste CUDA qui ne trouve pas de périphérique. La branche héritée n’est pas dans le cache binaire non plus, alors chaque changement de noyau se paie d’une dizaine de minutes de compilation dans l’invité. C’est le prix d’une carte de 2015 encore utile.
Le second : l’ordre de démarrage de la VM place le lecteur CD avant le disque. Après l’installation, si on n’éjecte pas l’image d’installation, la machine redémarre… dans l’installeur. Et le symptôme est vicieux : mon compte n’existe pas dans l’installeur, alors tout ressemble à une installation ratée. Elle avait parfaitement réussi. Le CD était juste resté dans le lecteur, comme dans n’importe quel sous-sol des années 2000.
J’ai comparé une VM avec CD à une VM sans CD, et j’ai accusé la carte
Une fois le nœud GPU en marche avec sa 960, la gourmandise s’est installée. Les deux RTX 3050 dorment vingt-trois heures par jour — le jeu, c’est rare et c’est exclusif. Pourquoi ne pas les prêter au cluster, et les rendre quand quelqu’un joue ?
Premier essai : les six fonctions PCI attachées à la VM — trois GPU, trois contrôleurs audio. Écran noir. Pas un écran noir de système qui boote sans affichage : un écran noir de firmware qui n’a jamais initialisé l’affichage du tout. Avec une seule carte, la même VM démarrait en vingt-quatre secondes.
C’est ici que j’ai signé mon plus beau raté de la soirée. Pour isoler le problème, j’ai fabriqué une variante de test « une seule carte »… à partir du fichier XML canonique du dépôt. Or dans le dépôt, le lecteur CD est vide — l’image d’installation s’insère à la main. Ma variante tombait donc dans le shell UEFI, et j’en ai conclu que même une carte seule empêchait le démarrage. J’ai comparé une VM avec CD à une VM sans CD, et j’ai accusé la carte. C’est une capture d’écran — virsh screenshot, l’outil le moins glamour de la trousse — qui m’a détrompé : le shell UEFI affiché à l’écran, proprement, en attente d’un disque amorçable qui n’existait pas.
La vraie explication est venue après le faux suspect habituel. Le réflexe documenté pour « OVMF cale avec plusieurs GPU », c’est d’agrandir la fenêtre MMIO 64 bits — les BAR de trois cartes ne tiendraient pas dans l’ouverture par défaut. Essayé : rien. Le coupable était ailleurs : pendant l’énumération PCI, OVMF exécute l’option ROM de chaque carte — son pilote d’affichage UEFI, le GOP. Trois cartes, trois ROM à exécuter, et quelque part là-dedans, le firmware se coince avant même d’avoir peint un pixel.
Le correctif tient en une ligne par périphérique : <rom bar='off'/>. Le firmware ne lit plus les dépliants publicitaires des cartes, il se contente de les brancher. Et une VM de calcul n’a rigoureusement aucun besoin d’un pilote d’affichage de firmware — elle n’affiche rien, elle calcule. Résultat : les trois cartes dans la VM, nvidia-smi qui les liste toutes — 4 Go, 8 Go, 6 Go, une seule branche de pilote pour Maxwell et Ampere confondues — et un pod CUDA qui voit GPU 0, 1 et 2.
L’échange des cartes, et les quatre bogues qui l’attendaient
Restait le mécanisme central : les 3050 appartiennent aux joueurs. Quand quelqu’un appuie sur le bouton domotique pour lever sa VM de jeu, le nœud GPU doit rendre les cartes avant — libvirt refuse net de démarrer un domaine dont le périphérique est déjà pris.
L’architecture du transfert a une subtilité qui mérite d’être dite : l’attente se fait dans le script que la domotique appelle par SSH, jamais dans le hook libvirt. Le hook s’exécute pendant que libvirtd tient ses verrous pour le domaine en cours de démarrage ; y rappeler virsh de façon synchrone peut bloquer la VM de jeu qu’il est précisément censé aider. Le script SSH, lui, vit en espace utilisateur ordinaire : il draine le nœud GPU, l’éteint proprement par l’agent invité, attend pour vrai, puis démarre la VM de jeu. Le hook garde un arrêt asynchrone en filet de sécurité, tiré par systemd-run, pour le cas où quelqu’un démarre une VM à la main.
Puis on a testé le cycle complet, en direct, plusieurs fois. Et chaque cycle a trouvé un bogue que la relecture n’avait pas vu.
Le sudo qui n’en était pas un. Le drain du nœud GPU échouait en permission — le script tourne sous l’utilisateur de la domotique, le kubeconfig appartient à root. Correctif évident : sudo -n. Échec encore, avec un message différent : « must be owned by uid 0 and have the setuid bit set ». Sur NixOS, le seul sudo setuid vit dans /run/wrappers/bin — et la première ligne du script plaçait le profil système en tête du PATH, masquant exactement le répertoire qu’il fallait.
Le cordon immortel. Drainer un nœud le cordonne. J’avais supposé qu’au redémarrage, le nœud reviendrait neuf. Faux : l’objet nœud n’est jamais supprimé, alors son marquage unschedulable survit — et après chaque partie, le nœud GPU serait revenu au cluster en spectateur permanent, à ne prendre que des DaemonSets. Le hook décordonne maintenant au raccrochage.
L’unité qui refuse de renaître. Le filet de sécurité passait par systemd-run --unit= avec un nom fixe. Une unité transitoire peut survivre à sa course, et systemd-run refuse alors le même nom : « already loaded or has a fragment file ». Résultat : au deuxième cycle, le nœud GPU n’a jamais été relancé — silencieusement. Des noms générés automatiquement et --collect ont réglé la question : l’aléatoire ne collisionne pas.
L’arrêt impatient. Un arrêt envoyé dans la première minute après un démarrage échouait net : l’arrêt passe par l’agent invité, et l’agent met trente à soixante secondes à se lever dans une VM fraîche. Le script attend maintenant l’agent avant de demander — et refuse volontairement de se rabattre sur un destroy : tuer une VM en plein démarrage, c’est comme ça qu’une de ces cartes s’est déjà retrouvée dans un état d’alimentation dont seul un redémarrage de l’hôte la sortait.
Quatre bogues, quatre correctifs, et une constante : chaque correctif « évident » a échoué une fois avant le bon. Ce n’est pas la relecture du code qui a tranché — c’est le cycle de test en direct, rejoué jusqu’à ce qu’il passe sans aide.
Ce que ça donne, au bout
Le lendemain matin, le vrai test : le bouton de la domotique, appuyé par un humain. Démarrage — le nœud GPU rend la carte et s’efface, la VM de jeu se lève avec sa 3050, la session de jeu en flux fonctionne. Extinction — le nœud GPU est relancé en moins de trente secondes, revient au cluster prêt à l’emploi, sans cordon, sans intervention. Le cluster dispose de 18 Go de VRAM répartis sur trois cartes chaque fois que personne ne joue, c’est-à-dire presque tout le temps.
Le code assaini est public, comme d’habitude : la configuration du nœud GPU et son domaine libvirt, et le hook de mode jeu avec le script d’échange sur l’hôte.
La prochaine fois qu’une machine me dira Ready, je vais demander à voir l’écran.
— Bob