Aller au contenu
Virtualisation medium

Terraform + libvirt (KVM) : déployer des VMs avec Cloud-Init

60 min de lecture

Logo KVM

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
ÉlémentVersionVérification
KVM/libvirtUbuntu 22.04+ ou équivalentvirsh version
Terraform ou OpenTofu≥ 1.6.0terraform version ou tofu version
Image cloudUbuntu 24.04 QCOW2Téléchargement ci-dessous
Clé SSHEd25519 recommandéels ~/.ssh/id_ed25519.pub

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.yaml

Architecture 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é │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

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.

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.

  1. Vérifier que libvirtd fonctionne

    Fenêtre de terminal
    systemctl is-active libvirtd

    Sortie attendue : active

  2. 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
  3. Tester l'accès à qemu:///system

    Fenêtre de terminal
    virsh -c qemu:///system list --all

    Cette commande doit s'exécuter sans sudo. Si elle échoue, voir la section Dépannage.

Les images cloud sont des QCOW2 minimalistes, optimisées pour Cloud-Init :

Fenêtre de terminal
# Créer un dossier pour les images de base
sudo mkdir -p /var/lib/libvirt/images/base
# Télécharger Ubuntu 24.04 cloud image
sudo 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échargement
qemu-img info /var/lib/libvirt/images/base/ubuntu-24.04-server-cloudimg-amd64.img

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.

Fenêtre de terminal
# Générer une clé Ed25519 (plus sécurisée que RSA)
ssh-keygen -t ed25519 -C "terraform-kvm" -f ~/.ssh/id_ed25519 -N ""

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.

Deux répertoires suffisent, le projet lui-même et un sous-dossier pour les gabarits Cloud-Init :

Fenêtre de terminal
mkdir -p ~/terraform-kvm/cloudinit
cd ~/terraform-kvm

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 :

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"
}

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 :

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"
}

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 :

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 configuration
resource "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 devices
resource "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
}

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 :

cloudinit/user-data.yaml
#cloud-config
# Hostname de la VM
hostname: ${hostname}
fqdn: ${hostname}.local
manage_etc_hosts: true
# Utilisateur par défaut
users:
- 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 à installer
packages:
- qemu-guest-agent # Indispensable pour récupérer l'IP
- curl
- vim
- htop
# Activer l'agent QEMU au démarrage
runcmd:
- systemctl enable --now qemu-guest-agent
# Autoriser SSH par clé uniquement
ssh_pwauth: false
# Mise à jour des paquets au premier boot
package_update: true
package_upgrade: false # Évite les surprises de temps
# Message final
final_message: |
Cloud-init terminé après $UPTIME secondes.
Hostname: ${hostname}
Connectez-vous avec: ssh ubuntu@<IP>

Créez cloudinit/network-config.yaml :

cloudinit/network-config.yaml
version: 2
ethernets:
default:
match:
driver: virtio* # Match n'importe quelle interface virtio
dhcp4: true
dhcp6: false

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 :

outputs.tf
# Data source pour récupérer les IPs via DHCP leases
data "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'usage
output "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"
)
}

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.

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.

  1. Initialiser Terraform

    Fenêtre de terminal
    cd ~/terraform-kvm
    terraform init

    Sortie 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!
  2. Valider la configuration

    Fenêtre de terminal
    terraform validate

    Sortie attendue : Success! The configuration is valid.

  3. Prévisualiser les changements

    Fenêtre de terminal
    terraform plan

    Vérifiez que 5 ressources seront créées, c'est-à-dire l'intégralité du main.tf précédent :

    • libvirt_volume.base, l'image cloud téléchargée, partagée entre les VM
    • libvirt_volume.os_disk, le disque système dérivé de cette base
    • libvirt_cloudinit_disk.init, la configuration cloud-init
    • libvirt_volume.cloudinit, le volume ISO qui la porte
    • libvirt_domain.vm, la machine virtuelle qui assemble le tout
  4. Appliquer

    Fenêtre de terminal
    terraform apply

    Tapez yes pour confirmer.

  5. Récupérer l'IP

    Fenêtre de terminal
    terraform output ip_addresses

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.

Fenêtre de terminal
# État de la VM
virsh list --all
# Détails
virsh dominfo tf-kvm-01
# IP via l'agent QEMU
virsh domifaddr tf-kvm-01 --source agent

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 :

Fenêtre de terminal
# Récupérer la commande SSH
terraform output ssh_command
# Ou directement (remplacez l'IP)
ssh ubuntu@192.168.122.xxx

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.

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.

Fenêtre de terminal
virsh console tf-kvm-01

Pour quitter : Ctrl + ]

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

Fenêtre de terminal
# Statut global
cloud-init status --long
# Logs détaillés
sudo cat /var/log/cloud-init-output.log
# Configuration appliquée
sudo cat /var/lib/cloud/instance/user-data.txt
StatutSignification
status: doneCloud-Init terminé avec succès
status: runningEncore en cours (patience)
status: errorErreur, consultez les logs

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ômeCause probableSolution
Error: virsh: command not foundlibvirt non installéGuide installation
failed to connect to the hypervisorlibvirtd arrêté ou permissionssudo systemctl start libvirtd + vérifier groupe
IP vide même après 1 minuteqemu-guest-agent non installéVérifier Cloud-Init user-data
Permission denied sur le poolDroits fichier ou AppArmorVoir section Sécurité ci-dessous
Disque corrompu après applyImage source modifiéeUtiliser une image "golden" en lecture seule
Boot bloqué dans /init (AlmaLinux/RHEL 10)Secure Boot enrôlé par libvirt + clés MSAjouter 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 modernesAjouter cpu = { mode = "host-passthrough" } au domain
Provider produced inconsistent result sur firmware_info.featuresBug provider 0.9.x : tri alphabétique au retourDé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 RAMAjouter 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 illisibleVérifier l'image (qemu-img check, tenter un montage) et la retélécharger depuis la source officielle

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

Fenêtre de terminal
# Logs en temps réel
journalctl -u libvirtd -f
# Erreurs récentes
journalctl -u libvirtd --since "10 minutes ago" | grep -i error

Partie 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.

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

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.

  1. Vérifier le profil AppArmor

    Fenêtre de terminal
    sudo aa-status | grep libvirt
  2. 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.libvirtd

    Ajoutez :

    /data/vms/** rwk,
  3. Recharger AppArmor

    Fenêtre de terminal
    sudo systemctl reload apparmor
    sudo systemctl restart libvirtd

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.

Fenêtre de terminal
# Vérifier le propriétaire
ls -la /var/lib/libvirt/images/
# Doit appartenir à root:root ou libvirt-qemu selon la distro
# Avec les permissions 711 sur le dossier

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.

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émentProvider 0.8.xProvider 0.9.x
libvirt_volumesource, formatcreate.content.url, target.format.type
backing filebase_volume_idbacking_store = { path, format }
libvirt_cloudinit_diskmeta_data optionnelmeta_data obligatoire
libvirt_domainblocs disk {}, network_interface {}devices = { disks = [...], interfaces = [...] }
driver qcow2auto-détectéexplicite obligatoire
récupération IPnetwork_interface[0].addressesdata.libvirt_domain_interface_addresses
CPU pour distros récentesimplicitecpu = { mode = "host-passthrough" } souvent obligatoire
Secure Boot UEFInon auto-enrôléauto-enrôlé sur libvirt 10+ → firmware_info.features à désactiver

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
}

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
}

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.

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 VM
resource "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-init
resource "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 VM
data "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")
}
}
}

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 }
}
}

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.

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.

Cloud-Init ne s'exécute qu'une fois par instance. Pour le rejouer :

Fenêtre de terminal
# Option 1 : Supprimer l'état Cloud-Init (VM existante)
sudo cloud-init clean
sudo rm /etc/machine-id
sudo reboot
# Option 2 : Recréer la VM (recommandé avec Terraform)
terraform destroy
terraform apply

Le fichier terraform.tfstate contient l'état de votre infrastructure. Sauvegardez-le :

Fenêtre de terminal
# Sauvegarder localement
cp terraform.tfstate terraform.tfstate.backup
# Ou utilisez un backend distant (S3, GitLab, etc.)

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.

Si vous avez configuré un bridge (voir guide réseaux), modifiez la section interfaces dans le bloc devices :

# Syntaxe 0.9.x pour bridge
interfaces = [
{
type = "bridge"
model = { type = "virtio" }
source = {
bridge = { bridge = "br0" }
}
}
]

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 ...
}

OpenTofu est un fork open source de Terraform, compatible avec les configurations existantes.

Fenêtre de terminal
# Remplacer terraform par tofu
tofu init
tofu plan
tofu apply

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 :

Fenêtre de terminal
terraform destroy

Vérifiez que tout est supprimé :

Fenêtre de terminal
virsh list --all
virsh vol-list default
  1. Provider 0.9.x : structure complètement différente de 0.8.x, consultez la section Migration
  2. Driver qcow2 explicite : sans driver = { name = "qemu", type = "qcow2" }, la VM ne boot pas
  3. Memory : memory_unit obligatoire : l'unité par défaut du provider est KiB, pas MiB. Sans memory_unit = "MiB", memory = 2048 alloue 2 MiB et la VM ne boote pas (kernel qui thrashe, aucun réseau). Toujours expliciter memory_unit = "MiB".
  4. meta_data obligatoire : libvirt_cloudinit_disk requiert meta_data = yamlencode({...})
  5. Cloud-Init réseau : utilisez match: { driver: virtio* } au lieu de eth0
  6. Récupération IP : data source libvirt_domain_interface_addresses avec source = "lease"
  7. Sécurité : ne désactivez jamais security_driver, corrigez les permissions
  8. CPU host-passthrough : obligatoire pour AlmaLinux/RHEL 10 et Fedora récents, sinon stuck dans /init au boot
  9. Secure Boot UEFI : auto-enrôlé sur libvirt 10+ → désactiver via firmware_info.features pour les images cloud Linux non signées MS
  10. Debug : virsh console + cloud-init status --long + virsh net-dhcp-leases default

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn