Este tutorial é a evolução de Criando uma VM KVM/Libvirt com Terraform e Cloud-Init: em vez de valores fixos no código, toda a configuração vem de variáveis, e as VMs são definidas em um mapa de servidores — adicionar uma nova VM é acrescentar uma entrada no servers.auto.tfvars, sem tocar no HCL dos recursos.
Código completo: pasta
parte-2-multiplas-vmsdo repositório da série no GitLab.
O que muda em relação ao tutorial anterior:
| Antes (hardcoded) | Agora (parametrizado) |
|---|---|
| Uma VM, valores fixos nos recursos | N VMs, definidas no mapa servers (for_each) |
| Chave SSH lida de arquivo no código | Variável sensitive, passada por ambiente ou -var |
| Template Debian única | Perfis de SO (os_profiles): template e grupos por distribuição |
| Usuário fixo no user-data | Usuário padrão configurável (default_vm_user) |
| Recursos sem chave no state | Recursos indexados: libvirt_domain.domain["vm-tf"] |
~/.ssh/kvm;templates (seção 3 do tutorial anterior);terraform-kvm-libvirt/
├── provider.tf # Provedor libvirt
├── variables.tf # Declaração de todas as variáveis
├── terraform.tfvars # Valores globais (pools, perfis de SO, usuário)
├── servers.auto.tfvars # Mapa de VMs (o arquivo que você mais edita)
├── cloudinit.tf # Disco de cloud-init por VM
├── volumes.tf # Volume qcow2 por VM
├── domain.tf # Definição das VMs
└── user-data.yaml # Template do cloud-init
A separação tem propósito: terraform.tfvars guarda o que muda raramente (infraestrutura), enquanto servers.auto.tfvars guarda o que muda sempre (as VMs). O sufixo .auto.tfvars faz o Terraform carregar o arquivo automaticamente, sem -var-file.
# provider.tf
terraform {
required_version = "~> 1.14"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.9"
}
}
}
provider "libvirt" {
uri = var.libvirt_uri
}
A sintaxe é a série 0.9 do provider — incompatível com exemplos da 0.8. Detalhes no tutorial anterior.
# variables.tf
variable "libvirt_uri" {
description = "URI de conexão com o libvirt"
type = string
default = "qemu:///system"
}
variable "ssh_public_key" {
description = "Chave pública SSH injetada no usuário da VM"
type = string
sensitive = true
}
variable "pool_images" {
description = "Pool libvirt onde os volumes das VMs serão criados"
type = string
default = "default"
}
variable "path_templates" {
description = "Diretório onde estão as templates qcow2"
type = string
}
variable "user-data" {
description = "Arquivo de template do cloud-init user-data"
type = string
default = "user-data.yaml"
}
variable "os_profiles" {
description = "Perfis de SO: template qcow2 e grupos padrão do usuário"
type = map(object({
template_name = string
default_groups = list(string)
}))
}
variable "default_vm_user" {
description = "Usuário padrão criado via cloud-init"
type = object({
name = string
gecos = string
})
}
variable "servers" {
description = "Mapa de VMs a serem criadas"
type = map(object({
vcpus = number
memory_mib = number
disk_size_gb = number
base_os = string
network_name = string
description = string
}))
}
Dois destaques:
sensitive = true na chave SSH: o valor não aparece no plan nem no show (o state exibe (sensitive value)), evitando vazamento em logs e pipelines;os_profiles desacopla a VM da distribuição: para suportar Ubuntu ou Oracle Linux, basta baixar a template e adicionar uma entrada no mapa — nenhum recurso HCL muda.# terraform.tfvars
pool_images = "default"
path_templates = "/datastore/templates"
user-data = "user-data.yaml"
os_profiles = {
"debian" = {
template_name = "debian-13-generic-amd64.qcow2"
default_groups = ["users", "sudo"]
}
}
default_vm_user = {
name = "suporte"
gecos = "Suporte User"
}
# servers.auto.tfvars
servers = {
"vm-tf" = {
vcpus = 2
memory_mib = 2048
disk_size_gb = 16
base_os = "debian"
network_name = "default"
description = "VM Terraform — Exemplo usando variáveis"
}
# Para uma segunda VM, basta adicionar outra entrada aqui:
# "vm-tf2" = { vcpus = 1, memory_mib = 1024, ... }
}
Fora do laboratório, nomeie as chaves do mapa seguindo a taxonomia de nomenclatura (
lab-nat-web01,lab-nat-db01...) — a chave vira o nome da VM no hypervisor, o hostname interno e o prefixo dos volumes.
# cloudinit.tf
resource "libvirt_cloudinit_disk" "common_init" {
for_each = var.servers
name = "${each.key}_init.iso"
user_data = templatefile("${path.module}/${var.user-data}", {
ssh_key = trimspace(var.ssh_public_key)
vm_user = var.default_vm_user
groups = join(", ", var.os_profiles[each.value.base_os].default_groups)
})
meta_data = yamlencode({
instance-id = each.key
local-hostname = each.key
})
}
resource "libvirt_volume" "common_init_volume" {
for_each = var.servers
name = "${each.key}_init.iso"
pool = var.pool_images
create = {
content = {
url = libvirt_cloudinit_disk.common_init[each.key].path
}
}
}
# user-data.yaml
#cloud-config
manage_etc_hosts: true
users:
- name: ${vm_user.name}
gecos: "${vm_user.gecos}"
sudo: "ALL=(ALL) NOPASSWD:ALL"
groups: ${groups}
shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- ${ssh_key}
Note como for_each transforma o código: each.key é o nome da VM ("vm-tf"), each.value o objeto com seus atributos, e cada recurso passa a ser indexado — libvirt_volume.os_volume["vm-tf"]. O mesmo alerta do tutorial anterior vale aqui: sudo NOPASSWD é prática de laboratório.
# volumes.tf
resource "libvirt_volume" "os_volume" {
for_each = var.servers
name = "${each.key}.qcow2"
pool = var.pool_images
capacity = each.value.disk_size_gb * 1024 * 1024 * 1024
target = {
format = {
type = "qcow2"
}
}
backing_store = {
path = "${var.path_templates}/${var.os_profiles[each.value.base_os].template_name}"
format = {
type = "qcow2"
}
}
}
O domain.tf é idêntico ao do tutorial anterior, com três diferenças: for_each = var.servers, os atributos vindos de each.value (nome, memória, vCPU, descrição, rede) e as referências aos volumes indexadas por [each.key]:
# domain.tf (trechos que mudaram)
resource "libvirt_domain" "domain" {
for_each = var.servers
name = each.key
description = each.value.description
memory = each.value.memory_mib
vcpu = each.value.vcpus
# ... restante idêntico ao tutorial anterior, exceto:
# nos discos:
# pool = libvirt_volume.os_volume[each.key].pool
# volume = libvirt_volume.os_volume[each.key].name
# no cdrom de cloud-init:
# pool = libvirt_volume.common_init_volume[each.key].pool
# volume = libvirt_volume.common_init_volume[each.key].name
# na interface:
# network = each.value.network_name
}
O
domain.tfcompleto está no tutorial anterior — aplique apenas as substituições acima. Manter o arquivo íntegro em um só lugar evita divergência entre os guias.
# 1. Chave pública SSH (variável sensível — via ambiente, não fica no histórico do shell como -var)
export TF_VAR_ssh_public_key="$(cat ~/.ssh/kvm.pub)"
# 2. (Opcional) URI do libvirt, se diferente de qemu:///system
export TF_VAR_libvirt_uri="qemu:///system"
# 3. Fluxo normal
terraform init
terraform validate
terraform plan # deve mostrar 4 recursos POR VM
terraform apply
Com for_each, o state passa a ser indexado pelas chaves do mapa — é o que permite criar, alterar ou destruir uma VM sem afetar as demais:
terraform state list
libvirt_cloudinit_disk.common_init["vm-tf"]
libvirt_domain.domain["vm-tf"]
libvirt_volume.common_init_volume["vm-tf"]
libvirt_volume.os_volume["vm-tf"]
virsh list
Id Name State
-----------------------
1 vm-tf running
virsh domifaddr vm-tf # descobre o IP (ex.: 192.168.122.55)
ssh -i ~/.ssh/kvm suporte@192.168.122.55
Para remover uma única VM, apague a entrada dela no servers.auto.tfvars e rode terraform apply — o Terraform destruirá só os recursos daquela chave. terraform destroy remove tudo.
outputs.tf: exporte os IPs das VMs (requer qemu-guest-agent na template) para montar inventário Ansible automaticamente;default;for_each meta-argument e expressões eachsensitive e arquivos .auto.tfvars)