Módulos no Terraform: Reusabilidade e Organização

[295] Módulos no Terraform: Reusabilidade e Organização

A saída para a duplicação de configuração entre ambientes: a anatomia de um módulo com variáveis, recursos e outputs, a construção de módulos de VPC e de RDS, o consumo deles em um projeto raiz, os módulos públicos do Registry, testes com Terratest e a organização em projetos grandes.
DevOps

21 min de leitura

Nos artigos anteriores (Notificações, Logs e Monitoramento do Pipeline, Introdução ao Terraform: Infraestrutura que Você Pode Versionar, Seus Primeiros Recursos na AWS com Terraform), toda a infraestrutura foi escrita em um único conjunto de arquivos. Essa abordagem funciona bem para um projeto isolado, mas rapidamente se torna insustentável quando a organização cresce.

Imagine uma empresa com cinco sistemas diferentes, cada um com três ambientes — desenvolvimento, staging e produção. São quinze configurações de infraestrutura. Se cada uma delas precisar de uma VPC com subnets públicas e privadas, grupos de segurança, NAT Gateways e tabelas de rotas, o código se repete quinze vezes. Quando o padrão de segurança muda — e ele sempre muda — a atualização precisa ser replicada manualmente em todas as quinze configurações. O risco de inconsistências é alto e o custo de manutenção é proibitivo.

Módulos são a solução do Terraform para esse problema. Um módulo é um conjunto de arquivos de configuração Terraform agrupados em um diretório, que encapsula um conjunto de recursos relacionados e os expõe como uma unidade reutilizável com uma interface bem definida — entradas, saídas e comportamento.

A analogia com programação é direta: módulos são funções ou classes da linguagem de infraestrutura. Assim como uma função encapsula lógica reutilizável, um módulo encapsula infraestrutura reutilizável.

Anatomia de um Módulo

Todo diretório contendo arquivos .tf é um módulo. O que diferencia um módulo bem construído de um mal construído é a clareza da sua interface pública — as variáveis que aceita como entrada e os outputs que expõe como saída.

A estrutura recomendada para um módulo:

modules/
└── vpc/
    ├── main.tf        # Recursos do módulo
    ├── variables.tf   # Interface de entrada — o que o módulo aceita
    ├── outputs.tf     # Interface de saída — o que o módulo expõe
    ├── versions.tf    # Requisitos de versão
    └── README.md      # Documentação do módulo

O README.md não é opcional em módulos que serão compartilhados. Ele deve documentar o propósito do módulo, os inputs obrigatórios e opcionais, os outputs e um exemplo de uso.

Criando um Módulo de VPC

Transformando a configuração de VPC do artigo Seus Primeiros Recursos na AWS com Terraform em um módulo reutilizável:

# modules/vpc/variables.tf

variable "project_name" {
  description = "Nome do projeto — usado como prefixo nos nomes dos recursos"
  type        = string
}

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

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

variable "vpc_cidr" {
  description = "Bloco CIDR da VPC"
  type        = string
  default     = "10.0.0.0/16"

  validation {
    condition     = can(cidrnetmask(var.vpc_cidr))
    error_message = "O vpc_cidr deve ser um bloco CIDR válido."
  }
}

variable "availability_zones" {
  description = "Lista de availability zones para distribuição das subnets"
  type        = list(string)

  validation {
    condition     = length(var.availability_zones) >= 2
    error_message = "Pelo menos duas availability zones são necessárias para alta disponibilidade."
  }
}

variable "habilitar_nat_gateway" {
  description = "Se verdadeiro, cria NAT Gateways para as subnets privadas"
  type        = bool
  default     = true
}

variable "nat_gateway_por_az" {
  description = "Se verdadeiro, cria um NAT Gateway por AZ. Se falso, usa apenas um (menor custo, menor resiliência)"
  type        = bool
  default     = true
}

variable "tags_adicionais" {
  description = "Tags adicionais a serem aplicadas em todos os recursos"
  type        = map(string)
  default     = {}
}
# modules/vpc/main.tf

locals {
  # Determina quantos NAT Gateways criar
  qtd_nat_gateways = var.habilitar_nat_gateway ? (
    var.nat_gateway_por_az ? length(var.availability_zones) : 1
  ) : 0

  # Merge das tags padrão com as adicionais
  tags_comuns = merge(
    {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "terraform"
      Module      = "vpc"
    },
    var.tags_adicionais
  )
}

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-vpc"
  })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-igw"
  })
}

resource "aws_subnet" "publica" {
  count = length(var.availability_zones)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-subnet-pub-${count.index + 1}"
    Tier = "public"
  })
}

resource "aws_subnet" "privada" {
  count = length(var.availability_zones)

  vpc_id            = aws_vpc.this.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
  availability_zone = var.availability_zones[count.index]

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-subnet-priv-${count.index + 1}"
    Tier = "private"
  })
}

resource "aws_eip" "nat" {
  count  = local.qtd_nat_gateways
  domain = "vpc"

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-eip-nat-${count.index + 1}"
  })

  depends_on = [aws_internet_gateway.this]
}

resource "aws_nat_gateway" "this" {
  count = local.qtd_nat_gateways

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.publica[count.index].id

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-nat-${count.index + 1}"
  })

  depends_on = [aws_internet_gateway.this]
}

resource "aws_route_table" "publica" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-rt-publica"
  })
}

resource "aws_route_table" "privada" {
  count  = length(var.availability_zones)
  vpc_id = aws_vpc.this.id

  dynamic "route" {
    for_each = local.qtd_nat_gateways > 0 ? [1] : []
    content {
      cidr_block     = "0.0.0.0/0"
      nat_gateway_id = aws_nat_gateway.this[
        min(count.index, local.qtd_nat_gateways - 1)
      ].id
    }
  }

  tags = merge(local.tags_comuns, {
    Name = "${var.project_name}-${var.environment}-rt-privada-${count.index + 1}"
  })
}

resource "aws_route_table_association" "publica" {
  count          = length(var.availability_zones)
  subnet_id      = aws_subnet.publica[count.index].id
  route_table_id = aws_route_table.publica.id
}

resource "aws_route_table_association" "privada" {
  count          = length(var.availability_zones)
  subnet_id      = aws_subnet.privada[count.index].id
  route_table_id = aws_route_table.privada[count.index].id
}
# modules/vpc/outputs.tf

output "vpc_id" {
  description = "ID da VPC criada"
  value       = aws_vpc.this.id
}

output "vpc_cidr" {
  description = "Bloco CIDR da VPC"
  value       = aws_vpc.this.cidr_block
}

output "ids_subnets_publicas" {
  description = "Lista de IDs das subnets públicas"
  value       = aws_subnet.publica[*].id
}

output "ids_subnets_privadas" {
  description = "Lista de IDs das subnets privadas"
  value       = aws_subnet.privada[*].id
}

output "ids_nat_gateways" {
  description = "Lista de IDs dos NAT Gateways criados"
  value       = aws_nat_gateway.this[*].id
}

output "id_internet_gateway" {
  description = "ID do Internet Gateway"
  value       = aws_internet_gateway.this.id
}

Criando um Módulo de RDS

# modules/rds/variables.tf

variable "project_name" {
  type = string
}

variable "environment" {
  type = string
}

variable "vpc_id" {
  description = "ID da VPC onde o banco será criado"
  type        = string
}

variable "subnet_ids" {
  description = "IDs das subnets para o subnet group do RDS"
  type        = list(string)
}

variable "security_group_ids_permitidos" {
  description = "IDs dos security groups com permissão de acesso ao banco"
  type        = list(string)
}

variable "engine_version" {
  description = "Versão do PostgreSQL"
  type        = string
  default     = "16.1"
}

variable "instance_class" {
  description = "Classe da instância RDS"
  type        = string
  default     = "db.t3.micro"
}

variable "allocated_storage" {
  description = "Tamanho inicial do storage em GB"
  type        = number
  default     = 20
}

variable "db_name" {
  description = "Nome do banco de dados inicial"
  type        = string
}

variable "db_username" {
  description = "Username do banco de dados"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "Senha do banco de dados"
  type        = string
  sensitive   = true
}

variable "backup_retention_days" {
  description = "Dias de retenção dos backups automáticos"
  type        = number
  default     = 7
}

variable "multi_az" {
  description = "Habilita Multi-AZ para alta disponibilidade"
  type        = bool
  default     = false
}

variable "deletion_protection" {
  description = "Habilita proteção contra exclusão acidental"
  type        = bool
  default     = true
}
# modules/rds/main.tf

resource "aws_security_group" "rds" {
  name        = "${var.project_name}-${var.environment}-sg-rds"
  description = "Security group do RDS PostgreSQL"
  vpc_id      = var.vpc_id

  ingress {
    description     = "PostgreSQL dos security groups permitidos"
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = var.security_group_ids_permitidos
  }

  tags = {
    Name        = "${var.project_name}-${var.environment}-sg-rds"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_db_subnet_group" "this" {
  name       = "${var.project_name}-${var.environment}-db-subnet-group"
  subnet_ids = var.subnet_ids

  tags = {
    Name        = "${var.project_name}-${var.environment}-db-subnet-group"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_db_parameter_group" "this" {
  name   = "${var.project_name}-${var.environment}-pg16"
  family = "postgres16"

  parameter {
    name  = "log_min_duration_statement"
    value = "1000"
  }

  parameter {
    name  = "log_connections"
    value = "1"
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_db_instance" "this" {
  identifier = "${var.project_name}-${var.environment}-db"

  engine         = "postgres"
  engine_version = var.engine_version
  instance_class = var.instance_class

  allocated_storage     = var.allocated_storage
  max_allocated_storage = var.allocated_storage * 5
  storage_type          = "gp3"
  storage_encrypted     = true

  db_name  = var.db_name
  username = var.db_username
  password = var.db_password

  db_subnet_group_name   = aws_db_subnet_group.this.name
  parameter_group_name   = aws_db_parameter_group.this.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  multi_az            = var.multi_az
  publicly_accessible = false
  deletion_protection = var.deletion_protection

  backup_retention_period   = var.backup_retention_days
  backup_window             = "03:00-04:00"
  maintenance_window        = "Mon:04:00-Mon:05:00"
  skip_final_snapshot       = !var.deletion_protection
  final_snapshot_identifier = var.deletion_protection ? "${var.project_name}-${var.environment}-final" : null

  performance_insights_enabled = true

  tags = {
    Name        = "${var.project_name}-${var.environment}-db"
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}
# modules/rds/outputs.tf

output "endpoint" {
  description = "Endpoint de conexão do banco de dados"
  value       = aws_db_instance.this.endpoint
  sensitive   = true
}

output "porta" {
  description = "Porta do banco de dados"
  value       = aws_db_instance.this.port
}

output "nome_banco" {
  description = "Nome do banco de dados criado"
  value       = aws_db_instance.this.db_name
}

output "id_instancia" {
  description = "ID da instância RDS"
  value       = aws_db_instance.this.id
}

output "id_security_group" {
  description = "ID do security group do RDS"
  value       = aws_security_group.rds.id
}

output "database_url" {
  description = "URL de conexão completa"
  value = format(
    "postgresql://%s:%s@%s/%s",
    var.db_username,
    var.db_password,
    aws_db_instance.this.endpoint,
    var.db_name
  )
  sensitive = true
}

Usando os Módulos em um Projeto Raiz

Com os módulos criados, o código do projeto raiz torna-se significativamente mais limpo e legível:

# environments/staging/main.tf

terraform {
  required_version = ">= 1.7.0"

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

  backend "s3" {
    bucket = "minha-empresa-terraform-state"
    key    = "staging/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# ── Módulo de VPC ─────────────────────────────────
module "vpc" {
  source = "../../modules/vpc"

  project_name       = var.project_name
  environment        = "staging"
  vpc_cidr           = "10.1.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b"]

  # Staging usa apenas um NAT Gateway para reduzir custos
  nat_gateway_por_az = false

  tags_adicionais = {
    CostCenter = "engenharia"
  }
}

# Security group da aplicação para referenciar no RDS
resource "aws_security_group" "aplicacao" {
  name   = "${var.project_name}-staging-sg-app"
  vpc_id = module.vpc.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# ── Módulo de RDS ─────────────────────────────────
module "rds" {
  source = "../../modules/rds"

  project_name = var.project_name
  environment  = "staging"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.ids_subnets_privadas

  security_group_ids_permitidos = [aws_security_group.aplicacao.id]

  instance_class      = "db.t3.micro"
  allocated_storage   = 20
  db_name             = "staging_db"
  db_username         = var.db_username
  db_password         = var.db_password
  multi_az            = false
  deletion_protection = false
  backup_retention_days = 1
}

O mesmo projeto de produção usa os mesmos módulos com configurações diferentes — sem duplicar nenhuma lógica:

# environments/production/main.tf

module "vpc" {
  source = "../../modules/vpc"

  project_name       = var.project_name
  environment        = "production"
  vpc_cidr           = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

  # Produção usa NAT Gateway por AZ para alta disponibilidade
  nat_gateway_por_az = true
}

module "rds" {
  source = "../../modules/rds"

  project_name = var.project_name
  environment  = "production"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.ids_subnets_privadas

  security_group_ids_permitidos = [aws_security_group.aplicacao.id]

  instance_class        = "db.r6g.large"
  allocated_storage     = 100
  db_name               = "producao_db"
  db_username           = var.db_username
  db_password           = var.db_password
  multi_az              = true
  deletion_protection   = true
  backup_retention_days = 7
}

Módulos Públicos do Terraform Registry

O Terraform Registry hospeda módulos públicos e gratuitos mantidos pela comunidade e pelos próprios provedores. Em vez de escrever um módulo de VPC do zero, é possível usar o módulo oficial mantido pela própria HashiCorp:

# Usando o módulo oficial de VPC para AWS
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.2"

  name = "${var.project_name}-${var.environment}"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway     = true
  single_nat_gateway     = false
  one_nat_gateway_per_az = true

  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

A decisão entre usar módulos públicos e escrever os próprios depende do contexto. Módulos públicos bem mantidos — como os da terraform-aws-modules — incorporam anos de boas práticas e cobrem casos extremos que seriam difíceis de antecipar. A desvantagem é a dependência de um projeto externo. Para infraestrutura crítica de produção, muitas organizações preferem manter módulos internos com controle total sobre cada decisão.

Testando Módulos com Terratest

Módulos críticos merecem testes automatizados — assim como qualquer outro código de produção. O Terratest é uma biblioteca Go que permite escrever testes que provisionam infraestrutura real, verificam seu comportamento e a destroem ao final:

// modules/vpc/test/vpc_test.go
package test

import (
  "testing"

  "github.com/gruntwork-io/terratest/modules/terraform"
  "github.com/stretchr/testify/assert"
)

func TestVPCModule(t *testing.T) {
  t.Parallel()

  terraformOptions := &terraform.Options{
    TerraformDir: "../",
    Vars: map[string]interface{}{
      "project_name":       "test",
      "environment":        "development",
      "vpc_cidr":           "10.99.0.0/16",
      "availability_zones": []string{"us-east-1a", "us-east-1b"},
      "habilitar_nat_gateway": false,
    },
  }

  // Garante que a infraestrutura será destruída ao final
  defer terraform.Destroy(t, terraformOptions)

  // Provisiona a infraestrutura
  terraform.InitAndApply(t, terraformOptions)

  // Verifica os outputs
  vpcID := terraform.Output(t, terraformOptions, "vpc_id")
  assert.NotEmpty(t, vpcID)

  subnetsPubs := terraform.OutputList(t, terraformOptions, "ids_subnets_publicas")
  assert.Equal(t, 2, len(subnetsPubs))

  subnetsPrivs := terraform.OutputList(t, terraformOptions, "ids_subnets_privadas")
  assert.Equal(t, 2, len(subnetsPrivs))
}

Organização de Módulos em Projetos Grandes

Em organizações com múltiplos times e dezenas de projetos, a estrutura recomendada separa módulos em um repositório dedicado:

infraestrutura/
├── modules/                    # Repositório separado ou diretório central
│   ├── vpc/
│   ├── rds/
│   ├── ecs-service/
│   ├── alb/
│   ├── s3-bucket/
│   └── cloudfront/
│
├── environments/
│   ├── development/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   └── production/
│       ├── main.tf
│       ├── variables.tf
│       └── terraform.tfvars
│
└── shared/                     # Recursos compartilhados entre ambientes
    ├── ecr/                    # Registro de containers
    ├── route53/                # DNS
    └── acm/                    # Certificados SSL

Quando os módulos estão em um repositório separado, referenciados com versão específica:

module "vpc" {
  source  = "git::https://github.com/minha-empresa/terraform-modules.git//vpc?ref=v2.3.1"

  project_name       = var.project_name
  environment        = var.environment
  vpc_cidr           = var.vpc_cidr
  availability_zones = var.availability_zones
}

O uso de ?ref=v2.3.1 garante que o módulo é fixado em uma versão específica — mudanças no repositório de módulos não afetam projetos existentes sem uma atualização deliberada.

O Que Vem a Seguir

O próximo artigo aborda o state do Terraform — o componente mais crítico e frequentemente menos compreendido de toda a ferramenta. Entender como o state funciona, como armazená-lo com segurança em um backend remoto e como manipulá-lo quando as coisas saem do esperado é uma habilidade essencial para qualquer engenheiro que trabalha com Terraform em equipe.

Referências para Aprofundamento

Documentação oficial

Módulos de referência

  • terraform-aws-modules — GitHub — Organização no GitHub com módulos oficialmente mantidos para os serviços AWS mais usados. O código-fonte é uma excelente referência de boas práticas.

Testes

Exercícios

Exercício 1

O artigo abre com uma empresa de cinco sistemas e três ambientes cada. Qual é o número de configurações resultante, e qual problema concreto aparece quando o padrão de segurança muda? Complete a analogia: um módulo está para a infraestrutura assim como o quê está para a programação?

Ver resposta

✓ Resposta: São quinze configurações de infraestrutura (5 × 3). Sem módulos, cada uma repete a mesma VPC com subnets, security groups, NAT Gateways e tabelas de rotas — o mesmo código escrito quinze vezes.

O problema aparece na manutenção: quando o padrão de segurança muda, a atualização precisa ser replicada à mão nas quinze cópias. Nenhuma equipe acerta quinze edições manuais consistentemente, então algumas ficam para trás — e a divergência é silenciosa, já que nada no código denuncia que a cópia do sistema C ficou numa versão antiga da regra. Some-se a isso o custo de revisar quinze diffs para uma única mudança conceitual.

A analogia: um módulo está para a infraestrutura assim como uma função (ou classe) está para a programação — encapsula algo reutilizável atrás de uma interface definida, com entradas (variables), saídas (outputs) e comportamento próprio.

Exercício 2

Calcule o valor de local.qtd_nat_gateways nos três cenários abaixo.

qtd_nat_gateways = var.habilitar_nat_gateway ? (
  var.nat_gateway_por_az ? length(var.availability_zones) : 1
) : 0
  • A — produção: habilitar = true, por_az = true, 3 AZs
  • B — staging: habilitar = true, por_az = false, 2 AZs
  • C — ambiente de teste: habilitar = false, por_az = true, 2 AZs
Ver resposta

✓ Resposta: A = 3, B = 1, C = 0.

O ternário externo decide primeiro: se habilitar_nat_gateway é falso, o resultado é 0 e o valor de por_az se torna irrelevante — é o caso C, onde por_az = true não tem efeito nenhum. Sendo verdadeiro, o ternário interno escolhe entre um NAT por AZ (length(var.availability_zones), caso A) e um NAT único (1, caso B).

Esses três cenários traduzem uma escolha de custo contra resiliência. NAT Gateway é cobrado por hora e por volume trafegado: produção paga três para que a queda de uma AZ não deixe as outras sem saída de internet; staging paga um só, aceitando que uma falha de AZ afete todas as subnets privadas; o ambiente de teste não paga nenhum, o que serve quando nada ali precisa acessar a internet — exatamente o que o teste do Terratest faz ao passar habilitar_nat_gateway: false.

Exercício 3

Na tabela de rotas privada, o NAT é escolhido pela expressão abaixo. Explique o papel do min: o que acontece no cenário B do exercício anterior (2 AZs, 1 NAT Gateway) e qual erro ocorreria se estivesse escrito apenas aws_nat_gateway.this[count.index].id?

nat_gateway_id = aws_nat_gateway.this[
  min(count.index, local.qtd_nat_gateways - 1)
].id
Ver resposta

✓ Resposta: O min impede que o índice ultrapasse o último NAT Gateway existente. A tabela de rotas privada tem count = length(var.availability_zones) — uma por AZ —, mas a quantidade de NATs é independente disso: pode ser menor.

No cenário B há 2 tabelas de rotas e 1 NAT. Para count.index = 0: min(0, 0) = 0. Para count.index = 1: min(1, 0) = 0. As duas tabelas apontam para aws_nat_gateway.this[0] — que é justamente o comportamento desejado quando existe um NAT compartilhado.

Sem o min, a segunda tabela tentaria acessar aws_nat_gateway.this[1], que não existe, e o Terraform falharia no plan com erro de índice fora do intervalo. O detalhe é que a falha só apareceria com nat_gateway_por_az = false: em produção, onde a quantidade de NATs coincide com a de AZs, o código sem min funcionaria perfeitamente e a quebra ficaria escondida até alguém montar um ambiente mais barato.

Exercício 4

Por que a rota das subnets privadas foi declarada como um bloco dynamic "route" com for_each em vez de um bloco route comum? O que a tabela de rotas contém quando local.qtd_nat_gateways é 0?

dynamic "route" {
  for_each = local.qtd_nat_gateways > 0 ? [1] : []
  content { ... }
}
Ver resposta

✓ Resposta: Porque o bloco precisa ser condicional, e blocos aninhados comuns não aceitam condição — só é possível omitir um argumento, não um bloco inteiro. O dynamic resolve isso gerando tantas cópias do bloco quantos elementos houver em for_each: a lista [1] produz exatamente uma rota, e a lista vazia [] não produz nenhuma. É o idioma padrão do HCL para "crie este bloco apenas se…".

Com qtd_nat_gateways = 0, a tabela de rotas privada é criada, mas sem rota para 0.0.0.0/0. Ela mantém apenas a rota local implícita da VPC — que a AWS adiciona automaticamente e permite comunicação entre as subnets. Na prática, as instâncias privadas conversam entre si e com o resto da VPC, mas não têm nenhuma saída para a internet.

Sem o dynamic, um bloco route fixo referenciaria aws_nat_gateway.this[...] mesmo quando nenhum NAT existisse, e o plan quebraria com índice inválido. Aqui a expressão local.qtd_nat_gateways - 1 sequer chega a ser avaliada, porque o content não é gerado.

Exercício 5

Compare os dois usos do módulo de VPC — environments/staging e environments/production — e aponte o que muda entre eles. Depois explique por que o módulo do Registry declara version = "5.5.2" enquanto o módulo local (source = "../../modules/vpc") não declara versão alguma.

Ver resposta

✓ Resposta: Entre os dois ambientes mudam apenas os valores das variáveis: o CIDR (10.1.0.0/16 em staging, 10.0.0.0/16 em produção), a quantidade de AZs (2 contra 3) e nat_gateway_por_az (false contra true). No RDS, a classe da instância, o storage, multi_az, deletion_protection e a retenção de backup. Nenhuma linha de lógica é duplicada — é exatamente o ganho que o artigo prometia: uma correção no módulo passa a valer para os dois ambientes de uma vez.

Quanto ao versionamento: o módulo do Registry é código de terceiros, que evolui fora do seu controle e pode mudar de comportamento a qualquer momento. Fixar version = "5.5.2" garante que o terraform init traga sempre a mesma coisa; sem isso, uma atualização publicada pelo mantenedor poderia alterar sua infraestrutura sem nenhuma mudança do seu lado.

Já o módulo local vive no mesmo repositório, então ele já é versionado pelo Git junto com quem o usa: o commit define simultaneamente a configuração e o módulo. Um argumento version aqui nem seria aceito — ele só se aplica a fontes que suportam versionamento, como o Registry. Se o módulo estivesse em um repositório Git separado, o equivalente seria fixar a referência no próprio source, com ?ref=v1.2.0.

Comentários

Mais em DevOps

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

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

Testes Automatizados no Pipeline: Qualidade sem Atrito
Testes Automatizados no Pipeline: Qualidade sem Atrito

A pirâmide de testes aplicada ao pipeline: a proporção 70/20/10 e o antipadrão…

Git na Prática: Commits, Branches e Merges sem Medo
Git na Prática: Commits, Branches e Merges sem Medo

Git no fluxo real de trabalho: os três estados de um arquivo, o ciclo…