Introdução ao Terraform: Infraestrutura que Você Pode Versionar

[293] Introdução ao Terraform: Infraestrutura que Você Pode Versionar

O problema dos snowflake servers e a infraestrutura que se pode versionar: o que o Terraform é, os quatro comandos do ciclo — init, plan, apply e destroy —, a sintaxe do HCL, o primeiro projeto do zero, o grafo de dependências que decide a ordem de criação, e os blocos data.
DevOps

17 min de leitura

Imagine a seguinte situação, comum em empresas que não adotaram Infraestrutura como Código. Um servidor crítico de produção começa a apresentar problemas. A equipe decide provisionar um novo servidor idêntico para substituí-lo. Ninguém sabe exatamente como o servidor original foi configurado — foi feito manualmente por um engenheiro que saiu da empresa há dois anos, com ajustes adicionais feitos por outras três pessoas ao longo do tempo, nenhum deles documentado. O processo de recriar o ambiente leva dias, envolve tentativa e erro, e o resultado final provavelmente tem diferenças sutis em relação ao original que só serão descobertas em produção.

Esse problema tem um nome: snowflake servers — servidores floco de neve, únicos e impossíveis de reproduzir. São o resultado inevitável de gerenciar infraestrutura manualmente ao longo do tempo.

A Infraestrutura como Código — IaC — resolve isso aplicando à infraestrutura os mesmos princípios que já se aplicam ao código da aplicação: tudo é descrito em arquivos de texto, versionado no Git, revisado em Pull Requests e aplicado de forma automatizada. O estado desejado da infraestrutura é declarado explicitamente, e a ferramenta garante que o estado real corresponde ao declarado.

O Que é o Terraform

O Terraform é a ferramenta de IaC mais amplamente adotada no mercado. Criado pela HashiCorp em 2014 e disponível como open source sob a licença MPL, ele permite descrever infraestrutura em uma linguagem declarativa chamada HCL — HashiCorp Configuration Language — e aplicar essa descrição em dezenas de provedores de nuvem e serviços.

A proposta central do Terraform é ser agnóstico de provedor: o mesmo fluxo de trabalho que provisiona recursos na AWS funciona para provisionar recursos no Azure, no GCP, no Cloudflare, no Datadog, no GitHub e em centenas de outros provedores. Cada provedor é um plugin que traduz os recursos declarados em HCL para as APIs específicas daquele serviço.

O Terraform opera em um modelo declarativo: o engenheiro descreve o que quer — "quero um servidor com 2 vCPUs, 4GB de RAM, rodando Ubuntu 22.04, na região us-east-1" — e o Terraform determina o que precisa ser feito para chegar a esse estado. Se o recurso não existe, ele cria. Se existe mas com configuração diferente, ele atualiza. Se foi removido da configuração, ele destrói.

Instalando o Terraform

# No Ubuntu/Debian — via repositório oficial HashiCorp
wget -O- https://apt.releases.hashicorp.com/gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg

echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
  https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update && sudo apt install terraform

# Verifica a instalação
terraform version

Para gerenciar múltiplas versões do Terraform em diferentes projetos, recomenda-se o tfenv — um gerenciador de versões análogo ao nvm para Node.js:

# Instala o tfenv
git clone --depth=1 https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

# Instala uma versão específica do Terraform
tfenv install 1.7.0
tfenv use 1.7.0

# Lista versões disponíveis
tfenv list

Os Quatro Comandos Fundamentais

O fluxo de trabalho do Terraform gira em torno de quatro comandos que serão usados repetidamente ao longo de toda a carreira:

terraform init — inicializa o diretório de trabalho, baixando os plugins de provedor necessários e configurando o backend de estado. Deve ser executado sempre que um novo projeto é clonado ou quando provedores são adicionados ou atualizados.

terraform plan — compara o estado atual da infraestrutura com o estado declarado nos arquivos de configuração e exibe um plano de execução: o que será criado, modificado ou destruído. É essencialmente um dry run — nenhuma mudança real acontece.

terraform apply — aplica o plano, executando as mudanças necessárias para que a infraestrutura real corresponda ao estado declarado. Solicita confirmação interativa por padrão.

terraform destroy — destrói todos os recursos gerenciados pela configuração atual. Útil para ambientes temporários, mas deve ser usado com extrema cautela em produção.

A Linguagem HCL

A HCL foi projetada para ser legível por humanos sem ser um YAML ou JSON. É declarativa, tipada e suporta expressões, funções e referências entre recursos.

A estrutura básica de um arquivo Terraform:

# Bloco de configuração do Terraform — define versões e backend
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Bloco de provedor — configura as credenciais e região
provider "aws" {
  region = "us-east-1"
}

# Bloco de recurso — declara um recurso de infraestrutura
resource "aws_instance" "servidor_web" {
  ami           = "ami-0c55b159cbfafe1f0"  # Ubuntu 22.04
  instance_type = "t3.micro"

  tags = {
    Name        = "servidor-web"
    Environment = "producao"
    ManagedBy   = "terraform"
  }
}

A sintaxe resource "tipo_do_recurso" "nome_local" é o bloco mais fundamental do Terraform. O tipo do recurso (aws_instance) determina o que está sendo criado. O nome local (servidor_web) é usado para referenciar esse recurso em outras partes da configuração.

O Primeiro Projeto Terraform

Um projeto completo e funcional que cria uma instância EC2 com grupo de segurança e IP elástico na AWS. A estrutura de arquivos recomendada:

meu-projeto/
├── main.tf          # Recursos principais
├── variables.tf     # Declaração de variáveis
├── outputs.tf       # Outputs do projeto
├── versions.tf      # Versões do Terraform e provedores
└── terraform.tfvars # Valores das variáveis (não versionar se tiver secrets)

versions.tf — configuração do Terraform e provedores:

terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
    }
  }
}

variables.tf — declaração de variáveis com tipos e descrições:

variable "aws_region" {
  description = "Região AWS onde os recursos serão criados"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Nome do projeto — usado em tags e nomes de recursos"
  type        = string
}

variable "environment" {
  description = "Ambiente de implantação"
  type        = string

  validation {
    condition     = contains(["development", "staging", "production"], var.environment)
    error_message = "O ambiente deve ser development, staging ou production."
  }
}

variable "instance_type" {
  description = "Tipo da instância EC2"
  type        = string
  default     = "t3.micro"
}

variable "allowed_ssh_cidr" {
  description = "CIDR permitido para acesso SSH"
  type        = string
  default     = "0.0.0.0/0"
}

main.tf — recursos da infraestrutura:

# Busca a AMI mais recente do Ubuntu 22.04
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]  # Canonical

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# Grupo de segurança
resource "aws_security_group" "servidor_web" {
  name        = "${var.project_name}-${var.environment}-sg"
  description = "Security group do servidor web"

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.allowed_ssh_cidr]
  }

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Todo tráfego de saída"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-${var.environment}-sg"
  }
}

# Par de chaves SSH
resource "aws_key_pair" "deploy" {
  key_name   = "${var.project_name}-${var.environment}-key"
  public_key = file("~/.ssh/id_ed25519.pub")
}

# Instância EC2
resource "aws_instance" "servidor_web" {
  ami                    = data.aws_ami.ubuntu.id
  instance_type          = var.instance_type
  key_name               = aws_key_pair.deploy.key_name
  vpc_security_group_ids = [aws_security_group.servidor_web.id]

  root_block_device {
    volume_size           = 20
    volume_type           = "gp3"
    delete_on_termination = true
    encrypted             = true
  }

  user_data = <<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y docker.io docker-compose-plugin
    systemctl enable docker
    systemctl start docker
    usermod -aG docker ubuntu
  EOF

  tags = {
    Name = "${var.project_name}-${var.environment}-web"
  }
}

# IP elástico
resource "aws_eip" "servidor_web" {
  instance = aws_instance.servidor_web.id
  domain   = "vpc"

  tags = {
    Name = "${var.project_name}-${var.environment}-eip"
  }
}

outputs.tf — valores exportados após o apply:

output "ip_publico" {
  description = "IP público da instância"
  value       = aws_eip.servidor_web.public_ip
}

output "dns_publico" {
  description = "DNS público da instância"
  value       = aws_instance.servidor_web.public_dns
}

output "comando_ssh" {
  description = "Comando SSH para acessar a instância"
  value       = "ssh -i ~/.ssh/id_ed25519 ubuntu@${aws_eip.servidor_web.public_ip}"
}

output "id_instancia" {
  description = "ID da instância EC2"
  value       = aws_instance.servidor_web.id
}

terraform.tfvars — valores concretos das variáveis:

project_name     = "minha-api"
environment      = "staging"
instance_type    = "t3.micro"
allowed_ssh_cidr = "203.0.113.0/24"  # IP do escritório

Executando o Projeto

# 1. Inicializa o projeto — baixa os plugins de provedor
terraform init

# Saída esperada:
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.31"...
# - Installing hashicorp/aws v5.31.0...
# Terraform has been successfully initialized!

# 2. Valida a sintaxe dos arquivos
terraform validate

# 3. Formata os arquivos seguindo o padrão oficial
terraform fmt

# 4. Gera e exibe o plano de execução
terraform plan

# Saída esperada:
# Plan: 4 to add, 0 to change, 0 to destroy.
# + aws_eip.servidor_web
# + aws_instance.servidor_web
# + aws_key_pair.deploy
# + aws_security_group.servidor_web

# 5. Aplica as mudanças
terraform apply

# Para aplicar sem confirmação interativa (útil em pipelines)
terraform apply -auto-approve

# 6. Exibe os outputs após o apply
terraform output

# 7. Quando não precisar mais do ambiente
terraform destroy

Entendendo o Grafo de Dependências

O Terraform constrói automaticamente um grafo de dependências entre os recursos com base nas referências entre eles. No exemplo acima, o Terraform sabe que:

  • aws_eip depende de aws_instance (referência aws_instance.servidor_web.id)
  • aws_instance depende de aws_security_group e aws_key_pair
  • aws_security_group e aws_key_pair não têm dependências entre si

Com esse grafo, o Terraform pode criar aws_security_group e aws_key_pair em paralelo, depois criar aws_instance quando ambos estiverem prontos, e finalmente criar aws_eip. Essa paralelização automática acelera significativamente a aplicação de configurações complexas.

Para visualizar o grafo:

# Gera o grafo em formato DOT
terraform graph | dot -Tpng > grafo.png

Blocos de Dados: Consultando Recursos Existentes

O bloco data permite consultar informações de recursos que já existem na infraestrutura sem gerenciá-los. É útil para referenciar recursos criados fora do Terraform ou compartilhados entre projetos:

# Busca uma VPC existente pelo nome
data "aws_vpc" "principal" {
  filter {
    name   = "tag:Name"
    values = ["vpc-principal"]
  }
}

# Usa a VPC encontrada em um novo recurso
resource "aws_subnet" "app" {
  vpc_id     = data.aws_vpc.principal.id
  cidr_block = "10.0.10.0/24"
}

# Busca o ID da conta AWS atual
data "aws_caller_identity" "atual" {}

output "account_id" {
  value = data.aws_caller_identity.atual.account_id
}

O Que Vem a Seguir

O próximo artigo aprofunda o conceito de state — o arquivo que o Terraform mantém para rastrear o estado atual da infraestrutura. Entender o state é fundamental para trabalhar em equipe, configurar backends remotos e evitar os problemas mais comuns que os engenheiros enfrentam ao usar Terraform em produção.

Referências para Aprofundamento

Documentação oficial

Aprendizado interativo

  • HashiCorp Learn — Terraform — Tutoriais oficiais interativos da HashiCorp cobrindo desde os primeiros passos até tópicos avançados como módulos e workspaces. Incluem ambientes de laboratório sem necessidade de conta na nuvem.

Boas práticas

Exercícios

Exercício 1

O que é um snowflake server e por que ele surge naturalmente em ambientes geridos à mão? Explique como o modelo declarativo do Terraform ataca esse problema, citando as três decisões que ele toma ao comparar o declarado com o existente.

Ver resposta

✓ Resposta: É um servidor único e impossível de reproduzir — configurado manualmente, com ajustes acumulados por várias pessoas ao longo do tempo e nada documentado. Ele surge naturalmente porque cada intervenção manual é uma alteração que existe apenas naquela máquina e na memória de quem a fez; quando essa pessoa sai, a configuração real vira uma incógnita. Recriar o ambiente passa a ser tentativa e erro, e as diferenças sutis só aparecem em produção.

O Terraform ataca isso tornando o estado desejado explícito e versionado: a infraestrutura é descrita em arquivos de texto, revisada em Pull Request e aplicada de forma automatizada, como qualquer código. Ao comparar o declarado com o que existe, ele decide entre três ações: cria o recurso que não existe, atualiza o que existe com configuração divergente e destrói o que foi removido da configuração. A fonte de verdade deixa de ser o servidor e passa a ser o repositório.

Exercício 2

Quais são os quatro comandos fundamentais e o que cada um faz? Qual deles é um dry run, e por que terraform apply -auto-approve faz sentido em um pipeline mas é perigoso no terminal de alguém?

Ver resposta

✓ Resposta: init inicializa o diretório, baixando os plugins de provedor e configurando o backend de estado — roda ao clonar o projeto ou ao mudar provedores. plan compara o estado atual com o declarado e exibe o que seria criado, modificado ou destruído. apply executa essas mudanças. destroy remove todos os recursos gerenciados pela configuração.

O plan é o dry run: nenhuma mudança real acontece, ele apenas mostra o plano de execução (por exemplo, Plan: 4 to add, 0 to change, 0 to destroy).

O -auto-approve pula a confirmação interativa. Num pipeline isso é necessário — não há ninguém para digitar "yes", e a revisão já aconteceu antes, no Pull Request que alterou o HCL. No terminal, ele remove a última oportunidade de ler o plano antes de agir: é exatamente ali que se percebe um destroy inesperado, causado, por exemplo, por uma mudança de atributo que força recriação do recurso. A confirmação interativa não é burocracia, é o momento da conferência.

Exercício 3

O projeto declara quatro recursos: aws_security_group, aws_key_pair, aws_instance e aws_eip. Em que ordem o Terraform os cria, o que ele consegue fazer em paralelo, e como ele descobre isso sem que ninguém declare a ordem?

Ver resposta

✓ Resposta: Ele cria aws_security_group e aws_key_pair em paralelo, porque nenhum dos dois referencia o outro; em seguida aws_instance, que depende dos dois; e por último aws_eip, que depende da instância.

Ninguém declara essa ordem: o Terraform monta um grafo de dependências a partir das próprias referências no código. Como aws_instance usa aws_security_group.servidor_web.id e aws_key_pair.deploy.key_name, a dependência fica implícita — o valor só existe depois que o recurso é criado. O mesmo vale para aws_eip, que referencia aws_instance.servidor_web.id.

A consequência prática é que a paralelização é automática e correta por construção. O grafo pode ser inspecionado com terraform graph | dot -Tpng > grafo.png. Quando existe uma dependência real que não se expressa por referência, ela precisa ser declarada à mão com depends_on — do contrário o Terraform assume que os recursos são independentes.

Exercício 4

Qual a diferença entre um bloco resource e um bloco data? No projeto, o que exatamente data "aws_ami" "ubuntu" faz, e o que aconteceria com a instância se o valor fosse fixado como ami-0c55b159cbfafe1f0?

Ver resposta

✓ Resposta: resource declara algo que o Terraform gerencia — ele cria, atualiza e destrói, e o recurso passa a constar no state. data apenas consulta algo que já existe, sem assumir a posse: serve para referenciar recursos criados fora do Terraform ou compartilhados entre projetos, como no exemplo da VPC buscada por tag e do aws_caller_identity.

O data "aws_ami" "ubuntu" busca, em tempo de execução, a AMI mais recente (most_recent = true) publicada pela Canonical (owner 099720109477) que case com o filtro do Ubuntu 22.04 e virtualização HVM. A instância usa data.aws_ami.ubuntu.id, então ela sempre nasce com a imagem atual.

Com o ID fixo, a instância ficaria presa àquela imagem específica — sem as atualizações de segurança que a Canonical publica em imagens novas — e a configuração deixaria de funcionar em outras regiões, já que o ID de uma AMI é específico da região. Trocar de região exigiria descobrir e trocar o ID à mão, o que anula boa parte do ganho de ter aws_region como variável.

Exercício 5

Observe as duas variáveis abaixo. Qual delas protege contra erro de digitação e como? E que risco concreto existe na outra, considerando que terraform.tfvars define allowed_ssh_cidr = "203.0.113.0/24"?

variable "environment" {
  type = string
  validation {
    condition     = contains(["development", "staging", "production"], var.environment)
    error_message = "O ambiente deve ser development, staging ou production."
  }
}

variable "allowed_ssh_cidr" {
  type    = string
  default = "0.0.0.0/0"
}
Ver resposta

✓ Resposta: A environment se protege com o bloco validation: se alguém escrever prod ou producao em vez de production, o Terraform falha ainda no plan, com a mensagem declarada em error_message. Sem isso, o valor errado seria aceito e apenas se propagaria silenciosamente para as tags e para os nomes dos recursos (${var.project_name}-${var.environment}-sg), gerando recursos com nome inconsistente e dificultando qualquer filtro por ambiente depois.

O risco da allowed_ssh_cidr é o default inseguro. Ela alimenta o cidr_blocks da regra de ingress na porta 22, e 0.0.0.0/0 significa SSH aberto para a internet inteira. No projeto isso está mascarado porque o terraform.tfvars sobrescreve com o CIDR do escritório — mas basta aplicar a configuração em um contexto onde esse arquivo não está presente (outro ambiente, um pipeline que não o copia, um módulo reaproveitado) para que o valor padrão entre em vigor e o servidor suba exposto.

Um default deve ser o valor mais seguro, não o mais conveniente. Como não existe CIDR universalmente correto aqui, o certo seria omitir o default — tornando a variável obrigatória — de modo que esquecer de informá-la vire um erro no plan, e não um servidor aberto.

Comentários

Mais em DevOps

Seus Primeiros Recursos na AWS com Terraform
Seus Primeiros Recursos na AWS com Terraform

Uma infraestrutura AWS completa provisionada inteiramente por código: a…

Publicando Imagens no Docker Hub e GitHub Container Registry
Publicando Imagens no Docker Hub e GitHub Container Registry

Publicando imagens em registros de container: convenção de nomes no Docker Hub…

Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação
Geradores de Senhas Seguras: Implementações em 5 Linguagens de Programação

Quinze implementações de gerador de senha em Go, Rust, JavaScript, PHP e…