Série “Un cluster Kubernetes avec GPU pour faire de l’inférence LLM”
- Déployer un cluster Kubernetes immuable avec Talos sur Proxmox
- Passer un GPU NVIDIA dans un cluster Kubernetes (cet article)
- Piloter un cluster Kubernetes en GitOps avec Flux (En cours d’écriture)
- Inférence LLM auto-hébergée sur Kubernetes (En cours d’écriture)
Introduction
Le cluster Talos déployé dans la partie 1 est opérationnel, mais le nœud talos-gpu-01 ne voit pour l’instant aucune carte graphique. Dans cet article, je détaille comment j’ai rendu une GeForce RTX 3050 utilisable par les pods Kubernetes, en partant de l’hôte Proxmox. Le travail se découpe en quatre étapes : détacher la carte de l’hôte, l’attacher à la VM avec OpenTofu, charger les modules NVIDIA dans Talos, puis exposer le GPU au scheduler Kubernetes.

Le chemin du GPU, de l'hôte Proxmox au pod Kubernetes
Avant de passer aux commandes, j’explique ce que fait le passthrough, d’où viennent la plupart des contraintes rencontrées ensuite, comme la désactivation du ballooning ou la vérification des groupes IOMMU.
Contexte
L’objectif final de cette série est de faire tourner de l’inférence LLM en local dans mon homelab, ce qui demande un GPU accessible depuis le cluster. Pour donner une carte graphique à une VM, il existe deux grandes approches : la partager entre plusieurs VMs avec du vGPU, ou la dédier entièrement à une seule VM avec du PCI passthrough.
Dans mon cas, le choix était vite fait. NVIDIA ne propose le vGPU que sur ses gammes datacenter et professionnelles, il n’est pas disponible sur une GeForce. Il reste donc le passthrough, avec une conséquence directe : la carte est dédiée à la VM talos-gpu-01, l’hôte Proxmox n’y a plus accès et aucune autre VM ne peut la partager.
Le matériel utilisé est une GeForce RTX 3050 que j’avais sous la main.
Prérequis
- Un CPU et une carte mère supportant l’IOMMU (VT-d chez Intel, AMD-Vi chez AMD), activée dans le BIOS.
- Le cluster Talos de la partie 1.
- Un GPU qui n’est pas utilisé pour l’affichage de l’hôte. Dans mon cas, l’hôte Proxmox tourne sans écran et le GPU intégré du processeur est désactivé dans le BIOS, la RTX 3050 est donc la seule carte graphique de la machine.
Comprendre le passthrough
Un périphérique PCIe comme un GPU ne passe pas par le CPU pour lire ou écrire en mémoire. Il utilise le DMA (Direct Memory Access) pour accéder directement à la RAM, ce qui lui permet de transférer de gros volumes de données sans occuper le processeur.
Dans une VM, ce fonctionnement pose un problème. Les adresses mémoire que voit le système invité sont des adresses physiques invité (GPA, Guest Physical Address), qui ne correspondent pas aux adresses physiques réelles de l’hôte (HPA, Host Physical Address). Pour les accès du CPU, la traduction GPA vers HPA est faite par le matériel grâce à EPT (Extended Page Tables) chez Intel ou NPT (Nested Page Tables) chez AMD, une seconde table de pages que le processeur parcourt en plus de celle de l’invité. Un périphérique qui fait du DMA ne passe pas par cette traduction : si le driver de la VM donne une adresse GPA au GPU, la carte écrit à cette adresse dans la RAM de l’hôte, qui correspond à tout autre chose.
C’est le rôle de l’IOMMU (Input-Output Memory Management Unit). Elle fait pour les périphériques ce que la MMU (Memory Management Unit), le composant du processeur qui traduit les adresses virtuelles en adresses physiques, fait pour le CPU : elle traduit les adresses utilisées par le périphérique en adresses physiques réelles, et elle limite le périphérique aux zones mémoire qui lui ont été attribuées. Le GPU passé à la VM ne peut donc écrire que dans la mémoire de cette VM.
Cette isolation ne se fait pas par périphérique mais par groupe IOMMU. Un groupe rassemble les périphériques que le matériel n’est pas capable d’isoler les uns des autres, par exemple parce qu’ils peuvent communiquer entre eux sans passer par l’IOMMU. L’unité qu’on peut passer à une VM est donc le groupe entier, et il faut vérifier le contenu du groupe de la carte avant de commencer.
Enfin, côté hôte, la carte ne doit être prise par aucun driver graphique (nouveau ou nvidia). C’est le driver vfio-pci qui en prend possession. Il fait partie de VFIO (Virtual Function I/O), le framework du noyau Linux qui donne à un programme hors du noyau, ici QEMU, l’accès direct à un périphérique, en s’appuyant sur l’IOMMU pour limiter ce que le périphérique peut atteindre en mémoire. C’est par ce biais que QEMU présente la carte à la VM.

Traduction d'adresses : accès CPU et accès DMA du GPU
Pourquoi le ballooning doit être désactivé
Le ballooning permet à Proxmox de récupérer à chaud de la RAM inutilisée par une VM, grâce à un driver présent dans l’invité qui rend des pages mémoire à l’hôte. Avec un périphérique passé via VFIO, ce mécanisme ne peut plus fonctionner : le GPU peut écrire à tout moment dans les pages de la VM sans passer par le CPU, donc l’hôte n’a aucun moyen de savoir si une page est encore utilisée par la carte. Toute la mémoire de la VM est donc épinglée (pinned) au démarrage et ne peut plus être rendue à l’hôte.
Dans le provider bpg/proxmox, c’est l’argument floating du bloc memory qui contrôle le ballooning, et la valeur 0 le désactive. Je l’ai mis à 0 dans la ressource qui crée les nœuds Talos, donc sur les trois nœuds et pas uniquement sur le nœud GPU :
| |
Cela a une conséquence sur le dimensionnement : les 12 GB de RAM du nœud talos-gpu-01 sont réservés en permanence sur l’hôte, même quand le nœud ne fait rien.
Maintenant que le fonctionnement est posé, passons à la préparation de l’hôte Proxmox.
Étape 1 : préparer l’hôte Proxmox
Vérifier l’IOMMU et les groupes
La première chose à faire est d’identifier la carte et le groupe IOMMU auquel elle appartient :
| |
Ce qui donne dans mon cas :
| |
La carte expose deux fonctions : 01:00.0 pour la partie vidéo et 01:00.1 pour le contrôleur audio HDMI. Les deux sont seules dans le groupe IOMMU 10, ce qui veut dire que le groupe peut être passé à la VM tel quel, sans embarquer d’autre périphérique de l’hôte.
Si d’autres périphériques partageaient ce groupe, par exemple un contrôleur USB, il faudrait les passer eux aussi à la VM. Le patch ACS override est souvent proposé sur les forums pour découper artificiellement les groupes, mais il demande au noyau d’ignorer une isolation que le matériel ne garantit pas, les périphériques du groupe pouvant toujours communiquer entre eux. Le wiki Proxmox le présente d’ailleurs comme une dernière option, qui n’est pas sans risques.
Attacher la carte à vfio-pci
J’ai configuré l’hôte Proxmox pour que vfio-pci prenne la carte avant tout autre driver. Cette partie n’est pas gérée par mon code OpenTofu, je l’ai faite directement sur l’hôte Proxmox.
D’après la documentation Proxmox, l’IOMMU est activée par défaut sur Intel à partir du noyau 6.8, l’option intel_iommu=on que l’on trouve dans beaucoup de guides n’est donc plus nécessaire. Le seul paramètre utile est iommu=pt, qui limite la traduction d’adresses aux périphériques passés à une VM et peut améliorer les performances des autres.
Les paramètres du noyau se passent par la ligne de commande que GRUB, le chargeur de démarrage de l’hôte, transmet au noyau à chaque boot. Quand l’hôte démarre avec GRUB, ce qui est mon cas, elle se définit dans /etc/default/grub :
| |
GRUB_CMDLINE_LINUX_DEFAULT s’applique aux entrées de démarrage normales, mais pas au mode de récupération. quiet est présent par défaut sur Debian et Proxmox : il réduit les messages du noyau affichés à l’écran pendant le démarrage, sans rien changer à ce qui est enregistré dans les logs.
Le dmesg de l’hôte confirme que l’IOMMU est active, avec le mode passthrough demandé, et que les périphériques sont répartis en groupes IOMMU :
| |
Les modules VFIO sont chargés au démarrage via /etc/modules : vfio pour le cœur du framework, vfio_iommu_type1 pour piloter l’IOMMU, et vfio_pci pour les périphériques PCI :
| |
Pour que vfio-pci réserve la carte, je lui donne les identifiants des deux fonctions, la partie vidéo 10de:2507 et la partie audio 10de:228e :
| |
L’option disable_vga=1 empêche vfio-pci d’exposer les ressources VGA historiques de la carte à l’hôte. En complément, je bloque les drivers graphiques NVIDIA côté hôte pour qu’aucun ne prenne la carte au démarrage :
| |
Les modifications de /etc/modules et de /etc/modprobe.d/ ne sont prises en compte qu’après régénération de l’initramfs (update-initramfs -u -k all), et celles de /etc/default/grub après un update-grub. Après le redémarrage de l’hôte, la vérification se fait avec :
| |
Ce qui donne dans mon cas :
| |
Les deux fonctions sont bien prises par vfio-pci. La ligne Kernel modules liste les drivers capables de gérer la carte, pas ceux qui sont chargés : nouveau et nvidiafb sont bloqués et n’apparaissent pas dans lsmod.
AvertissementTant que
lspci -nnkn’affiche pasKernel driver in use: vfio-pcisur les deux fonctions, il ne sert à rien de continuer. La VM démarrera peut-être, mais la carte ne sera pas utilisable.
L’hôte est prêt, la carte peut maintenant être attachée à la VM.
Étape 2 : attacher le GPU à la VM avec OpenTofu
Pourquoi un hardware mapping et pas un ID PCI
Le provider bpg/proxmox propose deux manières de déclarer un périphérique dans le bloc hostpci d’une VM : son ID PCI brut avec l’argument id, ou un hardware mapping déclaré au niveau du cluster Proxmox avec l’argument mapping.
La documentation du provider précise que id n’est pas compatible avec une authentification par token API et qu’il demande le compte root avec son mot de passe. Toute mon IaC s’authentifie avec le token de l’utilisateur terraform@pve, je suis donc parti sur le mapping, qui était la seule option sans changer l’authentification du provider.
Le mapping est déclaré avec la ressource proxmox_hardware_mapping_pci :
| |
Les valeurs sont renseignées dans les variables :
| |
Le path est déclaré sans numéro de fonction (0000:01:00 et non 0000:01:00.0), ce qui couvre les deux fonctions de la carte d’un coup.
Les arguments iommu_group et subsystem_id sont optionnels pour l’API, mais sans eux Proxmox affiche le mapping comme incomplet dans l’interface. Je les ai donc renseignés à partir des commandes lspci -nnv -s 0000:01:00 et find /sys/kernel/iommu_groups -type l.
Les privilèges Proxmox
Le rôle utilisé par OpenTofu doit recevoir trois privilèges supplémentaires :
| |
- Mapping.Audit : lire les mappings existants.
- Mapping.Modify : créer, modifier et supprimer un mapping.
- Mapping.Use : attacher un mapping à une VM, en plus des droits habituels de modification de la VM.
Sans Mapping.Use, le mapping est bien créé mais l’attachement à la VM échoue.
Le bloc hostpci
Côté VM, une seule ressource sert les trois nœuds Talos. Le bloc hostpci est donc dynamique : il est généré à partir de la liste hostpci de chaque nœud, qui vaut [] par défaut. Les deux nœuds sans carte n’ont ainsi aucun périphérique attaché.
| |
Dans la partie 1, j’avais annoncé une sixième validation sur la variable talos_nodes. La voici, elle vérifie que chaque hostpci.mapping correspond bien à un mapping déclaré :
| |
Une faute de frappe dans le nom du mapping est ainsi détectée au plan, et pas sous la forme d’une erreur d’API au milieu de l’apply.
Une fois le pipeline passé, la configuration de la VM côté Proxmox reflète ce qui a été déclaré :
| |
| |
Le périphérique est référencé par le nom du mapping et non par son adresse PCI, et balloon: 0 correspond au floating = 0 du bloc memory.
La carte est désormais attachée à la VM, il reste à la rendre utilisable par Talos.
Étape 3 : les modules NVIDIA dans Talos
Un schematic dédié au nœud GPU
Comme expliqué dans la partie 1, les extensions NVIDIA ne sont pas dans l’image de base : les nœuds sans carte remontaient des erreurs de chargement des modules à chaque démarrage. Le nœud talos-gpu-01 utilise donc son propre schematic :
| |
Ce schematic ajoute deux extensions à qemu-guest-agent : nvidia-open-gpu-kernel-modules-production pour les drivers, et nvidia-container-toolkit-production pour que le runtime de conteneurs sache exposer la carte aux pods.
NoteJ’utilise les drivers open source de NVIDIA. Ils prennent en charge les GPU à partir de l’architecture Turing, ce qui inclut la RTX 3050 (Ampere). Sur du matériel plus ancien, il faut passer sur les extensions propriétaires.
Charger les modules
Les extensions embarquent les modules noyau mais ne les chargent pas. C’est le rôle d’un patch de machine config, placé au niveau nœud et non au niveau worker/ :
| |
Ce patch utilise le niveau nodes/ présenté dans la partie 1 : placé sous worker/, il serait aussi appliqué à talos-worker-01, dont l’image ne contient pas les extensions.
La liste des modules est celle de la documentation Talos. Le module nvidia_uvm est celui qu’utilise CUDA pour gérer la mémoire unifiée.
Appliquer et vérifier
Le schematic et le patch sont déployés par le pipeline GitLab CI décrit dans la partie 1, comme le reste de la configuration des nœuds. Je vérifie ensuite que les extensions sont bien présentes dans l’image du nœud :
| |
Ce qui donne le résultat suivant :
| |
Les deux extensions NVIDIA sont en version 595.71.05, le numéro du driver, et l’ID schematic est bien celui déclaré pour ce nœud. Les quatre modules du patch doivent ensuite être chargés :
| |
| |
Enfin, le fichier /proc/driver/nvidia/version n’existe que si le module nvidia est chargé, et il donne la version exacte du driver :
| |
| |
La mention Open Kernel Module confirme que ce sont bien les drivers open source qui tournent.
Le driver est chargé dans la VM, la dernière étape consiste à le faire connaître à Kubernetes.
Étape 4 : exposer le GPU à Kubernetes
Des modules chargés sur le nœud ne suffisent pas. Le scheduler Kubernetes ne connaît que le CPU et la mémoire, et pour qu’il puisse allouer un GPU à un pod, il faut une ressource étendue nvidia.com/gpu publiée sur le nœud par un device plugin. Un device plugin est un composant qui tourne sur le nœud, déclare au kubelet les GPU disponibles et leur état, puis choisit quelle carte attribuer à chaque pod qui en demande une.
NVIDIA GPU Operator
Pour ce faire, j’ai utilisé le NVIDIA GPU Operator plutôt que le seul device plugin NVIDIA, pour deux raisons : c’est la méthode décrite dans la documentation Talos, et l’opérateur déploie aussi le DCGM exporter, qui me permet de récupérer les métriques du GPU dans Prometheus.
Par défaut, l’opérateur installe lui-même les drivers et le container toolkit sur les nœuds, mais sur Talos ces deux composants sont déjà fournis par les extensions système. Il faut donc les désactiver pour éviter les conflits, comme l’indique la documentation Talos :
| |
- driver.enabled=false et toolkit.enabled=false : les drivers et le toolkit viennent des extensions Talos.
- hostPaths.driverInstallDir=/usr/local : l’extension Talos installe les fichiers du driver dans
/usr/local, alors que l’opérateur les cherche par défaut dans/run/nvidia/driver. - dcgmExporter.serviceMonitor.enabled=true : crée le
ServiceMonitorqui permet à Prometheus de collecter les métriques du GPU. - Le namespace doit autoriser les pods privilégiés, car les composants de l’opérateur accèdent au matériel du nœud.
NoteLa documentation Talos demande une version du GPU Operator supérieure ou égale à
v26.3.1.
Je n’ai pas eu besoin de nodeSelector pour cibler le nœud GPU. L’opérateur déploie d’abord Node Feature Discovery sur tous les nœuds, qui détecte les périphériques PCI de chacun. L’opérateur ajoute ensuite le label nvidia.com/gpu.present=true aux nœuds qui ont une carte NVIDIA, et ne déploie ses composants (device plugin, DCGM exporter, validation) que sur ces nœuds :
| |
| |
Seuls les workers NFD tournent sur les trois nœuds, tous les composants NVIDIA sont sur talos-gpu-01. Le pod nvidia-cuda-validator en Completed est normal : il lance un programme CUDA de test sur la carte, puis s’arrête.
Vérifier l’allocation
La ressource nvidia.com/gpu doit apparaître dans la capacité du nœud :
| |
| |
Le nœud talos-gpu-01 expose un GPU, les deux autres nœuds n’ont pas la ressource. Les labels ajoutés au nœud par le GPU Feature Discovery donnent aussi le détail de la carte vue par Kubernetes :
| |
| |
Le label nvidia.com/cuda.runtime-version.full=13.2 donne la version CUDA maximale supportée par le driver, ce qui sert à choisir l’image du pod de test.
Pour valider toute la chaîne, je lance un pod de test qui exécute nvidia-smi (NVIDIA System Management Interface), l’outil en ligne de commande livré avec le driver qui affiche les cartes visibles, la version du driver, l’occupation mémoire et les processus en cours. Si la commande voit la carte depuis un conteneur, c’est que le driver, le runtime et l’allocation fonctionnent :
| |
Le champ nvidia.com/gpu: 1 dans les limits suffit : le pod demande la ressource publiée par le device plugin, le scheduler ne peut donc le placer que sur talos-gpu-01, et le GPU lui est réservé tant qu’il tourne.
La documentation Talos ajoute aussi runtimeClassName: nvidia à son pod de test, pour passer par le runtime NVIDIA que l’extension nvidia-container-toolkit déclare dans containerd. Sur mon cluster, ce n’est pas nécessaire, car CDI (Container Device Interface) est activé dans le GPU Operator :
| |
| |
Avec CDI, le device plugin indique à containerd quels fichiers de périphérique et quelles libs du driver ajouter au conteneur, et containerd les injecte lui-même avec son runtime par défaut. C’est aussi de cette manière que le binaire nvidia-smi, absent de l’image nvidia/cuda, se retrouve dans le conteneur. D’après la documentation NVIDIA sur CDI, c’est le fonctionnement par défaut depuis la version v25.10.0 du GPU Operator, et runtimeClassName: nvidia ne reste nécessaire que pour les conteneurs qui accèdent aux GPU par la variable d’environnement NVIDIA_VISIBLE_DEVICES sans passer par le device plugin, comme les agents de supervision.
Une fois le pod terminé, ses logs contiennent la sortie de nvidia-smi :
| |
| |
Le conteneur voit la RTX 3050 avec ses 8 GB de mémoire, le driver 595.71.05 fourni par l’extension Talos et CUDA 13.2. Toute la chaîne fonctionne, de vfio-pci sur l’hôte Proxmox jusqu’au pod.
Conclusion
Le GPU est désormais allouable par le scheduler : le cluster expose une ressource nvidia.com/gpu sur le nœud talos-gpu-01, et un pod peut la demander comme il demanderait du CPU ou de la mémoire.
Il n’y a qu’une seule ressource nvidia.com/gpu dans tout le cluster, ce qui veut dire qu’un seul pod à la fois peut utiliser la carte. Pour la partager entre plusieurs pods, le GPU Operator propose du time-slicing, où les pods se partagent la carte à tour de rôle, et NVIDIA propose aussi MPS (Multi-Process Service), qui fait tourner les processus CUDA de plusieurs pods en même temps sur la carte. Ce sont des pistes que je n’ai pas encore mises en place.
De mon côté, cette partie m’a beaucoup appris sur le matériel. Mes connaissances en virtualisation restaient jusque-là à un niveau assez haut, celui de l’hyperviseur, et je ne connaissais pas les mécanismes bas niveau sur lesquels repose le passthrough, comme les entrées/sorties des périphériques, le DMA, la MMU ou l’IOMMU.
À ce stade, l’infrastructure est entièrement décrite en OpenTofu, mais tout ce qui tourne dans le cluster a été installé avec des helm install lancés à la main. C’est le même problème de dérive de configuration que Talos règle au niveau de l’OS, qui se retrouve un étage plus haut. La partie 3 met donc en place du GitOps avec Flux avant d’aller plus loin vers l’inférence.
