Karpenter no EKS com Terraform: Autoscaling de Nós com Spot

O Karpenter cria nós no EKS na hora em que um pod fica sem lugar, escolhendo o tipo de instância que cabe naquele pod, e remove os nós que sobram. Não há grupo de tamanho fixo para ajustar: você descreve o que é aceitável (famílias, arquitetura, Spot ou On-Demand) e ele decide o resto. Com Spot liberado, o mesmo cluster passa a rodar boa parte das cargas com desconto grande sobre o preço sob demanda.

Este é o sétimo artigo da série artigos-eks. O cluster é o mesmo do primeiro artigo, mas o papel do node group muda: ele fica pequeno e fixo, só para o sistema. Quem escala é o Karpenter, com a permissão de AWS vinda do EKS Pod Identity e os avisos de interrupção do Spot chegando por uma fila SQS.

Segurança: os comandos deste guia usam o perfil do AWS CLI para autenticar. Nunca coloque access keys no código ou em .tfvars versionado; prefira aws sso login ou um perfil com credenciais temporárias.

Sumário

Pré-requisitos

Os mesmos do artigo anterior: AWS CLI v2, Terraform >= 1.5, kubectl e os providers hashicorp/aws ~> 6.0 e hashicorp/helm ~> 3.0. O cluster nasce na 1.36. A versão do Karpenter usada é a 1.13.1, a correção mais recente da série 1.13. Para listar as versões do chart:

helm show chart oci://public.ecr.aws/karpenter/karpenter --version 1.13.1

Para usar Spot, a conta precisa da service-linked role do EC2 Spot. Ela é criada uma vez por conta. Se o comando responder que a role já existe, está tudo certo:

aws iam create-service-linked-role --aws-service-name spot.amazonaws.com

Como o Karpenter funciona

O Karpenter observa os pods pendentes. Quando um pod não cabe em nenhum nó, ele soma o que os pendentes pedem: CPU, memória, zona e arquitetura. Depois escolhe a instância mais adequada dentro das regras do NodePool e chama a API do EC2 direto, sem Auto Scaling Group no meio. O nó entra no cluster em cerca de um minuto.

Na volta, ele faz o caminho inverso: nó vazio, ou com pods que caberiam em outros nós, é drenado e encerrado. Esse processo se chama consolidação. Dois objetos do Kubernetes governam tudo:

  • EC2NodeClass: o lado AWS. AMI, subnets, security groups e a IAM role dos nós.
  • NodePool: o lado Kubernetes. Quais instâncias são aceitas, limites de capacidade e regras de remoção.

Se você já usou node groups com Spot, como no artigo de Managed Node Groups, a diferença é que lá você escolhe a lista de instâncias antes. Aqui o Karpenter escolhe na hora, a partir do que os pods precisam.

Node group só para o sistema

O controller do Karpenter não pode rodar num nó que ele mesmo gerencia. Se esse nó fosse consolidado, o controller cairia junto e ninguém criaria o próximo nó. Por isso o node group gerenciado continua existindo, só que pequeno e fixo, para o Karpenter, o CoreDNS e outros controllers:

module "ng_system" {
  source = "../../terraform-eks-node-group-modules"

  cluster_name   = module.eks.name
  name           = "system"
  subnet_ids     = module.vpc.private_subnet_ids
  instance_types = ["t3.medium"]

  min_size     = 2
  desired_size = 2
  max_size     = 2

  labels = { "node-role" = "system" }
}

O chart do Karpenter já vem com uma regra de afinidade que impede o controller de subir em nós criados pelo próprio Karpenter. Com dois nós no node group e duas réplicas do controller, uma réplica sobrevive à perda de um nó.

Tags de descoberta do Karpenter

O EC2NodeClass encontra subnets e security groups por tag. A convenção é karpenter.sh/discovery = <nome do cluster>. Nas subnets privadas, a tag entra pelo módulo da VPC. No security group que o EKS cria para o cluster, que não é gerenciado pelo Terraform, ela entra com um aws_ec2_tag:

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

  name               = "vpc-${var.cluster_name}"
  cidr_block         = var.vpc_cidr
  az_count           = 2
  single_nat_gateway = true

  private_subnet_tags = {
    "karpenter.sh/discovery" = var.cluster_name
  }
}

resource "aws_ec2_tag" "cluster_sg_discovery" {
  resource_id = module.eks.cluster_security_group_id
  key         = "karpenter.sh/discovery"
  value       = var.cluster_name
}

Só as subnets privadas recebem a tag. Assim os nós do Karpenter nunca nascem numa subnet pública, mesmo que alguém esqueça de restringir isso no EC2NodeClass.

O módulo do Karpenter

O projeto publica, no guia de primeiros passos, um template CloudFormation com tudo o que o Karpenter precisa na AWS. O módulo terraform-eks-karpenter-modules é a tradução desse template para Terraform, na mesma versão do chart. No ambiente, a chamada é curta:

module "karpenter" {
  source = "../../terraform-eks-karpenter-modules"

  cluster_name  = module.eks.name
  chart_version = var.karpenter_chart_version

  depends_on = [module.addons]
}

Dentro dele há quatro blocos. O primeiro é a role dos nós, com as mesmas políticas de um node group comum, mais uma access entry do tipo EC2_LINUX. Sem ela, a instância sobe mas não consegue se registrar no cluster. O tipo EC2_LINUX faz o EKS colocar o nó no grupo certo sozinho, sem política de acesso para associar:

resource "aws_eks_access_entry" "node" {
  cluster_name  = var.cluster_name
  principal_arn = aws_iam_role.node.arn
  type          = "EC2_LINUX"
  tags          = var.tags
}

O segundo é a role do controller, via Pod Identity. A política fica num template (controller-policy.json.tftpl) com 18 statements copiados do template oficial. As permissões de criar e apagar instâncias valem só para recursos com as tags do cluster e de um NodePool. Assim o Karpenter não consegue encerrar uma instância que não criou:

module "pod_identity" {
  source = "../terraform-eks-pod-identity-modules"

  cluster_name = var.cluster_name
  role_name    = "KarpenterController-${var.cluster_name}"

  service_accounts = [
    { namespace = var.namespace, name = var.service_account_name },
  ]

  inline_policy_json = jsonencode(jsondecode(templatefile("${path.module}/controller-policy.json.tftpl", {
    partition     = local.partition
    region        = local.region
    account_id    = local.account_id
    cluster_name  = var.cluster_name
    node_role_arn = aws_iam_role.node.arn
    queue_arn     = aws_sqs_queue.interruption.arn
  })))

  tags = var.tags
}

O jsonencode(jsondecode(...)) só compacta o JSON. Política inline tem limite de tamanho por role, e a versão compacta fica em cerca de 6 mil caracteres, dentro do limite de 10.240.

O terceiro bloco é a fila de interrupção, explicada na próxima seção. O quarto é o chart, instalado no kube-system a partir do registro OCI público:

resource "helm_release" "this" {
  name       = "karpenter"
  repository = "oci://public.ecr.aws/karpenter"
  chart      = "karpenter"
  version    = var.chart_version
  namespace  = var.namespace

  set = [
    { name = "settings.clusterName", value = var.cluster_name },
    { name = "settings.interruptionQueue", value = aws_sqs_queue.interruption.name },
    { name = "serviceAccount.name", value = var.service_account_name },
    { name = "replicas", value = tostring(var.replicas) },
    { name = "controller.resources.requests.cpu", value = "500m" },
    { name = "controller.resources.requests.memory", value = "512Mi" },
    { name = "controller.resources.limits.memory", value = "512Mi" },
  ]

  depends_on = [
    module.pod_identity,
    aws_eks_access_entry.node,
    aws_cloudwatch_event_target.interruption,
  ]
}

Interrupção do Spot via SQS

Uma instância Spot pode ser retomada pela AWS com aviso de dois minutos. Se o Karpenter souber disso a tempo, ele cria o substituto e drena o nó antes de a instância sumir. O aviso chega pelo EventBridge, que o entrega numa fila SQS lida pelo controller. O módulo cria a fila e cinco regras:

interruption_events = {
  scheduled-change     = { source = "aws.health", detail_type = "AWS Health Event" }
  spot-interruption    = { source = "aws.ec2", detail_type = "EC2 Spot Instance Interruption Warning" }
  rebalance            = { source = "aws.ec2", detail_type = "EC2 Instance Rebalance Recommendation" }
  instance-state       = { source = "aws.ec2", detail_type = "EC2 Instance State-change Notification" }
  capacity-reservation = { source = "aws.ec2", detail_type = "EC2 Capacity Reservation Instance Interruption Warning" }
}

Além do Spot, as regras cobrem manutenção agendada da AWS, recomendação de rebalanceamento e instâncias paradas ou encerradas por fora. A fila tem retenção de 5 minutos (aviso velho não serve para nada), criptografia gerenciada pelo SQS e uma política que só aceita mensagens do EventBridge e nega qualquer acesso sem TLS. O nome da fila é passado ao chart em settings.interruptionQueue.

terraform plan

terraform init
terraform validate
terraform plan -var 'public_access_cidrs=["SEU_IP/32"]' -out=tfplan

O código deste artigo foi validado com terraform validate e terraform plan contra uma conta AWS real, sem apply. O plan fecha em 51 recursos: os 28 do cluster base, a tag do security group e 22 do Karpenter (roles, access entry, fila, regras e alvos do EventBridge, associação do Pod Identity e o release do Helm):

  # aws_ec2_tag.cluster_sg_discovery will be created
  # module.karpenter.aws_cloudwatch_event_rule.interruption["spot-interruption"] will be created
  # module.karpenter.aws_eks_access_entry.node will be created
  # module.karpenter.aws_iam_role.node will be created
  # module.karpenter.aws_sqs_queue.interruption will be created
  # module.karpenter.helm_release.this will be created
  # module.karpenter.module.pod_identity.aws_eks_pod_identity_association.this["kube-system/karpenter"] will be created
  ...
Plan: 51 to add, 0 to change, 0 to destroy.

Um detalhe que apareceu no primeiro plan: a role dos nós usava o ARN completo das políticas, com a partition, como chave do for_each. Como o módulo é chamado com depends_on, o data source da partition só é lido no apply, e o plan falhou com Invalid for_each argument. A correção foi usar só o nome da política como chave e montar o ARN dentro do recurso.

Outputs do Terraform

Este é o bloco Changes to Outputs do plan. Repare que o nome da role dos nós já é conhecido no plan, porque é montado a partir do nome do cluster:

Changes to Outputs:
  + cluster_name                  = "eks-blog-castilho"
  + interruption_queue_name       = "eks-blog-castilho"
  + karpenter_chart_version       = "1.13.1"
  + karpenter_controller_role_arn = (known after apply)
  + karpenter_node_role_name      = "KarpenterNodeRole-eks-blog-castilho"
  + kubeconfig_command            = "aws eks update-kubeconfig --region us-east-1 --name eks-blog-castilho"
  + kubernetes_version            = "1.36"
  • cluster_name: também é o valor da tag karpenter.sh/discovery usada no EC2NodeClass.
  • kubernetes_version: versão do control plane.
  • karpenter_node_role_name: vai no campo spec.role do EC2NodeClass. Pegue com terraform output -raw karpenter_node_role_name.
  • karpenter_controller_role_arn: role do controller. Depois do apply, terá o formato arn:aws:iam::111122223333:role/KarpenterController-eks-blog-castilho (exemplo, com a conta fictícia da documentação da AWS).
  • interruption_queue_name: a fila SQS. Útil para conferir se as mensagens estão sendo consumidas: aws sqs get-queue-attributes --queue-url $(aws sqs get-queue-url --queue-name eks-blog-castilho --query QueueUrl --output text) --attribute-names ApproximateNumberOfMessages.
  • karpenter_chart_version: versão do chart instalada.
  • kubeconfig_command: configura o kubectl. Rode com $(terraform output -raw kubeconfig_command).

EC2NodeClass e NodePool com Spot

Os dois objetos ficam fora do Terraform, como YAML aplicado com kubectl depois do apply. O motivo é prático: o recurso kubernetes_manifest precisa do cluster existindo já no plan, o que impede criar cluster e manifestos no mesmo ambiente. Salve como karpenter.yaml:

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  role: KarpenterNodeRole-eks-blog-castilho
  amiSelectorTerms:
    - alias: al2023@latest
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: eks-blog-castilho
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: eks-blog-castilho
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]
      expireAfter: 720h
  limits:
    cpu: "100"
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m
    budgets:
      - nodes: "10%"
      - nodes: "0"
        schedule: "0 9 * * mon-fri"
        duration: 8h
  • capacity-type com spot e on-demand: quando os dois são permitidos, o Karpenter prefere Spot e só recorre a On-Demand se não houver capacidade Spot nos tipos aceitos.
  • Categorias c, m, r e geração maior que 5: quanto mais tipos aceitos, maior a chance de achar capacidade Spot e menor a chance de interrupção.
  • alias: al2023@latest: AMI otimizada do EKS com Amazon Linux 2023. O @latest faz nós novos pegarem a AMI mais nova; em produção, fixe a versão da AMI e suba de propósito, para que uma AMI nova não entre em todos os nós sem aviso.
  • limits.cpu: teto de vCPUs que este NodePool pode criar. É a proteção contra um Deployment com réplicas demais virar uma fatura alta.
  • expireAfter: 720h: todo nó é trocado em no máximo 30 dias, o que mantém AMI e patches em dia.

O valor de spec.role é o output karpenter_node_role_name, e o das tags é o nome do cluster. Aplique e confira o estado:

$(terraform output -raw kubeconfig_command)
kubectl apply -f karpenter.yaml
kubectl get ec2nodeclass,nodepool

Consolidação e disruption budgets

O bloco disruption controla quando o Karpenter pode remover nós:

  • WhenEmptyOrUnderutilized: remove nós vazios e também nós subutilizados, quando os pods deles cabem em outros nós ou numa instância menor e mais barata. Com WhenEmpty, só nós sem pods são removidos.
  • consolidateAfter: 1m: espera um minuto sem mudanças antes de consolidar, para não reagir a cada pod que sobe e desce.
  • budgets: o primeiro limita a remoção a 10% dos nós de cada vez. O segundo zera as remoções voluntárias das 9h às 17h, de segunda a sexta (o horário do schedule é em UTC). Interrupção do Spot não é voluntária e acontece mesmo dentro dessa janela.

Consolidação drena pods com frequência. Aplicações que não toleram isso precisam de PodDisruptionBudget, mais de uma réplica e, para casos extremos, a anotação karpenter.sh/do-not-disrupt: "true" no pod.

Testando a escala

O teste clássico da documentação usa um Deployment com a imagem pause, que não faz nada, mas reserva CPU. Com zero réplicas, nada acontece. Ao subir para dez, os pods ficam pendentes e o Karpenter cria nós:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate
spec:
  replicas: 0
  selector:
    matchLabels:
      app: inflate
  template:
    metadata:
      labels:
        app: inflate
    spec:
      terminationGracePeriodSeconds: 0
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.7
          resources:
            requests:
              cpu: "1"
kubectl apply -f inflate.yaml
kubectl scale deployment inflate --replicas 10

kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter -f
kubectl get nodeclaims
kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type

# volta a zero e observe a consolidação remover os nós
kubectl scale deployment inflate --replicas 0

Nos logs aparece o cálculo feito para os pods pendentes e a NodeClaim criada. A coluna capacity-type mostra se o nó saiu como Spot. Se nada acontecer, as causas mais comuns são a access entry dos nós ausente (a instância sobe, mas nunca fica Ready), tag de descoberta faltando ou a service-linked role do Spot não criada.

Custos e limpeza

  • Karpenter: sem custo próprio; roda no node group de sistema.
  • Nós criados: preço Spot ou On-Demand de cada instância. O teto é o limits.cpu do NodePool.
  • SQS e EventBridge: volume de mensagens baixo, custo desprezível perto do resto.
  • Cluster base: control plane, NAT Gateway e os dois nós do node group de sistema.

Na limpeza, a ordem é a parte mais importante deste artigo. Os nós criados pelo Karpenter não estão no state do Terraform. Se o destroy remover o controller antes, as instâncias ficam rodando sem ninguém para encerrá-las e ainda prendem as subnets. Apague primeiro os objetos do Kubernetes e espere os nós saírem:

kubectl delete deployment inflate
kubectl delete nodepool default
kubectl delete ec2nodeclass default
kubectl get nodeclaims            # espere a lista ficar vazia

terraform destroy -var 'public_access_cidrs=["SEU_IP/32"]'

# confira se sobrou instância do Karpenter
aws ec2 describe-instances \
  --filters Name=tag:karpenter.sh/nodepool,Values=default Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].InstanceId' --output text

Conclusão

Com o Karpenter, o tamanho do cluster deixa de ser uma decisão tomada antes e passa a seguir o que os pods pedem. O Terraform cuida da parte que precisa existir antes de tudo: as roles, a access entry dos nós, a fila de interrupção e o chart. O NodePool cuida do resto: Spot por padrão, On-Demand como reserva, consolidação contínua e janelas em que nada é removido.

Com nós aparecendo e sumindo o tempo todo, enxergar o que acontece no cluster fica ainda mais importante. Esse é o tema do próximo artigo da série.

Referências oficiais

Interessado em saber mais sobre esse assunto?

Deixe uma resposta

Descubra mais sobre Blog do Castilho - Tecnologia | FinOps | DevOps | Cloud

Assine agora mesmo para continuar lendo e ter acesso ao arquivo completo.

Continuar lendo