Aller au contenu
English
English
Virtualisation medium

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

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

  • Écrire un template Terraform réutilisable pour créer des VMs KVM
  • Construire une configuration Cloud-Init robuste : SSH, hostname, paquets
  • Épingler le provider communautaire et comprendre ce que cela implique
  • Appliquer les bonnes pratiques de sécurité, sans security_driver = none
  • Dépanner un déploiement sans jamais ouvrir d'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.

Une image cloud n'est pas une ISO d'installation : c'est un disque QCOW2 déjà installé, réduit au strict minimum, qui attend sa configuration de Cloud-Init au premier démarrage. Elle se télécharge une seule fois et sert ensuite de base commune à toutes les VM du projet, d'où le répertoire base séparé :

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. Ce sous-dossier compte : les fichiers qu'il contient sont lus par templatefile() et file() à travers path.module, la variable qui désigne le répertoire du projet, et non le répertoire depuis lequel vous lancez terraform :

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

Un point mérite d'être dit avant d'aller plus loin : dmacvicar/libvirt est un provider communautaire. Ni HashiCorp ni le projet libvirt ne le maintiennent, et il ne porte donc aucun engagement de support ni cycle de publication garanti. Cela ne le disqualifie pas, c'est la voie de référence pour piloter libvirt en Terraform, mais deux conséquences se planifient plutôt qu'elles ne se subissent : une version de libvirt peut sortir avant que le provider ne la gère, et un correctif attendu dépend de la disponibilité de ses mainteneurs. D'où les deux réflexes qui suivent, épingler la version et committer .terraform.lock.hcl, pour que l'équipe et la CI utilisent exactement le même binaire.

La seconde ligne du bloc provider mérite autant d'attention que la contrainte de version. L'URI de connexion décide de l'endroit où vos machines vont naître, et les deux valeurs possibles n'offrent pas les mêmes capacités : le mode système donne accès au pool partagé et à tous les modes réseau, le mode session reste confiné à l'utilisateur courant avec un réseau en mode usermode, beaucoup plus limité.

URIAccèsPool par défautRéseau
qemu:///systemTous les utilisateurs/var/lib/libvirt/imagesComplet (NAT, bridge)
qemu:///sessionUtilisateur courant~/.local/share/libvirt/imagesLimité (usermode)

Retenez qemu:///system pour la quasi-totalité des cas : c'est le mode le plus proche d'un usage de production, et le seul qui permette d'attacher une VM à un bridge.

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 de configuration des machines cloud au premier démarrage : hostname, utilisateurs, clés SSH, paquets et commandes de fin. Le projet en utilise deux fichiers, séparés par rôle : user-data.yaml décrit le système et les comptes, network-config.yaml décrit uniquement les interfaces réseau. Le premier est un gabarit rempli par Terraform, d'où les ${hostname} et ${public_key} qui y figurent.

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

Le match sur le pilote, et non sur un nom d'interface, est le point à retenir de ce fichier. Les images cloud Ubuntu modernes emploient des noms prédictibles dérivés de l'emplacement matériel (enp1s0, ens3), et eth0 n'existe plus. Écrire match: { driver: virtio* } cible donc toutes les interfaces virtio quel que soit le nom que la VM leur donnera au démarrage, ce qui rend le gabarit réutilisable d'une machine à l'autre.

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.9...
    Terraform has been successfully initialized!

    Le numéro de correctif dépend du jour : la contrainte ~> 0.9.0 retient la version la plus récente de la branche 0.9, 0.9.9 au 19 septembre 2026, et refuse tout passage en 0.10. Le fichier .terraform.lock.hcl fige ensuite ce choix pour toute l'équipe.

  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

Le second piège produit exactement le même symptôme, un blocage silencieux dans /init, pour une raison totalement différente : le modèle de processeur. Sans cpu = { mode = "host-passthrough" }, libvirt expose à la VM un CPU qemu64 générique qui n'a ni AES-NI, ni SSE4, ni AVX. Les noyaux d'AlmaLinux 10, de RHEL 10 et des Fedora récents lancent des appels cryptographiques au démarrage qui exigent ces instructions modernes, et la machine se fige au chargement du premier module.

La vérification se fait après l'apply : virsh dumpxml <vm> | grep cpu doit afficher mode='host-passthrough'. Un mode='custom' accompagné de <model>qemu64</model> désigne la cause sans ambiguïté. Le mode host-passthrough a une contrepartie, la migration à chaud devient impossible vers un hôte doté d'un processeur différent : acceptable en homelab et en formation, à peser en production multi-hôte.

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.

Le raccourci s'explique, même s'il ne se justifie pas. Quand Terraform, ou vous, crée un fichier dans /var/lib/libvirt/images, ce fichier peut porter de mauvais labels de sécurité : libvirt refuse alors de démarrer la machine avec une erreur de permission, et le message ne dit pas que le coupable est AppArmor ou SELinux. Face à une erreur opaque, couper le mécanisme entier est la solution qui demande le moins de réflexion, et c'est celle qui circule le plus.

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.

Un disque QCOW2 posé en copie sur écriture ne contient que ses différences avec l'image de base : c'est ce qui rend la création instantanée. Le revers apparaît quand des terraform apply successifs modifient la définition du disque, car chaque couche s'ajoute à la précédente au lieu de la remplacer. La chaîne s'allonge, les performances baissent, et la suppression d'un maillon intermédiaire rend tous les suivants illisibles.

Règle : si vous voulez une VM propre, enchaînez terraform destroy puis terraform apply plutôt que des apply successifs.

Cloud-Init ne s'exécute qu'une fois par instance, et il reconnaît une instance à son instance-id et à son machine-id. Modifier user-data.yaml puis relancer un apply ne change donc rien à une VM déjà démarrée : le fichier est bien régénéré, mais la machine ne le relit jamais. Deux sorties existent, l'une locale et l'autre passant par Terraform :

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 est la mémoire de Terraform : il associe chaque ressource déclarée à l'objet réel créé dans libvirt. Le perdre ne détruit aucune VM, mais Terraform ne les reconnaît plus et voudra toutes les recréer, en butant sur des noms déjà pris. C'est le seul fichier du projet qui ne se régénère pas, et il peut contenir des valeurs sensibles : il ne se met pas dans Git avec les .tf.

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.

En NAT, la VM est joignable depuis l'hôte mais invisible du reste du réseau ; en bridge, elle reçoit une adresse du DHCP du LAN et devient une machine comme les autres. Côté Terraform, le changement se réduit à trois lignes : le type passe de network à bridge, et le bloc source cite le nom du bridge au lieu du réseau libvirt. 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 qui lit les mêmes fichiers .tf, le même state et les mêmes providers : aucune ligne de la configuration précédente ne change, seul le nom de la commande diffère. C'est la porte de sortie si le changement de licence de Terraform pose un problème dans votre contexte.

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

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  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
  • Projet final : Enchaîner à la main ce que Terraform vient d'automatiser, et mesurer chaque étape sur deux hôtes.
  • Examen final : Vérifier ce qui est acquis sur l'ensemble du parcours.

Ce site vous est utile ?

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

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. 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