Featured image of post Passer un GPU NVIDIA dans un cluster Kubernetes

Passer un GPU NVIDIA dans un cluster Kubernetes

PCI passthrough d'une RTX 3050 depuis Proxmox vers un nœud Talos avec OpenTofu, puis exposition du GPU aux pods Kubernetes

Série “Un cluster Kubernetes avec GPU pour faire de l’inférence LLM”

  1. Déployer un cluster Kubernetes immuable avec Talos sur Proxmox
  2. Passer un GPU NVIDIA dans un cluster Kubernetes (cet article)
  3. Piloter un cluster Kubernetes en GitOps avec Flux (En cours d’écriture)
  4. 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.

Schéma des quatre étapes : sur l'hôte Proxmox, la RTX 3050 est liée à vfio-pci et isolée par l'IOMMU ; OpenTofu l'attache à la VM talos-gpu-01 par un hardware mapping ; Talos charge les modules NVIDIA des extensions ; le GPU Operator publie la ressource nvidia.com/gpu sur le nœud, qu'un pod demande et reçoit via CDI Schéma des quatre étapes : sur l'hôte Proxmox, la RTX 3050 est liée à vfio-pci et isolée par l'IOMMU ; OpenTofu l'attache à la VM talos-gpu-01 par un hardware mapping ; Talos charge les modules NVIDIA des extensions ; le GPU Operator publie la ressource nvidia.com/gpu sur le nœud, qu'un pod demande et reçoit via CDI

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.

Schéma de la traduction d'adresses dans l'hôte Proxmox : les accès CPU de la VM passent par la MMU et EPT/NPT, les accès DMA du GPU passent par l'IOMMU, les deux arrivent en adresses HPA dans la mémoire épinglée de la VM, et l'IOMMU bloque l'accès au reste de la RAM Schéma de la traduction d'adresses dans l'hôte Proxmox : les accès CPU de la VM passent par la MMU et EPT/NPT, les accès DMA du GPU passent par l'IOMMU, les deux arrivent en adresses HPA dans la mémoire épinglée de la VM, et l'IOMMU bloque l'accès au reste de la RAM

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 :

1
2
3
4
5
#talos.tf
memory {
  dedicated = each.value.memory
  floating  = 0
}

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 :

1
2
3
4
5
# La carte et ses fonctions
lspci -nn | grep -i nvidia

# Le groupe IOMMU auquel elle appartient
find /sys/kernel/iommu_groups -type l | grep '01:00'

Ce qui donne dans mon cas :

1
2
3
4
5
01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA106 [Geforce RTX 3050] [10de:2507] (rev a1)
01:00.1 Audio device [0403]: NVIDIA Corporation GA106 High Definition Audio Controller [10de:228e] (rev a1)

/sys/kernel/iommu_groups/10/devices/0000:01:00.0
/sys/kernel/iommu_groups/10/devices/0000:01:00.1

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 :

1
2
#/etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet iommu=pt"

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 :

1
2
3
[    0.284240] iommu: Default domain type: Passthrough (set via kernel command line)
[    0.329656] pci 0000:00:00.0: Adding to iommu group 0
[    0.329665] pci 0000:00:01.0: Adding to iommu group 1

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 :

1
2
3
4
#/etc/modules
vfio
vfio_iommu_type1
vfio_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 :

1
2
#/etc/modprobe.d/vfio.conf
options vfio-pci ids=10de:2507,10de:228e disable_vga=1

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 :

1
2
3
#/etc/modprobe.d/blacklist.conf
blacklist nouveau
blacklist nvidia*

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 :

1
lspci -nnk -s 01:00

Ce qui donne dans mon cas :

1
2
3
4
5
6
7
8
01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA106 [Geforce RTX 3050] [10de:2507] (rev a1)
        Subsystem: NVIDIA Corporation GA106 [Geforce RTX 3050] [10de:2507]
        Kernel driver in use: vfio-pci
        Kernel modules: nvidiafb, nouveau
01:00.1 Audio device [0403]: NVIDIA Corporation GA106 High Definition Audio Controller [10de:228e] (rev a1)
        Subsystem: NVIDIA Corporation Device [10de:2507]
        Kernel driver in use: vfio-pci
        Kernel modules: snd_hda_intel

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.

⚠️ Avertissement

Tant que lspci -nnk n’affiche pas Kernel driver in use: vfio-pci sur 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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#hardware_mapping.tf
resource "proxmox_hardware_mapping_pci" "this" {
  ...
  for_each = var.pci_hardware_mappings

  name    = each.key
  comment = each.value.comment

  map = [
    {
      node         = coalesce(each.value.node, var.proxmox_node)
      path         = each.value.path
      id           = each.value.id
      iommu_group  = each.value.iommu_group
      subsystem_id = each.value.subsystem_id
    }
  ]
}

Les valeurs sont renseignées dans les variables :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#variables.auto.tfvars
pci_hardware_mappings = {
  gpu = {
    comment      = "GeForce RTX 3050 - managed by Terraform"
    path         = "0000:01:00"
    id           = "10de:2507"
    iommu_group  = 10
    subsystem_id = "10de:2507"
  }
}

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 :

1
2
3
4
#users.tf
"Mapping.Audit",
"Mapping.Modify",
"Mapping.Use",
  • 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é.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#talos.tf
dynamic "hostpci" {
  for_each = { for device in each.value.hostpci : device.device => device }

  content {
    device  = hostpci.value.device
    mapping = proxmox_hardware_mapping_pci.this[hostpci.value.mapping].name
    pcie    = hostpci.value.pcie
    rombar  = hostpci.value.rombar
    xvga    = hostpci.value.xvga
  }
}

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é :

1
2
3
4
5
6
7
8
9
#variables.tf
validation {
  condition = alltrue(flatten([
    for node in var.talos_nodes : [
      for device in node.hostpci : contains(keys(var.pci_hardware_mappings), device.mapping)
    ]
  ]))
  error_message = "Every Talos node hostpci.mapping must be a key of var.pci_hardware_mappings."
}

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é :

1
qm config 123 | grep -E '^(hostpci|balloon|memory)'
1
2
3
balloon: 0
hostpci0: mapping=gpu,pcie=1,rombar=1,x-vga=0
memory: 12288

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#variables.auto.tfvars
{
  hostname     = "talos-gpu-01"
  ip           = "192.168.1.123"
  cores        = 4
  memory       = 12 * 1024,
  disk_size    = 100,
  role         = "worker"
  schematic_id = "a31d81eceab05afdc2c59b0002701173e117a5fb81bdc1f64062d27655145848" # With NVIDIA GPU support
  hostpci = [
    {
      device  = "hostpci0"
      mapping = "gpu"
    }
  ]
}

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.

📝 Note

J’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/ :

1
2
3
4
5
6
7
8
# patches/nodes/talos-gpu-01/30-nvidia-modules.yaml.tftpl
machine:
  kernel:
    modules:
      - name: nvidia
      - name: nvidia_uvm
      - name: nvidia_drm
      - name: nvidia_modeset

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 :

1
talosctl -n 192.168.1.123 get extensions

Ce qui donne le résultat suivant :

1
2
3
4
5
6
NODE            NAMESPACE   TYPE              ID            VERSION   NAME                                        VERSION
192.168.1.123   runtime     ExtensionStatus   0             1         nvidia-container-toolkit-production         595.71.05-v1.19.1
192.168.1.123   runtime     ExtensionStatus   1             1         nvidia-open-gpu-kernel-modules-production   595.71.05-v1.13.8
192.168.1.123   runtime     ExtensionStatus   2             1         qemu-guest-agent                            11.0.2
192.168.1.123   runtime     ExtensionStatus   3             1         schematic                                   a31d81eceab05afdc2c59b0002701173e117a5fb81bdc1f64062d27655145848
192.168.1.123   runtime     ExtensionStatus   modules.dep   1         modules.dep                                 6.18.42-talos

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 :

1
talosctl -n 192.168.1.123 get loadedkernelmodules | grep -i nvidia
1
2
3
4
192.168.1.123   runtime     LoadedKernelModule   nvidia                      2         Live
192.168.1.123   runtime     LoadedKernelModule   nvidia_drm                  2         Live
192.168.1.123   runtime     LoadedKernelModule   nvidia_modeset              2         Live
192.168.1.123   runtime     LoadedKernelModule   nvidia_uvm                  1         Live

Enfin, le fichier /proc/driver/nvidia/version n’existe que si le module nvidia est chargé, et il donne la version exacte du driver :

1
talosctl -n 192.168.1.123 read /proc/driver/nvidia/version
1
2
NVRM version: NVIDIA UNIX Open Kernel Module for x86_64  595.71.05  Release Build  (dvs-builder@U22-I3-G08-03-1)  Fri Apr 24 06:42:30 UTC 2026
GCC version:  Selected multilib: .;@m64

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
kubectl create namespace gpu-operator
kubectl label --overwrite namespace gpu-operator pod-security.kubernetes.io/enforce=privileged

helm repo add nvidia https://helm.ngc.nvidia.com/nvidia
helm repo update
helm install gpu-operator nvidia/gpu-operator \
  --namespace gpu-operator \
  --version v26.3.3 \
  --set driver.enabled=false \
  --set toolkit.enabled=false \
  --set hostPaths.driverInstallDir=/usr/local \
  --set dcgmExporter.serviceMonitor.enabled=true
  • 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 ServiceMonitor qui 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.
📝 Note

La 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 :

1
kubectl get pods -n gpu-operator -o wide
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
NAME                                                         READY   STATUS      RESTARTS         AGE     IP             NODE                    NOMINATED NODE   READINESS GATES
gpu-feature-discovery-9ll6h                                  1/1     Running     0                52d     10.244.0.134   talos-gpu-01            <none>           <none>
gpu-operator-57d75775c8-ls7hf                                1/1     Running     0                4d12h   10.244.2.150   talos-controlplane-01   <none>           <none>
gpu-operator-node-feature-discovery-gc-847bb8f7b6-9gmkf      1/1     Running     0                4d12h   10.244.0.231   talos-gpu-01            <none>           <none>
gpu-operator-node-feature-discovery-master-d98f944cd-vhzdw   1/1     Running     0                4d12h   10.244.2.188   talos-controlplane-01   <none>           <none>
gpu-operator-node-feature-discovery-worker-kvvfl             1/1     Running     0                52d     10.244.0.249   talos-gpu-01            <none>           <none>
gpu-operator-node-feature-discovery-worker-ntvzt             1/1     Running     0                52d     10.244.2.39    talos-controlplane-01   <none>           <none>
gpu-operator-node-feature-discovery-worker-s95fk             1/1     Running     0                52d     10.244.1.208   talos-worker-01         <none>           <none>
nvidia-cuda-validator-gfd6h                                  0/1     Completed   0                6m42s   10.244.0.109   talos-gpu-01            <none>           <none>
nvidia-dcgm-exporter-rkt88                                   1/1     Running     0                52d     10.244.0.161   talos-gpu-01            <none>           <none>
nvidia-device-plugin-daemonset-pgnqw                         1/1     Running     0                52d     10.244.0.164   talos-gpu-01            <none>           <none>
nvidia-operator-validator-xqdd8                              1/1     Running     0                52d     10.244.0.50    talos-gpu-01            <none>           <none>

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 :

1
kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, gpu: .status.capacity."nvidia.com/gpu"}'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "name": "talos-controlplane-01",
  "gpu": null
}
{
  "name": "talos-gpu-01",
  "gpu": "1"
}
{
  "name": "talos-worker-01",
  "gpu": null
}

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 :

1
kubectl get node talos-gpu-01 --show-labels | tr ',' '\n' | grep nvidia.com
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
nvidia.com/cuda.driver-version.full=595.71.05
...
nvidia.com/cuda.runtime-version.full=13.2
...
nvidia.com/gpu.count=1
...
nvidia.com/gpu.family=ampere
...
nvidia.com/gpu.memory=8192
...
nvidia.com/gpu.product=NVIDIA-GeForce-RTX-3050
...

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
  name: nvidia-smi
spec:
  restartPolicy: Never
  containers:
    - name: nvidia-smi
      image: nvidia/cuda:13.2.1-base-ubuntu24.04
      command: ["nvidia-smi"]
      resources:
        limits:
          nvidia.com/gpu: 1

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 :

1
kubectl get clusterpolicy -o jsonpath='{.items[0].spec.cdi}'
1
{"default":false,"enabled":true,"nriPluginEnabled":false}

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 :

1
kubectl logs nvidia-smi
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 595.71.05              Driver Version: 595.71.05      CUDA Version: 13.2     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce RTX 3050        On  |   00000000:01:00.0 Off |                  N/A |
|  0%   45C    P8              7W /  130W |       1MiB /   8192MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+

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.

Liens utiles

Généré avec Hugo
Thème Stack conçu par Jimmy