Post

Guia Completo: Construindo um Data Center Local com Terraform e KVM/Libvirt

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

Plano de Endereçamento

Definimos um plano de endereçamento claro para organizar nossos serviços:

Função da RedeSub-rede IPv4Sub-rede IPv6
DMZ10.32.16.0/24fd00:32:16::/64
CGR10.48.32.0/24fd00:48:32::/64
EVE-NG Hosts10.64.48.0/24fd00:64:48::/64
Kubernetes Pods10.80.64.0/16fd00:80:64::/56
Kubernetes Services10.96.80.0/16fd00:96:80::/64
Docker Hosts10.112.96.0/16fd00:112:96::/56
DHCP Usuários10.128.112.0/20fd00: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çoIPv4IPv6Hostname
EVE-NG (Ubuntu)10.32.16.2fd00:32:16::2eve-ng.lab.test
DNS Primário (OL9)10.32.16.3fd00:32:16::3ns1.lab.test
DNS Secundário (OL9)10.32.16.4fd00:32:16::4ns2.lab.test
Postfix (deb)10.32.16.5fd00:32:16::5mail.lab.test
MySQL (deb)10.32.16.6fd00:32:16::6mysql.lab.test
PostgreSQL (OL9)10.32.16.7fd00:32:16::7psql.lab.test
OpenLDAP Primário (OL9)10.32.16.8fd00:32:16::8ldap01.lab.test
OpenLDAP Secundário (OL9)10.32.16.9fd00:32:16::9ldap02.lab.test
FreeIPA Primário (OL9)10.32.16.10fd00:32:16::aipa01.lab.test
FreeIPA Secundário (OL9)10.32.16.11fd00:32:16::bipa02.lab.test
Kubernetes Controller (deb)10.32.16.12fd00:32:16::ckube-ctrl.lab.test
Kubernetes Worker 1 (deb)10.32.16.13fd00:32:16::dkube-wk01.lab.test
Kubernetes Worker 2 (deb)10.32.16.14fd00:32:16::ekube-wk02.lab.test
Docker Swarm Node 1 (OL9)10.32.16.15fd00:32:16::fswarm-nd01.lab.test
Docker Swarm Node 2 (OL9)10.32.16.16fd00:32:16::10swarm-nd02.lab.test
Docker Swarm Node 3 (OL9)10.32.16.17fd00:32:16::11swarm-nd03.lab.test
NFS Server (OL9)10.32.16.18fd00:32:16::12nfs.lab.test
ELK (OL9)10.32.16.19fd00:32:16::13elk.lab.test
HAProxy (OL9)10.32.16.20fd00:32:16::14haproxy.lab.test
Asterisk (OL9)10.32.16.21fd00:32:16::15pabx.lab.test
FreeRADIUS (Debian)10.32.16.22fd00:32:16::16radius.lab.test
Graylog (OL9)10.32.16.23fd00:32:16::17graylog.lab.test
HTTP Apache (OL9)10.32.16.24fd00:32:16::18apache.lab.test
HTTP Nginx (OL9)10.32.16.25fd00:32:16::19nginx.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:

Se preferir criar seus próprios templates:

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:

  1. Inicialize o Terraform:
    1
    
    terraform init
    
  2. Valide a configuração:
    1
    
    terraform validate
    
  3. Planeje a execução (opcional, mas recomendado):
    1
    
    terraform plan
    
  4. 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.

This post is licensed under CC BY 4.0 by the author.