Capstone: Provisionando a Infraestrutura Completa

[317] Capstone: Provisionando a Infraestrutura Completa

A infraestrutura do capstone provisionada em seis camadas, na ordem das dependências: rede e security groups, KMS e Secrets Manager, um RDS por serviço com read replica, ElastiCache e filas SQS com DLQ, o cluster EKS com node groups separados, e os namespaces com quotas e add-ons via Helm.
DevOps

27 min de leitura

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

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 dissosensitive 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 primeiro apply.
  • 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 nodeSelector nos pods do namespace — e essa é a substituição direta da anotação pretendida.
  • Declarar nodeSelector nos 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.

Comentários

Mais em DevOps

Processos, Serviços e o Comando `systemctl`
Processos, Serviços e o Comando `systemctl`

Processos e serviços no Linux: leitura de ps aux e top, busca com pgrep, a…

Projeto Capstone: Arquitetura do Sistema Completo
Projeto Capstone: Arquitetura do Sistema Completo

A arquitetura do projeto que integra toda a série em um sistema de produção…

Kubernetes em Produção: Segurança, GitOps e Deploys Avançados
Kubernetes em Produção: Segurança, GitOps e Deploys Avançados

A distância entre funcionar e estar pronto para produção: os perfis do Pod…