Guia Completo: Construindo um Data Center Local com Terraform e KVM/Libvirt
Criar e gerenciar um ambiente de laboratório com dezenas de servidores, múltiplas redes e configurações específicas pode ser uma tarefa complexa e repetitiva. Neste guia completo, vamos resolver esse problema de uma vez por todas usando Infraestrutura como Código (IaC).
Vamos construir um laboratório de estudos de servidores Linux do zero, de forma totalmente automatizada, usando Terraform para orquestrar a criação de máquinas virtuais e redes sobre um hipervisor KVM/Libvirt. Ao final, você terá um ambiente robusto, declarativo e facilmente reproduzível, ideal para estudos, testes e desenvolvimento.
1. O Cenário: Planejando Nosso Laboratório
Antes de escrever qualquer linha de código, um bom planejamento é essencial. Nosso objetivo é criar um ambiente que simule uma pequena infraestrutura corporativa.
Ambiente Físico
- Estação de Trabalho: Uma máquina com Ubuntu Desktop 24.04, de onde executaremos o Terraform.
- Hipervisor: Um servidor dedicado com Oracle Linux 7 (IP: 192.168.0.254), rodando KVM/Libvirt, que hospedará nossas VMs.
   graph TD
     A[Estacao de Trabalho] -->|Terraform| B[Hipervisor KVM]
     B --> C[Gateway]
     C --> D[DMZ]
     D --> E[Servidores]
     C --> F[Rede CGR]
     C --> G[Rede DHCP]
Pré-requisitos
- Estação de trabalho configurada: Configurando o Ubuntu 24.04 para SysAdmins.
- Hipervisor configurado: Instalação do KVM no Oracle Linux 7.
Plano de Endereçamento
Definimos um plano de endereçamento claro para organizar nossos serviços:
| Função da Rede | Sub-rede IPv4 | Sub-rede IPv6 | 
|---|---|---|
| DMZ | 10.32.16.0/24 | fd00:32:16::/64 | 
| CGR | 10.48.32.0/24 | fd00:48:32::/64 | 
| EVE-NG Hosts | 10.64.48.0/24 | fd00:64:48::/64 | 
| Kubernetes Pods | 10.80.64.0/16 | fd00:80:64::/56 | 
| Kubernetes Services | 10.96.80.0/16 | fd00:96:80::/64 | 
| Docker Hosts | 10.112.96.0/16 | fd00:112:96::/56 | 
| DHCP Usuários | 10.128.112.0/20 | fd00:128:112::/64 | 
Nota: As redes EVE-NG Hosts, Kubernetes Pods, Kubernetes Services e Docker Hosts não serão criadas diretamente no hipervisor; elas são redes internas dos servidores das aplicações. Foram incluídas aqui apenas para fins de documentação e informação.
Arquitetura dos Servidores
Nosso laboratório contará com uma variedade de serviços distribuídos na rede DMZ, todos com IPs e hostnames pré-definidos. O ponto central da operação será um Gateway Debian 12 que conectará todas as sub-redes.
Servidores na DMZ (Rede 10.32.16.0/24 | fd00:32:16::/64)
| Serviço | IPv4 | IPv6 | Hostname | 
|---|---|---|---|
| EVE-NG (Ubuntu) | 10.32.16.2 | fd00:32:16::2 | eve-ng.lab.test | 
| DNS Primário (OL9) | 10.32.16.3 | fd00:32:16::3 | ns1.lab.test | 
| DNS Secundário (OL9) | 10.32.16.4 | fd00:32:16::4 | ns2.lab.test | 
| Postfix (deb) | 10.32.16.5 | fd00:32:16::5 | mail.lab.test | 
| MySQL (deb) | 10.32.16.6 | fd00:32:16::6 | mysql.lab.test | 
| PostgreSQL (OL9) | 10.32.16.7 | fd00:32:16::7 | psql.lab.test | 
| OpenLDAP Primário (OL9) | 10.32.16.8 | fd00:32:16::8 | ldap01.lab.test | 
| OpenLDAP Secundário (OL9) | 10.32.16.9 | fd00:32:16::9 | ldap02.lab.test | 
| FreeIPA Primário (OL9) | 10.32.16.10 | fd00:32:16::a | ipa01.lab.test | 
| FreeIPA Secundário (OL9) | 10.32.16.11 | fd00:32:16::b | ipa02.lab.test | 
| Kubernetes Controller (deb) | 10.32.16.12 | fd00:32:16::c | kube-ctrl.lab.test | 
| Kubernetes Worker 1 (deb) | 10.32.16.13 | fd00:32:16::d | kube-wk01.lab.test | 
| Kubernetes Worker 2 (deb) | 10.32.16.14 | fd00:32:16::e | kube-wk02.lab.test | 
| Docker Swarm Node 1 (OL9) | 10.32.16.15 | fd00:32:16::f | swarm-nd01.lab.test | 
| Docker Swarm Node 2 (OL9) | 10.32.16.16 | fd00:32:16::10 | swarm-nd02.lab.test | 
| Docker Swarm Node 3 (OL9) | 10.32.16.17 | fd00:32:16::11 | swarm-nd03.lab.test | 
| NFS Server (OL9) | 10.32.16.18 | fd00:32:16::12 | nfs.lab.test | 
| ELK (OL9) | 10.32.16.19 | fd00:32:16::13 | elk.lab.test | 
| HAProxy (OL9) | 10.32.16.20 | fd00:32:16::14 | haproxy.lab.test | 
| Asterisk (OL9) | 10.32.16.21 | fd00:32:16::15 | pabx.lab.test | 
| FreeRADIUS (Debian) | 10.32.16.22 | fd00:32:16::16 | radius.lab.test | 
| Graylog (OL9) | 10.32.16.23 | fd00:32:16::17 | graylog.lab.test | 
| HTTP Apache (OL9) | 10.32.16.24 | fd00:32:16::18 | apache.lab.test | 
| HTTP Nginx (OL9) | 10.32.16.25 | fd00:32:16::19 | nginx.lab.test | 
2. A Estrutura do Projeto Terraform
Para manter nosso código organizado, usaremos a seguinte estrutura de arquivos:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── cloud-init/
│   ├── network_config.yml
│   ├── network_config_gateway.yml
│   └── user_data.yml
├── providers.tf
├── variables.tf
├── network.tf
├── volumes.tf
├── cloudinit.tf
├── domain.tf
├── terraform.tfvars
├── servers.auto.tfvars
└── networks.auto.tfvars
3. Construindo a Infraestrutura: O Código
Agora, vamos ao que interessa. Abaixo estão os arquivos Terraform completos que definem nosso laboratório.
providers.tf
Declaramos a versão do Terraform e o provedor libvirt. Note que a uri pode ser ajustada para um host remoto via SSH.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# providers.tf 
terraform {
  required_version = ">= 1.5"
  required_providers {
    libvirt = {
      source  = "dmacvicar/libvirt"
      version = ">= 0.7.1"
    }
  }
}
provider "libvirt" {
  #uri = "qemu+ssh://gean@192.168.0.254/system"
  uri = "qemu:///system"
}
variables.tf
Este é o coração do nosso design. Definimos o “contrato” para todas as nossas variáveis. A parte mais importante é a estrutura de servers, onde usamos optional() para username, gecos e groups, permitindo-nos definir padrões e manter nosso código limpo.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# variables.tf
# Declaração da variável 'servers' que vem de servers.auto.tfvars
variable "servers" {
  description = "Um mapa de objetos que definem as máquinas virtuais a serem criadas."
  type = map(object({
    username  = optional(string)
    gecos     = optional(string)
    groups      = optional(list(string))
    vcpus     = number
    memory    = string
    os        = string
    networks = list(object({
      name        = string
      ipv4        = string
      ipv4_prefix = number
      ipv6        = string
      ipv6_prefix = number
      if_name     = string
    }))
  }))
}
variable "default_vm_user" {
  description = "Configurações do usuário padrão para as VMs."
  type = object({
    name  = string
    gecos = string
  })
}
variable "os_profiles" {
  description = "Define perfis completos (template, grupos, etc.) para cada tipo de SO."
  type = map(object({
    template_name  = string
    default_groups = list(string)
  }))
}
# Declaração da variável 'networks' que vem de networks.auto.tfvars
variable "networks" {
  description = "Um mapa de objetos que definem as redes virtuais a serem criadas no Libvirt."
  type = map(object({
    net_name  = string
    net_mode  = string
    ipv4_cidr = string
    ipv6_cidr = string
  }))
}
# Declaração da variável 'ssh_public_key' que vem de terraform.tfvars
variable "ssh_public_key" {
  description = "Chave pública SSH a ser injetada nas máquinas virtuais."
  type        = string
  sensitive   = true # Marca a variável como sensível para não exibi-la nos logs
}
# Declaração da variável 'network_dmz' que vem de terraform.tfvars
variable "network_dmz" {
  description = "Configurações de gateway e DNS para a rede DMZ."
  type = object({
    gateway_v4 = string
    gateway_v6 = string
    ns1_v4     = string
    ns1_v6     = string
    ns2_v4     = string
    ns2_v6     = string
  })
}
network.tf
Aqui criamos as redes virtuais no Libvirt. Usamos o modo "none" para criar bridges de rede isoladas, já que todo o roteamento será feito pelo nosso gateway.
1
2
3
4
5
6
7
8
# network.tf 
# Definições de rede LIBVIRT a serem criadas
resource "libvirt_network" "network" {
  for_each  = var.networks
  name      = each.value.net_name
  mode      = each.value.net_mode 
  autostart = true
}
volumes.tf
Nossa estratégia de disco é simples e robusta: usamos as imagens de template no tamanho original, sem redimensionar. Se precisarmos de mais espaço para uma aplicação, criaremos e anexaremos um disco de dados separado. Este arquivo apenas garante que as imagens base estejam disponíveis no Libvirt.
1
2
3
4
5
6
7
8
9
# volumes.tf 
resource "libvirt_volume" "os_image" {
  for_each         = { for vm_name, config in var.servers : vm_name => config }
  name             = "${each.key}.qcow2"
  pool             = "default"
  base_volume_name = var.os_profiles[each.value.os].template_name
  base_volume_pool = "templates"
  format           = "qcow2"
}
cloudinit.tf
Este arquivo orquestra a criação dos arquivos de configuração do Cloud-Init para cada VM. Ele usa um local para escolher dinamicamente o template de rede correto (um para o gateway, outro para os demais) e aplica os padrões de usuário e grupos que definimos.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# cloudinit.tf 
locals {
  # Cria um mapa que associa cada servidor ao seu template de rede correto.
  network_templates = {
    for k, v in var.servers : k => k == "gateway" ?
    "${path.module}/cloud-init/network_config_gateway.yml" :
    "${path.module}/cloud-init/network_config.yml"
  }
}
resource "libvirt_cloudinit_disk" "cloudinit" {
  for_each = var.servers
  name = "cloudinit-${each.key}.iso"
  pool = "default"
  # --- USER DATA ---
  user_data = templatefile("${path.module}/cloud-init/user_data.yml", {
    hostname  = each.key
    user_name = coalesce(each.value.username, var.default_vm_user.name)
    gecos     = coalesce(each.value.gecos, var.default_vm_user.gecos)
    groups    = coalesce(each.value.groups, var.os_profiles[each.value.os].default_groups)
    ssh_key   = var.ssh_public_key
  })
  # --- NETWORK CONFIG ---
  # Usa um template para o gateway e outro para os demais.
  network_config = templatefile(
    local.network_templates[each.key], # Usa o mapa local
    {
      interfaces = each.value.networks
      dmz_config = var.network_dmz
    }
  )
}
domain.tf
Finalmente, este arquivo junta todas as peças para criar as máquinas virtuais (domains). Ele anexa o disco do SO, o disco do Cloud-Init e conecta as interfaces de rede corretas, usando blocos dynamic para flexibilidade.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# domain.tf 
resource "libvirt_domain" "domain" {
  for_each = var.servers
  name   = each.key
  memory = each.value.memory
  vcpu   = each.value.vcpus
  cpu {
    mode = "host-passthrough"
  }
  depends_on = [
    libvirt_network.network,
    libvirt_volume.os_image
  ]
  cloudinit = libvirt_cloudinit_disk.cloudinit[each.key].id
  disk {
    volume_id = libvirt_volume.os_image[each.key].id
  }
  # Bloco dinâmico para internet APENAS no gateway
  dynamic "network_interface" {
    for_each = each.key == "gateway" ? [1] : []
    content {
      network_name   = "default"
      wait_for_lease = true
    }
  }
  # Um único bloco dinâmico que anexa todas as redes definidas no .tfvars
  dynamic "network_interface" {
    for_each = each.value.networks
    content {
      # Conecta à rede pelo nome (ex: "dmz", "cgr")
      network_name = network_interface.value.name
    }
  }
  console {
    type        = "pty"
    target_type = "serial"
    target_port = "0"
  }
  graphics {
    type        = "spice"
    listen_type = "address"
    autoport    = true
  }
}
4. Definindo Nossos Dados: *.tfvars
Com a lógica pronta, agora só precisamos alimentar nosso código com os dados específicos do nosso laboratório.
terraform.tfvars
Aqui definimos nossas variáveis globais: a chave SSH, os perfis de SO (com caminhos de template e grupos padrão) e o usuário padrão. Esta centralização é a chave para um código manutenível.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# terraform.tfvars 
# Chave SSH usada para acessar as VMs. Substitua pela sua chave SSH pública real.
ssh_public_key = "ssh-ed25519 AAAAC... user@host"
# Perfis de SO
os_profiles = {
  "debian12" = {
    template_name  = "debian-12-amd64.qcow2",
    default_groups = ["users", "sudo"]
  },
  "oracle9" = {
    template_name  = "ol9-amd64.qcow2",
    default_groups = ["users", "wheel"]
  }
}
# Usuário padrão para todas as VMs
default_vm_user = {
  name  = "suporte"
  gecos = "Suporte User"
}
# Gateway e DNS usados pelas VMs da rede DMZ
network_dmz = {
  gateway_v4 = "10.32.16.1",
  gateway_v6 = "fd00:32:16::1",
  ns1_v4     = "10.32.16.3",
  ns1_v6     = "fd00:32:16::3",
  ns2_v4     = "10.32.16.4",
  ns2_v6     = "fd00:32:16::4"
}
URLs para fazer download dos templates:
- Debian: https://cloud.debian.org/images/cloud
- Oracle Linux: https://yum.oracle.com/oracle-linux-templates.html
Se preferir criar seus próprios templates:
- Criação de Template Debian 12 QEMU/KVM com Packer
- Criação de Template Oracle Linux 9 QEMU/KVM com Packer
networks.auto.tfvars e servers.auto.tfvars
Estes arquivos são o inventário da nossa infraestrutura. Graças ao nosso design, o servers.auto.tfvars é extremamente limpo, contendo apenas as informações únicas de cada servidor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# networks.auto.tfvars 
# As redes terão MODE como NONE já que a saída para internet será através do gateway
# Os IPs aqui são apenas para documentação, não serão usados na definição das interfaces que serão do tipo "none", isolada.
networks = {
  dmz = {
    net_name  = "dmz"
    net_mode  = "none"
    ipv4_cidr = "10.32.16.0/24"
    ipv6_cidr = "fd00:32:16::/64"
  },
  cgr = {
    net_name  = "cgr"
    net_mode  = "none"
    ipv4_cidr = "10.48.32.0/24"
    ipv6_cidr = "fd00:48:32::/64"
  },
  dhcp = {
    net_name  = "dhcp"
    net_mode  = "none"
    ipv4_cidr = "10.128.112.0/20"
    ipv6_cidr = "fd00:128:112::/64"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# servers.auto.tfvars 
# Definição dos servidores
servers = {
  gateway = {
    vcpus     = 2,
    memory    = "2048",
    os        = "debian12",
    networks = [
      { name = "dmz", ipv4 = "10.32.16.1", ipv4_prefix = 24, ipv6 = "fd00:32:16::1", ipv6_prefix = 64, if_name = "ens4" },
      { name = "cgr", ipv4 = "10.48.32.1", ipv4_prefix = 24, ipv6 = "fd00:48:32::1", ipv6_prefix = 64, if_name = "ens5" },
      { name = "dhcp", ipv4 = "10.128.112.1", ipv4_prefix = 24, ipv6 = "fd00:128:112::1", ipv6_prefix = 64, if_name = "ens6" }
    ]
  },
  ns1 = {
    vcpus     = 2,
    memory    = "2048",
    os        = "oracle9",
    networks  = [{ name = "dmz", ipv4 = "10.32.16.3", ipv4_prefix = 24, ipv6 = "fd00:32:16::3", ipv6_prefix = 64, if_name = "ens3" }]
  },
  ns2 = {
    vcpus     = 2,
    memory    = "2048",
    os        = "oracle9",
    networks  = [{ name = "dmz", ipv4 = "10.32.16.4", ipv4_prefix = 24, ipv6 = "fd00:32:16::4", ipv6_prefix = 64, if_name = "ens3" }]
  }
Nota: Os outros servidores foram omitidos, podendo ser adicionados todos de uma vez ou adicionando-os conforme o laboratório se desenvolve.
5. A Mágica do Cloud-Init: Templates de Configuração
O Cloud-Init é responsável por configurar cada VM na primeira inicialização.
- user_data.yml: Cria o usuário, define a chave SSH e o hostname.
- network_config_*.yml: Configura os endereços IP estáticos, rotas e DNS.
cloud-init/network_config_gateway.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#cloud-config
network:
  version: 2
  ethernets:
    # Interface conectada à rede 'default' do libvirt para acesso à internet
    ens3:
      dhcp4: true
      dhcp6: true
    # Interfaces para as redes internas
%{ for iface in interfaces ~}
    ${iface.if_name}:
      dhcp4: no
      dhcp6: no
      accept-ra: false
      addresses:
        - ${iface.ipv4}/${iface.ipv4_prefix}
        - ${iface.ipv6}/${iface.ipv6_prefix}
%{ endfor ~}
cloud-init/network_config.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#cloud-config
network:
  version: 2
  ethernets:
    # Itera sobre as interfaces passadas para o template
%{ for iface in interfaces ~}
    ${iface.if_name}:
      dhcp4: no
      dhcp6: no
      accept-ra: false
      addresses:
        - ${iface.ipv4}/${iface.ipv4_prefix}
        - ${iface.ipv6}/${iface.ipv6_prefix}
      nameservers:
        addresses:
          - ${dmz_config.ns1_v4}
          - ${dmz_config.ns2_v4}
          - ${dmz_config.ns1_v6}
          - ${dmz_config.ns2_v6}
      routes:
        - to: 0.0.0.0/0
          via: ${dmz_config.gateway_v4}
        - to: "::/0"
          via: ${dmz_config.gateway_v6}
%{ endfor ~}
cloud-init/user_data.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#cloud-config
users:
  - name: ${user_name}
    gecos: ${gecos}
    sudo: ALL=(ALL) NOPASSWD:ALL
    # Usa a função jsonencode para formatar a lista de grupos corretamente
    groups: ${jsonencode(groups)}
    shell: /bin/bash
    lock_passwd: true
    ssh_authorized_keys:
      - "${ssh_key}"
# Desabilita login por senha e do usuário root
disable_root: true
ssh_pwauth: false
# Define o hostname da máquina
runcmd:
  - hostnamectl set-hostname ${hostname}
6. Executando e Gerenciando o Laboratório
Com todos os arquivos no lugar, o processo é simples:
- Inicialize o Terraform:1 terraform init 
- Valide a configuração:1 terraform validate 
- Planeje a execução (opcional, mas recomendado):1 terraform plan 
- Aplique e construa o laboratório!1 terraform apply 
Para destruir o ambiente, basta executar terraform destroy.
Dicas Adicionais
Usando Senhas (Alternativa)
Se preferir usar senhas em vez de chaves SSH, você pode adaptar seu user_data.yml.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#cloud-config
ssh_pwauth: yes
users:
  - name: ${user_name}
    gecos: ${gecos}
    sudo: ALL=(ALL) NOPASSWD:ALL 
    groups: ${jsonencode(groups)}
    shell: /bin/bash
    lock_passwd: false
    passwd: ${user_password}
chpasswd:
  list: |
    root:${root_password}
  expire: False 
runcmd:
  - hostnamectl set-hostname ${hostname}
Nota: Aqui temos a opção de criar um usuário com e sem senha. Se for usar o Ansible durante o desenvolvimento do laboratório, crie o usuário com senha ou copie a chave privada para o servidor de gerência de onde o Ansible será executado.
Para gerar o hash da senha, use o comando mkpasswd:
1
mkpasswd --method=SHA-512
Gerando Chaves SSH
Para gerar uma nova chave SSH do tipo ed25519:
1
ssh-keygen -t ed25519 -C "seu_email@exemplo.com" -f ~/.ssh/minha_chave_lab
Conclusão
Parabéns! Você acaba de construir uma base sólida para um laboratório de estudos completo, tudo gerenciado como código. A partir daqui, as possibilidades são infinitas: você pode adicionar discos de dados, configurar provisionadores do Ansible, criar módulos reutilizáveis e muito mais.
Este projeto não é apenas um exercício técnico; é uma demonstração poderosa de como os princípios de IaC podem trazer ordem, repetibilidade e eficiência para qualquer ambiente de infraestrutura.