DevOps, CI/CD e contêineres
Infraestrutura como código: Terraform, Ansible e infraestrutura imutável
Avançado12 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Infraestrutura como código transforma servidores, redes e contas de nuvem em arquivos que você revisa, testa e reverte como código de aplicação. Este guia separa provisionamento de gerenciamento de configuração, explora o modelo de state do Terraform e o design de módulos, mostra o lado do Ansible e trata das realidades operacionais: drift, locking, secrets e a execução de plan e apply a partir de um pipeline em vez de um notebook.
O que você vai aprender
- Diferenciar provisionamento declarativo de gerenciamento de configuração imperativo e explicar idempotência
- Escrever configuração Terraform com providers, resources, variables, outputs e módulos
- Explicar o que é o state, por que ele é bloqueado e como backends remotos o protegem
- Escrever um playbook Ansible idempotente e saber quando o gerenciamento de configuração ainda faz sentido
- Detectar e tratar drift, e manter secrets fora do código e do state
- Rodar plan em cada pull request e apply no merge, com verificações de política no pipeline
Toda infraestrutura chega, cedo ou tarde, ao ponto em que ninguém sabe dizer exatamente o que está rodando. Uma regra de firewall foi adicionada durante um incidente, um servidor foi corrigido na mão, o timeout de um load balancer foi aumentado para uma demo três anos atrás. Reconstruir o ambiente vira um projeto de pesquisa, e o ambiente de homologação divergiu da produção há tanto tempo que testar nele prova muito pouco.
Infraestrutura como código é a disciplina que evita isso: a infraestrutura é descrita em arquivos, os arquivos ficam no controle de versão e as mudanças chegam ao ambiente pela aplicação desses arquivos, e não por cliques ou por login nas máquinas. O ganho não é só automação. Você ganha revisão, histórico, a possibilidade de comparar dois ambientes e a de reconstruir tudo do zero — o que transforma “nosso data center pegou fogo” de uma ameaça existencial em um dia longo.
Dois trabalhos, dois tipos de ferramenta
O termo abrange duas atividades relacionadas que as pessoas confundem, e pagam caro por isso.
Provisionamento cria e destrói recursos: redes virtuais, sub-redes, security groups, máquinas virtuais, bancos de dados gerenciados, clusters Kubernetes, registros DNS, roles do IAM. Os recursos não existem até a ferramenta criá-los. É aqui que ficam o Terraform e seu fork OpenTofu, além de ferramentas nativas de nuvem como CloudFormation, Bicep e Pulumi.
Gerenciamento de configuração pega uma máquina existente e faz o conteúdo dela corresponder a uma descrição: pacotes instalados, arquivos gravados, serviços habilitados, usuários criados. É aqui que ficam Ansible, Chef, Puppet e Salt.
Os fluxos de trabalho também são diferentes. As ferramentas de provisionamento são declarativas e mantêm um registro persistente do que é delas, então conseguem calcular a diferença entre sua descrição e a realidade e destruir o que você removeu do arquivo. Os gerenciadores de configuração costumam ser procedurais, porém idempotentes: um playbook é uma lista ordenada de tarefas, cada uma escrita de forma que executá-la em uma máquina já correta não mude nada.
Idempotência é a propriedade que torna tudo isso seguro. Aplicar a mesma configuração duas vezes produz o mesmo resultado que aplicá-la uma vez, e é por isso que uma execução com falha pode simplesmente ser repetida e que a ferramenta pode rodar de forma agendada para corrigir drift. Scripts shell são o contraexemplo: useradd deploy funciona uma vez e falha dali em diante, então todo script vai acumulando condicionais até virar uma reimplementação pouco confiável de um gerenciador de configuração.
Terraform: o modelo
O Terraform lê arquivos .tf escritos em HCL, conversa com providers (plugins para AWS, Azure, Google Cloud, Kubernetes, GitHub, PostgreSQL e centenas de outros) e mantém um state que mapeia os recursos da sua configuração para os objetos reais que ele criou.
terraform {
required_version = "~> 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
Environment = var.environment
ManagedBy = "terraform"
Repository = "acme/infrastructure"
}
}
}
variable "environment" {
description = "Deployment environment name"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "region" {
type = string
default = "eu-west-3"
}
resource "aws_vpc" "main" {
cidr_block = "10.20.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${var.environment}-vpc" }
}
resource "aws_subnet" "private" {
for_each = { for i, az in local.azs : az => cidrsubnet(aws_vpc.main.cidr_block, 4, i) }
vpc_id = aws_vpc.main.id
availability_zone = each.key
cidr_block = each.value
tags = { Name = "${var.environment}-private-${each.key}" }
}
locals {
azs = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
}
output "vpc_id" {
value = aws_vpc.main.id
}
Vale adotar cedo dois hábitos que aparecem aqui. default_tags no provider rotula tudo o que o Terraform cria, o que mais tarde torna possível atribuir custos e caçar recursos órfãos. E for_each sobre um map, em vez de count sobre um número, dá a cada sub-rede um endereço estável indexado pela zona de disponibilidade — com count, remover o elemento do meio renumera todos os seguintes e o Terraform propõe destruir recursos que não deveriam ter sido tocados. Fixe também as versões do Terraform e dos providers, e faça commit do .terraform.lock.hcl para que todas as máquinas resolvam os mesmos builds de providers.
O fluxo de trabalho
terraform init # download providers and configure the backend
terraform fmt -recursive
terraform validate # syntax and internal consistency
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy # tear the environment down
A etapa importante é o plan, e a habilidade importante é saber lê-lo. Um plan lista cada ação que o Terraform pretende executar: criar, atualizar no lugar, destruir ou o temido “must be replaced”. Substituição significa destruir e depois criar, e em um banco de dados isso é uma perda de dados anunciada em uma linha de saída que passa rápido pela tela.
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ engine_version = "16.3" -> "17.2" # forces replacement
~ id = "shop-prod-db" -> (known after apply)
}
Sempre aplique um arquivo de plan salvo em vez de rodar terraform apply sozinho. Sem plan, o apply refaz o planejamento e pede confirmação, e o que ele aplica pode ser diferente do que você revisou se o mundo mudou nesse meio-tempo.
Um plan sem mudanças deve exibir “No changes”. Se, em vez disso, toda execução propõe atualizar o mesmo recurso, você está diante de um bug de provider ou de um valor que a nuvem normaliza de forma diferente do que você escreveu. Vá atrás disso: se o plan nunca sai limpo, um plan limpo deixa de servir como sinal.
State, e por que ele precisa de um backend
O state é um documento JSON que mapeia cada endereço de recurso da sua configuração (aws_vpc.main) para o identificador e os atributos do recurso real. O Terraform precisa dele para saber o que criou, detectar drift e calcular dependências.
Um arquivo de state local serve para um tutorial e é inviável para um time: não é compartilhado, não pode ser bloqueado, contém secrets, e perdê-lo significa que o Terraform esquece tudo o que é dele. Um backend remoto resolve os quatro problemas.
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "eu-west-3"
encrypt = true
use_lockfile = true # native S3 locking, Terraform 1.10+
}
}
Configurações mais antigas usam uma tabela do DynamoDB para locking; os backends de Azure e Google Cloud fazem lock com suas próprias primitivas de armazenamento, e Terraform Cloud, Scalr e Spacelift oferecem state gerenciado com locking e histórico de execuções. Seja qual for sua escolha, habilite o versionamento do bucket para poder reverter um state corrompido.
Daí decorrem duas consequências. Primeiro, o state é sensível: senhas, chaves e certificados gerados pelos recursos ficam armazenados nele em texto puro. Criptografe o backend, restrinja quem pode ler o bucket e nunca faça commit do state no Git. Segundo, o state precisa ser dividido. Um único state enorme para uma organização inteira significa que toda mudança é planejada contra tudo, que um apply leva vinte minutos e que um único recurso quebrado bloqueia trabalhos sem relação nenhuma. Divida por ciclo de vida e raio de impacto — rede, armazenamento de dados, cluster, aplicações — e leia através dessas fronteiras com data sources ou com os outputs do remote state.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "eu-west-3"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
# ...
}
Quando precisar intervir no state — um recurso renomeado, um recurso criado na mão, um recurso que não deve mais ser gerenciado —, use os comandos próprios em vez de editar o arquivo: terraform state mv, terraform state rm e import. Desde o Terraform 1.5, os blocos moved e import permitem expressar essas operações de forma declarativa na configuração, então elas passam por revisão como todo o resto:
moved {
from = aws_instance.web
to = aws_instance.frontend
}
import {
to = aws_s3_bucket.legacy_assets
id = "acme-legacy-assets"
}
Módulos
Um módulo é um diretório de arquivos Terraform com entradas e saídas. A configuração raiz é ela própria um módulo; todo o resto é um módulo reutilizável.
module "service" {
source = "git::https://git.example.com/acme/tf-modules.git//service?ref=v2.3.0"
name = "shop-api"
environment = var.environment
vpc_id = data.terraform_remote_state.network.outputs.vpc_id
subnet_ids = data.terraform_remote_state.network.outputs.private_subnet_ids
image_tag = var.image_tag
min_size = 3
max_size = 12
}
Bons módulos se parecem com boas funções: um propósito claro, entradas tipadas e bem nomeadas com valores padrão sensatos, e saídas que expõem o que quem chama precisa, e não tudo. Fixe a versão com ?ref= apontando para uma tag — um módulo referenciado por branch muda debaixo dos seus pés, e o dia em que você descobre isso é durante um incidente.
Resista a duas tentações. Não envolva um único recurso em um módulo que só acrescenta indireção. E não construa um módulo gigante com quarenta flags booleanas — composição vence configuração, e um módulo cujo comportamento depende de count = var.enabled ? 1 : 0 em vinte lugares é ilegível.
Para ambientes, prefira diretórios separados, cada um com sua chave de backend e seus arquivos .tfvars, em vez de workspaces do Terraform. Workspaces compartilham uma única configuração e um único prefixo de chave de backend, o que torna fácil demais rodar um apply de produção apontando para o workspace errado. Diretórios separados deixam o alvo explícito.
Ansible: configurando o que já existe
O Terraform para na fronteira da máquina. Se você roda máquinas virtuais em vez de contêineres, alguma coisa precisa instalar pacotes, gravar arquivos de configuração e iniciar serviços. O Ansible faz isso via SSH, sem agente para instalar.
Um inventário lista hosts e grupos; um playbook associa grupos a roles e tarefas; módulos executam operações idempotentes.
- name: Configure application servers
hosts: app_servers
become: true
vars:
java_package: openjdk-21-jre-headless
app_user: shop
tasks:
- name: Install runtime packages
ansible.builtin.apt:
name:
- "{{ java_package }}"
- ca-certificates
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the service account
ansible.builtin.user:
name: "{{ app_user }}"
system: true
shell: /usr/sbin/nologin
- name: Deploy the systemd unit
ansible.builtin.template:
src: templates/shop-api.service.j2
dest: /etc/systemd/system/shop-api.service
owner: root
group: root
mode: "0644"
notify: Restart shop-api
- name: Enable and start the service
ansible.builtin.systemd_service:
name: shop-api
enabled: true
state: started
daemon_reload: true
handlers:
- name: Restart shop-api
ansible.builtin.systemd_service:
name: shop-api
state: restarted
Cada módulo é idempotente: apt com state: present só instala se o pacote estiver faltando, template só reescreve o arquivo quando o conteúdo renderizado muda, e o handler só reinicia o serviço quando alguma tarefa o notificou. Rode primeiro com --check --diff para ver o que mudaria sem mudar nada — o equivalente a um plan do Terraform.
ansible-playbook -i inventories/prod site.yml --check --diff --limit app_servers
ansible-playbook -i inventories/prod site.yml --limit app_servers
Organize as tarefas em roles (roles/app-server/{tasks,templates,handlers,defaults}) para manter os playbooks legíveis, e submeta-as ao mesmo processo de revisão de todo o resto. Os nomes das tarefas aparecem na saída, então escreva-os como descrições que um colega consiga acompanhar.
Infraestrutura imutável
O gerenciamento de configuração parte do princípio de que existem servidores de vida longa que você vai corrigindo. A alternativa é parar de corrigir e começar a substituir: construir uma imagem de máquina ou de contêiner com o sistema totalmente configurado, subir novas instâncias a partir dela e nunca modificar uma instância em execução.
O pipeline passa a ser: construir uma imagem (Packer para imagens de VM, um Dockerfile para contêineres), testá-la e deixar a ferramenta de provisionamento substituir as instâncias por outras baseadas na nova imagem. Nada é corrigido no lugar, então não há drift para detectar nem mudança aplicada pela metade, e o rollback é fazer deploy da imagem anterior.
source "amazon-ebs" "app" {
region = "eu-west-3"
source_ami_filter {
filters = { name = "ubuntu/images/*24.04-amd64-server-*" }
most_recent = true
owners = ["099720109477"]
}
instance_type = "t3.small"
ssh_username = "ubuntu"
ami_name = "shop-api-{{timestamp}}"
}
build {
sources = ["source.amazon-ebs.app"]
provisioner "ansible" {
playbook_file = "./ansible/site.yml"
}
}
Repare que o Ansible não desapareceu — agora ele roda uma única vez, no build da imagem, em vez de rodar continuamente contra a produção. Esse é o meio-termo pragmático a que a maioria dos times chega, e é exatamente o mesmo argumento dos contêineres: o pipeline de entrega constrói um artefato imutável uma única vez e o promove sem alterações.
A imutabilidade tem limites: bancos de dados guardam um estado que não pode ser substituído de qualquer jeito. Aplique o padrão onde ele se encaixa e mantenha o gerenciamento de configuração para o resto.
Drift, e o que fazer com ele
Drift é qualquer diferença entre o código e a realidade: uma mudança pelo console durante um incidente, um autoscaler ajustando um valor, um provedor de nuvem mudando um padrão, a automação de outro time mexendo em um recurso compartilhado.
Detecte-o de forma agendada, e não durante um deploy:
terraform plan -detailed-exitcode -lock=false
# exit 0 = no changes, 1 = error, 2 = changes pending
Um job noturno no pipeline que rode esse comando e dispare um alerta no código de saída 2 transforma o drift em notificação em vez de surpresa. O modo --check do Ansible fornece o sinal equivalente para os hosts configurados.
Tratar drift é uma questão de julgamento. Normalmente você reconcilia reaplicando o código, o que desfaz a mudança manual — seguro quando a mudança foi acidental, perigoso quando era uma correção urgente que ninguém avisou. Às vezes você incorpora a mudança ao código porque ela estava certa. De vez em quando você aceita que um campo é gerenciado em outro lugar e diz ao Terraform para parar de se importar com ele:
lifecycle {
ignore_changes = [tags["LastScanned"], desired_count]
prevent_destroy = true
}
prevent_destroy em bancos de dados e buckets de state é um seguro barato contra um plan que ninguém leu com atenção.
Secrets
Nunca coloque uma credencial em um arquivo .tf ou .yml. As opções realistas, em ordem crescente de maturidade: variáveis de ambiente fornecidas pelo pipeline (TF_VAR_db_password); um gerenciador de secrets referenciado por uma data source no momento do plan; e recursos que nunca chegam a guardar o secret, como um banco de dados cuja senha é gerada direto no gerenciador de secrets do provedor de nuvem.
O Ansible tem o Vault para criptografar arquivos de variáveis no lugar, o que permite manter secrets no Git de forma criptografada:
ansible-vault encrypt group_vars/prod/secrets.yml
ansible-playbook site.yml --vault-password-file ~/.ansible-vault-pass
E lembre-se da restrição mencionada antes: um secret que o Terraform cria ou lê vai parar no state em texto puro. Marcar uma variável com sensitive = true apenas a esconde da saída do console. Se um valor nunca pode ficar no state, o recurso dono dele não pode ser gerenciado pelo Terraform.
Rodando a partir de um pipeline
Aplicar a partir de notebooks é a origem da maioria dos incidentes de IaC: versões diferentes das ferramentas, providers desatualizados, sem lock, sem trilha de auditoria e credenciais esquecidas no perfil de shell de alguém. Leve isso para a CI.
O formato padrão é plan no pull request e apply no merge:
Em todo pull request
Rodefmt -check,validate, uma varredura de segurança (tfsec, Checkov ou o modo IaC do Trivy) eplan -out=tfplan. Publique o plan como comentário para que os revisores vejam o diff de infraestrutura ao lado do diff de código.Confie em políticas, não em vigilância
Adicione policy-as-code — OPA/Conftest, Sentinel ou as regras do scanner — para as invariantes que precisam valer sempre: nenhum bucket de armazenamento público, criptografia habilitada, tags obrigatórias, nenhum security group aberto para o mundo na porta 22.Aplique no merge o plan revisado
Aplique o artefato de plan salvo na execução do pull request, e não um recém-calculado, para que o que vai para produção seja o que foi aprovado. Use credenciais de curta duração obtidas via federação OIDC em vez de chaves de acesso de longa duração.Promova entre ambientes
As mesmas versões de módulos são aplicadas em dev, depois em staging e depois em produção, com arquivos de variáveis diferentes. As diferenças entre ambientes devem ser valores, nunca código separado.Detecte drift toda noite
Umplan -detailed-exitcodeagendado para cada state, alertando quando a realidade se afastou do código.
A extensão self-service disso é uma plataforma: os mesmos módulos expostos aos times de produto por trás de um pull request, de modo que um time consegue uma fila, um bucket ou um banco de dados adicionando dez linhas a um repositório em vez de abrir um chamado — o modelo de time de plataforma descrito na introdução a DevOps.
Onde as coisas dão errado
- State no Git. Ele contém secrets, gera conflito a cada merge e vai acabar se perdendo. Use um backend remoto desde o primeiro dia.
- Um único state para tudo. Plans lentos, raio de impacto enorme, disputa constante pelo lock. Divida por ciclo de vida.
- Módulos referenciados por branch. Um commit upstream muda o plan de todos os consumidores sem aviso. Fixe tags.
countonde deveria haverfor_each. O endereçamento por índice transforma a remoção de um item no meio de uma lista em uma cascata de substituições.- Ignorar o plan. “Must be replaced” em um banco de dados não é uma formalidade. Leia cada linha de destruição antes de digitar yes.
- Infraestrutura migrada pela metade. Alguns recursos no código, outros criados na mão pelo console, e ninguém sabe ao certo quais. Importe os recursos soltos ou apague-os, e implante uma política de tags para que recursos não gerenciados fiquem visíveis.
- Tratar IaC como algo que só se escreve. Código que ninguém reaplica há um ano é uma hipótese. Reconstrua regularmente um ambiente efêmero do zero para provar que ele ainda funciona.
Principais pontos
- Ferramentas de provisionamento criam recursos e os registram no state; gerenciadores de configuração fazem máquinas existentes corresponderem a uma descrição. A maioria dos ambientes precisa dos dois, ou de um build de imagem que incorpore o segundo ao primeiro.
- A idempotência é o que torna seguro rodar de novo, e é isso que viabiliza a correção automática e pipelines sem supervisão.
- O plan do Terraform é o artefato de revisão: leia-o, salve-o, aplique o arquivo salvo e nunca deixe um “must be replaced” passar sem exame.
- O state é compartilhado, sensível e bloqueável — coloque-o em um backend remoto criptografado e com versionamento, divida-o por raio de impacto e altere-o com
moved,importe os comandos de state, e não na mão. - Escreva módulos como funções, fixe-os por tag, prefira
for_eachacounte use diretórios separados em vez de workspaces para os ambientes. - Imagens imutáveis eliminam o drift substituindo servidores em vez de corrigi-los; mantenha o gerenciamento de configuração para as partes que não podem ser reconstruídas.
- Detecte drift de forma agendada com
plan -detailed-exitcodee decida deliberadamente se vai reconciliar, adotar ou ignorar cada diferença. - Rode plan nos pull requests com verificações de política, aplique o plan aprovado no merge com credenciais de curta duração e promova os mesmos módulos entre ambientes mudando apenas os valores.
Fontes e leituras complementares
Perguntas frequentes
- Terraform ou Ansible: qual eu preciso usar?
- Eles resolvem problemas diferentes e muitas vezes são usados juntos. O Terraform é uma ferramenta de provisionamento: cria e destrói recursos de nuvem como redes, bancos de dados, clusters e registros DNS, e registra no state o que é dele. O Ansible é um gerenciador de configuração: faz uma máquina existente corresponder a uma configuração descrita, instalando pacotes e gravando arquivos via SSH. Provisione com um e configure com o outro, ou embuta a configuração em uma imagem e pule a segunda etapa.
- Posso alterar a infraestrutura no console da nuvem depois do apply?
- Pode, e o próximo plan vai propor desfazer a alteração. Isso é drift, e é o mecanismo funcionando exatamente como deveria. Mudanças emergenciais pelo console às vezes são inevitáveis; a regra é reproduzir a mudança no código no mesmo dia. Se um recurso precisa ser gerenciado fora do Terraform, exclua-o explicitamente em vez de deixar dois donos brigando por ele a cada apply.
- O que acontece se duas pessoas rodarem apply ao mesmo tempo?
- Com um backend que suporta locking, a segunda execução falha na hora com uma mensagem dizendo quem está com o lock. Sem locking, as duas execuções leem o mesmo state, as duas o gravam e um dos conjuntos de mudanças se perde, deixando recursos que existem na nuvem mas não no state. Esse é o motivo mais forte para usar um backend remoto e rodar apply a partir de um pipeline, e não de notebooks.
- Secrets ficam seguros no state do Terraform?
- Não. Valores como senhas geradas e chaves de acesso são gravados no state em texto puro mesmo quando a variável está marcada como sensitive, o que apenas os esconde da saída do console. Trate o state como um secret: armazene-o em um backend criptografado com controle de acesso rigoroso, nunca faça commit dele e prefira recursos que referenciam um gerenciador de secrets a recursos que criam e guardam o secret eles mesmos.