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

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
}

Versionar infraestrutura muda menos a forma de criar recursos do que a de mudá-los. O plan é o coração da ferramenta — ele mostra o que vai acontecer antes que aconteça, e ler esse diff com atenção separa quem usa Terraform de quem usa Terraform como script de criação. O grafo de dependências resolve a ordem sozinho, desde que os recursos se refiram uns aos outros em vez de repetirem valores fixos.

Fontes e leituras recomendadas

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

Capstone: Provisionando a Infraestrutura Completa
Capstone: Provisionando a Infraestrutura Completa

A infraestrutura do capstone provisionada em seis camadas, na ordem das…

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

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

Rastreamento Distribuído com OpenTelemetry
Rastreamento Distribuído com OpenTelemetry

O que fazer quando a latência sobe e os logs não acusam nada: a instrumentação…