Voltar para o blog
Publicado em 23 de setembro de 2026

Cortes inteligentes na nuvem: Spot, Karpenter e Kubecost sem comprometer cargas críticas

FinOpsKubernetesKarpenterKubecostSpotSRE

Este é o guia que eu queria ter tido antes da primeira vez que configurei Spot em um cluster de produção. Cobre o caminho completo — do diagnóstico de onde o dinheiro está sendo desperdiçado até os YAMLs exatos que garantem que uma instância Spot sendo retomada pela AWS não vire um incidente de madrugada. Cada bloco de configuração abaixo é comentado linha a linha porque a maioria dos incidentes que vejo com essas ferramentas não vem delas serem ruins — vem de alguém copiar um YAML de um blog sem entender o que cada campo faz.

1. Introdução e o dilema das cargas em Kubernetes

Todo cluster EKS de produção vive uma tensão constante: alta disponibilidade pede capacidade reservada, redundância entre zonas e margem de sobra para picos — tudo isso custa dinheiro parado a maior parte do tempo. Eficiência financeira pede o oposto: usar só o que é necessário, aproveitar capacidade mais barata (Spot) e consolidar workloads em menos nós. Tratar isso como dois times em guerra — engenharia de confiabilidade de um lado, FinOps do outro — é o erro mais comum. Os dois objetivos convivem bem quando o desenho reconhece que nem toda carga tem o mesmo perfil de risco. Uma API que atende cliente pagante em tempo real não é a mesma coisa que um worker de fila assíncrona que pode esperar 2 minutos e tentar de novo. O resto deste guia é, no fundo, sobre como classificar corretamente cada carga e aplicar a ferramenta certa para o perfil dela.

2. Diagnóstico de desperdício com Kubecost

Antes de qualquer otimização, é preciso um número de referência. Sem isso, qualquer "economia" reportada depois é uma opinião, não um fato. O Kubecost (ou uma alternativa equivalente como OpenCost) instrumenta o cluster para atribuir custo real de CPU, memória e armazenamento a cada namespace, deployment ou label — usando os preços reais da conta AWS (incluindo desconto de Reserved Instances e Savings Plans já aplicados), não uma estimativa genérica.

Alocação de custo por namespace e deployment

A primeira pergunta que o Kubecost responde é simples e quase sempre reveladora: "quais 20% dos workloads respondem por 80% do gasto?". Na prática, times descobrem que um namespace de staging esquecido, um job de processamento que nunca foi desligado, ou um deployment com réplicas excessivas "por segurança" respondem por uma fatia desproporcional da conta — antes de qualquer discussão sobre Spot ou Karpenter.

Requests vs. limits vs. uso real

O relatório de "efficiency" do Kubecost cruza três números por pod: o que foi requested (o que o Kubernetes reserva e cobra do node), o limit (o teto antes de throttle/OOMKill) e o uso real observado. Um request de 2 vCPU num processo que usa em média 300m é capacidade paga e nunca usada — e é o tipo de desperdício que nenhuma estratégia de Spot ou consolidação de nó resolve, porque o problema está no dimensionamento do pod, não na infraestrutura embaixo dele.

Alocação de custos compartilhados

Nem todo custo é atribuível a um único time: o control plane do EKS, DaemonSets de observabilidade (Datadog agent, Fluent Bit) e a capacidade ociosa entre pods no mesmo nó são custos compartilhados. O Kubecost distribui isso por métodos configuráveis — proporcional ao uso, dividido igualmente, ou atribuído a um workload específico — e essa escolha importa: sem ela, times cost-conscious acabam "pagando" pelo desperdício de outro time que nunca ajustou seus requests.

3. Autoscaling de próxima geração com Karpenter

Com o desperdício de requests corrigido, a próxima camada é a infraestrutura que sobe e desce em torno dessa demanda real. O Karpenter substitui o modelo de Auto Scaling Groups fixos por dois recursos customizados: o NodePool (o que pode ser provisionado e como ele se comporta) e o EC2NodeClass (os detalhes específicos da AWS — AMI, subnets, security groups, disco).

NodePool completo e comentado

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    metadata:
      labels:
        workload-tier: general
    spec:
      # nodeClassRef aponta para o EC2NodeClass que define AMI, subnets,
      # security groups e o resto da configuração específica da AWS.
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default

      # requirements funcionam como um seletor: definem quais tipos de
      # instância, arquitetura e capacity-type o Karpenter pode escolher
      # para atender aos pods pendentes. Quanto mais aberto, melhor o
      # bin-packing (mais opções = decisão mais eficiente).
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: kubernetes.io/os
          operator: In
          values: ["linux"]
        # Aceita tanto Spot quanto On-Demand — a decisão de qual pod vai
        # para qual capacity-type é feita via taint/affinity (seção 4),
        # não aqui. Isso mantém um único NodePool flexível.
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["4"]

      # Taint aplicado a TODO nó criado por este NodePool. Só pods com a
      # toleration correspondente conseguem ser agendados aqui — isso
      # evita que workloads não preparadas para Spot caiam por acidente
      # num nó que pode ser retomado pela AWS a qualquer momento.
      taints:
        - key: karpenter.sh/capacity-type
          value: spot
          effect: NoSchedule

      # Rotação forçada: nenhum nó vive mais que 30 dias, mesmo que
      # esteja saudável. Isso limita blast radius de drift de patch de
      # segurança e evita "nós de estimação" que ninguém lembra de onde
      # vieram.
      expireAfter: 720h

  # limits.cpu funciona como um teto de segurança: o Karpenter nunca
  # provisiona além disso neste NodePool, mesmo sob demanda de scale-up
  # legítima — protege contra bugs de aplicação que geram scale-up
  # infinito e uma conta surpreendente no fim do mês.
  limits:
    cpu: 1000
    memory: 4000Gi

  disruption:
    # WhenEmptyOrUnderutilized é o nome atual (Karpenter v1 GA) da
    # política que, em versões anteriores (v1beta1), era chamada de
    # WhenUnderutilized. Ela permite consolidação tanto de nós vazios
    # quanto de nós subutilizados que podem ser esvaziados e removidos
    # reagrupando os pods em menos nós.
    consolidationPolicy: WhenEmptyOrUnderutilized
    # Tempo de espera antes de agir sobre um nó candidato à consolidação
    # — evita "flapping" de criar/destruir nó em cargas com pico curto.
    consolidateAfter: 1m

EC2NodeClass completo e comentado

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  # AL2023 é a AMI recomendada atualmente para Karpenter/EKS — imagem
  # enxuta, com boot mais rápido, o que ajuda diretamente no tempo de
  # provisionamento de nós Spot em picos de demanda.
  amiFamily: AL2023
  amiSelectorTerms:
    - alias: al2023@latest

  # IAM role que os nós vão assumir — precisa ter as policies mínimas de
  # nó de EKS (AmazonEKSWorkerNodePolicy, AmazonEC2ContainerRegistryReadOnly,
  # AmazonEKS_CNI_Policy) e nada além disso (princípio do menor privilégio).
  role: "KarpenterNodeRole-prod-cluster"

  # subnetSelectorTerms e securityGroupSelectorTerms usam tags para
  # descobrir recursos automaticamente — evita hardcode de subnet-id/sg-id
  # que quebra silenciosamente quando a infraestrutura é recriada.
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-cluster"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-cluster"

  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 60Gi
        volumeType: gp3
        # gp3 com throughput e iops explícitos custa menos que gp2
        # equivalente e evita throttling silencioso sob I/O pesado.
        throughput: 150
        iops: 3000
        encrypted: true
        deleteOnTermination: true

  # Metadata options mais restritivas (IMDSv2 obrigatório, hop limit 2
  # para permitir pods acessarem via hostNetwork quando necessário, mas
  # sem abrir IMDS para qualquer container por padrão).
  metadataOptions:
    httpEndpoint: enabled
    httpTokens: required
    httpPutResponseHopLimit: 2

  tags:
    team: platform
    environment: production
    managed-by: karpenter

Consolidação, expireAfter e gerenciamento de drift

  • Consolidação: com consolidationPolicy: WhenEmptyOrUnderutilized, o Karpenter monitora continuamente se os pods de um conjunto de nós cabem em menos nós (ou em nós mais baratos) e migra a carga automaticamente, respeitando PodDisruptionBudgets.
  • expireAfter: força a substituição de um nó após um tempo máximo de vida, independentemente de utilização — a forma mais simples de garantir que nenhum nó rode uma AMI ou kernel desatualizado por meses.
  • Drift: quando o NodePool ou o EC2NodeClass são alterados (por exemplo, uma nova AMI ou uma mudança de subnet), o Karpenter detecta que os nós existentes "driftaram" da especificação atual e os substitui gradualmente — sem exigir um processo manual de rolling replace do cluster inteiro.

4. Estratégia segura e resiliente com instâncias Spot

Spot custa até 90% menos que On-Demand pelo mesmo tipo de instância, mas vem com uma contrapartida clara: a AWS pode retomá-la a qualquer momento, avisando com cerca de 2 minutos de antecedência. Toda a engenharia desta seção existe para que esses 2 minutos sejam suficientes.

Taints e tolerations

Um taint no NodePool Spot (como no YAML da seção 3) impede que qualquer pod sem a toleration correspondente seja agendado ali por acidente. Isso inverte o modelo de risco: em vez de confiar que ninguém vai esquecer de configurar uma exceção, o padrão seguro é "não roda em Spot a menos que a aplicação declare explicitamente que tolera".

Node affinity e topologySpreadConstraints

Node affinity do tipo preferred (soft) permite pedir Spot como preferência sem travar o pod caso não haja capacidade Spot disponível naquele momento — ele cai para On-Demand em vez de ficar pendente. topologySpreadConstraints garante que réplicas do mesmo workload fiquem distribuídas entre zonas de disponibilidade, o que reduz o impacto de uma interrupção em massa, já que ela costuma atingir um tipo de instância específico numa AZ específica, não o cluster inteiro de uma vez.

PodDisruptionBudgets para drenagens voluntárias

É importante separar dois cenários que parecem iguais mas não são: uma drenagem voluntária (o Karpenter consolidando nós, ou um upgrade de versão do Kubernetes) respeita o PodDisruptionBudget e nunca derruba mais réplicas do que o permitido. Já uma interrupção involuntária de Spot é a AWS retomando a instância — o PDB não impede isso, porque não é uma ação iniciada pelo cluster. É por isso que PDB sozinho não é suficiente: ele protege contra manutenção planejada, não contra a natureza do Spot em si.

Graceful shutdown e o handling nativo de interrupção do Karpenter

Historicamente, o AWS Node Termination Handler (NTH) era o projeto usado para reagir a avisos de interrupção Spot. Ao adotar Karpenter, isso passa a ser nativo: o Karpenter observa uma fila SQS alimentada por regras do EventBridge para os eventos EC2 Spot Interruption Warning, Instance Rebalance Recommendation e Instance State-change Notification, e já inicia o cordon/drain do nó afetado assim que o aviso chega — sem precisar rodar o NTH como um componente separado. O que fica sob responsabilidade da aplicação é o preStop hook e um terminationGracePeriodSeconds realista, para que o processo tenha tempo de fechar conexões e confirmar mensagens em andamento antes do SIGKILL.

Deployment completo com tolerations e node affinity

apiVersion: apps/v1
kind: Deployment
metadata:
  name: batch-worker
  namespace: workloads
spec:
  replicas: 6
  selector:
    matchLabels:
      app: batch-worker
  template:
    metadata:
      labels:
        app: batch-worker
    spec:
      # Toleration correspondente ao taint do NodePool Spot — sem isso,
      # o pod nunca seria agendado em nenhum nó Spot deste cluster.
      tolerations:
        - key: karpenter.sh/capacity-type
          operator: Equal
          value: spot
          effect: NoSchedule

      affinity:
        nodeAffinity:
          # preferredDuringScheduling (soft) — o scheduler tenta colocar
          # em Spot primeiro, mas cai para On-Demand se não houver
          # capacidade Spot disponível no momento, em vez de a aplicação
          # ficar pendente esperando.
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              preference:
                matchExpressions:
                  - key: karpenter.sh/capacity-type
                    operator: In
                    values: ["spot"]

      # topologySpreadConstraints espalha as réplicas entre zonas de
      # disponibilidade diferentes — importante em Spot porque uma
      # interrupção em massa tende a acontecer por tipo de instância
      # dentro de uma AZ específica, não no cluster inteiro.
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: batch-worker

      # terminationGracePeriodSeconds alinhado à janela real de aviso de
      # interrupção Spot da AWS (~2 minutos) menos uma margem de
      # segurança para o processo de shutdown realmente terminar antes
      # do SIGKILL.
      terminationGracePeriodSeconds: 100
      containers:
        - name: batch-worker
          image: registry.internal/batch-worker:1.4.2
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "1Gi"
          # preStop dá tempo do processo drenar filas/conexões em
          # andamento antes do pod ser removido durante uma interrupção
          # ou uma consolidação voluntária do Karpenter.
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 15"]

PodDisruptionBudget completo

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: batch-worker-pdb
  namespace: workloads
spec:
  # minAvailable garante que, mesmo durante uma drenagem voluntária
  # (consolidação do Karpenter, upgrade de nó, ou node draining manual),
  # pelo menos 4 das 6 réplicas continuam de pé. Isso NÃO protege contra
  # a interrupção involuntária de Spot em si — PDB só governa drenagens
  # voluntárias iniciadas pelo cluster.
  minAvailable: 4
  selector:
    matchLabels:
      app: batch-worker

5. Arquitetura prática e padrões de deployment

Na prática, a forma mais segura de aplicar tudo isso é ter pelo menos dois NodePools claramente separados:

  • critical-on-demand: sem taint de Spot, requirements restritos a capacity-type: on-demand, para APIs voltadas ao cliente, bancos de dados self-managed e qualquer coisa latência-crítica sem redundância suficiente para absorver uma interrupção.
  • general-purpose (Spot): o NodePool da seção 3, para workers assíncronos, jobs em lote, ambientes de CI/build, e réplicas extras de serviços que já têm redundância real entre zonas.

A regra de bolso que uso para classificar uma carga como candidata a Spot: ela sobrevive a perder uma réplica com ~2 minutos de aviso, sem intervenção manual, sem perda de dados e sem violar um SLA já assumido com um cliente? Se a resposta for sim, ela pertence ao NodePool Spot. Se for "depende" ou "não sei", ela fica em On-Demand até essa resposta ficar clara.

6. Estudo de caso real de FinOps

Um exemplo concreto de como essas decisões de arquitetura se traduzem em economia real: a migração da camada de banco de dados PostgreSQL de instâncias gerenciadas no Amazon RDS para StackGres operando dentro do próprio cluster Kubernetes, com automação de failover, alta disponibilidade e monitoramento próprios substituindo a camada gerenciada da AWS. Diferente da estratégia de Spot (que atua na camada de compute genérico), essa mudança atacou diretamente o custo de uma camada gerenciada cara mantendo os requisitos de confiabilidade — o resultado foi uma redução de aproximadamente US$ 18 mil por mês em custos de infraestrutura, sem abrir mão de disponibilidade ou performance. É o mesmo princípio deste guia aplicado em outra camada: medir onde o dinheiro realmente está indo, e não assumir que "gerenciado pela AWS" é sinônimo de "mais barato".

7. Checklist de implementação e métricas de ROI

  1. Instrumentar antes de otimizar: Kubecost (ou equivalente) rodando e com pelo menos duas semanas de dados históricos antes de qualquer mudança — sem isso não existe "antes e depois" para medir ROI.
  2. Corrigir requests/limits nos workloads com maior gap entre reservado e usado, identificados no relatório de efficiency.
  3. Classificar workloads em critical (On-Demand) vs. tolerante a interrupção (Spot), documentando o critério usado — isso vira input direto para os NodePools.
  4. Implantar Karpenter com NodePools separados, taints/tolerations e limits de segurança por NodePool.
  5. Configurar PDB em todo Deployment com mais de uma réplica antes de habilitar consolidação agressiva.
  6. Validar a fila de interrupção (SQS + EventBridge) do Karpenter com um teste controlado antes de confiar nela em produção.
  7. Medir de novo depois de 2-4 semanas: custo por namespace, taxa de interrupção Spot observada, e se algum incidente foi causado por uma carga classificada incorretamente como tolerante.

O ROI dessa iniciativa raramente vem de um único corte grande — vem da soma de dimensionamento correto de requests, consolidação de nós ociosos, e uso de Spot onde ele é seguro. Times que tentam pular direto para "ativar Spot em tudo" sem os passos 1 a 3 são exatamente os que acabam com um incidente de produção e uma desconfiança permanente da ferramenta — quando o problema nunca foi o Spot, foi a ordem em que ele foi adotado.

Se você está considerando essa jornada e quer validar a arquitetura antes de rodar em produção — principalmente a parte de classificação de workloads e configuração de PDB — um diagnóstico técnico direcionado custa muito menos do que aprender isso via incidente.