Em projetos reais, a infraestrutura raramente é provisionada de uma vez. Ela evolui incrementalmente — começando com o mínimo necessário para rodar a primeira versão e crescendo conforme o sistema e os requisitos amadurecem. O capstone adota essa abordagem: a infraestrutura é provisionada em camadas, cada uma construída sobre a anterior, com o Terraform como linguagem única de declaração do estado desejado.
A sequência de provisionamento segue a ordem de dependências naturais do sistema: rede primeiro, porque tudo depende dela; segredos e KMS, porque recursos seguros dependem de chaves; banco de dados e cache, porque os serviços dependem dos dados; EKS, porque os pods dependem do cluster; e finalmente os add-ons do cluster, porque dependem do EKS estar operacional.
Camada 1: Rede e Segurança Fundacional
# infrastructure/terraform/modules/networking/main.tf
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 3)
cidr_vpc = "10.0.0.0/16"
# Subnets calculadas automaticamente para 3 AZs
cidrs_publicos = [for i, az in local.azs : cidrsubnet(local.cidr_vpc, 8, i)]
cidrs_privados = [for i, az in local.azs : cidrsubnet(local.cidr_vpc, 8, i + 10)]
cidrs_dados = [for i, az in local.azs : cidrsubnet(local.cidr_vpc, 8, i + 20)]
}
data "aws_availability_zones" "available" {
state = "available"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5"
name = "${var.project_name}-${var.environment}"
cidr = local.cidr_vpc
azs = local.azs
public_subnets = local.cidrs_publicos
private_subnets = local.cidrs_privados
database_subnets = local.cidrs_dados
# NAT Gateways — um por AZ em produção para resiliência
enable_nat_gateway = true
single_nat_gateway = var.environment != "production"
one_nat_gateway_per_az = var.environment == "production"
# DNS interno para service discovery
enable_dns_hostnames = true
enable_dns_support = true
# Tags necessárias para o EKS descobrir as subnets
public_subnet_tags = {
"kubernetes.io/role/elb" = 1
"kubernetes.io/cluster/${var.project_name}-${var.environment}" = "shared"
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1
"kubernetes.io/cluster/${var.project_name}-${var.environment}" = "shared"
"karpenter.sh/discovery" = "${var.project_name}-${var.environment}"
}
tags = local.tags_comuns
}
# Security Groups para cada camada da aplicação
resource "aws_security_group" "alb" {
name = "${var.project_name}-${var.environment}-alb"
description = "Security group do Application Load Balancer"
vpc_id = module.vpc.vpc_id
ingress {
description = "HTTPS da internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP — redirecionado para HTTPS"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Tráfego para os nós EKS"
from_port = 0
to_port = 65535
protocol = "tcp"
cidr_blocks = [local.cidr_vpc]
}
tags = merge(local.tags_comuns, { Name = "${var.project_name}-${var.environment}-alb" })
}
resource "aws_security_group" "eks_nodes" {
name = "${var.project_name}-${var.environment}-eks-nodes"
description = "Security group dos nós worker do EKS"
vpc_id = module.vpc.vpc_id
ingress {
description = "Tráfego do ALB"
from_port = 0
to_port = 65535
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
ingress {
description = "Comunicação entre nós"
from_port = 0
to_port = 65535
protocol = "-1"
self = true
}
egress {
description = "Todo tráfego de saída"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(local.tags_comuns, { Name = "${var.project_name}-${var.environment}-eks-nodes" })
}
resource "aws_security_group" "rds" {
name = "${var.project_name}-${var.environment}-rds"
description = "Security group do RDS PostgreSQL"
vpc_id = module.vpc.vpc_id
ingress {
description = "PostgreSQL dos nós EKS"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.eks_nodes.id]
}
tags = merge(local.tags_comuns, { Name = "${var.project_name}-${var.environment}-rds" })
}
resource "aws_security_group" "elasticache" {
name = "${var.project_name}-${var.environment}-elasticache"
description = "Security group do ElastiCache Redis"
vpc_id = module.vpc.vpc_id
ingress {
description = "Redis dos nós EKS"
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.eks_nodes.id]
}
tags = merge(local.tags_comuns, { Name = "${var.project_name}-${var.environment}-elasticache" })
}
Camada 2: KMS e Secrets Manager
# infrastructure/terraform/modules/secrets/main.tf
# Chave KMS mestre — usada para criptografar todos os outros recursos
resource "aws_kms_key" "principal" {
description = "Chave KMS principal — ${var.project_name} ${var.environment}"
deletion_window_in_days = 7
enable_key_rotation = true
multi_region = false
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AdministracaoRaiz"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "PermitirEKS"
Effect = "Allow"
Principal = {
AWS = module.eks.eks_managed_node_groups["geral"].iam_role_arn
}
Action = [
"kms:Decrypt",
"kms:GenerateDataKey",
]
Resource = "*"
}
]
})
tags = local.tags_comuns
}
resource "aws_kms_alias" "principal" {
name = "alias/${var.project_name}-${var.environment}"
target_key_id = aws_kms_key.principal.key_id
}
# Secrets por serviço — cada serviço tem suas próprias credenciais
locals {
servicos = ["catalog", "user", "order", "notification", "api-gateway"]
}
resource "aws_secretsmanager_secret" "servico" {
for_each = toset(local.servicos)
name = "${var.project_name}/${var.environment}/${each.key}"
description = "Credenciais do serviço ${each.key}"
kms_key_id = aws_kms_key.principal.arn
# Rotação automática — requer implementação do Lambda de rotação
# Habilitado apenas em produção
dynamic "rotation_rules" {
for_each = var.environment == "production" ? [1] : []
content {
automatically_after_days = 30
}
}
recovery_window_in_days = var.environment == "production" ? 7 : 0
tags = merge(local.tags_comuns, { Servico = each.key })
}
# Valores iniciais dos secrets — substituídos em produção por pipeline seguro
resource "aws_secretsmanager_secret_version" "servico" {
for_each = toset(local.servicos)
secret_id = aws_secretsmanager_secret.servico[each.key].id
secret_string = jsonencode({
DATABASE_URL = each.key != "notification" ? (
"postgresql://${each.key}_user:SUBSTITUIR_EM_PRODUCAO@${
aws_route53_record.rds[each.key].fqdn
}:5432/${each.key}_db"
) : null
REDIS_URL = "rediss://:SUBSTITUIR_EM_PRODUCAO@${
aws_elasticache_replication_group.redis.primary_endpoint_address
}:6379"
JWT_SECRET = "SUBSTITUIR_EM_PRODUCAO"
})
lifecycle {
# Nunca sobrescreve valores que foram atualizados manualmente
ignore_changes = [secret_string]
}
}
Camada 3: Banco de Dados por Serviço
# infrastructure/terraform/modules/databases/main.tf
locals {
# Serviços que precisam de banco de dados próprio
servicos_com_db = {
catalog = {
classe_staging = "db.t3.small"
classe_producao = "db.r6g.large"
storage_inicial = 20
storage_maximo = 500
}
user = {
classe_staging = "db.t3.small"
classe_producao = "db.r6g.large"
storage_inicial = 20
storage_maximo = 200
}
order = {
classe_staging = "db.t3.medium"
classe_producao = "db.r6g.xlarge" # Maior — core do negócio
storage_inicial = 50
storage_maximo = 2000
}
}
}
# Subnet group compartilhado para todos os bancos
resource "aws_db_subnet_group" "principal" {
name = "${var.project_name}-${var.environment}"
subnet_ids = module.vpc.database_subnets
tags = local.tags_comuns
}
# Parameter group otimizado para produção
resource "aws_db_parameter_group" "postgres16" {
name = "${var.project_name}-${var.environment}-pg16"
family = "postgres16"
parameter {
name = "shared_buffers"
value = "{DBInstanceClassMemory/4096}"
}
parameter {
name = "log_min_duration_statement"
value = var.environment == "production" ? "1000" : "100"
}
parameter {
name = "log_lock_waits"
value = "1"
}
parameter {
name = "log_checkpoints"
value = "1"
}
parameter {
name = "max_connections"
value = "200"
}
lifecycle {
create_before_destroy = true
}
}
# Um banco RDS por serviço
resource "aws_db_instance" "servico" {
for_each = local.servicos_com_db
identifier = "${var.project_name}-${var.environment}-${each.key}"
engine = "postgres"
engine_version = "16.1"
instance_class = var.environment == "production" ? (
each.value.classe_producao
) : each.value.classe_staging
allocated_storage = each.value.storage_inicial
max_allocated_storage = each.value.storage_maximo
storage_type = "gp3"
storage_encrypted = true
kms_key_id = aws_kms_key.principal.arn
db_name = "${replace(each.key, "-", "_")}_db"
username = "${replace(each.key, "-", "_")}_admin"
manage_master_user_password = true
master_user_secret_kms_key_id = aws_kms_key.principal.arn
multi_az = var.environment == "production"
db_subnet_group_name = aws_db_subnet_group.principal.name
vpc_security_group_ids = [aws_security_group.rds.id]
publicly_accessible = false
parameter_group_name = aws_db_parameter_group.postgres16.name
backup_retention_period = var.environment == "production" ? 14 : 3
backup_window = "02:00-03:00"
maintenance_window = "Mon:03:00-Mon:04:00"
auto_minor_version_upgrade = true
deletion_protection = var.environment == "production"
skip_final_snapshot = var.environment != "production"
final_snapshot_identifier = var.environment == "production" ? (
"${var.project_name}-${var.environment}-${each.key}-final"
) : null
performance_insights_enabled = true
performance_insights_retention_period = 7
monitoring_interval = 60
monitoring_role_arn = aws_iam_role.rds_monitoring.arn
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
tags = merge(local.tags_comuns, {
Servico = each.key
DBName = "${replace(each.key, "-", "_")}_db"
})
}
# Read Replica para o serviço de catálogo — alta taxa de leitura
resource "aws_db_instance" "catalog_replica" {
count = var.environment == "production" ? 1 : 0
identifier = "${var.project_name}-${var.environment}-catalog-replica"
replicate_source_db = aws_db_instance.servico["catalog"].identifier
instance_class = "db.r6g.large"
storage_encrypted = true
publicly_accessible = false
vpc_security_group_ids = [aws_security_group.rds.id]
parameter_group_name = aws_db_parameter_group.postgres16.name
backup_retention_period = 0
skip_final_snapshot = true
auto_minor_version_upgrade = true
performance_insights_enabled = true
tags = merge(local.tags_comuns, {
Servico = "catalog"
Role = "read-replica"
})
}
# Registros DNS internos para abstrair os endpoints
resource "aws_route53_zone" "interna" {
name = "interno.${var.domain_name}"
vpc {
vpc_id = module.vpc.vpc_id
}
tags = local.tags_comuns
}
resource "aws_route53_record" "rds" {
for_each = local.servicos_com_db
zone_id = aws_route53_zone.interna.zone_id
name = "${each.key}-db.interno.${var.domain_name}"
type = "CNAME"
ttl = 60
records = [aws_db_instance.servico[each.key].address]
}
Camada 4: ElastiCache e Filas
# infrastructure/terraform/modules/messaging/main.tf
# Redis compartilhado para cache dos serviços
resource "aws_elasticache_subnet_group" "redis" {
name = "${var.project_name}-${var.environment}-redis"
subnet_ids = module.vpc.private_subnets
tags = local.tags_comuns
}
resource "aws_elasticache_parameter_group" "redis7" {
name = "${var.project_name}-${var.environment}-redis7"
family = "redis7"
parameter {
name = "maxmemory-policy"
value = "allkeys-lru" # Remove chaves menos usadas quando memória esgota
}
parameter {
name = "notify-keyspace-events"
value = "Ex"
}
}
resource "aws_elasticache_replication_group" "redis" {
replication_group_id = "${var.project_name}-${var.environment}"
description = "Cache Redis — ${var.project_name} ${var.environment}"
node_type = var.environment == "production" ? "cache.r6g.large" : "cache.t3.micro"
num_cache_clusters = var.environment == "production" ? 3 : 1
port = 6379
parameter_group_name = aws_elasticache_parameter_group.redis7.name
subnet_group_name = aws_elasticache_subnet_group.redis.name
security_group_ids = [aws_security_group.elasticache.id]
engine_version = "7.1"
automatic_failover_enabled = var.environment == "production"
multi_az_enabled = var.environment == "production"
at_rest_encryption_enabled = true
transit_encryption_enabled = true
auth_token = random_password.redis_auth.result
auth_token_update_strategy = "ROTATE"
snapshot_window = "02:00-03:00"
snapshot_retention_limit = var.environment == "production" ? 7 : 1
maintenance_window = "tue:03:00-tue:04:00"
log_delivery_configuration {
destination = aws_cloudwatch_log_group.redis_slow.name
destination_type = "cloudwatch-logs"
log_format = "json"
log_type = "slow-log"
}
tags = local.tags_comuns
}
resource "random_password" "redis_auth" {
length = 32
special = false # Redis auth token não suporta alguns caracteres especiais
}
# Armazena o token do Redis no Secrets Manager
resource "aws_secretsmanager_secret_version" "redis_auth" {
secret_id = aws_secretsmanager_secret.redis_auth.id
secret_string = random_password.redis_auth.result
}
resource "aws_secretsmanager_secret" "redis_auth" {
name = "${var.project_name}/${var.environment}/redis-auth-token"
kms_key_id = aws_kms_key.principal.arn
tags = local.tags_comuns
}
# ── Filas SQS ──────────────────────────────────────────────────────
locals {
filas = {
"pedido-confirmado" = {
descricao = "Notifica serviços quando pedido é confirmado"
retencao = 86400 # 1 dia
visibilidade = 30
max_receives = 3
}
"pedido-cancelado" = {
descricao = "Notifica serviços quando pedido é cancelado"
retencao = 86400
visibilidade = 30
max_receives = 3
}
"estoque-atualizado" = {
descricao = "Notifica catálogo quando estoque muda"
retencao = 3600 # 1 hora — baixa prioridade
visibilidade = 60
max_receives = 5
}
}
}
# Dead Letter Queues — capturam mensagens que falharam repetidamente
resource "aws_sqs_queue" "dlq" {
for_each = local.filas
name = "${var.project_name}-${var.environment}-${each.key}-dlq"
message_retention_seconds = 1209600 # 14 dias para investigação
kms_master_key_id = aws_kms_key.principal.arn
tags = merge(local.tags_comuns, {
Fila = each.key
Tipo = "dlq"
})
}
# Filas principais com referência às DLQs
resource "aws_sqs_queue" "principal" {
for_each = local.filas
name = "${var.project_name}-${var.environment}-${each.key}"
message_retention_seconds = each.value.retencao
visibility_timeout_seconds = each.value.visibilidade
kms_master_key_id = aws_kms_key.principal.arn
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.dlq[each.key].arn
maxReceiveCount = each.value.max_receives
})
tags = merge(local.tags_comuns, {
Fila = each.key
Descricao = each.value.descricao
})
}
# Alarmes para mensagens acumulando nas DLQs
resource "aws_cloudwatch_metric_alarm" "dlq_mensagens" {
for_each = local.filas
alarm_name = "${var.project_name}-${var.environment}-dlq-${each.key}"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Sum"
threshold = 0 # Qualquer mensagem na DLQ é alerta
dimensions = {
QueueName = aws_sqs_queue.dlq[each.key].name
}
alarm_description = "Mensagens na DLQ ${each.key} — investigar falhas de processamento"
alarm_actions = [aws_sns_topic.alertas.arn]
tags = local.tags_comuns
}
Camada 5: Cluster EKS
# infrastructure/terraform/modules/eks/main.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "${var.project_name}-${var.environment}"
cluster_version = "1.29"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
control_plane_subnet_ids = module.vpc.private_subnets
cluster_endpoint_public_access = true
cluster_endpoint_public_access_cidrs = var.acesso_publico_cidrs
cluster_endpoint_private_access = true
cluster_encryption_config = {
provider_key_arn = aws_kms_key.principal.arn
resources = ["secrets"]
}
cluster_addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = {
most_recent = true
service_account_role_arn = module.vpc_cni_irsa.iam_role_arn
}
aws-ebs-csi-driver = {
most_recent = true
service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
}
}
eks_managed_node_groups = {
# Node group de sistema — para add-ons e workloads da plataforma
sistema = {
name = "${var.project_name}-${var.environment}-sistema"
instance_types = ["m6i.large"]
capacity_type = "ON_DEMAND"
min_size = 2
max_size = 4
desired_size = 2
labels = { role = "sistema" }
taints = [{
key = "CriticalAddonsOnly"
value = "true"
effect = "NO_SCHEDULE"
}]
block_device_mappings = {
xvda = {
device_name = "/dev/xvda"
ebs = {
volume_size = 50
volume_type = "gp3"
encrypted = true
kms_key_id = aws_kms_key.eks_nodes.arn
delete_on_termination = true
}
}
}
}
# Node group de aplicação — para os microsserviços
aplicacao = {
name = "${var.project_name}-${var.environment}-aplicacao"
instance_types = ["m6i.large", "m6a.large", "m7i.large"]
capacity_type = var.environment == "production" ? "ON_DEMAND" : "SPOT"
min_size = var.environment == "production" ? 3 : 1
max_size = 20
desired_size = var.environment == "production" ? 3 : 1
labels = { role = "aplicacao" }
block_device_mappings = {
xvda = {
device_name = "/dev/xvda"
ebs = {
volume_size = 50
volume_type = "gp3"
encrypted = true
kms_key_id = aws_kms_key.eks_nodes.arn
delete_on_termination = true
}
}
}
}
}
# Acesso ao cluster via IAM Identity Center
access_entries = {
admin = {
principal_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/AWSReservedSSO_AWSAdministratorAccess_${var.sso_suffix}"
policy_associations = {
admin = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope = { type = "cluster" }
}
}
}
pipeline = {
principal_arn = aws_iam_role.pipeline_deploy.arn
policy_associations = {
editor = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy"
access_scope = {
type = "namespace"
namespaces = ["producao", "staging"]
}
}
}
}
}
tags = local.tags_comuns
}
resource "aws_kms_key" "eks_nodes" {
description = "KMS para volumes EBS dos nós EKS"
deletion_window_in_days = 7
enable_key_rotation = true
tags = local.tags_comuns
}
Camada 6: Namespaces e Configurações do Cluster
# infrastructure/terraform/modules/kubernetes-base/main.tf
# Namespaces com labels de Pod Security Standards
resource "kubernetes_namespace" "producao" {
metadata {
name = "producao"
labels = {
"pod-security.kubernetes.io/enforce" = "restricted"
"pod-security.kubernetes.io/enforce-version" = "latest"
"pod-security.kubernetes.io/audit" = "restricted"
"pod-security.kubernetes.io/warn" = "restricted"
}
annotations = {
"scheduler.alpha.kubernetes.io/node-selector" = "role=aplicacao"
}
}
}
resource "kubernetes_namespace" "monitoring" {
metadata {
name = "monitoring"
labels = {
"pod-security.kubernetes.io/enforce" = "baseline"
}
}
}
# Resource Quotas por namespace
resource "kubernetes_resource_quota" "producao" {
metadata {
name = "quota-producao"
namespace = kubernetes_namespace.producao.metadata[0].name
}
spec {
hard = {
"requests.cpu" = "20"
"requests.memory" = "40Gi"
"limits.cpu" = "40"
"limits.memory" = "80Gi"
"pods" = "100"
}
}
}
# LimitRange — define padrões de resources para pods sem especificação
resource "kubernetes_limit_range" "producao" {
metadata {
name = "limitrange-producao"
namespace = kubernetes_namespace.producao.metadata[0].name
}
spec {
limit {
type = "Container"
default = {
cpu = "500m"
memory = "512Mi"
}
default_request = {
cpu = "100m"
memory = "128Mi"
}
max = {
cpu = "4"
memory = "4Gi"
}
}
}
}
# StorageClass gp3 como padrão
resource "kubernetes_storage_class" "gp3" {
metadata {
name = "gp3"
annotations = {
"storageclass.kubernetes.io/is-default-class" = "true"
}
}
storage_provisioner = "ebs.csi.aws.com"
volume_binding_mode = "WaitForFirstConsumer"
allow_volume_expansion = true
parameters = {
type = "gp3"
iops = "3000"
throughput = "125"
encrypted = "true"
kmsKeyId = aws_kms_key.principal.arn
}
}
# Add-ons via Helm
resource "helm_release" "aws_load_balancer_controller" {
name = "aws-load-balancer-controller"
repository = "https://aws.github.io/eks-charts"
chart = "aws-load-balancer-controller"
namespace = "kube-system"
version = "1.7.1"
set {
name = "clusterName"
value = module.eks.cluster_name
}
set {
name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = module.alb_controller_irsa.iam_role_arn
}
}
resource "helm_release" "external_secrets" {
name = "external-secrets"
repository = "https://charts.external-secrets.io"
chart = "external-secrets"
namespace = "external-secrets"
create_namespace = true
version = "0.9.13"
set {
name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = module.external_secrets_irsa.iam_role_arn
}
depends_on = [module.eks]
}
resource "helm_release" "argocd" {
name = "argocd"
repository = "https://argoproj.github.io/argo-helm"
chart = "argo-cd"
namespace = "argocd"
create_namespace = true
version = "6.4.0"
values = [file("${path.module}/argocd-values.yaml")]
depends_on = [module.eks]
}
resource "helm_release" "kube_prometheus_stack" {
name = "kube-prometheus-stack"
repository = "https://prometheus-community.github.io/helm-charts"
chart = "kube-prometheus-stack"
namespace = "monitoring"
version = "56.6.2"
values = [file("${path.module}/prometheus-values.yaml")]
depends_on = [kubernetes_namespace.monitoring]
}
Outputs da Infraestrutura
# infrastructure/terraform/environments/production/outputs.tf
output "cluster_name" {
description = "Nome do cluster EKS"
value = module.eks.cluster_name
}
output "cluster_endpoint" {
description = "Endpoint da API do EKS"
value = module.eks.cluster_endpoint
sensitive = true
}
output "vpc_id" {
description = "ID da VPC"
value = module.vpc.vpc_id
}
output "rds_endpoints" {
description = "Endpoints dos bancos de dados por serviço"
value = {
for servico, db in aws_db_instance.servico :
servico => db.address
}
sensitive = true
}
output "redis_endpoint" {
description = "Endpoint primário do Redis"
value = aws_elasticache_replication_group.redis.primary_endpoint_address
sensitive = true
}
output "sqs_urls" {
description = "URLs das filas SQS"
value = {
for nome, fila in aws_sqs_queue.principal :
nome => fila.url
}
}
output "secrets_arns" {
description = "ARNs dos secrets no Secrets Manager por serviço"
value = {
for servico, secret in aws_secretsmanager_secret.servico :
servico => secret.arn
}
sensitive = true
}
output "nameservers" {
description = "Nameservers do Route53 — configurar no registrador"
value = aws_route53_zone.principal.name_servers
}
Executando o Provisionamento
#!/bin/bash
# scripts/provisionar-infraestrutura.sh
# Provisiona a infraestrutura completa na ordem correta de dependências
set -euo pipefail
AMBIENTE="${1:?Uso: $0 <staging|production>}"
REGIAO="us-east-1"
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
cd infrastructure/terraform/environments/$AMBIENTE
log "=== Provisionando infraestrutura — ambiente: $AMBIENTE ==="
# Inicializa o Terraform com backend remoto
log "Inicializando Terraform..."
terraform init \
-backend-config="bucket=${TF_STATE_BUCKET}" \
-backend-config="key=${AMBIENTE}/terraform.tfstate" \
-backend-config="region=${REGIAO}" \
-backend-config="dynamodb_table=${TF_LOCK_TABLE}"
# Valida a configuração
log "Validando configuração..."
terraform validate
# Plano com saída para arquivo
log "Gerando plano..."
terraform plan \
-var="environment=${AMBIENTE}" \
-var="aws_region=${REGIAO}" \
-out=tfplan
# Em produção, requer aprovação manual antes de aplicar
if [ "$AMBIENTE" = "production" ]; then
echo ""
echo "⚠️ ATENÇÃO: Aplicando mudanças em PRODUÇÃO"
echo "Revise o plano acima cuidadosamente."
read -rp "Digite 'aplicar' para confirmar: " confirmacao
if [ "$confirmacao" != "aplicar" ]; then
log "Operação cancelada pelo usuário"
exit 0
fi
fi
log "Aplicando mudanças..."
terraform apply tfplan
# Atualiza o kubeconfig
log "Atualizando kubeconfig..."
aws eks update-kubeconfig \
--name "$(terraform output -raw cluster_name)" \
--region "$REGIAO"
log "✅ Infraestrutura provisionada com sucesso!"
log "Cluster: $(terraform output -raw cluster_name)"
O Que Vem a Seguir
Com a infraestrutura provisionada, o próximo artigo implementa os cinco microsserviços: código funcional com todas as práticas de resiliência, observabilidade e segurança integradas, Dockerfiles otimizados, manifestos Kubernetes completos e a configuração do ArgoCD para deploy declarativo.
Referências para Aprofundamento
Terraform e IaC
- Terraform Best Practices — developer.hashicorp.com — Guia oficial de boas práticas para estruturação de módulos Terraform, incluindo convenções de nomenclatura, composição de módulos e estratégias de versionamento.
- AWS VPC Terraform Module — registry.terraform.io — Documentação do módulo oficial de VPC para AWS com todos os parâmetros disponíveis e exemplos de uso para diferentes arquiteturas de rede.
EKS
- EKS Best Practices Guide — aws.github.io — Guia oficial de boas práticas para EKS mantido pela AWS, cobrindo segurança, networking, escalabilidade e monitoramento com recomendações detalhadas para cada área.
- EKS Workshop — eksworkshop.com — Workshop hands-on oficial da AWS para EKS com laboratórios práticos cobrindo desde a criação do cluster até práticas avançadas de segurança e observabilidade.
Exercícios
Exercício 1
O módulo de rede calcula as subnets assim:
cidr_vpc = "10.0.0.0/16"
cidrs_privados = [for i, az in local.azs : cidrsubnet(local.cidr_vpc, 8, i + 10)]
Sabendo que o node group aplicacao tem max_size = 20 com instâncias m6i.large e que o VPC CNI dá a cada pod um IP da subnet privada, calcule se o endereçamento comporta o cluster no tamanho máximo.
Ver resposta
✓ Resposta: Não comporta com folga alguma — o cluster esbarra em esgotamento de IPs antes de esbarrar em CPU ou memória.
Oferta. cidrsubnet("10.0.0.0/16", 8, …) soma 8 bits ao prefixo, produzindo /24 por subnet: 256 endereços, dos quais a AWS reserva 5 (rede, VPC router, DNS, futuro, broadcast) — restam 251 por AZ, ou 753 no total nas três privadas.
Demanda. Uma m6i.large suporta 3 ENIs × 10 IPv4 = 30 endereços, menos o primário do nó: 29 pods. Com 20 nós de aplicação mais até 4 do node group sistema, são 24 nós × 30 = 720 IPs. Sobram cerca de 33 de 753 — e ainda faltam os IPs do ALB, que usa target-type: ip e consome endereços da mesma subnet.
O agravante é o warm pool do CNI: por padrão ele mantém uma ENI extra pré-alocada por nó, então cada nó reserva ~10 IPs mesmo rodando um único pod. Os endereços somem antes de qualquer pod existir, e o sintoma é um pod preso em ContainerCreating com failed to assign an IP address to container — que ninguém associa a CIDR, porque a mensagem fala de container.
O ADR do artigo anterior chamava o CIDR planning de "decisão difícil de desfazer", e é literal: não se pode redimensionar uma subnet existente na AWS. Corrigir significa criar novas subnets e migrar os nós. Por isso o dimensionamento adequado seria /20 para as privadas (4.091 IPs úteis por AZ), deixando as públicas pequenas — elas só hospedam NAT Gateway e ALB.
# privadas grandes (/20), públicas e de dados pequenas (/24)
cidrs_privados = [for i, az in local.azs : cidrsubnet(local.cidr_vpc, 4, i + 1)]
A alternativa sem re-endereçar é o CNI custom networking, que coloca os pods em um CIDR secundário da VPC — inclusive em faixas 100.64.0.0/10, não roteáveis para fora. Funciona, mas acrescenta complexidade permanente a algo que um prefixo maior resolveria de graça no dia zero.
Exercício 2
O token do Redis é gerado assim, e depois gravado no Secrets Manager:
resource "random_password" "redis_auth" {
length = 32
special = false
}
Aparentemente ninguém nunca vê essa senha — ela nasce e vai direto para um cofre criptografado com KMS. Onde ela está exposta?
Ver resposta
✓ Resposta: No arquivo de state do Terraform, em texto claro.
O random_password precisa produzir sempre o mesmo valor entre execuções — do contrário, todo apply giraria a senha e derrubaria as conexões. A única forma de conseguir isso é persistir o valor gerado, e o lugar onde o Terraform persiste tudo é o state. O mesmo vale para o secret_string do aws_secretsmanager_secret_version: qualquer atributo de qualquer recurso vai para lá, e marcar um output como sensitive não altera nada disso — sensitive apenas oculta o valor na saída do terminal.
Quem lê o state lê a senha. E o state é lido por mais gente do que se imagina: quem roda terraform show, quem baixa o arquivo para depurar, quem tem s3:GetObject no bucket, e o próprio pipeline de CI. Um state com terraform.tfstate versionado no Git — erro comum em projetos jovens — publica todos os segredos no histórico do repositório.
As mitigações são cumulativas, e nenhuma delas sozinha resolve:
- Criptografar o state em repouso e restringir o bucket: o backend S3 do script usa KMS e trava de acesso — é o mínimo, e o artigo já faz.
- Tirar o segredo do Terraform quando possível. É exatamente o que o RDS faz neste mesmo módulo com
manage_master_user_password = true: a AWS gera e guarda a senha, e o Terraform nunca a vê. Redis não oferece equivalente, mas a rotação via Lambda tira o valor de circulação depois do primeiroapply. - Nunca versionar o state, e tratá-lo como material sensível em qualquer cópia local.
Repare na incoerência que o próprio módulo expõe: cria-se uma chave KMS dedicada, ativa-se rotação, criptografa-se o Secrets Manager — e a senha fica legível em um arquivo JSON. A segurança de um segredo é a do lugar menos protegido em que ele aparece, e o state costuma ser esse lugar.
Exercício 3
Os secrets por serviço nascem com "SUBSTITUIR_EM_PRODUCAO" nos campos de senha e JWT_SECRET, sob este bloco:
lifecycle {
ignore_changes = [secret_string]
}
Seis meses depois, o serviço de usuários é comprometido. Descobre-se que o JWT_SECRET em produção era literalmente SUBSTITUIR_EM_PRODUCAO. Como o ignore_changes contribuiu para isso?
Ver resposta
✓ Resposta: Ele transformou o placeholder em um valor permanente e invisível.
O ignore_changes = [secret_string] existe por uma razão legítima: se alguém trocar a senha no console da AWS, o Terraform não deve reverter para o valor do código no próximo apply. O efeito colateral é que o Terraform para de comparar aquele campo — e passa a relatar "no changes" tanto para o secret que foi devidamente rotacionado quanto para o que continua com o texto de exemplo. Os dois casos ficam idênticos aos olhos da ferramenta.
Com isso, some o mecanismo que naturalmente denunciaria o problema. Um plan mostrando o placeholder seria um lembrete a cada execução; com o campo ignorado, o silêncio é indistinguível de sucesso. É o mesmo modo de falha do CFR sem etiqueta e do relatório de segurança com jobs pulados: a ausência de sinal lida como sinal positivo.
O erro de fundo, porém, é anterior: não se deve semear um secret com um valor que funciona. "SUBSTITUIR_EM_PRODUCAO" é uma string válida — a aplicação sobe, assina tokens e opera normalmente com ela. Nada quebra, e é justamente por isso que ninguém substitui.
Duas correções, e ambas valem a pena. A primeira é não criar o valor pelo Terraform: provisionar apenas o aws_secretsmanager_secret (o cofre) e deixar que o pipeline de bootstrap grave o conteúdo. Sem versão, a aplicação falha ao iniciar com ResourceNotFoundException — falha alto e cedo, que é o comportamento desejado. A segunda é uma verificação independente, já que o Terraform abriu mão de conferir:
aws secretsmanager get-secret-value --secret-id "$ARN" --query SecretString --output text \
| grep -q "SUBSTITUIR_EM_PRODUCAO" \
&& { echo "::error::secret com placeholder em produção"; exit 1; }
Note ainda o recovery_window_in_days = 0 fora de produção: ele permite apagar e recriar secrets imediatamente em staging, sem os 7 a 30 dias de janela de recuperação — conveniente para iterar, e exatamente o que não se quer em produção, onde a janela é a proteção contra exclusão acidental.
Exercício 4
O namespace de produção declara:
annotations = {
"scheduler.alpha.kubernetes.io/node-selector" = "role=aplicacao"
}
A intenção é que todo pod do namespace caia obrigatoriamente nos nós de aplicação. Um engenheiro testa criando um pod sem nodeSelector e ele é agendado normalmente — mas o time não consegue confirmar se a regra funcionou. Por quê?
Ver resposta
✓ Resposta: Porque essa anotação não faz efeito no EKS. Ela é interpretada pelo admission controller PodNodeSelector, que precisa estar habilitado no kube-apiserver via --enable-admission-plugins. No EKS o control plane é gerenciado pela AWS, e essas flags não são configuráveis pelo cliente. A anotação fica no objeto, legível e inerte — não gera erro, aviso ou evento.
É um caso particularmente traiçoeiro porque o teste do engenheiro não distingue os cenários. O pod foi agendado nos nós de aplicação de qualquer forma, mas não pela anotação: o outro node group, sistema, carrega o taint CriticalAddonsOnly=true:NoSchedule, que repele qualquer pod sem a toleration correspondente. O resultado certo aconteceu pelo motivo errado — e a equipe conclui que a regra está valendo.
A diferença aparece no dia em que alguém adicionar um terceiro node group sem taints (nós de GPU, nós Spot mais baratos, nós de outra arquitetura). Os pods de produção passarão a ser agendados livremente ali, e a "garantia" descrita no namespace não impedirá nada.
No EKS, restrições desse tipo se aplicam por um destes caminhos, todos efetivos:
- Taints nos nós — o mecanismo nativo, que é o que de fato está funcionando aqui. Repelir é mais confiável do que atrair.
- Política de admissão com Kyverno ou OPA Gatekeeper, que injetam o
nodeSelectornos pods do namespace — e essa é a substituição direta da anotação pretendida. - Declarar
nodeSelectornos manifestos, com uma política que rejeite pods sem ele.
Vale a lição geral sobre o prefixo alpha no nome da chave: anotações alpha e beta dependem de feature gates e plugins específicos, e o Kubernetes ignora silenciosamente qualquer anotação que não reconheça. Uma configuração de segurança que falha sem avisar é pior que a ausência dela, porque produz confiança.
Exercício 5
O namespace de produção tem uma ResourceQuota exigindo requests.cpu, requests.memory, limits.cpu e limits.memory, e também um LimitRange com default e default_request. Um desenvolvedor aplica um Deployment cujo container não declara resources. O pod sobe? E se o LimitRange não existisse?
Ver resposta
✓ Resposta: Sobe, com os valores do LimitRange injetados: 100m/128Mi de request e 500m/512Mi de limit. Sem o LimitRange, o pod seria rejeitado na criação.
A regra é a seguinte: quando uma ResourceQuota restringe requests.cpu ou limits.memory, o Kubernetes passa a exigir que todo container do namespace declare esses campos — sem eles não há como contabilizar a cota, e o pod é recusado com must specify limits.cpu. Os dois objetos formam um par: a quota impõe a obrigação e o LimitRange a satisfaz por padrão, para que o desenvolvedor não precise saber disso.
A falha sem o LimitRange é operacionalmente desagradável: o kubectl apply do Deployment funciona — o objeto é aceito. Quem falha é o ReplicaSet ao tentar criar os pods, e o erro aparece apenas em kubectl describe replicaset ou nos eventos do namespace. O desenvolvedor vê um Deployment com 0 pods e nenhuma mensagem óbvia.
Vale conferir a aritmética do namespace: a quota permite requests.cpu = 20 e pods = 100. Com o default de 100m, cem pods somam 10 CPUs — cabe. Mas os serviços do capstone declaram seus próprios valores, bem acima do default; com 500m de request cada, o teto de CPU é atingido em 40 pods, muito antes do limite de 100. Quem esgota primeiro define a capacidade real, e não é o número que está escrito em pods.
O max do LimitRange completa a proteção por outro lado: nenhum container pode pedir mais de 4 CPUs ou 4 GiB, o que impede que um único pod mal configurado consuma sozinho boa parte da cota do namespace e bloqueie os deploys de todos os outros times.