Lab 55 — Terraform em módulo, com estado remoto e drift
O problema, e a empresa que o tem
A Órbita tem dois squads — Pedidos e Pagamentos — que mantêm, cada um, uma cópia do padrão ECS + ALB + RDS do L01. Nenhum dos dois usa CI/CD ainda: o laboratório anterior, o L54, resolveu como o pipeline se autentica na AWS sem chave estática. O que ainda não existe é ONDE o Terraform grava o que aplicou, e QUEM garante que dois applies não colidam.
Hoje cada squad roda terraform apply do próprio notebook, com `terraform.tfstate` gravado ao lado do código, sem versionamento e sem lock. Duas vezes nos últimos dois meses, dois engenheiros aplicaram quase ao mesmo tempo — uma delas corrompeu o estado do squad Pagamentos, e a correção levou uma tarde de `terraform import` recurso por recurso.
O outro incidente não veio de concorrência: durante uma indisponibilidade, um engenheiro de plantão subiu a classe da instância RDS pelo console para aguentar o pico, resolveu o problema e seguiu para o próximo alerta. Ninguém atualizou o HCL. Três semanas depois, um `apply` de rotina tentou baixar a instância de volta para a classe antiga — e quase reduziu a capacidade de produção no meio do expediente.
O que este laboratório NÃO é
Não é o pipeline em si — o papel federado por OIDC, o build e a esteira de aprovação já foram resolvidos no L54, e este módulo roda DENTRO dele. Também não é gestão de múltiplas contas: três ambientes do mesmo código com paridade garantida é o L56, e ele depende deste. Misturar as duas coisas aqui faria você depurar promoção de ambiente enquanto tenta entender por que um lock trava.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com um comando na seção de implantação, não com a sensação de ter entendido.
- Explicar por que dois `terraform apply` simultâneos corrompem o estado sem lock, e como o lock impede isso.
- Definir drift com precisão: a diferença entre o estado GRAVADO e o que a API REAL devolve agora — e só nesse momento.
- Configurar backend S3 com versionamento e lock em DynamoDB, e justificar cada atributo do bucket.
- Extrair um padrão repetido (ECS + ALB + RDS) para um módulo Terraform com variáveis e validação.
- Chamar o mesmo módulo a partir de dois ambientes distintos, com uma `key` de estado por ambiente.
- Agendar um `plan -detailed-exitcode` que roda sozinho, sem depender de alguém lembrar.
- Distinguir os três códigos de saída do `-detailed-exitcode` e o que cada um deve disparar.
- Provar, com número, que perder um notebook não exige reconstruir a infraestrutura.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Estado remoto e por que ele existe | DOP-C02, SAP-C02 | backend S3 versionado, substituindo o notebook | que o estado é a fonte da verdade do Terraform, não a infraestrutura em si |
| Lock de estado | DOP-C02 | item condicional no DynamoDB com chave `LockID` | que o lock impede escrita concorrente, não leitura, e tem timeout configurável |
| Drift | DOP-C02, SAP-C02 | plan agendado com `-detailed-exitcode` | que drift só é detectado quando alguém roda `plan` — nunca automaticamente |
| Módulo reutilizável | DOP-C02 | padrão ECS + ALB + RDS extraído com `source` e `variable` | a diferença entre copiar HCL e reutilizar módulo, e o que cada uma custa em manutenção |
| Idempotência | DOP-C02, SAP-C02 | reaplicar o mesmo módulo não recria recurso já correto | por que Terraform compara estado, não reexecuta comandos |
| Códigos de saída de automação | DOP-C02 | 0 / 1 / 2 do `-detailed-exitcode` decidindo o próximo passo do pipeline | não confundir erro de execução (1) com mudança detectada (2) |
| Criptografia de estado em repouso | SAP-C02, SCS-C02 | SSE-KMS no bucket do backend | por que o estado pode conter dado sensível e precisa do mesmo cuidado que um segredo |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve dois engenheiros aplicando ao mesmo tempo sem lock e pede o resultado. A resposta não é "o Terraform detecta e recusa" — sem lock, não há nada detectando nada: as duas chamadas de API correm e o estado de quem não gravou por último fica desatualizado, sem erro nenhum no momento do apply.
Requisitos, e como cada um muda o desenho
Requisito não funcional que não aparece numa linha de configuração é intenção. A coluna da direita é onde cada um deixou marca.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Zero corrupção de estado por apply concorrente | obrigatório | exige lock com escrita condicional — DynamoDB (`LockID`) neste módulo |
| Estado sobrevive à perda de um notebook | obrigatório | estado sai do disco local para um backend S3 versionado |
| Drift visível em até 1 dia útil | hipótese declarada | exige execução AGENDADA de `plan`, não o plan manual do próximo apply |
| Dois squads reutilizam a mesma definição de infraestrutura | obrigatório | extrai o padrão repetido em módulo com `variable`, chamado por `source` |
| Sem produto novo na fatura | restrição de orçamento | descarta backend gerenciado de terceiro; usa S3 e DynamoDB, que a conta já paga por outro motivo |
| Deploy e drift sem chave estática | herdado do L54 | apply e plan de drift rodam sob o papel federado OIDC, nunca de notebook |
| Estado pode conter dado sensível | assumido por padrão | SSE-KMS no bucket do backend, e acesso restrito ao papel do pipeline |
Arquitetura mínima: dois notebooks, uma corrida
Este é o desenho que a Órbita tem hoje, e ele é legítimo como ponto de partida: sobe infraestrutura de verdade, com poucas linhas. O laboratório começa medindo o problema, porque um número torna a corrida discutível — "às vezes dá erro" não é.
- → apply, sem lock
- → apply concorrente, mesmo recurso
- → muda a classe da instância no console
- → encaminha tráfego
- → string de conexão
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
Este desenho sobe de verdade com poucas linhas, e é por isso que sobrevive: cada squad roda `terraform apply` do próprio notebook, contra o mesmo padrão ECS + ALB + RDS do L01. O defeito não está em nenhuma linha errada — está em duas ausências. Percorra os passos e repare que a corrida entre os dois applies não é hipótese: é aritmética de quem grava por último.
- Dois notebooks, dois arquivos de estado. Cada apply lê e grava um `terraform.tfstate` LOCAL, no disco de quem o roda. Não existe uma fonte única de verdade: existem duas crenças sobre o mesmo recurso, e elas divergem no instante em que um dos dois aplica algo que o outro não viu.
- Apply concorrente não espera — ele corrompe. Sem lock, as duas chamadas de API correm ao mesmo tempo. A AWS aplica a última que chegou; mas o `terraform.tfstate` de quem NÃO chegou por último continua descrevendo um recurso que já não existe daquele jeito. O próximo apply desse squad vai tentar "corrigir" a realidade de volta para uma crença desatualizada.
- O notebook é o backup — de uma pessoa só. Se o notebook do squad Pagamentos for perdido, roubado ou simplesmente formatado, o `terraform.tfstate` some com ele. Ninguém mais consegue rodar `plan` ou `destroy` com segurança: o único jeito de continuar é importar cada recurso, um por um, torcendo para lembrar todos os IDs.
- Mudança no console não deixa rastro nenhum. O engenheiro de plantão resolve o incidente mudando a classe do RDS pelo console — decisão correta sob pressão. O problema não é a mudança: é que ela não aparece em nenhum HCL, em nenhum estado, em nenhum lugar que alguém vá olhar depois. É aqui que o drift nasce.
- Nada roda `plan` sozinho. A única forma de alguém notar a mudança do console é rodar `terraform plan` e comparar com a API real. Mas ninguém agendou isso: o plan só roda, por acaso, quando um dos dois squads decide aplicar de novo — e mesmo aí, só contra o estado (desatualizado) daquele squad.
- Por que os dois squads operam assim. Porque é o caminho mais curto entre "preciso de infraestrutura" e "está no ar": copiar o Terraform do L01, ajustar dois nomes, rodar `apply` do próprio notebook. Funciona perfeitamente enquanto for uma pessoa, uma vez. A segunda pessoa é quem paga a conta.
O `terraform.tfstate` pode conter segredo em texto claro
Se algum recurso do módulo tiver um atributo sensível sem `sensitive = true` declarado no provider, ele é gravado no estado sem cifra. Comitar esse arquivo no Git — algo que `git add .` faz por engano com facilidade — expõe esse segredo para sempre no histórico do repositório, mesmo que o arquivo seja removido depois. É exposição, não corrupção: não tem "desfazer".
Antes de mudar qualquer coisa, meça a corrida. Rode dois applies contra o mesmo ambiente quase ao mesmo tempo e registre o resultado — sem lock, os dois "terminam com sucesso", e é isso que engana.
Arquitetura para produção
Cada peça nova abaixo rastreia a uma linha da tabela de requisitos. Se você não conseguir apontar o requisito, a peça é adorno — e este desenho não tem nenhuma.
- → push aciona o pipeline
- → lê e grava terraform.tfstate
- → adquire e libera o LockID
- → aplica o módulo reutilizável
- → dispara plan sozinho, sem apply
- → compara estado gravado × API real
- → também adquire lock, mesmo sem aplicar
- → publica achado quando há diferença
- → encaminha tráfego
- → string de conexão
- Fora da AWS
- Integração de apps
- Gestão e governança
- Conceito de arquitetura
- Armazenamento
- Banco de dados
- Rede e entrega
- Compute
A diferença estrutural não é uma caixa a mais: é o estado saindo do notebook para o S3, o lock saindo da honra para o DynamoDB, e a pergunta sobre drift saindo da memória de alguém para o relógio do EventBridge. Percorra os passos: cada peça nova resolve uma das ausências do desenho anterior — nenhuma é adorno.
- O push aciona o apply — o notebook não aciona nada. A única forma de mudar infraestrutura é uma alteração comitada que passa pelo papel federado do L54. Não existe mais `terraform apply` rodado à mão contra a conta de produção.
- O estado mora no S3, não no disco de ninguém. Perder qualquer notebook deixa de ser incidente de infraestrutura. O bucket é versionado, então um `apply` ruim também tem um "antes" recuperável — algo que o notebook nunca ofereceu.
- O lock é o que torna dois applies simultâneos seguros. Antes de tocar no estado, o pipeline escreve um item condicional no DynamoDB com a chave `LockID`. Quem chega segundo não sobrescreve: recebe erro, com o nome de quem já segura o lock, e espera ou desiste.
- O módulo aplica a mesma infraestrutura com variáveis. Pedidos e Pagamentos chamam o MESMO módulo `servico-web`, com `source` apontando para um único lugar. Uma correção de segurança no módulo se propaga para os dois squads no próximo apply — em vez de exigir duas correções coladas à mão.
- Drift não avisa sozinho — o agendamento pergunta por ele. Terraform não sincroniza estado em segundo plano. A regra do EventBridge é o que transforma "será que alguém mudou algo" numa pergunta que É feita, com frequência conhecida, em vez de depender de alguém lembrar.
- O plano de drift compara, e não aplica nada. A execução agendada roda `terraform plan -detailed-exitcode` — nunca `apply`. Ela ainda precisa do lock, porque também lê o estado com consistência, mas não muda um único recurso.
- Divergência vira alerta — não vira crença. Código de saída 2 publica no tópico; código 0 não publica nada. Ninguém precisa "lembrar de checar": o silêncio do canal É a informação de que não houve drift desde a última execução.
A diferença estrutural em relação ao desenho anterior não é o S3 e o DynamoDB aparecendo: é a existência de DUAS execuções do mesmo pipeline — uma que aplica, disparada por push, e outra que só pergunta, disparada por agendamento. Nenhuma delas depende de um notebook estar ligado.
A pergunta que o desenho de produção torna respondível
"O que está realmente no ar agora, e bate com o que o Terraform acha que aplicou?" No desenho mínimo essa pergunta não tem dono. Aqui, ela tem: a execução agendada é a única responsável por fazê-la, com frequência conhecida.
O ciclo de um apply e o ciclo de um drift, ponta a ponta
Os dois ciclos são a MESMA cadeia de comandos até o passo 5. A diferença inteira do módulo mora em o que acontece depois: um aplica, o outro só compara e avisa.
O refresh é onde a mudança do console vira visível
Antes do passo 4, o Terraform não sabe de nada que aconteceu fora dele. Ele não monitora a conta continuamente — a consulta à API real acontece SOB DEMANDA, dentro de um `plan` ou `apply`. É por isso que a resposta a "há quanto tempo esse drift existe?" é sempre "desde a última vez que alguém rodou plan", nunca "desde que aconteceu".
O texto abaixo não é ilustrativo: é a saída real do Terraform quando o segundo apply encontra o lock ocupado. Vale reconhecer, porque é exatamente isto que aparece no log do pipeline quando dois squads colidem.
# Saída real do Terraform quando o segundo apply encontra o lock ocupado.
# Não é uma mensagem inventada: é o texto padrão do provider ao falhar em
# adquirir o lock — vale reconhecer, porque é isto que aparece no log do
# pipeline quando dois squads colidem.
Error: Error acquiring the state lock
Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
ID: 7f3a2e10-8c4b-4a1f-9e2d-1b6c5a8f0d33
Path: vetor-orbita-terraform-state/ambientes/pagamentos/terraform.tfstate
Operation: OperationTypeApply
Who: squad-pedidos@runner-ci-04
Version: 1.9.x
Created: 2026-08-08 14:22:31.618418 +0000 UTC
Info:
Terraform acquires a state lock to protect the state from being written by
multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.
O `Info:` vazio no Lock Info não é bug
O campo existe para uma mensagem opcional que o operador pode anexar ao lock; a maioria das execuções de CI não a preenche, então ele aparece em branco. O que importa para diagnosticar é `Who` (identidade de quem segura o lock) e `Created` (há quanto tempo) — se `Created` for de horas atrás, é lock órfão, não colisão real.
As decisões, e o que se perde em cada uma
📋 Dois squads (Pedidos e Pagamentos) da Órbita aplicam Terraform contra a mesma conta AWS, cada um com sua cópia colada à mão do padrão ECS + ALB + RDS do L01, sem orçamento para contratar um produto novo de gestão de estado.
Resolve as duas ausências reais — corrida entre applies e notebook como ponto único de falha — com dois serviços que a conta já paga por outro motivo (S3 e DynamoDB), sem produto novo na fatura. O módulo reutilizável reduz onde o drift pode nascer: em vez de duas cópias de HCL divergindo em silêncio, existe uma definição, chamada duas vezes com variáveis diferentes. E como o apply já roda no pipeline do L54, agendar uma segunda execução — só de `plan` — custa uma regra de EventBridge, não infraestrutura nova.
Alt: Terraform Cloud / HCP Terraform (gerenciado) — Resolve estado, lock e execução agendada de drift com um produto pronto, mas é uma conta e um login novos, com custo por usuário. Vale a pena quando o número de squads justificar não manter a esteira própria — e está fora do orçamento deste laboratório.
Alt: CloudFormation / CDK com drift detection nativo — CloudFormation tem `detect-stack-drift` embutido, sem precisar de nenhum agendamento externo — é uma vantagem real da ferramenta. Mas os dois squads já investiram no módulo Terraform; reescrever para CFN é o custo mais alto entre as opções, para resolver um problema que o agendamento já resolve.
Alt: OpenTofu com o mesmo backend S3 — É compatível com boa parte do HCL usado aqui e reaproveitaria backend e módulo quase sem alteração. Trocar de ferramenta é uma decisão de outra escala — de organização, não deste laboratório — e não muda nada do raciocínio sobre estado, lock e drift.
Alt: Convenção de "só uma pessoa aplica por vez" — É o desenho mínimo deste módulo. Funciona até o dia em que alguém esquece a convenção, ou a pessoa que sabia disso sai de férias com o notebook que tem o único estado.
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Mecanismo de lock | DynamoDB, chave `LockID` | lock nativo do S3 (`use_lockfile`); nenhum lock | é o padrão ainda dominante nas bases existentes e o que a certificação testa | HashiCorp já marcou este caminho como legado — backend novo deveria avaliar `use_lockfile` |
| Onde roda o drift | agendado dentro do pipeline do L54 | produto gerenciado (Terraform Cloud); cron num servidor à parte | reaproveita o papel federado e a esteira que já existem, sem infraestrutura nova | acoplado à disponibilidade do pipeline; se ele cair, o drift também para de ser perguntado |
| Modelagem do módulo | um módulo, duas chamadas com `variable` | copiar e colar por ambiente; um módulo por squad | uma correção se propaga para os dois squads no próximo apply | squads perdem a liberdade de divergir infraestrutura sem negociar a interface do módulo |
| Billing mode da tabela de lock | PAY_PER_REQUEST | capacidade provisionada fixa | tráfego de lock é em rajada — vários applies no mesmo minuto, depois nada por horas | custo por requisição pode superar capacidade provisionada em volume muito alto e constante |
| Chave de estado por ambiente | uma `key` distinta por squad, mesmo bucket | um bucket por squad; um estado único para todos | isola o lock e o raio de impacto de um apply ruim sem multiplicar infraestrutura de backend | nada crítico aqui — é o desenho recomendado; só exige disciplina de nunca reusar a mesma key |
A dívida que este módulo cria, e que ele não paga
Os dois squads ainda compartilham o mesmo bucket e a mesma tabela de lock, sem fronteira de conta. Um squad com acesso de escrita ao backend pode, em teoria, ler o estado do outro. Isolar por conta é o L56, e ele depende deste.
Construir: o backend, aplicado antes de existir backend
É um paradoxo de partida, e vale nomear: você precisa de um lugar para guardar o estado, e esse lugar TAMBÉM é Terraform. A saída é aplicar o bootstrap com estado LOCAL, uma única vez, e nunca mais tocar nele por esse caminho.
# bootstrap/estado.tf — aplicado UMA vez, com estado local, antes de existir
# backend remoto para guardar o resto. É o paradoxo de partida: você precisa de
# infraestrutura para guardar o estado, e essa infraestrutura também é Terraform.
resource "aws_s3_bucket" "estado" {
bucket = "vetor-orbita-terraform-state"
# Sem isto, um apply ruim sobrescreve o estado anterior sem deixar rastro.
# Com isto, "restaurar a versão de ontem do estado" é um comando do S3, não
# uma reconstrução.
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "estado" {
bucket = aws_s3_bucket.estado.id
versioning_configuration {
status = "Enabled"
}
}
# O estado pode conter dado sensível em texto claro — segredo passado como
# atributo de um recurso, ARN completo, endpoint interno. Cifrar em repouso não
# é opcional aqui.
resource "aws_s3_bucket_server_side_encryption_configuration" "estado" {
bucket = aws_s3_bucket.estado.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = aws_kms_key.estado.arn
}
}
}
resource "aws_s3_bucket_public_access_block" "estado" {
bucket = aws_s3_bucket.estado.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# A tabela de lock. O nome do atributo de chave é fixo pelo backend do
# Terraform: TEM de se chamar `LockID`, do tipo String — não é escolha de
# estilo, é contrato do provider.
resource "aws_dynamodb_table" "lock" {
name = "vetor-orbita-terraform-lock"
billing_mode = "PAY_PER_REQUEST" # trafego de lock e em rajada, nao constante;
# capacidade provisionada baixa gera throttling
# justamente no pico em que varios squads aplicam juntos
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
output "bucket_estado" {
value = aws_s3_bucket.estado.bucket
}
output "tabela_lock" {
value = aws_dynamodb_table.lock.name
}
Por que o bootstrap não migra para o próprio backend que cria
Seria elegante fazer o bootstrap apontar para o backend que ele mesmo criou — mas isso reintroduz o problema original: quem aplica o bootstrap de novo (para adicionar um squad, por exemplo) precisaria já ter o estado remoto configurado para gerenciar o recurso que O CRIA. Manter o bootstrap com estado local, aplicado raramente e por poucas pessoas, é a exceção deliberada à regra do resto do módulo.
Construir: o backend de cada ambiente, e o módulo reutilizável
O arquivo abaixo não cria nada: ele diz ao Terraform ONDE ler e gravar o estado deste ambiente específico. A `key` é o que separa o lock de Pedidos do lock de Pagamentos dentro do mesmo bucket.
# backend.tf — vive em CADA ambiente (ambientes/pedidos, ambientes/pagamentos).
# Os valores de conta e regiao NAO ficam fixos aqui: entram via
# `-backend-config` no pipeline, para o mesmo HCL nao carregar o ID da conta
# de exemplo para dentro de producao por engano.
terraform {
backend "s3" {
bucket = "vetor-orbita-terraform-state"
key = "ambientes/pagamentos/terraform.tfstate" # UMA key por ambiente —
# e o que isola o lock de um
# squad do lock do outro
region = "us-east-1"
# Lock via DynamoDB. NOTA DE ATUALIZACAO: versoes recentes do Terraform
# tambem oferecem lock nativo do S3 com `use_lockfile = true`, sem
# DynamoDB — a HashiCorp marcou o lock via DynamoDB como caminho legado.
# Este modulo ensina DynamoDB porque e o que a maioria das bases hoje usa
# e o que a certificacao ainda cobra; ao criar um backend NOVO, avalie
# `use_lockfile` no lugar desta linha.
dynamodb_table = "vetor-orbita-terraform-lock"
encrypt = true
}
}
O módulo é o padrão ECS + ALB + RDS do L01, com os valores fixos virando `variable`. A interface — o arquivo de variáveis — é o contrato entre os dois squads e a infraestrutura que eles compartilham conceitualmente.
# modules/servico-web/variables.tf — a interface do modulo. Cada squad passa
# valores; nenhum squad copia recurso.
variable "projeto" {
type = string
description = "Nome curto do squad; vira prefixo de todo recurso."
}
variable "vpc_id" {
type = string
}
variable "subnets_publicas" {
type = list(string)
}
variable "subnets_privadas" {
type = list(string)
}
variable "instance_class_rds" {
type = string
default = "db.t4g.medium"
# `validation` existe desde o Terraform 0.13: recusa um valor claramente
# errado ANTES do plan, em vez de deixar a AWS rejeitar na hora do apply.
validation {
condition = can(regex("^db\\.[a-z0-9]+\\.[a-z]+$", var.instance_class_rds))
error_message = "instance_class_rds precisa seguir o formato db.<familia>.<tamanho>, ex.: db.t4g.medium."
}
}
variable "desired_count" {
type = number
default = 2
}
# modules/servico-web/main.tf — o padrao ECS + ALB + RDS do L01, extraido UMA
# vez. Antes deste modulo, Pedidos e Pagamentos tinham 312 linhas de HCL quase
# identico, cada copia divergindo um pouco mais a cada mudanca manual.
resource "aws_lb" "principal" {
name = "${var.projeto}-alb"
load_balancer_type = "application"
subnets = var.subnets_publicas
security_groups = [aws_security_group.alb.id]
}
resource "aws_ecs_service" "api" {
name = "${var.projeto}-api"
cluster = aws_ecs_cluster.principal.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.subnets_privadas
security_groups = [aws_security_group.task.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 8080
}
}
resource "aws_db_instance" "principal" {
identifier = "${var.projeto}-db"
instance_class = var.instance_class_rds
engine = "postgres"
# A classe vem de VARIAVEL agora. Mudar em producao passa por um plan
# revisado, nao por um clique no console — e e exatamente isso que fecha o
# buraco por onde o drift deste laboratorio nasceu.
db_subnet_group_name = aws_db_subnet_group.principal.name
vpc_security_group_ids = [aws_security_group.rds.id]
skip_final_snapshot = false
final_snapshot_identifier = "${var.projeto}-final"
}
output "endpoint_rds" {
value = aws_db_instance.principal.endpoint
sensitive = true
}
output "dns_alb" {
value = aws_lb.principal.dns_name
}
O que a validação de variável evita
Sem o bloco `validation`, um valor como `instance_class_rds = "grande"` só falha na hora em que a AWS rejeita a chamada de API — depois do plan já ter sido revisado e aprovado no pipeline. Com a validação, o erro aparece antes, com uma mensagem que diz exatamente o formato esperado.
Construir: dois squads, uma definição
Nada aqui é recurso de ECS, ALB ou RDS — é só a chamada ao módulo, com os valores que realmente diferem entre os dois squads. Antes deste arquivo, essa diferença estava espalhada dentro de 150 linhas de HCL quase idênticas.
# ambientes/pagamentos/main.tf — o squad Pagamentos chama o MESMO modulo que
# Pedidos chama, so muda a variavel. Nao ha um unico recurso de ECS, ALB ou RDS
# escrito aqui: tudo vem de `source`.
module "servico" {
source = "../../modules/servico-web"
projeto = "pagamentos"
vpc_id = data.aws_vpc.compartilhada.id
subnets_publicas = data.aws_subnets.publicas.ids
subnets_privadas = data.aws_subnets.privadas.ids
instance_class_rds = "db.t4g.large" # Pagamentos processa mais volume; a
# UNICA diferenca de infraestrutura
# entre os dois squads e esta linha
desired_count = 3
}
output "dns_pagamentos" {
value = module.servico.dns_alb
}
// appsettings lido no Program.cs — o app consome a saida do modulo, nao um
// valor fixo. Quando um squad muda a classe do RDS pela VARIAVEL (nao pelo
// console), o pool de conexao pode ser recalculado no proximo deploy, em vez
// de continuar dimensionado para uma instancia que ja nao existe daquele jeito.
var builder = WebApplication.CreateBuilder(args);
// TAMANHO_POOL_DB vem do pipeline, que le a saida `dns_alb` / atributos do
// modulo no momento do deploy — nao e um numero fixo no codigo-fonte.
var tamanhoPool = builder.Configuration.GetValue("TAMANHO_POOL_DB", 20);
builder.Services.AddNpgsqlDataSource(
builder.Configuration.GetConnectionString("Padrao")!,
dataSourceBuilder =>
{
// Pool dimensionado pela classe da instancia DECLARADA no modulo. Se
// alguem mudar a classe pelo console (o antipadrao do desenho minimo),
// este numero fica errado e ninguem percebe ate saturar conexao —
// mais um sintoma de drift que nao aparece como erro do Terraform.
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = tamanhoPool;
});
var app = builder.Build();
app.MapGet("/health/ready", async (NpgsqlDataSource db) =>
{
await using var conn = await db.OpenConnectionAsync();
return Results.Ok(new { pool = tamanhoPool });
});
app.Run();
Por que o C# aparece num laboratório de Terraform
A aplicação não muda de comportamento por causa deste módulo — mas ela CONSOME um valor que só existe porque a infraestrutura agora é declarada, não editada no console. Antes, o tamanho do pool de conexão era um número fixo no código, dimensionado para uma classe de instância que podia mudar sem aviso. Agora ele acompanha a variável que também dimensiona o banco.
Construir: a pergunta que roda sozinha
Este workflow é quase idêntico ao de deploy do L54 — a diferença inteira está no gatilho (`schedule` em vez de `push`) e no passo final (nunca chama `terraform apply`).
# .github/workflows/drift.yml — roda no papel federado do L54 (OIDC), sem
# chave de longa duracao armazenada no GitHub. So difere do workflow de deploy
# em duas linhas: o gatilho e o comando final.
name: plano-de-drift
on:
schedule:
- cron: '0 8 * * 1-5' # 08:00 UTC, dias uteis — NAO verificado contra o
# horario de pico real da Orbita; ajuste ao seu.
workflow_dispatch: {}
permissions:
id-token: write # exigido para o OIDC assumir o papel do L54
contents: read
jobs:
drift:
strategy:
matrix:
ambiente: [pedidos, pagamentos]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/orbita-terraform-drift
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- name: init
working-directory: ambientes/${{ matrix.ambiente }}
run: terraform init -input=false
- name: plan (sem apply) e captura do codigo de saida
id: plano
working-directory: ambientes/${{ matrix.ambiente }}
run: |
set +e
terraform plan -input=false -detailed-exitcode -out=plano.tfplan
echo "codigo=$?" >> "$GITHUB_OUTPUT"
exit 0
# 0 = sem mudanca; 2 = drift encontrado; 1 = erro real do plan.
# So o 2 e drift — o 1 NAO deve virar alerta de drift, e sim de pipeline quebrado.
- name: publica alerta se houve drift
if: steps.plano.outputs.codigo == '2'
working-directory: ambientes/${{ matrix.ambiente }}
run: |
RESUMO=$(terraform show -no-color plano.tfplan | head -c 3000)
aws sns publish \
--topic-arn arn:aws:sns:us-east-1:111122223333:orbita-drift-${{ matrix.ambiente }} \
--subject "Drift encontrado em ${{ matrix.ambiente }}" \
--message "$RESUMO"
- name: falha o job se o plan deu erro de verdade
if: steps.plano.outputs.codigo == '1'
run: exit 1
Confundir código 1 com código 2 transforma erro de pipeline em falso positivo
Um `-detailed-exitcode` que devolve 1 significa que o `plan` FALHOU — por exemplo, credencial OIDC expirada ou permissão insuficiente. Tratar esse código como "drift encontrado" produz um alerta de drift enganoso, e a equipe passa a investigar mudança de infraestrutura que não existe, enquanto o problema real — o pipeline quebrado — fica sem ninguém olhando.
Implantar, e provar que o lock e o estado remoto fazem o prometido
#!/usr/bin/env bash
# aplicar.sh — roda DENTRO do pipeline do L54, nunca de um notebook.
set -euo pipefail
AMBIENTE="${1:?uso: aplicar.sh <ambiente>}"
cd "ambientes/${AMBIENTE}"
terraform init -input=false
# -lock-timeout explicito: se outro squad segura o lock, espera ate 5 min em
# vez de falhar na hora. Sem isto, o padrao e falhar imediatamente.
terraform plan -input=false -lock-timeout=5m -out=plano.tfplan
# Em pipeline, o plan revisado (nao um auto-approve cego) e o que separa
# "aplicar o que foi decidido" de "aplicar o que aconteceu de ser gerado".
terraform apply -input=false -lock-timeout=5m plano.tfplan
echo "aplicado: ${AMBIENTE}"
Cinco provas. Nenhuma aceita "parece que aplicou" como resultado — cada uma tem um número, e a quarta é a que mais evidencia o ganho do módulo reutilizável.
# provas.sh — cinco medicoes; nenhuma aceita "parece que aplicou" como resultado.
# Prova 1: o lock bloqueia apply concorrente — nao deixa passar silenciosamente.
# Dispare dois applies quase juntos contra o MESMO ambiente e conte falhas.
( cd ambientes/pagamentos && terraform apply -auto-approve -lock-timeout=2s & )
sleep 1
cd ambientes/pagamentos && terraform apply -auto-approve -lock-timeout=2s
# Esperado: um dos dois processos termina com "Error acquiring the state lock"
# e o Lock Info (ver LOCK_ERRO). Zero applies concorrentes bem-sucedidos.
# Prova 2: o numero de recursos a IMPORTAR depois de perder o notebook.
# No desenho MINIMO (estado local), simule perda apagando o estado local e
# conte quantos `terraform import` sao necessarios para recuperar:
rm -f terraform.tfstate
terraform plan | grep -c "will be created"
# Na Orbita: 3 recursos (ALB, servico ECS, instancia RDS) — 3 comandos de
# import, cada um exigindo o ID exato do recurso na AWS.
# No desenho de PRODUCAO (estado no S3), o mesmo comando sem `rm` no S3:
cd ambientes/pagamentos && terraform init && terraform plan
# Esperado: "No changes." — o notebook perdido nao tirou nada do estado.
# Prova 3: drift detectado pela execucao agendada, com numero no diff.
aws rds modify-db-instance --db-instance-identifier pagamentos-db \
--db-instance-class db.t4g.xlarge --apply-immediately
terraform plan -input=false -detailed-exitcode; echo "codigo de saida: $?"
# Esperado: codigo 2, e o plan lista "1 to change" na linha do RDS.
# Prova 4: linhas de HCL antes e depois do modulo — a duplicacao removida.
wc -l ambientes/pedidos/*.tf.bak ambientes/pagamentos/*.tf.bak 2>/dev/null | tail -1
wc -l modules/servico-web/*.tf
# Na Orbita: 312 linhas duplicadas entre as duas copias antigas viraram 140
# linhas no modulo, chamado 2 vezes com 6 linhas de variavel cada.
# Prova 5: lock-timeout realmente expira, e nao fica preso para sempre.
time terraform apply -lock-timeout=10s -auto-approve || true
# Esperado: falha em ~10s (nao em 0s, nem em minutos) quando o lock esta
# ocupado — o valor configurado e o teto, nao uma sugestao.
| Prova | Comando | Resultado que aprova | O que reprova, e o que significa |
|---|---|---|---|
| 1 · Lock impede apply concorrente | dois `apply` quase simultâneos | um dos dois falha com "Error acquiring the state lock" | os dois terminarem com sucesso significa que o backend não está com `dynamodb_table` configurado |
| 2 · Notebook perdido não exige reconstrução | apagar estado local vs. remoto, depois `plan` | local: "3 will be created"; remoto: "No changes." | se o remoto também pedir criação, o backend não está apontando para o bucket certo |
| 3 · Drift é detectado com número | mudar RDS no console, depois `plan -detailed-exitcode` | código de saída 2, com "1 to change" na linha do RDS | código 0 significa que o `refresh` não viu a API real — confira se `-refresh=false` não está setado |
| 4 · Duplicação removida pelo módulo | contagem de linhas antes/depois | redução de centenas de linhas duplicadas para um módulo único chamado 2× | se as linhas do módulo cresceram tanto quanto as cópias antigas, a extração não capturou o padrão real |
| 5 · O timeout do lock é um teto de verdade | `apply` com `-lock-timeout=10s` contra lock ocupado | falha perto de 10 s, não imediatamente nem em minutos | falha imediata sugere que o valor não foi lido; nunca falhar sugere lock nunca disputado no teste |
Quebrar de propósito: três falhas e o diagnóstico
As três parecem, de longe, o mesmo problema — "o Terraform não fez o que eu esperava". O que as separa é onde a causa realmente mora.
| Falha | Como provocar | Sintoma | Onde olhar | Correção |
|---|---|---|---|---|
| Lock órfão | mate o processo do `apply` no meio (Ctrl+C forçado) sem deixar liberar o lock | todo `apply` seguinte falha com o mesmo Lock Info, minutos ou horas depois | `Created` no Lock Info muito mais antigo que qualquer apply em andamento | confira que ninguém está aplicando de verdade, e só então `terraform force-unlock <ID>`, com o ID exato |
| Estado divergente entre ambientes por `key` errada | copie o `backend.tf` de um ambiente para outro sem trocar a `key` | o `apply` de um squad some ou sobrescreve recurso do outro | compare a `key` em cada `backend.tf` com o nome do ambiente | uma `key` distinta por ambiente, nunca reaproveitada |
| Drift "fantasma" por atributo computado | rode `plan` num recurso com atributo que a AWS recalcula sozinha (ex.: um timestamp) | o plan mostra mudança a cada execução, mesmo sem ninguém mexer em nada | o mesmo atributo aparece como "diff" em execuções consecutivas sem mudança real | `lifecycle { ignore_changes = [...] }` no atributo específico, nunca no recurso inteiro |
Force-unlock sem conferir o dono pode corromper um apply legítimo em andamento
`terraform force-unlock` remove o item do DynamoDB independentemente de quem o criou. Se outro apply está genuinamente em andamento — não travado, só demorado — destravar na marra libera um SEGUNDO processo para escrever o mesmo estado ao mesmo tempo que o primeiro. É a mesma corrida que o lock existe para impedir, causada pela ferramenta que existe para corrigir lock. Confira `Created` e `Who` antes, sempre.
Dois engenheiros rodam `terraform apply` quase ao mesmo tempo contra o mesmo estado, com backend S3 e lock em DynamoDB configurado. O que acontece?
Segurança: o estado é tão sensível quanto um segredo
O `terraform.tfstate` guarda, em texto claro salvo declaração explícita de `sensitive`, todo atributo de todo recurso que ele gerencia — o que inclui, com alguns providers, dado que você não esperaria ver ali.
| Risco | Probabilidade | Impacto | Controle preventivo | Detecção | Resposta |
|---|---|---|---|---|---|
| Segredo em texto claro no estado | média | alto | SSE-KMS no bucket; nunca comitar `.tfstate` no Git (`.gitignore` + regra no pipeline) | CloudTrail em `GetObject` no bucket de estado, fora do papel do pipeline | rotacionar o segredo exposto; o dado já vazou, cifrar depois não desfaz |
| Lock órfão trava toda a equipe | média | médio | timeout de lock explícito nos scripts de apply | alarme sobre item no DynamoDB mais antigo que o maior apply esperado | `force-unlock` só após confirmar `Who` e `Created` |
| Backend parcial aponta para conta errada | baixa | alto | `-backend-config` explícito por ambiente; `aws sts get-caller-identity` antes do apply | CloudTrail cross-conta com identidade inesperada | reverter pelo estado versionado no S3; revisar quem tinha a credencial |
| Bucket de estado sem versionamento perde histórico | baixa | alto | `versioning_configuration` habilitado no bootstrap | checagem do status de versionamento no console | restaurar a versão anterior do objeto — só existe se versionamento estava ligado |
| Papel do pipeline com permissão além do módulo | média | médio | política derivada dos recursos reais do módulo, não `*` | IAM Access Analyzer sobre uso real do papel | restringir a política e reaplicar — é o L41 |
O `*` que aparece na política do backend, e por que ele se justifica
`dynamodb:DescribeTable` sobre a tabela específica não precisa de `*` — mas algumas ações de bootstrap inicial (criar o próprio bucket, antes de ele existir) rodam com um papel humano mais amplo, uma única vez. A política do PIPELINE, que roda depois, é restrita ao bucket e à tabela nomeados; o papel amplo do bootstrap não é reutilizado para nada além da criação inicial.
Observabilidade: as perguntas que o painel tem de responder
| Pergunta | Métrica ou consulta | O que significa mudar | Limiar inicial |
|---|---|---|---|
| Existe lock preso agora? | idade do item `LockID` no DynamoDB | apply travado ou processo morto sem liberar | > 15 min sem apply conhecido em execução |
| O último plan agendado encontrou drift? | código de saída do job de drift | infraestrutura divergiu do declarado | qualquer execução com código 2 |
| Quantos ambientes compartilham a mesma `key` por engano? | auditoria do `backend.tf` de cada ambiente | dois squads escrevendo no mesmo estado sem saber | 0 esperado — cada ambiente com `key` própria |
| Quanto tempo um apply espera pelo lock? | duração do passo "lock" no log do pipeline | contenção crescente entre squads | p95 subindo mês a mês |
| Alguém aplicou fora do pipeline? | CloudTrail `PutObject` no bucket de estado | apply rodado de notebook, contornando o L54 | qualquer identidade que não seja o papel do pipeline |
| O módulo mudou de versão sem os squads saberem? | referência de `source` em cada ambiente | módulos divergindo silenciosamente entre squads | qualquer diferença entre ambientes que deveriam estar pareados |
A métrica que confunde drift com erro de execução
Um painel que conta "execuções de drift com código != 0" mistura duas coisas diferentes: código 2 é infraestrutura divergente, código 1 é o pipeline quebrado. Contar os dois juntos faz um problema de permissão OIDC parecer um problema de infraestrutura desalinhada, e a investigação começa no lugar errado.
Escala: 2 squads, 20 squads, a organização inteira
| Volume | O que acontece com o backend | O que passa a doer | O que fazer |
|---|---|---|---|
| 2 squads, 1 ambiente cada | poucos applies por dia; lock raramente disputado | nada; é o cenário deste laboratório | nada |
| 20 squads, 1-2 ambientes cada | dezenas de applies concorrentes possíveis por hora | fila de espera pelo lock em horário de pico; o timeout de 5 min passa a ser atingido | reduzir o escopo de cada `key` (menos recursos por estado) para encurtar o tempo que cada apply segura o lock |
| Organização inteira, centenas de estados | um único bucket concentra TODO o tráfego de leitura/escrita de estado | o bucket compartilhado vira ponto de contenção observável, não só teórico | múltiplos backends por domínio, em vez de um bucket gigante — é parte do que o L56 e o L98 tratam |
| Falha de AZ | S3 e DynamoDB são serviços regionais, com redundância multi-AZ nativa | o backend do estado sobrevive; a infraestrutura APLICADA (ECS, RDS) pode não | nada a fazer no backend; a resiliência da infraestrutura gerenciada é assunto de cada módulo, não deste |
| Pico de applies simultâneos (ex.: deploy de sexta à tarde de todos os squads) | fila de lock cresce rápido, mesmo com backend saudável | tempo de fila vira o gargalo real do pipeline, não a AWS | escalonar horário de deploy por squad, ou aceitar a fila como comportamento esperado e dimensionar timeout para ela |
O que NÃO escala neste desenho, e por quê
O bucket e a tabela são regionais e escalam sozinhos — não é aí que a dor aparece. O gargalo é humano e organizacional: mais squads compartilhando o MESMO backend sem fronteira de conta significa mais gente que PODE ler o estado de infraestrutura que não é sua. Isso não é um limite de capacidade da AWS; é um limite de governança, e o L43 e o L56 são onde ele se resolve.
Custo: o que este laboratório acrescenta à fatura
É um dos laboratórios mais baratos da série: nenhum recurso novo fica ligado por hora. O que existe é tráfego — de requisição ao S3, de leitura/escrita no DynamoDB, e de minutos de execução do pipeline.
| Cenário | Volume | O que acrescenta | Tendência | Otimização |
|---|---|---|---|---|
| Protótipo | 2 squads, poucos applies por dia | centavos em requisições S3 e DynamoDB; minutos de CI para o job de drift | desprezível | nenhuma; otimizar aqui é atenção gasta onde não há dinheiro |
| Produção pequena | 5-10 squads, dezenas de applies por dia | requisições S3/DynamoDB crescem linearmente com applies; execuções de drift diárias por ambiente | baixa e previsível | agrupar ambientes pequenos num único job de drift com matriz, em vez de um job por ambiente |
| Alta escala | organização inteira, centenas de estados | volume de requisições ao bucket compartilhado vira linha visível; minutos de CI de drift multiplicam por ambiente × frequência | cresce com o número de estados, não com o tamanho de cada um | múltiplos backends por domínio reduzem contenção, mas NÃO reduzem custo total de requisição — é otimização de governança, não de fatura |
| Dimensão | Cobra por | Cuidado |
|---|---|---|
| S3 (estado) | requisições GET/PUT + GB-mês armazenado | versionamento mantém toda revisão anterior do estado — arquivo pequeno, mas cresce sem lifecycle |
| DynamoDB (lock) | leitura/escrita sob demanda (PAY_PER_REQUEST) | tráfego é em rajada; provisionar capacidade fixa tende a custar mais ou throttlar no pico |
| Minutos de pipeline (drift agendado) | minuto de execução × frequência × número de ambientes | agendar de hora em hora "para garantir" multiplica custo sem multiplicar valor — 1 dia útil já era o requisito declarado |
| SNS (alerta de drift) | por notificação publicada | valor pequeno; não é onde se otimiza |
| KMS (cifra do bucket) | por chamada de API + por chave-mês | cada leitura/escrita do estado é uma chamada ao KMS — pequena, mas soma em alta frequência de apply |
O ganho de custo que não está em nenhuma linha da AWS
A tarde perdida reconstruindo o estado corrompido do squad Pagamentos, e o quase-incidente de reduzir a capacidade do RDS em produção por drift não visto, não aparecem em nenhuma fatura da AWS. Apareciam na folha de pagamento e no risco assumido sem ninguém decidir assumi-lo.
Well-Architected nos seis pilares
| Pilar | Situação ao fim deste laboratório | Risco que fica | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | estado versionado, lock sob prova, drift perguntado com frequência conhecida | ainda um backend compartilhado entre squads sem fronteira de conta | estado e conta por ambiente (L56) | alta |
| Segurança | estado cifrado em repouso, acesso restrito ao papel do pipeline | segredo pode acabar em texto claro no estado se um provider não marcar o atributo como sensível | auditoria periódica do estado por padrão conhecido de segredo | alta |
| Confiabilidade | perda de notebook deixou de ser incidente de infraestrutura | lock órfão ainda exige intervenção manual para destravar | alarme automático sobre item de lock além de um teto de idade | média |
| Eficiência de performance | módulo reutilizável reduz superfície de divergência entre squads | bucket único pode virar gargalo de contenção em escala maior | múltiplos backends por domínio (L56/L98) quando o volume justificar | baixa |
| Otimização de custos | nenhum recurso novo cobrando por hora ligada | execução de drift por ambiente pode ficar redundante em escala | agrupar ambientes pequenos num job de drift com matriz | baixa |
| Sustentabilidade | sem excedente de computação — o que existe é requisição, não instância ociosa | histórico de versões do estado cresce sem lifecycle configurado | política de expiração de versões antigas do objeto de estado, mantendo um piso mínimo | baixa |
Evolução em níveis: o que muda, e o que passa a doer
A terceira arquitetura não é um desenho: é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco e compra outro — e a segunda coluna é a que raramente se escreve.
Estado local, sem lock, um squad, um notebook. É onde a Órbita estava, e continua legítimo para uma pessoa em ambiente de teste.Backend S3 versionado, lock em DynamoDB, módulo reutilizável, plan de drift agendado dentro do pipeline do L54.Conta separada por ambiente, promoção de mudança entre `dev` e `prod` com o mesmo módulo (L56).Um estágio adicional no pipeline avalia o JSON do `plan` contra regras antes de liberar o `apply` — por exemplo, recusar remoção de instância com dado de produção sem aprovação explícita.Módulos internos versionados num registro privado; Organizations e Identity Center controlando quem pode aplicar o quê, em qual conta (L43, L98).O histórico de plans, applies e drifts — quais recursos mais divergem, quais squads geram mais drift, quais mudanças historicamente precederam incidente — vira dado; um modelo aprende a priorizar revisão humana nos planos de maior risco.A ordem não é negociável, e o motivo é concreto
Política como código sobre o plano (nível 4) depende de o plano já vir de um pipeline confiável, com estado remoto e lock — que é o nível 2. Aplicar regra sobre um plan gerado do notebook de alguém, sem estado compartilhado, avalia uma decisão que outro apply concorrente pode invalidar no minuto seguinte.
Onde IA entra nesta arquitetura, e onde não entra
Neste módulo, IA não resolve o problema central, e forçá-la seria o antipadrão que a própria série critica. "Há corrida entre applies?" e "há drift?" têm resposta determinística: o próprio Terraform já calcula as duas, com `lock` e `plan -detailed-exitcode`. Um modelo não melhora nenhuma dessas duas perguntas — elas são comparação exata entre estado, HCL e API.
Há um lugar onde IA acrescentaria valor real, e ele aparece só depois de o volume de drift crescer: nem todo drift importa igualmente. Uma tag alterada no console pesa diferente de uma classe de instância reduzida em produção. Um classificador sobre o histórico de plans — recurso, atributo, squad, se precedeu incidente — poderia priorizar QUAL drift alguém olha primeiro, numa organização com centenas de estados.
| Pergunta | Resposta honesta para este módulo |
|---|---|
| Qual problema a IA resolveria? | priorizar qual drift merece atenção humana primeiro, quando há muitos |
| Por que uma regra não bastaria? | uma regra bastaria para começar: "drift em atributo de rede ou capacidade → alta prioridade" cobre a maior parte. IA só se justifica depois que a regra simples mostrar seu limite |
| De onde viriam os dados? | o próprio histórico de execuções de `plan` no pipeline, mais o CloudTrail de quem mudou o quê pelo console |
| Qual o risco? | aprender de poucos incidentes e classificar drift crítico como rotina, ou o inverso; exige avaliação com dado retido e caminho manual sempre disponível |
| Por que não agora? | a Órbita tem 2 squads e um punhado de drifts por mês. Modelo sobre esse volume é superstição com aparência de estatística |
O uso de IA que parece atraente e é armadilha aqui
Pedir a um modelo para "ler o `terraform plan` e decidir se pode aplicar sozinho" troca um sinal determinístico — o diff exato entre estado e API real — por um probabilístico, no exato ponto em que a exatidão é o requisito. O `plan` já diz, com precisão total, o que vai mudar; a decisão de aplicar automaticamente é de política (nível 4 da evolução), não de inferência.
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Por que é problema | Sintoma em produção | Forma correta | Quando é aceitável |
|---|---|---|---|---|---|
| `terraform.tfstate` comitado no Git | parece prático versionar tudo junto, e `git add .` pega o arquivo sem ninguém perceber | expõe qualquer segredo em texto claro no estado — para sempre no histórico, mesmo depois de removido | segredo encontrado em auditoria de repositório, meses depois do commit | backend remoto + `.gitignore` cobrindo `*.tfstate` + regra de bloqueio no pipeline | nunca em ambiente com credencial real; em laboratório isolado, com dado descartável, o risco é outro |
| `apply` sempre com `-auto-approve`, sem revisar o plan | economiza um passo manual no dia a dia | aplica mudança não revisada; a única defesa contra plano errado é justamente o olho humano no plan | recurso destruído ou recriado sem ninguém ter dito "sim" para aquilo especificamente | plan revisado e aprovado antes do apply, mesmo em pipeline — a aprovação pode ser automática SE o diff bater com o esperado | pipeline com política automatizada que já reprova diff fora do esperado (nível 4 da evolução); nunca por padrão manual |
| Copiar e colar o módulo em vez de usar `source` | parece mais simples que aprender a interface de variáveis do módulo | duas cópias divergem silenciosamente a cada ajuste feito em só uma delas | dois ambientes que deveriam ser iguais respondem diferente ao mesmo incidente | `source` apontando para um único módulo, versionado, chamado com variáveis distintas | nunca como padrão; cópia isolada pode ser aceitável num experimento que será descartado |
| Ignorar mudança de emergência feita no console, "só dessa vez" | incidente urgente, sem tempo para abrir PR revisando Terraform | vira drift permanente; o próximo apply de rotina tenta reverter a mudança que salvou o incidente | capacidade reduzida sem aviso, no meio do expediente, por um apply "normal" | codificar a mudança assim que o incidente for contido, antes do próximo apply de qualquer squad | a mudança emergencial em si é aceitável; deixar de codificá-la depois, não |
| Tabela de lock com capacidade provisionada baixa | parece mais barato que `PAY_PER_REQUEST` à primeira vista | throttling justamente no pico — vários squads aplicando ao mesmo tempo | `ProvisionedThroughputExceededException` em vez de um lock limpo | `PAY_PER_REQUEST` para tráfego em rajada e imprevisível como o de lock | organização com centenas de applies constantes e previsíveis, onde o padrão de tráfego já é conhecido |
| Ignorar o aviso de depreciação do lock em DynamoDB | "funciona, não mexe" — e o lock continua funcionando hoje | quando o suporte for removido, backends que dependem só dele quebram sem aviso prévio planejado | apply falhando em atualização futura do Terraform, sem mudança nenhuma no código | avaliar `use_lockfile` em backend NOVO; migrar backend existente com planejamento, não em pânico | manter DynamoDB em backend já estável, com plano de migração registrado — não como decisão permanente |
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Onde olhar | Correção |
|---|---|---|---|---|
| "Error acquiring the state lock" persistente | lock órfão — processo morto sem liberar | confira `Created` no Lock Info contra qualquer apply genuinamente em andamento | `aws dynamodb get-item` na tabela de lock | `terraform force-unlock <ID>` só após confirmar que não há apply real rodando |
| `terraform init` reclama de backend não migrado | mudança no `backend.tf` sem `-migrate-state` | compare a configuração de backend anterior com a nova | saída do `terraform init` | `terraform init -migrate-state` (ou `-reconfigure`, se não deve copiar o estado antigo) |
| `plan` mostra recriação de RDS inesperada | atributo imutável (ex.: engine_version) mudou fora do Terraform | compare `terraform show` com `describe-db-instances` campo a campo | diff do plan na linha da instância | decidir entre `terraform import`/ajustar o HCL para bater com a realidade, ou aceitar a recriação |
| Squad aplica variável errada e afeta o outro squad | os dois ambientes compartilham a mesma `key` de estado por engano | confira a `key` em cada `backend.tf` | arquivo `backend.tf` de cada ambiente | uma `key` distinta por ambiente — nunca reaproveitada entre squads |
| Job de drift nunca dispara | regra do EventBridge/`schedule` desabilitada ou expressão cron errada | `aws events describe-rule`, ou o histórico de execuções do workflow | estado da regra agendada | habilitar a regra; validar a expressão cron contra o horário esperado |
| `-detailed-exitcode` retorna 1 em vez de 2 | erro real de execução — ex.: credencial OIDC expirada — não é drift | log completo da execução do `plan`, não só o código de saída | saída de erro do Terraform no job | corrigir a causa do erro; só reinterpretar como drift depois de o plan rodar sem falha |
| `terraform import` feito, mas `plan` ainda mostra mudança | algum atributo aplicado depois manualmente não foi coberto pelo import | diff campo a campo do plan pós-import | saída do `plan` logo após o `import` | ajustar o HCL para bater exatamente com o real, ou aceitar com `ignore_changes` explícito e documentado |
A pergunta que resolve metade destes casos
Antes de mexer em qualquer configuração, pergunte: isto é o Terraform reagindo a algo que mudou DE VERDADE na AWS, ou o Terraform e o real nunca bateram para começar? A primeira categoria se resolve ajustando infraestrutura ou HCL; a segunda quase sempre é `key` errada, import incompleto, ou atributo computado que nunca deveria ter entrado na comparação.
Limpeza: o que o destroy de cada ambiente não leva
O backend em si — bucket e tabela de lock — não pertence a nenhum ambiente: ele é o que GUARDA o estado dos ambientes. Destruí-lo antes deles apaga a única cópia de como desfazer o resto.
#!/usr/bin/env bash
# limpar.sh — o que o destroy de cada AMBIENTE nao leva.
set -euo pipefail
for AMBIENTE in pedidos pagamentos; do
( cd "ambientes/${AMBIENTE}" && terraform destroy -auto-approve )
done
# O BACKEND (bucket + tabela de lock) NAO faz parte do estado de nenhum
# ambiente — ele e o que guarda o estado dos outros. Destruir por ultimo, e so
# se voce nao vai criar mais ambiente nenhum.
echo "Backend do estado continua de pe: bucket vetor-orbita-terraform-state"
echo "e tabela vetor-orbita-terraform-lock. Isto e intencional."
echo "Para remover de verdade (ordem importa: esvaziar antes de destruir):"
echo " aws s3 rm s3://vetor-orbita-terraform-state --recursive"
echo " cd bootstrap && terraform destroy -auto-approve"
# Snapshot final do RDS: `skip_final_snapshot = false` no modulo GARANTE um
# snapshot na hora do destroy — e ele sobrevive ao destroy e cobra por GB-mes.
aws rds describe-db-snapshots \
--query "DBSnapshots[?contains(DBSnapshotIdentifier, 'orbita')].DBSnapshotIdentifier" \
--output table
# Prova final: nada com a tag do projeto de pe, exceto o backend (esperado).
aws resourcegroupstaggingapi get-resources \
--tag-filters Key=Projeto,Values=orbita \
--query "ResourceTagMappingList[].ResourceARN" --output table
Snapshot final do RDS sobrevive ao destroy e cobra por GB-mês para sempre
O módulo grava `skip_final_snapshot = false` de propósito — é a rede de segurança contra um `destroy` acidental em produção. O preço dessa segurança é que o snapshot NÃO desaparece com o `terraform destroy`: ele fica, cobrando armazenamento, até alguém apagá-lo manualmente. Some à sua lista de limpeza sempre que o módulo for usado contra dado real.
| Recurso | Sai no destroy do ambiente? | Cobra parado? | Por que fica |
|---|---|---|---|
| ALB, serviço ECS | sim | sim, por hora, enquanto de pé | gerenciados pelo módulo dentro do ambiente |
| Instância RDS | sim | não, após removida | gerenciada pelo módulo; o snapshot final é o que sobrevive, não a instância |
| Snapshot final do RDS | não — é criado justamente NO destroy | sim, GB-mês | `skip_final_snapshot = false` por desenho |
| Bucket de estado (backend) | não faz parte do estado de nenhum ambiente | sim, GB-mês do histórico versionado | guarda o estado dos outros ambientes; destruir junto apaga o "como desfazer" |
| Tabela de lock (backend) | idem | centavos | compartilhada por todos os ambientes que ainda existirem |
| Versões antigas do objeto de estado | não, mesmo com o bucket esvaziado sem `--recursive` completo | sim, GB-mês | versionamento mantém cada revisão até exclusão explícita |
Resumo: problema, peça e motivo
| Problema | Peça | Por que ela, e não outra |
|---|---|---|
| Dois applies concorrentes corrompem o estado | lock via DynamoDB, chave `LockID` | escrita condicional impede que o segundo apply sobrescreva sem saber do primeiro |
| Notebook perdido é infraestrutura sem estado | backend S3 versionado | o estado sobrevive a qualquer notebook, e o versionamento dá um "antes" recuperável |
| Mudança no console some sem rastro | módulo com `variable`, mudança só por HCL | a classe do RDS deixa de ser algo que se muda clicando |
| Ninguém percebe drift entre um apply e o próximo | plan agendado com `-detailed-exitcode` | transforma "espero que alguém note" numa pergunta que roda sozinha, com frequência conhecida |
| Dois squads mantêm HCL quase idêntico e divergente | módulo reutilizável com `source` | uma correção se propaga para os dois no próximo apply, em vez de duas correções manuais |
| Deploy e drift exigem chave estática | papel federado OIDC herdado do L54 | nenhuma credencial de longa duração no ambiente que aplica ou que verifica drift |
| Falha | O que a protege | O que ela NÃO protege |
|---|---|---|
| Apply concorrente corrompendo estado | lock com escrita condicional | drift causado por mudança fora do Terraform — o lock não vê o console |
| Perda de notebook | estado remoto versionado | perda de acesso ao bucket em si — é por isso que ele tem seu próprio backup/versionamento |
| Drift nunca percebido | plan agendado | drift entre a última execução agendada e agora — a frequência do agendamento é o limite de detecção |
| Duplicação de infraestrutura entre squads | módulo reutilizável | divergência de VALORES intencional entre squads — o módulo padroniza a definição, não a decisão de negócio |
| Lock preso para sempre | nada automático neste desenho | é a lacuna que fica; force-unlock continua manual, e o nível 3+ é onde alarme automático entra |
- O commit muda o módulo ou um ambiente, e o push aciona o pipeline pelo papel federado do L54.
- O Terraform inicializa contra o backend S3 e baixa o estado remoto do ambiente específico.
- Uma escrita condicional grava o `LockID` no DynamoDB antes de qualquer leitura de estado prosseguir.
- O refresh consulta a API real de cada recurso — é aqui que uma mudança de console se torna visível.
- O plan compara HCL, estado gravado e API real, e mostra o diff resultante.
- Se é a execução de deploy, o apply grava a mudança, atualiza o estado no S3 e libera o lock.
- Se é a execução agendada, nada é aplicado: só o código de saída do plan é lido.
- Código 2 publica um alerta com o resumo do diff; código 0 não publica nada.
- Uma pessoa decide como reconciliar — aplicar, importar, ou aceitar com `ignore_changes` — nunca o pipeline sozinho.
Perguntas frequentes
❓ Por que dois `terraform apply` ao mesmo tempo corrompem a infraestrutura?
❓ DynamoDB para lock ainda é necessário, ou o backend S3 já resolve sozinho?
❓ O que exatamente é "drift" em Terraform, e como ele é detectado?
❓ O Terraform atualiza o estado sozinho quando alguém muda algo pelo console?
❓ Por que extrair um módulo reutilizável em vez de copiar e colar o Terraform entre squads?
❓ O que `-detailed-exitcode` retorna, e como isso vira um alerta de drift?
❓ Perder o notebook do dev que aplicou o Terraform destrói a infraestrutura?
❓ Um `terraform.tfstate` comitado no Git é um problema de segurança real?
Fixando
Um engenheiro muda a classe de uma instância RDS pelo console durante um incidente, sem atualizar o Terraform. Duas semanas depois, ninguém percebeu a mudança. Por quê?
Dois squads usam o mesmo módulo Terraform, mas apontam para chaves de estado (`key`) diferentes no mesmo bucket S3. Um deles aplica uma mudança de variável que aumenta o `desired_count`. O que acontece com o outro squad?
Conhecimentos, próximo módulo e documentação
| Item | Conteúdo |
|---|---|
| Conhecimentos anteriores necessários | L54 concluído (pipeline com papel federado OIDC), Terraform básico (resource, variable, output), o padrão ECS + ALB + RDS do L01 |
| Conhecimentos adquiridos | como lock com escrita condicional impede corrupção por apply concorrente; a definição precisa de drift e por que ele exige pergunta agendada, não notificação automática; como extrair módulo reutilizável com variável validada; os três códigos de `-detailed-exitcode` e o que cada um deve disparar |
| Limitação que fica | bucket e tabela de lock ainda compartilhados entre squads, sem fronteira de conta; lock órfão ainda exige `force-unlock` manual, sem alarme automático |
| Próximo exemplo recomendado | L56 — um ambiente por conta, sem copiar e colar. Reutiliza o módulo e o backend deste laboratório, e é onde a fronteira de conta entre squads se resolve |
| Também habilitado por este módulo | L98 (plataforma multi-time com cota) depende de módulo publicado e estado isolado por domínio, construídos aqui |
| Data da última validação técnica | 8 de agosto de 2026 |
Documentação oficial consultada: Terraform language — Backend Type: s3 — a existência dos dois mecanismos de lock (DynamoDB, com o schema exato da chave `LockID`; e o lock nativo `use_lockfile`), com a nota de depreciação do caminho via DynamoDB, e as permissões de IAM exigidas em cada um; e Terraform CLI — comando plan — a natureza sob demanda do refresh de estado (não há sincronização automática em segundo plano) e os três códigos de `-detailed-exitcode`. Os valores de preço não aparecem neste módulo por decisão: use o AWS Pricing Calculator, porque preço varia por região e envelhece mais rápido que o conteúdo.
O que não foi verificado, e você deve conferir na sua conta
O tempo de propagação de uma mudança de console até ela aparecer num `plan` é IMEDIATO por natureza (a próxima consulta à API já vê o estado real) — o que NÃO é imediato é a FREQUÊNCIA com que alguém pergunta. O cron de exemplo (`0 8 * * 1-5`) não foi validado contra o padrão real de incidentes da sua organização; ajuste a frequência ao seu apetite de risco, não copie o valor. Da mesma forma, a contagem de "312 linhas duplicadas viraram 140" é a medida do exemplo da Órbita, não uma constante — meça a duplicação real da sua base antes de prometer o ganho para o seu time.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…