EKS: Kubernetes Gerenciado na AWS

[308] EKS: Kubernetes Gerenciado na AWS

Kubernetes gerenciado sem a responsabilidade de operar o plano de controle: o provisionamento do cluster e dos node groups com Terraform, o IRSA que dá permissões AWS aos pods sem chave estática, o Karpenter provisionando nós sob medida, os add-ons via Helm e o External Secrets Operator.
DevOps

21 min de leitura

Operar o plano de controle do Kubernetes — manter o etcd replicado, atualizar os componentes do control plane, garantir sua disponibilidade, monitorar sua saúde — é um trabalho de tempo integral que exige profundo conhecimento do sistema. Para a maioria das organizações, esse esforço operacional não gera vantagem competitiva: o negócio se beneficia de ter aplicações bem orquestradas, não de operar o orquestrador em si.

O Amazon Elastic Kubernetes Service — EKS — resolve esse problema gerenciando o plano de controle da AWS. A AWS garante a disponibilidade do kube-apiserver, do etcd e dos demais componentes do control plane, aplica patches de segurança e disponibiliza novas versões do Kubernetes. O time de engenharia mantém o controle sobre os nós worker, os workloads e a configuração do cluster — sem a responsabilidade de operar o que está abaixo.

O EKS é compatível com todas as ferramentas e manifestos Kubernetes padrão. Um cluster EKS aceita os mesmos YAMLs que rodam em um cluster local criado com kind ou kubeadm — a portabilidade é uma das promessas fundamentais do Kubernetes.

Provisionando um Cluster EKS com Terraform

A criação de um cluster EKS envolve múltiplos componentes: o próprio cluster, os node groups, as configurações de rede e as permissões IAM. O módulo oficial terraform-aws-modules/eks/aws encapsula essa complexidade:

# eks.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31"
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.25"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.12"
    }
  }
}

# Dados do cluster após criação — necessários para configurar providers
data "aws_eks_cluster" "principal" {
  name = module.eks.cluster_name
}

data "aws_eks_cluster_auth" "principal" {
  name = module.eks.cluster_name
}

# Provider Kubernetes aponta para o cluster EKS
provider "kubernetes" {
  host                   = data.aws_eks_cluster.principal.endpoint
  cluster_ca_certificate = base64decode(
    data.aws_eks_cluster.principal.certificate_authority[0].data
  )
  token = data.aws_eks_cluster_auth.principal.token
}

provider "helm" {
  kubernetes {
    host                   = data.aws_eks_cluster.principal.endpoint
    cluster_ca_certificate = base64decode(
      data.aws_eks_cluster.principal.certificate_authority[0].data
    )
    token = data.aws_eks_cluster_auth.principal.token
  }
}

# Módulo EKS — provisiona cluster e node groups
module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"

  cluster_name    = "${var.project_name}-${var.environment}"
  cluster_version = "1.29"

  # Rede
  vpc_id                   = module.vpc.vpc_id
  subnet_ids               = module.vpc.ids_subnets_privadas
  control_plane_subnet_ids = module.vpc.ids_subnets_privadas

  # Acesso à API pública — restringe por CIDR
  cluster_endpoint_public_access       = true
  cluster_endpoint_public_access_cidrs = var.eks_public_access_cidrs
  cluster_endpoint_private_access      = true

  # Criptografia dos secrets do etcd com KMS
  cluster_encryption_config = {
    provider_key_arn = aws_kms_key.eks.arn
    resources        = ["secrets"]
  }

  # Add-ons gerenciados pela AWS
  cluster_addons = {
    coredns = {
      most_recent = true
      configuration_values = jsonencode({
        replicaCount = 2
        resources = {
          requests = { cpu = "100m", memory = "70Mi" }
          limits   = { memory = "170Mi" }
        }
      })
    }
    kube-proxy = {
      most_recent = true
    }
    vpc-cni = {
      most_recent              = true
      service_account_role_arn = module.vpc_cni_irsa.iam_role_arn
      configuration_values = jsonencode({
        enableNetworkPolicy = "true"
      })
    }
    aws-ebs-csi-driver = {
      most_recent              = true
      service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
    }
  }

  # Node Groups gerenciados
  eks_managed_node_groups = {
    # Node group para cargas de trabalho gerais
    geral = {
      name            = "${var.project_name}-${var.environment}-geral"
      instance_types  = ["m6i.large", "m6a.large", "m7i.large"]
      capacity_type   = "ON_DEMAND"
      min_size        = 2
      max_size        = 10
      desired_size    = 3

      # Spread entre AZs para resiliência
      subnet_ids = module.vpc.ids_subnets_privadas

      labels = {
        role        = "geral"
        environment = var.environment
      }

      taints = []

      block_device_mappings = {
        xvda = {
          device_name = "/dev/xvda"
          ebs = {
            volume_size           = 50
            volume_type           = "gp3"
            iops                  = 3000
            encrypted             = true
            kms_key_id            = aws_kms_key.eks_nodes.arn
            delete_on_termination = true
          }
        }
      }

      update_config = {
        max_unavailable_percentage = 33
      }

      tags = local.tags_comuns
    }

    # Node group para workloads com GPU (ML/inferência)
    gpu = {
      name           = "${var.project_name}-${var.environment}-gpu"
      instance_types = ["g4dn.xlarge"]
      capacity_type  = "SPOT"
      min_size       = 0
      max_size       = 5
      desired_size   = 0

      labels = {
        role             = "gpu"
        "nvidia.com/gpu" = "true"
      }

      taints = [{
        key    = "nvidia.com/gpu"
        value  = "true"
        effect = "NO_SCHEDULE"
      }]

      tags = local.tags_comuns
    }
  }

  # Acesso ao cluster — administradores
  access_entries = {
    admin = {
      kubernetes_groups = []
      principal_arn     = "arn:aws:iam::${data.aws_caller_identity.atual.account_id}:role/eks-admin"
      policy_associations = {
        admin = {
          policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
          access_scope = {
            type = "cluster"
          }
        }
      }
    }
  }

  tags = local.tags_comuns
}

# KMS para criptografia dos secrets do etcd
resource "aws_kms_key" "eks" {
  description             = "KMS key para secrets do cluster EKS"
  deletion_window_in_days = 7
  enable_key_rotation     = true
  tags                    = local.tags_comuns
}

resource "aws_kms_key" "eks_nodes" {
  description             = "KMS key para volumes EBS dos nós EKS"
  deletion_window_in_days = 7
  enable_key_rotation     = true
  tags                    = local.tags_comuns
}

IRSA: IAM Roles for Service Accounts

O IRSA — IAM Roles for Service Accounts — é o mecanismo que permite que pods no EKS assumam IAM Roles sem precisar de chaves de acesso estáticas. Funciona via OIDC: o cluster EKS age como um provedor de identidade OIDC, emitindo tokens JWT para as service accounts dos pods. A AWS verifica esses tokens e assume a IAM Role correspondente.

# irsa.tf

# OIDC Provider do cluster EKS
data "tls_certificate" "eks" {
  url = module.eks.cluster_oidc_issuer_url
}

resource "aws_iam_openid_connect_provider" "eks" {
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
  url             = module.eks.cluster_oidc_issuer_url

  tags = local.tags_comuns
}

# IRSA para o VPC CNI — gerencia ENIs para os pods
module "vpc_cni_irsa" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name             = "${var.project_name}-${var.environment}-vpc-cni"
  attach_vpc_cni_policy = true
  vpc_cni_enable_ipv4   = true

  oidc_providers = {
    main = {
      provider_arn               = aws_iam_openid_connect_provider.eks.arn
      namespace_service_accounts = ["kube-system:aws-node"]
    }
  }
}

# IRSA para o EBS CSI Driver — gerencia volumes EBS
module "ebs_csi_irsa" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name             = "${var.project_name}-${var.environment}-ebs-csi"
  attach_ebs_csi_policy = true

  oidc_providers = {
    main = {
      provider_arn               = aws_iam_openid_connect_provider.eks.arn
      namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
    }
  }
}

# IRSA para a aplicação — acesso ao S3 e Secrets Manager
module "minha_api_irsa" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name = "${var.project_name}-${var.environment}-minha-api"

  oidc_providers = {
    main = {
      provider_arn               = aws_iam_openid_connect_provider.eks.arn
      namespace_service_accounts = ["producao:minha-api"]
    }
  }
}

resource "aws_iam_role_policy" "minha_api" {
  name = "permissoes-minha-api"
  role = module.minha_api_irsa.iam_role_name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
        Resource = "${aws_s3_bucket.assets.arn}/*"
      },
      {
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = "arn:aws:secretsmanager:${var.aws_region}:*:secret:${var.project_name}/*"
      }
    ]
  })
}

A Service Account que referencia a IAM Role:

# kubernetes/service-account.yml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: minha-api
  namespace: producao
  annotations:
    # Referencia a IAM Role via IRSA
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/minha-api-producao-minha-api
    eks.amazonaws.com/token-expiration: "86400"

O Deployment que usa a Service Account:

spec:
  template:
    spec:
      serviceAccountName: minha-api
      # Não é necessário configurar credenciais AWS —
      # o SDK detecta automaticamente via IRSA
      containers:
        - name: api
          image: ghcr.io/empresa/minha-api:1.5.0

Karpenter: Auto Scaling Inteligente de Nós

O Karpenter é o provisionador de nós recomendado para EKS — mais rápido e eficiente que o Cluster Autoscaler tradicional. Enquanto o Cluster Autoscaler escala node groups inteiros, o Karpenter provisiona nós individualmente com o tipo exato que melhor atende às necessidades dos pods pendentes.

# karpenter.tf

# IRSA para o Karpenter
module "karpenter_irsa" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name                          = "${var.project_name}-${var.environment}-karpenter"
  attach_karpenter_controller_policy = true

  karpenter_controller_cluster_name       = module.eks.cluster_name
  karpenter_controller_node_iam_role_arns = [module.eks.eks_managed_node_groups["geral"].iam_role_arn]

  oidc_providers = {
    main = {
      provider_arn               = aws_iam_openid_connect_provider.eks.arn
      namespace_service_accounts = ["kube-system:karpenter"]
    }
  }
}

# Instala o Karpenter via Helm
resource "helm_release" "karpenter" {
  namespace        = "kube-system"
  name             = "karpenter"
  repository       = "oci://public.ecr.aws/karpenter"
  chart            = "karpenter"
  version          = "0.34.0"
  create_namespace = false

  set {
    name  = "settings.clusterName"
    value = module.eks.cluster_name
  }

  set {
    name  = "settings.interruptionQueue"
    value = aws_sqs_queue.karpenter.name
  }

  set {
    name  = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
    value = module.karpenter_irsa.iam_role_arn
  }

  set {
    name  = "controller.resources.requests.cpu"
    value = "100m"
  }

  set {
    name  = "controller.resources.requests.memory"
    value = "256Mi"
  }

  set {
    name  = "controller.resources.limits.memory"
    value = "512Mi"
  }
}

# Fila SQS para notificações de interrupção de instâncias Spot
resource "aws_sqs_queue" "karpenter" {
  name                      = "${var.project_name}-${var.environment}-karpenter"
  message_retention_seconds = 300
  sqs_managed_sse_enabled   = true
  tags                      = local.tags_comuns
}

Com o Karpenter instalado, configura-se um NodePool — o conjunto de regras que define quais tipos de nós podem ser provisionados:

# kubernetes/karpenter/nodepool.yml
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: geral
spec:
  template:
    metadata:
      labels:
        role: geral
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1beta1
        kind: EC2NodeClass
        name: geral

      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["m", "c", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]

      # Expira nós após 720h para forçar renovação com AMIs atualizadas
      expireAfter: 720h

  limits:
    cpu: "200"
    memory: "800Gi"

  disruption:
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 30s
---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
  name: geral
spec:
  amiFamily: AL2
  role: "${var.project_name}-${var.environment}-node"

  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${var.project_name}-${var.environment}"

  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "${var.project_name}-${var.environment}"

  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 50Gi
        volumeType: gp3
        iops: 3000
        encrypted: true
        deleteOnTermination: true

  tags:
    Name: "${var.project_name}-${var.environment}-karpenter-node"
    Environment: "${var.environment}"

Instalando Add-ons Essenciais com Helm

Um cluster EKS em produção precisa de uma série de componentes adicionais instalados via Helm:

# addons.tf

# AWS Load Balancer Controller — cria ALBs a partir de Ingress resources
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.aws_load_balancer_controller_irsa.iam_role_arn
  }

  set {
    name  = "replicaCount"
    value = "2"
  }
}

# External Secrets Operator — sincroniza secrets do Secrets Manager para K8s
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
  }
}

# Metrics Server — necessário para HPA funcionar
resource "helm_release" "metrics_server" {
  name       = "metrics-server"
  repository = "https://kubernetes-sigs.github.io/metrics-server/"
  chart      = "metrics-server"
  namespace  = "kube-system"
  version    = "3.11.0"
}

# Prometheus Stack — monitoramento completo do cluster
resource "helm_release" "prometheus_stack" {
  name             = "kube-prometheus-stack"
  repository       = "https://prometheus-community.github.io/helm-charts"
  chart            = "kube-prometheus-stack"
  namespace        = "monitoring"
  create_namespace = true
  version          = "56.6.2"

  values = [
    yamlencode({
      grafana = {
        enabled        = true
        adminPassword  = var.grafana_password
        ingress = {
          enabled          = true
          ingressClassName = "alb"
          annotations = {
            "alb.ingress.kubernetes.io/scheme"      = "internet-facing"
            "alb.ingress.kubernetes.io/target-type" = "ip"
          }
          hosts = ["grafana.${var.domain_name}"]
        }
      }
      prometheus = {
        prometheusSpec = {
          retention            = "15d"
          storageSpec = {
            volumeClaimTemplate = {
              spec = {
                storageClassName = "gp3"
                accessModes      = ["ReadWriteOnce"]
                resources = {
                  requests = { storage = "50Gi" }
                }
              }
            }
          }
        }
      }
      alertmanager = {
        alertmanagerSpec = {
          storage = {
            volumeClaimTemplate = {
              spec = {
                storageClassName = "gp3"
                accessModes      = ["ReadWriteOnce"]
                resources = {
                  requests = { storage = "10Gi" }
                }
              }
            }
          }
        }
      }
    })
  ]
}

External Secrets: Sincronizando Secrets do AWS Secrets Manager

Com o External Secrets Operator instalado, secrets do AWS Secrets Manager são automaticamente sincronizados como Kubernetes Secrets — sem armazená-los em repositórios Git ou gerenciá-los manualmente:

# kubernetes/external-secrets/cluster-secret-store.yml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets
---
# kubernetes/external-secrets/minha-api-secrets.yml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: minha-api-secrets
  namespace: producao
spec:
  refreshInterval: 1h

  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore

  target:
    name: minha-api-secrets
    creationPolicy: Owner
    template:
      type: Opaque

  data:
    - secretKey: database-url
      remoteRef:
        key: minha-api/producao
        property: DATABASE_URL

    - secretKey: redis-url
      remoteRef:
        key: minha-api/producao
        property: REDIS_URL

    - secretKey: jwt-secret
      remoteRef:
        key: minha-api/producao
        property: JWT_SECRET

Deploy no EKS via GitHub Actions

# .github/workflows/deploy-eks.yml
name: Deploy no EKS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production

    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v4

      - name: Configura credenciais AWS via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1

      - name: Login no ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Constrói e publica imagem
        id: build
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/minha-api:$IMAGE_TAG .
          docker push $ECR_REGISTRY/minha-api:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/minha-api:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Configura kubectl
        run: |
          aws eks update-kubeconfig \
            --name minha-api-production \
            --region us-east-1

      - name: Atualiza imagem no Deployment
        run: |
          kubectl set image deployment/minha-api \
            api=${{ steps.build.outputs.image }} \
            -n producao

      - name: Aguarda rollout completar
        run: |
          kubectl rollout status deployment/minha-api \
            -n producao \
            --timeout=300s

      - name: Verifica pods saudáveis
        run: |
          kubectl get pods -n producao -l app=minha-api

          READY=$(kubectl get deployment minha-api -n producao \
            -o jsonpath='{.status.readyReplicas}')
          DESIRED=$(kubectl get deployment minha-api -n producao \
            -o jsonpath='{.spec.replicas}')

          if [ "$READY" != "$DESIRED" ]; then
            echo "ERRO: $READY/$DESIRED pods prontos"
            kubectl describe deployment minha-api -n producao
            exit 1
          fi

          echo "Deploy concluído: $READY/$DESIRED pods saudáveis"

StorageClass com EBS CSI Driver

Para workloads stateful — bancos de dados, filas, caches — o EBS CSI Driver provê volumes persistentes via EBS:

# kubernetes/storage/storage-class.yml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
parameters:
  type: gp3
  iops: "3000"
  throughput: "125"
  encrypted: "true"
  kmsKeyId: arn:aws:kms:us-east-1:123456789:key/abc-def

O Que Vem a Seguir

O próximo artigo encerra o tema abordando as práticas avançadas de Kubernetes em produção — segurança com Pod Security Standards, GitOps com ArgoCD para deploys declarativos, e estratégias de deploy avançadas como Blue-Green e Canary no contexto do Kubernetes.

Referências para Aprofundamento

Documentação oficial

Módulos Terraform

Ferramentas do ecossistema

Exercícios

Exercício 1

O Deployment que usa IRSA declara serviceAccountName: minha-api e nenhuma credencial AWS. Um desenvolvedor cria um novo Deployment no namespace producao, copia o container mas esquece a linha serviceAccountName. O pod sobe normalmente e as chamadas ao S3 funcionam em staging, mas falham em produção com AccessDenied. Explique o que está acontecendo — e por que "funcionar" seria pior do que falhar.

Ver resposta

✓ Resposta: Sem serviceAccountName, o pod usa a ServiceAccount default do namespace — que não tem a annotation eks.amazonaws.com/role-arn. Sem essa annotation, o webhook do EKS não injeta o token OIDC nem as variáveis AWS_ROLE_ARN/AWS_WEB_IDENTITY_TOKEN_FILE, e a cadeia de credenciais do SDK não encontra o IRSA. Ela então segue para o próximo elo: o IMDS do nó, assumindo o instance profile do node group. O AccessDenied vem daí — a role do nó não tem permissão no bucket.

E é justamente por isso que funcionar seria pior. Se a role do node group tivesse a permissão (algo comum em clusters que herdaram políticas amplas), o pod acessaria o S3 normalmente e ninguém notaria. Só que a partir daí todo pod naquele nó teria as mesmas permissões, independentemente da service account — o isolamento por workload que o IRSA existe para dar simplesmente não estaria valendo. O erro só apareceria muito depois, num incidente de segurança.

A diferença entre os ambientes é o sintoma clássico dessa confusão: staging "funciona" porque a role dos nós é mais permissiva ali. Duas defesas contra o problema: restringir a role do node group ao mínimo (CNI, ECR pull, logs) e bloquear o acesso dos pods ao IMDS — com httpPutResponseHopLimit = 1 nas instâncias, o metadata service deixa de ser alcançável de dentro do container.

Exercício 2

O node group gpu tem min_size = 0 e desired_size = 0 — nenhum nó rodando — e um taint nvidia.com/gpu=true:NO_SCHEDULE. Se o taint impede o agendamento e não há nós, para que serve esse node group? O que um pod de inferência precisa declarar para de fato rodar nele?

Ver resposta

✓ Resposta: O node group serve como capacidade sob demanda a custo zero quando ociosa. Instâncias g4dn.xlarge são caras para ficar ligadas sem uso; com desired_size = 0, nada é cobrado até que um pod exija GPU. Aí o autoscaler sobe um nó (até max_size = 5) e o derruba quando a carga acaba. O capacity_type = "SPOT" reduz mais ainda o custo, aceitando em troca interrupções com dois minutos de aviso — tolerável para inferência em lote, arriscado para requisição síncrona.

O taint não impede o uso: ele reserva esses nós. Sem ele, assim que um nó GPU subisse, qualquer pod comum sem destino poderia ser agendado nele — ocupando uma máquina cara sem tocar na GPU. O taint garante que só entra quem declarar explicitamente que quer.

O pod precisa de três coisas — as duas primeiras são frequentemente confundidas:

spec:
  nodeSelector:                 # 1. atrai: escolhe o nó com esse label
    role: gpu
  tolerations:                  # 2. permite: aceita o taint do nó
    - key: nvidia.com/gpu
      value: "true"
      effect: NoSchedule
  containers:
    - name: inferencia
      resources:
        limits:
          nvidia.com/gpu: 1     # 3. reserva a GPU de fato

A toleration sozinha apenas permite o agendamento — não direciona nada; sem o nodeSelector o pod pode acabar em um nó comum. E o limits: nvidia.com/gpu: 1 é o que reserva o dispositivo: sem ele o container sobe no nó GPU mas não enxerga a placa. Esse recurso só existe se o NVIDIA device plugin estiver instalado no cluster, expondo a GPU como recurso agendável.

Exercício 3

A StorageClass gp3 usa volumeBindingMode: WaitForFirstConsumer. O modo alternativo, Immediate, cria o volume assim que o PVC é criado — o que parece mais rápido e mais simples. Que falha o WaitForFirstConsumer evita em um cluster com nós espalhados por várias AZs?

Ver resposta

✓ Resposta: Evita o pod ficar permanentemente Pending por causa de uma incompatibilidade de zona. Volumes EBS são zonais: um volume criado em us-east-1a só pode ser montado por uma instância em us-east-1a.

Com Immediate, o EBS CSI Driver provisiona o volume no instante em que o PVC aparece — antes de existir qualquer pod, e portanto sem saber onde ele será agendado. A zona é escolhida sem essa informação. Quando o scheduler finalmente for posicionar o pod, ele fica restrito à AZ do volume; se ali não houver nó com recursos livres (ou se o Karpenter tiver consolidado os nós daquela zona), o pod não é agendado — e não adianta haver capacidade sobrando nas outras AZs. O volume "prende" o pod a uma zona escolhida às cegas.

Com WaitForFirstConsumer, a ordem se inverte: o PVC fica em Pending até que um pod o consuma; o scheduler escolhe o nó considerando recursos, afinidades e taints; só então o volume é criado, já na zona certa. A decisão de agendamento passa a mandar no armazenamento, e não o contrário.

Vale notar o efeito colateral, que costuma assustar quem vê pela primeira vez: um PVC criado sem nenhum pod associado fica indefinidamente em Pending — e isso é o comportamento esperado, não um erro. O allowVolumeExpansion: true resolve outro problema, independente: permite aumentar o volume depois, editando o PVC, sem recriar nada.

Exercício 4

O NodePool do Karpenter define consolidationPolicy: WhenUnderutilized com consolidateAfter: 30s, e expireAfter: 720h. Um time relata que, em horários de baixa demanda, seus pods são reiniciados várias vezes sem que ninguém tenha feito deploy. Explique a causa e diga o que falta configurar.

Ver resposta

✓ Resposta: É a consolidação do Karpenter funcionando exatamente como configurada. Com WhenUnderutilized, ele procura continuamente por nós subutilizados cujos pods caibam em outros nós já existentes (ou em um nó menor e mais barato). Ao encontrar, faz o cordon, despeja os pods e termina a instância. Em baixa demanda é precisamente quando há mais nós ociosos — daí a correlação com o horário. O consolidateAfter: 30s torna o gatilho bem agressivo: meio minuto de subutilização já basta.

O expireAfter: 720h soma um segundo motivo de reciclagem: todo nó é substituído após 30 dias, para que o cluster não acumule instâncias com AMIs antigas e sem patches. É desejável do ponto de vista de segurança, e igualmente invisível para quem só observa os pods reiniciando.

O que falta é um PodDisruptionBudget. Ele é o contrato que limita quantos pods de um workload podem ficar indisponíveis simultaneamente em despejos voluntários — e a consolidação do Karpenter respeita PDBs:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: minha-api
  namespace: producao
spec:
  minAvailable: 2          # ou maxUnavailable: 1
  selector:
    matchLabels:
      app: minha-api

Com isso o Karpenter continua consolidando, mas de forma escalonada, sem nunca derrubar réplicas demais ao mesmo tempo. Duas ressalvas: um PDB mal calibrado (minAvailable igual ao número de réplicas) bloqueia a consolidação por completo e trava também os upgrades do cluster; e um workload que realmente não pode ser movido deve usar a annotation karpenter.sh/do-not-disrupt: "true" no pod — que exclui aquele nó da consolidação enquanto o pod existir.

Exercício 5

No workflow de deploy, o passo kubectl rollout status --timeout=300s falha porque a imagem nova tem um bug e nenhum pod fica ready. O job do GitHub Actions termina em vermelho. Descreva em que estado o cluster fica nesse momento — e se os usuários estão sendo afetados.

Ver resposta

✓ Resposta: Os usuários não são afetados, e nada é revertido — o cluster fica travado no meio do rollout.

Como o Deployment usa maxUnavailable: 0, o controlador só remove um pod antigo depois que um pod novo passa no readinessProbe. Como nenhum passa, nenhum pod antigo é removido: as três réplicas da versão anterior continuam no ar servindo tráfego normalmente. Ao lado delas fica o pod novo permitido pelo maxSurge: 1, em CrashLoopBackOff ou nunca ficando ready — e fora do Service, porque o readiness reprova. É o rollout progressivo protegendo a produção: a versão quebrada nunca recebe uma requisição real.

O ponto importante é que o --timeout apenas encerra a espera do comando; ele não cancela nem reverte coisa alguma. O spec do Deployment continua apontando para a imagem defeituosa, e o controlador segue tentando indefinidamente. O job vermelho é só o mensageiro. Enquanto ninguém intervier, o cluster permanece assim — o que é seguro para os usuários, mas bloqueia todos os deploys seguintes, inclusive o do commit que corrige o bug.

Para reverter é preciso um passo explícito, que o workflow não tem:

      - name: Rollback em caso de falha
        if: failure()
        run: |
          kubectl rollout undo deployment/minha-api -n producao
          kubectl rollout status deployment/minha-api -n producao --timeout=120s

Um detalhe que sustenta esse rollback: o Deployment do artigo anterior traz a annotation kubernetes.io/change-cause, que é o que dá conteúdo útil ao kubectl rollout history — sem ela, o histórico lista revisões sem dizer o que cada uma mudou.

Comentários

Mais em DevOps

Introdução ao Kubernetes: Orquestrando Containers em Escala
Introdução ao Kubernetes: Orquestrando Containers em Escala

A orquestração de containers além de uma única máquina: os componentes do…

Route53, CloudFront e ACM: Rede e Entrega de Conteúdo
Route53, CloudFront e ACM: Rede e Entrega de Conteúdo

A camada de entrega que fica entre o usuário e a aplicação: hosted zones e…

Bitbucket e o Ecossistema Atlassian
Bitbucket e o Ecossistema Atlassian

A plataforma que não compete pelo melhor repositório Git, e sim por conversar…