
Créer une VM KVM à la main, c'est bien pour apprendre. Mais pour un lab de 5, 10 ou 20 machines ? Terraform automatise la création de VMs libvirt avec des configurations reproductibles. Ce guide vous montre comment faire proprement, avec Cloud-Init et les bonnes pratiques 2026.
À la fin de ce guide, vous aurez :
- Un template Terraform réutilisable pour créer des VMs KVM
- Une configuration Cloud-Init robuste (SSH, hostname, packages)
- Les bonnes pratiques sécurité (pas de
security_driver = none) - Des commandes de debug pour dépanner sans interface graphique
Prérequis
Section intitulée « Prérequis »| Élément | Version | Vérification |
|---|---|---|
| KVM/libvirt | Ubuntu 22.04+ ou équivalent | virsh version |
| Terraform ou OpenTofu | ≥ 1.6.0 | terraform version ou tofu version |
| Image cloud | Ubuntu 24.04 QCOW2 | Téléchargement ci-dessous |
| Clé SSH | Ed25519 recommandée | ls ~/.ssh/id_ed25519.pub |
Ce que vous allez construire
Section intitulée « Ce que vous allez construire »Le projet tient en quatre fichiers HCL (le langage de configuration de Terraform) et deux fichiers Cloud-Init. Ce découpage est la convention Terraform : les ressources d'un côté, les variables ajustables de l'autre, les valeurs renvoyées après création dans un troisième, et les contraintes de version dans le dernier. Rien n'oblige à séparer ainsi, mais c'est ce qui rend un projet relisible par quelqu'un d'autre.
terraform-kvm/├── main.tf # Ressources principales├── variables.tf # Variables configurables├── outputs.tf # Sorties (IP, hostname)├── versions.tf # Providers et versions└── cloudinit/ ├── user-data.yaml # Configuration Cloud-Init └── network-config.yamlArchitecture cible :
┌─────────────────────────────────────────────────────────┐│ Hôte KVM ││ ┌───────────────┐ ┌───────────────┐ ││ │ Pool default │ │ Réseau NAT │ ││ │ (images) │ │ (192.168.122.x)│ ││ └───────┬───────┘ └───────┬───────┘ ││ │ │ ││ ▼ ▼ ││ ┌─────────────────────────────────────┐ ││ │ VM créée par Terraform │ ││ │ • Ubuntu 24.04 (cloud image) │ ││ │ • Cloud-Init configuré │ ││ │ • qemu-guest-agent installé │ ││ └─────────────────────────────────────┘ │└─────────────────────────────────────────────────────────┘Partie 1 : Préparer l'environnement
Section intitulée « Partie 1 : Préparer l'environnement »Terraform ne parle pas directement à KVM : il passe par libvirt, la couche de gestion qui expose les hyperviseurs derrière une API commune. Avant d'écrire la moindre ligne de configuration, il faut donc que le démon libvirtd tourne, que votre compte y ait accès sans sudo, et qu'une image de système d'exploitation soit disponible localement.
Vérifier libvirt
Section intitulée « Vérifier libvirt »Ces trois contrôles évitent les erreurs les plus déroutantes, celles où Terraform échoue sur un problème qui n'a rien à voir avec votre code.
-
Vérifier que libvirtd fonctionne
Fenêtre de terminal systemctl is-active libvirtdSortie attendue :
active -
Vérifier votre appartenance au groupe libvirt
Fenêtre de terminal groups | grep -E "libvirt|kvm"Si absent :
Fenêtre de terminal sudo usermod -aG libvirt,kvm $USER# Puis déconnectez-vous et reconnectez-vous -
Tester l'accès à qemu:///system
Fenêtre de terminal virsh -c qemu:///system list --allCette commande doit s'exécuter sans sudo. Si elle échoue, voir la section Dépannage.
Télécharger l'image cloud Ubuntu
Section intitulée « Télécharger l'image cloud Ubuntu »Les images cloud sont des QCOW2 minimalistes, optimisées pour Cloud-Init :
# Créer un dossier pour les images de basesudo mkdir -p /var/lib/libvirt/images/base
# Télécharger Ubuntu 24.04 cloud imagesudo wget -O /var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img \ https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Vérifier le téléchargementqemu-img info /var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.imgCréer la clé SSH (si absente)
Section intitulée « Créer la clé SSH (si absente) »Cloud-Init déposera la clé publique dans la VM ; c'est le seul moyen de vous y connecter, l'authentification par mot de passe étant désactivée dans la configuration proposée. L'option -N "" crée la clé sans passphrase, acceptable pour un lab, à proscrire pour une clé personnelle.
# Générer une clé Ed25519 (plus sécurisée que RSA)ssh-keygen -t ed25519 -C "terraform-kvm" -f ~/.ssh/id_ed25519 -N ""Partie 2 : Projet Terraform
Section intitulée « Partie 2 : Projet Terraform »On écrit maintenant les quatre fichiers du projet. Terraform lit tous les fichiers .tf d'un répertoire et les fusionne : leurs noms n'ont donc aucune signification technique, ils servent uniquement à organiser la lecture. L'ordre proposé va du plus contraignant au plus variable : versions, variables, ressources, sorties.
Créer la structure du projet
Section intitulée « Créer la structure du projet »Deux répertoires suffisent, le projet lui-même et un sous-dossier pour les gabarits Cloud-Init :
mkdir -p ~/terraform-kvm/cloudinitcd ~/terraform-kvmFichier versions.tf, Providers et versions
Section intitulée « Fichier versions.tf, Providers et versions »Un provider est le greffon qui traduit vos ressources en appels à une API, ici celle de libvirt. Épingler sa version n'est pas un détail sur ce provider précis : la branche 0.9.x a réécrit la structure de presque toutes les ressources, et une configuration écrite pour la 0.8 ne s'applique pas telle quelle. La contrainte ~> 0.9.0 autorise les correctifs mais bloque le passage à une branche suivante. Créez versions.tf :
terraform { required_version = ">= 1.6.0"
required_providers { libvirt = { source = "dmacvicar/libvirt" version = "~> 0.9.0" # Version 2025+ avec breaking changes } }}
provider "libvirt" { # Connexion au démon libvirt système (pas session utilisateur) uri = "qemu:///system"}Fichier variables.tf, Configuration
Section intitulée « Fichier variables.tf, Configuration »Les variables regroupent tout ce que vous voudrez ajuster sans relire le code des ressources : dimensionnement, chemins, noms de réseau et de pool de stockage. Chacune porte une valeur default, donc le projet fonctionne sans qu'aucune ne soit fournie ; passer une valeur différente se fait ensuite par -var, un fichier .tfvars ou une variable d'environnement TF_VAR_. Créez variables.tf :
variable "vm_name" { description = "Nom de la VM" type = string default = "tf-kvm-01"}
variable "memory_mb" { description = "Mémoire RAM en Mo" type = number default = 2048}
variable "vcpu" { description = "Nombre de vCPU" type = number default = 2}
variable "disk_size_gb" { description = "Taille du disque en Go (expansion de l'image cloud)" type = number default = 20}
variable "base_image_path" { description = "Chemin vers l'image cloud de base" type = string default = "/var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img"}
variable "ssh_public_key_path" { description = "Chemin vers la clé SSH publique" type = string default = "~/.ssh/id_ed25519.pub"}
variable "network_name" { description = "Nom du réseau libvirt" type = string default = "default"}
variable "pool_name" { description = "Nom du pool de stockage libvirt" type = string default = "default"}Fichier main.tf, Ressources
Section intitulée « Fichier main.tf, Ressources »C'est le cœur du projet, et il déclare cinq ressources qui s'enchaînent. Deux volumes de stockage d'abord : l'image de base importée dans le pool, puis le disque de la VM créé par-dessus en copie sur écriture (Copy-on-Write), ce qui évite de dupliquer plusieurs gigaoctets par machine. Ensuite la configuration Cloud-Init et l'image ISO qui la transporte, enfin le domaine, c'est-à-dire la VM elle-même. Terraform déduit seul l'ordre de création à partir des références entre ressources. Créez main.tf :
# ============================================================# Provider libvirt 0.9.x - Configuration complète# ============================================================
# 1) Volume de base (image cloud Ubuntu - copie locale)resource "libvirt_volume" "base" { name = "ubuntu-24.04-base.qcow2" pool = var.pool_name
target = { format = { type = "qcow2" } }
create = { content = { url = var.base_image_path } }}
# 2) Volume OS de la VM (overlay CoW sur l'image de base)resource "libvirt_volume" "os_disk" { name = "${var.vm_name}.qcow2" pool = var.pool_name capacity = var.disk_size_gb * 1024 * 1024 * 1024 # Conversion en bytes
target = { format = { type = "qcow2" } }
# Utilise l'image de base comme backing store (Copy-on-Write) backing_store = { path = libvirt_volume.base.path format = { type = "qcow2" } }}
# 3) Cloud-Init : génération de l'ISO de configurationresource "libvirt_cloudinit_disk" "init" { name = "${var.vm_name}-cloudinit"
user_data = templatefile("${path.module}/cloudinit/user-data.yaml", { hostname = var.vm_name public_key = file(pathexpand(var.ssh_public_key_path)) })
# meta_data est OBLIGATOIRE en 0.9.x meta_data = yamlencode({ instance-id = var.vm_name local-hostname = var.vm_name })
network_config = file("${path.module}/cloudinit/network-config.yaml")}
# 4) Volume pour l'ISO Cloud-Init (upload dans le pool)resource "libvirt_volume" "cloudinit" { name = "${var.vm_name}-cloudinit.iso" pool = var.pool_name
create = { content = { url = libvirt_cloudinit_disk.init.path } }}
# 5) Domaine (VM) - Syntaxe 0.9.x avec bloc devicesresource "libvirt_domain" "vm" { name = var.vm_name type = "kvm" # Type obligatoire en 0.9.x memory = var.memory_mb memory_unit = "MiB" # OBLIGATOIRE : l'unité par défaut du provider est KiB vcpu = var.vcpu
# CPU host-passthrough : expose tous les flags CPU de l'hôte (AES-NI, # SSE4.x, AVX...). Sans ça, libvirt utilise mode=custom model=qemu64 # qui n'expose que des instructions x86_64 minimales, et les kernels # récents (RHEL/AlmaLinux 10, Fedora ≥ 40) bloquent au chargement # des modules crypto. cpu = { mode = "host-passthrough" }
os = { type = "hvm" type_machine = "q35" firmware = "efi" # Désactive Secure Boot : libvirt 10+ enrôle par défaut les clés # Microsoft (OVMF_CODE_4M.ms.fd) qui rejettent les kernels non # signés MS. Les images cloud Linux démarrent puis se bloquent à # /init faute de modules virtio chargés. # Ordre alphabétique des features OBLIGATOIRE : le provider 0.9.x # retourne les features triées et compare strictement avec ce # qu'on lui donne (sinon "Provider produced inconsistent result"). firmware_info = { features = [ { enabled = "no", name = "enrolled-keys" }, { enabled = "no", name = "secure-boot" }, ] } }
features = { acpi = true }
# Bloc devices : structure obligatoire en 0.9.x devices = { # Disque OS disks = [ { # Driver explicite obligatoire pour qcow2 ! driver = { name = "qemu" type = "qcow2" } source = { volume = { pool = var.pool_name volume = libvirt_volume.os_disk.name } } target = { dev = "vda" bus = "virtio" } }, # Disque Cloud-Init (CDROM) { device = "cdrom" driver = { name = "qemu" type = "raw" } source = { volume = { pool = var.pool_name volume = libvirt_volume.cloudinit.name } } target = { dev = "sda" bus = "sata" } } ]
# Interface réseau interfaces = [ { type = "network" model = { type = "virtio" } source = { network = { network = var.network_name } } } ]
# Console série (indispensable pour debug) serials = [ { type = "pty" target = { port = 0 type = "isa-serial" } } ]
# Support graphique (optionnel, pour virt-manager) graphics = [ { spice = { autoport = "yes" } } ] }
running = true}Configuration Cloud-Init
Section intitulée « Configuration Cloud-Init »Cloud-Init est le standard pour configurer les VMs cloud au premier démarrage. Il gère : hostname, utilisateurs, clés SSH, packages, commandes.
Créez cloudinit/user-data.yaml :
#cloud-config
# Hostname de la VMhostname: ${hostname}fqdn: ${hostname}.localmanage_etc_hosts: true
# Utilisateur par défautusers: - name: ubuntu groups: [adm, sudo, docker] shell: /bin/bash sudo: ALL=(ALL) NOPASSWD:ALL lock_passwd: true # Désactive le mot de passe (SSH key only) ssh_authorized_keys: - ${public_key}
# Paquets à installerpackages: - qemu-guest-agent # Indispensable pour récupérer l'IP - curl - vim - htop
# Activer l'agent QEMU au démarrageruncmd: - systemctl enable --now qemu-guest-agent
# Autoriser SSH par clé uniquementssh_pwauth: false
# Mise à jour des paquets au premier bootpackage_update: truepackage_upgrade: false # Évite les surprises de temps
# Message finalfinal_message: | Cloud-init terminé après $UPTIME secondes. Hostname: ${hostname} Connectez-vous avec: ssh ubuntu@<IP>Créez cloudinit/network-config.yaml :
version: 2ethernets: default: match: driver: virtio* # Match n'importe quelle interface virtio dhcp4: true dhcp6: falseFichier outputs.tf, Récupérer les informations
Section intitulée « Fichier outputs.tf, Récupérer les informations »En provider 0.9.x, l'IP n'est plus accessible directement via network_interface[0].addresses. On utilise un data source dédié :
Créez outputs.tf :
# Data source pour récupérer les IPs via DHCP leasesdata "libvirt_domain_interface_addresses" "vm" { domain = libvirt_domain.vm.name source = "lease" # Utilise le serveur DHCP de libvirt
depends_on = [libvirt_domain.vm]}
output "vm_name" { description = "Nom de la VM" value = libvirt_domain.vm.name}
output "vm_id" { description = "ID libvirt de la VM" value = libvirt_domain.vm.id}
output "vm_uuid" { description = "UUID de la VM" value = libvirt_domain.vm.uuid}
# Toutes les IPs (format 0.9.x : interfaces[].addrs[].addr)output "ip_addresses" { description = "Adresses IP de la VM" value = [ for iface in data.libvirt_domain_interface_addresses.vm.interfaces : [for a in iface.addrs : a.addr] ]}
# IP principale pour faciliter l'usageoutput "primary_ip" { description = "Adresse IP principale de la VM" value = try( data.libvirt_domain_interface_addresses.vm.interfaces[0].addrs[0].addr, "Non disponible" )}
output "ssh_command" { description = "Commande SSH pour se connecter" value = try( "ssh ubuntu@${data.libvirt_domain_interface_addresses.vm.interfaces[0].addrs[0].addr}", "IP non disponible - attendre cloud-init puis: tofu refresh" )}Partie 3 : Déployer la VM
Section intitulée « Partie 3 : Déployer la VM »Le déploiement suit le cycle habituel de Terraform : télécharger le provider, valider la syntaxe, prévisualiser les changements, puis appliquer. Seule la dernière commande modifie quoi que ce soit sur la machine ; les trois précédentes sont sans effet de bord et peuvent être relancées librement.
Initialiser et appliquer
Section intitulée « Initialiser et appliquer »Exécutez ces cinq étapes depuis le répertoire du projet. La récupération de l'adresse IP est traitée à part car elle dépend du démarrage de la VM, pas de Terraform.
-
Initialiser Terraform
Fenêtre de terminal cd ~/terraform-kvmterraform initSortie attendue :
Initializing the backend...Initializing provider plugins...- Finding dmacvicar/libvirt versions matching "~> 0.9.0"...- Installing dmacvicar/libvirt v0.9.0...Terraform has been successfully initialized! -
Valider la configuration
Fenêtre de terminal terraform validateSortie attendue :
Success! The configuration is valid. -
Prévisualiser les changements
Fenêtre de terminal terraform planVérifiez que 5 ressources seront créées, c'est-à-dire l'intégralité du
main.tfprécédent :libvirt_volume.base, l'image cloud téléchargée, partagée entre les VMlibvirt_volume.os_disk, le disque système dérivé de cette baselibvirt_cloudinit_disk.init, la configuration cloud-initlibvirt_volume.cloudinit, le volume ISO qui la portelibvirt_domain.vm, la machine virtuelle qui assemble le tout
-
Appliquer
Fenêtre de terminal terraform applyTapez
yespour confirmer. -
Récupérer l'IP
Fenêtre de terminal terraform output ip_addresses
Vérifier la VM
Section intitulée « Vérifier la VM »Ces commandes interrogent libvirt directement, sans passer par Terraform : elles montrent l'état réel de l'hyperviseur, indépendamment de ce que Terraform croit avoir créé. L'option --source agent s'appuie sur qemu-guest-agent installé par Cloud-Init dans la VM, et donne l'adresse vue de l'intérieur.
# État de la VMvirsh list --all
# Détailsvirsh dominfo tf-kvm-01
# IP via l'agent QEMUvirsh domifaddr tf-kvm-01 --source agentSe connecter en SSH
Section intitulée « Se connecter en SSH »Le compte créé par Cloud-Init s'appelle ubuntu et n'accepte que votre clé. La sortie ssh_command construit la ligne complète à partir de l'adresse récupérée, ce qui évite de la recopier à la main :
# Récupérer la commande SSHterraform output ssh_command
# Ou directement (remplacez l'IP)ssh ubuntu@192.168.122.xxxPartie 4 : Debug et dépannage
Section intitulée « Partie 4 : Debug et dépannage »Les pannes se répartissent en deux familles, qu'il faut distinguer avant de chercher : soit la VM ne démarre pas du tout, soit elle démarre mais reste injoignable. La première se diagnostique à la console série, la seconde dans les journaux de Cloud-Init. Une VM listée comme running par virsh ne prouve rien : elle peut très bien être figée au chargement du noyau.
Console série (sans réseau)
Section intitulée « Console série (sans réseau) »Si la VM ne démarre pas ou n'obtient pas d'IP, la console série reste le seul canal disponible : elle est rattachée au port série virtuel du domaine et affiche les messages du noyau depuis le tout premier instant du démarrage, avant que le réseau existe.
virsh console tf-kvm-01Pour quitter : Ctrl + ]
Vérifier Cloud-Init
Section intitulée « Vérifier Cloud-Init »Cloud-Init s'exécute au premier démarrage et applique la configuration transportée par l'ISO : hostname, compte, clé SSH, paquets. S'il échoue, la VM démarre mais reste inutilisable, sans compte ni clé. Ces trois commandes, lancées depuis la console ou en SSH, disent où il en est et pourquoi il a échoué :
# Statut globalcloud-init status --long
# Logs détailléssudo cat /var/log/cloud-init-output.log
# Configuration appliquéesudo cat /var/lib/cloud/instance/user-data.txt| Statut | Signification |
|---|---|
status: done | Cloud-Init terminé avec succès |
status: running | Encore en cours (patience) |
status: error | Erreur, consultez les logs |
Problèmes courants
Section intitulée « Problèmes courants »Ce tableau condense les erreurs réellement rencontrées avec le provider 0.9.x, dont plusieurs n'ont aucun message explicite : la VM démarre, consomme du CPU, et reste muette. Les trois dernières lignes concernent des pièges propres aux versions récentes de libvirt et du provider, difficiles à deviner sans les avoir vus.
| Symptôme | Cause probable | Solution |
|---|---|---|
Error: virsh: command not found | libvirt non installé | Guide installation |
failed to connect to the hypervisor | libvirtd arrêté ou permissions | sudo systemctl start libvirtd + vérifier groupe |
| IP vide même après 1 minute | qemu-guest-agent non installé | Vérifier Cloud-Init user-data |
Permission denied sur le pool | Droits fichier ou AppArmor | Voir section Sécurité ci-dessous |
| Disque corrompu après apply | Image source modifiée | Utiliser une image "golden" en lecture seule |
Boot bloqué dans /init (AlmaLinux/RHEL 10) | Secure Boot enrôlé par libvirt + clés MS | Ajouter firmware_info.features avec secure-boot=no + enrolled-keys=no |
| Kernel panic au démarrage (distros récentes) | CPU qemu64 générique sans flags modernes | Ajouter cpu = { mode = "host-passthrough" } au domain |
Provider produced inconsistent result sur firmware_info.features | Bug provider 0.9.x : tri alphabétique au retour | Déclarer les features dans l'ordre alphabétique du name |
| VM à ~100% CPU, aucun réseau, pas de SSH (elle "tourne" mais reste injoignable) | memory sans memory_unit : le défaut est KiB, donc memory = 2048 alloue 2 MiB de RAM | Ajouter memory_unit = "MiB" au libvirt_domain |
Boot qui tombe dans l'initramfs BusyBox (mount /dev/vda1 ... failed: Invalid argument) | Image source tronquée ou corrompue : root FS illisible | Vérifier l'image (qemu-img check, tenter un montage) et la retélécharger depuis la source officielle |
Logs libvirtd
Section intitulée « Logs libvirtd »Quand l'erreur survient avant même le démarrage de la VM (refus de permission, volume introuvable, firmware absent), c'est le démon qui la journalise, pas Terraform. Le message de terraform apply n'en est souvent qu'un résumé tronqué :
# Logs en temps réeljournalctl -u libvirtd -f
# Erreurs récentesjournalctl -u libvirtd --since "10 minutes ago" | grep -i errorPartie 5 : Sécurité, Ne pas désactiver les protections
Section intitulée « Partie 5 : Sécurité, Ne pas désactiver les protections »Les erreurs de permission sur le pool de stockage sont fréquentes, et la solution la plus partagée sur le web consiste à couper la protection qui les provoque. C'est un mauvais échange : le mécanisme en cause, sVirt, est précisément ce qui empêche une VM compromise d'accéder aux disques des autres. Le corriger prend quelques minutes de plus que le désactiver.
Pourquoi les tutoriels le font ?
Section intitulée « Pourquoi les tutoriels le font ? »Quand Terraform (ou vous) crée un fichier dans /var/lib/libvirt/images, il peut avoir les mauvais labels de sécurité. libvirt refuse alors de démarrer la VM avec une erreur de permission.
La solution "facile" (et dangereuse) : désactiver le driver de sécurité.
La vraie solution
Section intitulée « La vraie solution »Elle diffère selon le module de sécurité de votre distribution : AppArmor sur Debian et Ubuntu, SELinux sur RHEL, Rocky et AlmaLinux. Dans les deux cas, il s'agit d'autoriser le chemin du pool au lieu de désarmer le mécanisme.
-
Vérifier le profil AppArmor
Fenêtre de terminal sudo aa-status | grep libvirt -
Ajouter le chemin au profil si nécessaire
Si vous utilisez un pool personnalisé (ex:
/data/vms), modifiez :Fenêtre de terminal sudo nano /etc/apparmor.d/local/usr.sbin.libvirtdAjoutez :
/data/vms/** rwk, -
Recharger AppArmor
Fenêtre de terminal sudo systemctl reload apparmorsudo systemctl restart libvirtd
-
Vérifier le contexte des fichiers
Fenêtre de terminal ls -Z /var/lib/libvirt/images/Les fichiers doivent avoir le contexte
svirt_image_t. -
Restaurer le contexte si incorrect
Fenêtre de terminal sudo restorecon -Rv /var/lib/libvirt/images/ -
Pour un chemin personnalisé
Fenêtre de terminal sudo semanage fcontext -a -t svirt_image_t "/data/vms(/.*)?"sudo restorecon -Rv /data/vms/
Permissions du pool par défaut
Section intitulée « Permissions du pool par défaut »Avant de soupçonner AppArmor ou SELinux, vérifiez les permissions UNIX classiques du répertoire : un pool créé à la main avec un mauvais propriétaire produit exactement la même erreur.
# Vérifier le propriétairels -la /var/lib/libvirt/images/
# Doit appartenir à root:root ou libvirt-qemu selon la distro# Avec les permissions 711 sur le dossierMigration 0.8 → 0.9
Section intitulée « Migration 0.8 → 0.9 »Si vous avez des configurations existantes avec le provider 0.7 ou 0.8, voici les changements à appliquer. La branche 0.9.x n'est pas une évolution incrémentale : les attributs plats ont été remplacés par des objets imbriqués, et les blocs répétables par des listes. Une configuration existante ne se corrige donc pas ligne à ligne, elle se réécrit.
Tableau récapitulatif des changements
Section intitulée « Tableau récapitulatif des changements »Chaque ligne indique la forme ancienne et son remplaçant. Les deux dernières ne sont pas des changements du provider mais des ajouts devenus nécessaires avec libvirt 10 et les distributions récentes.
| Élément | Provider 0.8.x | Provider 0.9.x |
|---|---|---|
| libvirt_volume | source, format | create.content.url, target.format.type |
| backing file | base_volume_id | backing_store = { path, format } |
| libvirt_cloudinit_disk | meta_data optionnel | meta_data obligatoire |
| libvirt_domain | blocs disk {}, network_interface {} | devices = { disks = [...], interfaces = [...] } |
| driver qcow2 | auto-détecté | explicite obligatoire |
| récupération IP | network_interface[0].addresses | data.libvirt_domain_interface_addresses |
| CPU pour distros récentes | implicite | cpu = { mode = "host-passthrough" } souvent obligatoire |
| Secure Boot UEFI | non auto-enrôlé | auto-enrôlé sur libvirt 10+ → firmware_info.features à désactiver |
Exemple de migration : libvirt_volume
Section intitulée « Exemple de migration : libvirt_volume »Le changement le plus visible : une ressource unique devient deux. En 0.8, un seul libvirt_volume référençait l'image source ; en 0.9, l'image de base est importée dans le pool par sa propre ressource, que le disque de la VM référence ensuite comme backing store.
resource "libvirt_volume" "os_disk" { name = "vm.qcow2" pool = "default" source = "/var/lib/libvirt/images/base.img" format = "qcow2" size = 20 * 1024 * 1024 * 1024}# Image de base (nouvelle ressource séparée)resource "libvirt_volume" "base" { name = "base.qcow2" pool = "default"
target = { format = { type = "qcow2" } }
create = { content = { url = "/var/lib/libvirt/images/base.img" } }}
# Disque VM avec backing storeresource "libvirt_volume" "os_disk" { name = "vm.qcow2" pool = "default" capacity = 20 * 1024 * 1024 * 1024
target = { format = { type = "qcow2" } }
backing_store = { path = libvirt_volume.base.path format = { type = "qcow2" } }}Exemple de migration : libvirt_domain
Section intitulée « Exemple de migration : libvirt_domain »Pour le domaine, tous les blocs répétables (disk, network_interface, console) disparaissent au profit d'un objet devices contenant des listes. Notez aussi la disparition de qemu_agent et de l'attribut cloudinit : l'ISO Cloud-Init devient un disque comme un autre, déclaré en CDROM dans devices.disks.
resource "libvirt_domain" "vm" { name = "my-vm" memory = 2048 vcpu = 2
disk { volume_id = libvirt_volume.os_disk.id }
network_interface { network_name = "default" wait_for_lease = true }
cloudinit = libvirt_cloudinit_disk.init.id
console { type = "pty" target_port = "0" target_type = "serial" }
qemu_agent = true}resource "libvirt_domain" "vm" { name = "my-vm" type = "kvm" memory = 2048 memory_unit = "MiB" # sinon interprété en KiB (défaut) = 2 MiB vcpu = 2
os = { type = "hvm" } features = { acpi = true }
devices = { disks = [ { driver = { name = "qemu", type = "qcow2" } source = { volume = { pool = "default" volume = libvirt_volume.os_disk.name } } target = { dev = "vda", bus = "virtio" } }, { device = "cdrom" driver = { name = "qemu", type = "raw" } source = { volume = { pool = "default" volume = libvirt_volume.cloudinit.name } } target = { dev = "sda", bus = "sata" } } ]
interfaces = [ { type = "network" model = { type = "virtio" } source = { network = { network = "default" } } } ]
serials = [ { type = "pty" target = { port = 0, type = "isa-serial" } } ]
graphics = [{ spice = { autoport = "yes" } }] }
running = true}Partie 6 : Créer plusieurs VMs
Section intitulée « Partie 6 : Créer plusieurs VMs »Terraform propose deux mécanismes de duplication, et le choix entre les deux se joue sur une question : les machines sont-elles interchangeables ? count produit une liste indexée, adaptée à des VMs identiques ; for_each produit une collection nommée, indispensable dès que chaque machine a ses propres caractéristiques.
Avec count
Section intitulée « Avec count »count crée N copies d'une ressource, accessibles par leur position (count.index, à partir de 0). Le revers tient à cette indexation : retirer une VM du milieu décale toutes les suivantes, que Terraform détruira puis recréera. Pour créer 3 VMs identiques, modifiez variables.tf :
variable "vm_count" { description = "Nombre de VMs à créer" type = number default = 3}Et adaptez main.tf (syntaxe 0.9.x) :
# Volume de base (partagé par toutes les VMs)resource "libvirt_volume" "base" { name = "ubuntu-24.04-base.qcow2" pool = var.pool_name
target = { format = { type = "qcow2" } }
create = { content = { url = var.base_image_path } }}
# Volumes OS pour chaque VM (overlay CoW)resource "libvirt_volume" "os_disk" { count = var.vm_count name = "${var.vm_name}-${count.index + 1}.qcow2" pool = var.pool_name capacity = var.disk_size_gb * 1024 * 1024 * 1024
target = { format = { type = "qcow2" } }
backing_store = { path = libvirt_volume.base.path format = { type = "qcow2" } }}
# Cloud-init pour chaque VMresource "libvirt_cloudinit_disk" "init" { count = var.vm_count name = "${var.vm_name}-${count.index + 1}-cloudinit"
user_data = templatefile("${path.module}/cloudinit/user-data.yaml", { hostname = "${var.vm_name}-${count.index + 1}" public_key = file(pathexpand(var.ssh_public_key_path)) })
meta_data = yamlencode({ instance-id = "${var.vm_name}-${count.index + 1}" local-hostname = "${var.vm_name}-${count.index + 1}" })
network_config = file("${path.module}/cloudinit/network-config.yaml")}
# Volumes cloud-initresource "libvirt_volume" "cloudinit" { count = var.vm_count name = "${var.vm_name}-${count.index + 1}-cloudinit.iso" pool = var.pool_name
create = { content = { url = libvirt_cloudinit_disk.init[count.index].path } }}
# Domaines (VMs)resource "libvirt_domain" "vm" { count = var.vm_count name = "${var.vm_name}-${count.index + 1}" type = "kvm" memory = var.memory_mb memory_unit = "MiB" # défaut du provider = KiB, toujours expliciter vcpu = var.vcpu
os = { type = "hvm" } features = { acpi = true }
devices = { disks = [ { driver = { name = "qemu", type = "qcow2" } source = { volume = { pool = var.pool_name volume = libvirt_volume.os_disk[count.index].name } } target = { dev = "vda", bus = "virtio" } }, { device = "cdrom" driver = { name = "qemu", type = "raw" } source = { volume = { pool = var.pool_name volume = libvirt_volume.cloudinit[count.index].name } } target = { dev = "sda", bus = "sata" } } ] interfaces = [ { type = "network" model = { type = "virtio" } source = { network = { network = var.network_name } } } ] serials = [{ type = "pty", target = { port = 0, type = "isa-serial" } }] graphics = [{ spice = { autoport = "yes" } }] }
running = true}Adaptez outputs.tf :
# Data source pour chaque VMdata "libvirt_domain_interface_addresses" "vm" { count = var.vm_count domain = libvirt_domain.vm[count.index].name source = "lease"
depends_on = [libvirt_domain.vm]}
output "vms" { description = "Informations sur les VMs créées" value = { for i, vm in libvirt_domain.vm : vm.name => { id = vm.id ip = try(data.libvirt_domain_interface_addresses.vm[i].interfaces[0].addrs[0].addr, "Non disponible") } }}Avec for_each (VMs différentes)
Section intitulée « Avec for_each (VMs différentes) »for_each parcourt une map, ici un dictionnaire dont chaque clé nomme une machine et chaque valeur porte son dimensionnement. Comme l'identité de la ressource repose sur la clé et non sur un rang, supprimer une entrée ne touche pas aux autres. C'est la forme à privilégier pour un cluster où les rôles diffèrent :
variable "vms" { description = "Map des VMs à créer" type = map(object({ memory = number vcpu = number disk = number })) default = { "master" = { memory = 4096, vcpu = 2, disk = 40 } "worker1" = { memory = 2048, vcpu = 2, disk = 20 } "worker2" = { memory = 2048, vcpu = 2, disk = 20 } }}Partie 7 : Lifecycle et bonnes pratiques
Section intitulée « Partie 7 : Lifecycle et bonnes pratiques »Trois sujets se révèlent seulement après quelques semaines d'usage : l'empilement des couches de disque au fil des applications successives, le fait que Cloud-Init ne rejoue pas sa configuration sur une VM existante, et la fragilité du fichier d'état. Aucun ne pose problème le premier jour, tous en posent au bout d'un mois.
Éviter les chaînes QCOW2 incontrôlées
Section intitulée « Éviter les chaînes QCOW2 incontrôlées »Quand vous faites terraform apply plusieurs fois avec des modifications de disque, vous pouvez créer des chaînes de snapshots involontaires.
Règle : si vous voulez une VM "propre", faites un terraform destroy puis terraform apply, pas des apply successifs.
Re-exécuter Cloud-Init
Section intitulée « Re-exécuter Cloud-Init »Cloud-Init ne s'exécute qu'une fois par instance. Pour le rejouer :
# Option 1 : Supprimer l'état Cloud-Init (VM existante)sudo cloud-init cleansudo rm /etc/machine-idsudo reboot
# Option 2 : Recréer la VM (recommandé avec Terraform)terraform destroyterraform applySauvegarder le state
Section intitulée « Sauvegarder le state »Le fichier terraform.tfstate contient l'état de votre infrastructure. Sauvegardez-le :
# Sauvegarder localementcp terraform.tfstate terraform.tfstate.backup
# Ou utilisez un backend distant (S3, GitLab, etc.)Partie 8 : Variantes
Section intitulée « Partie 8 : Variantes »La configuration proposée jusqu'ici couvre le cas le plus courant, un lab isolé derrière le réseau NAT de libvirt. Trois adaptations reviennent régulièrement : exposer les VMs sur le réseau physique, leur ajouter un disque de données séparé du système, et remplacer Terraform par OpenTofu.
Utiliser un bridge au lieu de NAT
Section intitulée « Utiliser un bridge au lieu de NAT »Si vous avez configuré un bridge (voir guide réseaux), modifiez la section interfaces dans le bloc devices :
# Syntaxe 0.9.x pour bridgeinterfaces = [ { type = "bridge" model = { type = "virtio" } source = { bridge = { bridge = "br0" } } }]Ajouter un disque de données
Section intitulée « Ajouter un disque de données »Séparer le système et les données facilite le redimensionnement et la sauvegarde : le disque de données survit à une recréation du disque OS. Il se déclare comme un volume ordinaire, sans backing_store puisqu'il part vide, puis s'ajoute à la liste devices.disks avec un target.dev libre.
# Volume de données (syntaxe 0.9.x)resource "libvirt_volume" "data_disk" { name = "${var.vm_name}-data.qcow2" pool = var.pool_name capacity = 50 * 1024 * 1024 * 1024 # 50 Go
target = { format = { type = "qcow2" } }}
# Dans libvirt_domain, ajouter le disque dans devices.disks :devices = { disks = [ # ... disque OS existant ... { driver = { name = "qemu", type = "qcow2" } source = { volume = { pool = var.pool_name volume = libvirt_volume.data_disk.name } } target = { dev = "vdb" # Second disque bus = "virtio" } } ] # ... reste de la config ...}Utiliser OpenTofu
Section intitulée « Utiliser OpenTofu »OpenTofu est un fork open source de Terraform, compatible avec les configurations existantes.
# Remplacer terraform par tofutofu inittofu plantofu applyNettoyage
Section intitulée « Nettoyage »terraform destroy supprime les cinq ressources dans l'ordre inverse de leur création, VM d'abord, volumes ensuite. L'opération est irréversible et emporte le disque de la machine avec son contenu : il n'y a ni confirmation par ressource, ni corbeille. Pour supprimer toutes les ressources créées :
terraform destroyVérifiez que tout est supprimé :
virsh list --allvirsh vol-list defaultÀ retenir
Section intitulée « À retenir »- Provider 0.9.x : structure complètement différente de 0.8.x, consultez la section Migration
- Driver qcow2 explicite : sans
driver = { name = "qemu", type = "qcow2" }, la VM ne boot pas - Memory :
memory_unitobligatoire : l'unité par défaut du provider est KiB, pas MiB. Sansmemory_unit = "MiB",memory = 2048alloue 2 MiB et la VM ne boote pas (kernel qui thrashe, aucun réseau). Toujours explicitermemory_unit = "MiB". - meta_data obligatoire :
libvirt_cloudinit_diskrequiertmeta_data = yamlencode({...}) - Cloud-Init réseau : utilisez
match: { driver: virtio* }au lieu deeth0 - Récupération IP : data source
libvirt_domain_interface_addressesavecsource = "lease" - Sécurité : ne désactivez jamais
security_driver, corrigez les permissions - CPU host-passthrough : obligatoire pour AlmaLinux/RHEL 10 et Fedora récents, sinon stuck dans
/initau boot - Secure Boot UEFI : auto-enrôlé sur libvirt 10+ → désactiver via
firmware_info.featurespour les images cloud Linux non signées MS - Debug :
virsh console+cloud-init status --long+virsh net-dhcp-leases default
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Gérer le state Terraform : Le fichier d'état devient la mémoire de votre parc de VMs, à sauvegarder et verrouiller comme tel.
- Créer des modules Terraform : La définition d'une VM libvirt se factorise en module réutilisable et versionné.
- Cloud-init : automatiser la configuration des VMs : Les directives passées à
libvirt_cloudinit_diskméritent d'être maîtrisées au-delà deusersetpackages.