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.