Esta é a parte 3 da série Terraform + KVM/Libvirt. Nas partes anteriores (VM única e múltiplas VMs com for_each), todas as VMs usavam a rede default do hypervisor. Agora vamos criar as redes como código e montar um cenário realista de duas zonas:
host KVM
│
┌─────────┴──────────┐
rede "external" rede "internal"
NAT 10.16.0.0/24 isolada 10.16.1.0/24
DHCP .128–.254 sem DHCP, sem IP no host
│ │
┌───┴────────┐ │
│ gateway │─────────────┘ enp2s0: 10.16.1.1
│ (Debian) │ enp1s0: DHCP
└────────────┘
┌────┴─────┐
│ vm-01 │ eth0: 10.16.1.2
│ (Oracle) │ gateway: 10.16.1.1
└──────────┘
Código completo: pasta
parte-3-redesdo repositório da série no GitLab.
O que muda em relação à parte 2:
| Antes | Agora |
|---|---|
Rede default (criada na instalação do KVM) |
Redes declaradas no Terraform (libvirt_network) |
| Uma interface por VM, sempre DHCP | N interfaces por VM, DHCP ou IP estático |
| Sem controle de gateway/DNS por VM | is_default_gateway / is_default_dns por interface |
| Uma distribuição (Debian) | Dois perfis de SO: Debian 13 e Oracle Linux 9 |
| Acesso direto às VMs | Acesso à rede isolada via ProxyJump |
templates:sudo wget -c https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2 -P /datastore/templates/
sudo wget -c https://yum.oracle.com/templates/OracleLinux/OL9/u8/x86_64/OL9U8_x86_64-kvm-b293.qcow2 -P /datastore/templates/
sudo virsh pool-refresh templates && sudo virsh vol-list templates
A template do Oracle Linux 9 tem 37 GiB de tamanho virtual — os discos das VMs baseadas nela precisam de
disk_size_gbmaior que isso (usamos 64).
.
├── provider.tf # Provedor libvirt (igual à parte 2)
├── variables.tf # Variáveis da parte 2 + networks, dns_servers e interfaces
├── network.tf # NOVO: redes libvirt
├── cloudinit.tf # AGORA TAMBÉM: network-config v2 por VM
├── volumes.tf # Igual à parte 2
├── domain.tf # Interfaces passam a ser dinâmicas
├── user-data.yaml # Igual à parte 2
├── networks.auto.tfvars # NOVO: mapa de redes
├── servers.auto.tfvars # VMs agora com lista de interfaces
└── terraform.tfvars # + dns_servers e perfil "oracle"
networks.auto.tfvars)# networks.auto.tfvars
networks = {
# EXTERNAL — NAT: VMs recebem IP via DHCP e saem para a internet pelo host
"external" = {
forward_mode = "nat"
ipv4_cidr = "10.16.0.0/24"
ipv6_cidr = "fd00:0:1::/64"
dhcp_ipv4_start = "10.16.0.128"
dhcp_ipv4_end = "10.16.0.254"
dhcp_ipv6_start = "fd00:0:1::100"
dhcp_ipv6_end = "fd00:0:1::1ff"
}
# INTERNAL — isolada: só sai para a internet através da VM gateway.
# Sem faixas DHCP: a bridge não recebe IP no host e os CIDRs são documentais.
# Os gateway_* NÃO são usados na criação da rede — alimentam as rotas
# default das VMs via cloud-init (apontam para a interface interna do gateway)
"internal" = {
forward_mode = "none"
ipv4_cidr = "10.16.1.0/24"
ipv6_cidr = "fd00:0:1:1::/64"
gateway_ipv4 = "10.16.1.1"
gateway_ipv6 = "fd00:0:1:1::1"
}
}
Modos de forward_mode no Libvirt:
| Modo | Comportamento |
|---|---|
nat |
VMs saem para fora mascaradas pelo IP do host (como a rede default) |
route |
Pacotes roteados sem NAT — exige rotas no host/rede física |
none |
Isolada: bridge sem saída; interligação só via VMs roteadoras |
bridge |
Bridge direta para uma interface física do host |
network.tf)# network.tf
locals {
# Só gera IP no host + DHCP quando a faixa DHCP foi definida;
# redes isoladas ficam sem bloco `ips` (bridge sem IP no host)
network_ips = {
for name, net in var.networks : name => concat(
net.ipv4_cidr != null && net.dhcp_ipv4_start != null && net.dhcp_ipv4_end != null ? [
{
family = "ipv4"
address = cidrhost(net.ipv4_cidr, 1) # host sempre no .1
prefix = tonumber(element(split("/", net.ipv4_cidr), 1))
dhcp = {
ranges = [
{ start = net.dhcp_ipv4_start, end = net.dhcp_ipv4_end }
]
}
}
] : [],
net.ipv6_cidr != null && net.dhcp_ipv6_start != null && net.dhcp_ipv6_end != null ? [
{
family = "ipv6"
address = cidrhost(net.ipv6_cidr, 1)
prefix = tonumber(element(split("/", net.ipv6_cidr), 1))
dhcp = {
ranges = [
{ start = net.dhcp_ipv6_start, end = net.dhcp_ipv6_end }
]
}
}
] : []
)
}
}
resource "libvirt_network" "network" {
for_each = var.networks
name = each.key
autostart = true
forward = {
mode = each.value.forward_mode
}
# DNS (dnsmasq) só faz sentido quando a rede tem IP no host
dns = length(local.network_ips[each.key]) > 0 ? {
enable = "yes"
} : null
ips = length(local.network_ips[each.key]) > 0 ? local.network_ips[each.key] : null
}
A lógica central: a presença das faixas DHCP decide o perfil da rede. Com faixas → o host assume o .1, dnsmasq entrega DHCP e DNS. Sem faixas → a bridge nasce "muda", sem IP no host — exatamente o que se quer de uma zona isolada.
# servers.auto.tfvars
#
# ATENÇÃO: a ORDEM da lista `networks` define os slots PCI e, portanto,
# os nomes das interfaces no SO (1ª = enp1s0, 2ª = enp2s0, ...)
servers = {
# GATEWAY — conecta a rede isolada à external
"gateway" = {
vcpus = 2
memory_mib = 2048
disk_size_gb = 16
base_os = "debian"
description = "Gateway — Roteia a rede interna para a internet"
networks = [
{
network_name = "external"
interface_name = "enp1s0"
ipv4_address = "dhcp"
ipv6_address = "dhcp"
},
{
network_name = "internal"
interface_name = "enp2s0"
ipv4_address = "10.16.1.1"
ipv4_prefix = 24
ipv6_address = "fd00:0:1:1::1"
ipv6_prefix = 64
is_default_gateway = false # não recebe rota default nesta interface
is_default_dns = false
}
]
}
# VM-01 — isolada; sai para a internet através do gateway
"vm-01" = {
vcpus = 2
memory_mib = 2048
disk_size_gb = 64 # a template OL9 tem 37 GiB virtuais
base_os = "oracle"
description = "VM-01 — VM da rede isolada"
networks = [
{
network_name = "internal"
interface_name = "eth0" # a template OL9 usa eth0, não enp1s0
ipv4_address = "10.16.1.2"
ipv4_prefix = 24
ipv6_address = "fd00:0:1:1::2"
ipv6_prefix = 64
is_default_gateway = true # rota default via gateway_ipv4/ipv6 da rede
is_default_dns = true # recebe os dns_servers globais
}
]
}
}
Correção em relação às anotações originais: a
vm-01estava combase_os = "debian", mas os comentários (template OL9, interfaceeth0) e a configuração gerada no guest (/etc/sysconfig/network-scripts/ifcfg-eth0, estilo RHEL) mostram que ela roda Oracle Linux — o valor correto é"oracle". Preste atenção nesse detalhe: o nome da interface (eth0×enp1s0) muda conforme a distribuição da template.
network-config v2O cloudinit.tf da parte 2 ganha um quarto bloco: network_config, gerado por VM a partir da sua lista de interfaces. A lógica, em linguagem humana:
ethernets no formato netplan v2;"dhcp" vira dhcp4/dhcp6: true; IP estático vira item em addresses;is_default_gateway = true cria as rotas default (0.0.0.0/0 e ::/0) apontando para o gateway_ipv4/gateway_ipv6 definidos na rede — é assim que a vm-01 aprende que sua saída é 10.16.1.1;is_default_dns = true aplica os dns_servers globais.# cloudinit.tf — trecho novo dentro de libvirt_cloudinit_disk
network_config = yamlencode({
version = 2
ethernets = {
for net in each.value.networks : net.interface_name => merge(
{
match = { name = net.interface_name }
set-name = net.interface_name
dhcp4 = net.ipv4_address == "dhcp"
dhcp6 = net.ipv6_address == "dhcp"
},
net.ipv4_address != "dhcp" || net.ipv6_address != "dhcp" ? {
addresses = concat(
net.ipv4_address != "dhcp" ? ["${net.ipv4_address}/${net.ipv4_prefix}"] : [],
net.ipv6_address != "dhcp" ? ["${net.ipv6_address}/${net.ipv6_prefix}"] : []
)
} : {},
net.is_default_gateway ? {
routes = concat(
try(var.networks[net.network_name].gateway_ipv4, null) != null ? [
{ to = "0.0.0.0/0", via = var.networks[net.network_name].gateway_ipv4 }
] : [],
try(var.networks[net.network_name].gateway_ipv6, null) != null ? [
{ to = "::/0", via = var.networks[net.network_name].gateway_ipv6 }
] : []
)
} : {},
net.is_default_dns && length(var.dns_servers) > 0 ? {
nameservers = {
addresses = var.dns_servers
}
} : {}
)
}
})
O cloud-init renderiza esse YAML no formato nativo de cada distribuição — netplan no Debian, ifcfg no Oracle Linux (veja a validação na seção 7).
No domain.tf, apenas o bloco interfaces muda — passa a ser gerado da lista de redes de cada VM:
# domain.tf — bloco interfaces (o restante é idêntico à parte 2)
interfaces = [
for net in each.value.networks : {
type = "network"
model = {
type = "virtio"
}
source = {
network = {
# Rede gerenciada por ESTE Terraform → referência garante a ordem
# de criação; rede externa (ex.: "default") → usa o nome direto
network = try(libvirt_network.network[net.network_name].name, net.network_name)
}
}
}
]
O try() tem dupla função: para redes declaradas no var.networks, a referência ao recurso cria a dependência implícita (o Terraform cria a rede antes da VM); para redes que já existem no hypervisor, cai no nome literal.
Variáveis novas em relação à parte 2: networks (mapa das redes), dns_servers (lista global) e a lista networks dentro de cada entrada de servers — as demais (libvirt_uri, ssh_public_key, os_profiles, default_vm_user etc.) são iguais. No terraform.tfvars, adicione:
# terraform.tfvars — acréscimos em relação à parte 2
dns_servers = ["8.8.4.4", "1.0.0.1", "2001:4860:4860::8844", "2606:4700:4700::1001"]
os_profiles = {
"debian" = {
template_name = "debian-13-generic-amd64.qcow2"
default_groups = ["users", "sudo"]
}
"oracle" = {
template_name = "OL9U8_x86_64-kvm-b293.qcow2"
default_groups = ["users", "wheel"] # família RHEL: grupo administrativo é wheel
}
}
export TF_VAR_ssh_public_key="$(cat ~/.ssh/kvm.pub)"
terraform init && terraform validate && terraform plan && terraform apply
O plan deve mostrar 10 recursos: 2 redes + 4 recursos por VM (cloudinit disk, volume init, volume OS, domínio). Após o apply:
virsh net-list
Name State Autostart Persistent
----------------------------------------------
default active yes yes
external active yes yes
internal active yes yes
virsh list
Id Name State
-------------------------
1 vm-01 running
2 gateway running
virsh domifaddr gateway
Name MAC address Protocol Address
-------------------------------------------------------------------------------
vnet6 52:54:00:f2:96:de ipv6 fd00:0:1::154/64
- - ipv4 10.16.0.131/24
O cloud-init aplicou a configuração no formato nativo de cada SO — no gateway (Debian, netplan):
ssh -i ~/.ssh/kvm suporte@10.16.0.131 'sudo cat /etc/netplan/50-cloud-init.yaml'
network:
version: 2
ethernets:
enp1s0:
match: { name: "enp1s0" }
dhcp4: true
dhcp6: true
set-name: "enp1s0"
enp2s0:
match: { name: "enp2s0" }
addresses:
- "10.16.1.1/24"
- "fd00:0:1:1::1/64"
dhcp4: false
dhcp6: false
set-name: "enp2s0"
E na vm-01 (Oracle Linux, ifcfg — note o GATEWAY e os DNS vindos das flags):
cat /etc/sysconfig/network-scripts/ifcfg-eth0
BOOTPROTO=none
DEVICE=eth0
IPADDR=10.16.1.2
GATEWAY=10.16.1.1
DNS1=8.8.4.4
DNS2=1.0.0.1
IPV6ADDR=fd00:0:1:1::2/64
IPV6_DEFAULTGW=fd00:0:1:1::1
A vm-01 não tem rota a partir do host — o acesso é saltando pelo gateway, exatamente o padrão do nosso guia Acesso às VMs do homelab:
# ~/.ssh/config
Host gateway
HostName 10.16.0.131
User suporte
IdentityFile ~/.ssh/kvm
Host vm-01
HostName 10.16.1.2
User suporte
IdentityFile ~/.ssh/kvm
ProxyJump gateway
ssh gateway # direto
ssh vm-01 # via gateway, transparente
O Terraform configurou o endereçamento — não o roteamento. Para a
vm-01alcançar a internet através do gateway, falta habilitar nele o forwarding e o NAT:# no gateway (Debian) sudo sysctl -w net.ipv4.ip_forward=1 sudo sysctl -w net.ipv6.conf.all.forwarding=1 sudo nft add table ip nat sudo nft add chain ip nat postrouting '{ type nat hook postrouting priority 100; }' sudo nft add rule ip nat postrouting oifname "enp1s0" masqueradePara tornar isso permanente e declarativo, o caminho natural é adicionar
runcmd/write_filesnouser-data.yamldo gateway — ou configurar via Ansible. Sem este passo, avm-01só alcança a própria rede interna.
terraform destroy # remove VMs, volumes, ISOs e as duas redes
forward, blocos ip/dhcp/dnsnetwork_configlibvirt_network (série 0.9)