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
- Como o Karpenter funciona
- Node group só para o sistema
- Tags de descoberta do Karpenter
- O módulo do Karpenter
- Interrupção do Spot via SQS
- terraform plan
- Outputs do Terraform
- EC2NodeClass e NodePool com Spot
- Consolidação e disruption budgets
- Testando a escala
- Custos e limpeza
- Conclusão
- Referências oficiais
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 tagkarpenter.sh/discoveryusada noEC2NodeClass.kubernetes_version: versão do control plane.karpenter_node_role_name: vai no campospec.roledoEC2NodeClass. Pegue comterraform output -raw karpenter_node_role_name.karpenter_controller_role_arn: role do controller. Depois do apply, terá o formatoarn: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 okubectl. 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-typecomspoteon-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,re 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@latestfaz 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. ComWhenEmpty, 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 doscheduleé 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.cpudo 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
- Getting Started with Karpenter — karpenter.sh
- NodePools — karpenter.sh
- Disruption — karpenter.sh
- Release v1.13.1 — GitHub
- Scale cluster compute with Karpenter and Cluster Autoscaler — AWS Documentation
- Amazon EC2 Spot Instances pricing
Interessado em saber mais sobre esse assunto?