Aller au contenu
Virtualisation medium

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

38 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
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é │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
  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
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 ""
Fenêtre de terminal
mkdir -p ~/terraform-kvm/cloudinit
cd ~/terraform-kvm

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

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

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 * 1024 # Attention : en KiB, pas MiB !
vcpu = var.vcpu
os = {
type = "hvm"
}
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

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

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"
)
}
  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.8"...
    - Installing dmacvicar/libvirt v0.8.1...
    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 3 ressources seront créées :

    • libvirt_volume.os_disk
    • libvirt_cloudinit_disk.init
    • libvirt_domain.vm
  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
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
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

Si la VM ne démarre pas ou n’obtient pas d’IP, la console série est votre meilleur ami :

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

Pour quitter : Ctrl + ]

Depuis la console ou SSH :

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

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

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

É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 = [...] }
memoryen MiBen KiB (multiplier par 1024)
driver qcow2auto-détectéexplicite obligatoire
récupération IPnetwork_interface[0].addressesdata.libvirt_domain_interface_addresses
resource "libvirt_volume" "os_disk" {
name = "vm.qcow2"
pool = "default"
source = "/var/lib/libvirt/images/base.img"
format = "qcow2"
size = 20 * 1024 * 1024 * 1024
}
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
}

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 * 1024
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")
}
}
}

Pour des VMs avec des specs différentes :

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

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

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

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 en KiB : memory = 2048 * 1024 pour 2 Go (pas 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. 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 tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.