KEDA no EKS: Escalando Pods por Fila SQS com Terraform

O KEDA no EKS resolve um problema que o HPA sozinho não resolve bem: escalar pelo trabalho que está esperando, e não pelo esforço de quem já está trabalhando. Com o KEDA, a quantidade de mensagens numa fila SQS decide quantos pods rodam. Fila vazia, nenhum pod. Fila cheia, os pods sobem, e o Karpenter cria os nós para eles.

Este é o décimo primeiro artigo da série artigos-eks. Ele junta duas peças que já apareceram aqui: o EKS Pod Identity, que dá permissão de AWS sem access key, e o Karpenter, que escala os nós. O KEDA entra no meio e escala os pods. Tudo foi executado num laboratório real, com 1.000 mensagens na fila, e destruído no fim.

Segurança: os comandos deste guia usam o perfil do AWS CLI para autenticar. Nunca coloque access keys no código, no manifesto ou em .tfvars versionado; prefira aws sso login ou um perfil com credenciais temporárias. Dentro do cluster, nenhum pod usa chave: todos recebem credenciais pelo Pod Identity.

Sumário

Pré-requisitos

AWS CLI v2 autenticado, Terraform >= 1.5 e kubectl. As versões usadas no laboratório:

  • Kubernetes 1.36 no EKS;
  • KEDA 2.21.0 (chart kedacore/keda 2.21.0);
  • Karpenter 1.13.1, como no artigo do Karpenter;
  • providers hashicorp/aws ~> 6.0 e hashicorp/helm ~> 3.0.

O cluster fica na 1.36 de propósito. O artigo anterior da série usou a 1.37, mas o KEDA 2.21 é testado do Kubernetes 1.34 ao 1.36. Antes de subir a versão do cluster, confira a matriz de compatibilidade de cada controller instalado nele. A 1.36 tem suporte padrão no EKS até agosto de 2027.

Por que KEDA no EKS, e não só o HPA

O HPA escala por CPU e memória. Para uma API, isso funciona: mais requisições geram mais CPU. Para um consumidor de fila, a CPU é um sinal atrasado. Mil mensagens podem estar esperando enquanto os pods existentes trabalham tranquilos, sem pico de CPU. O HPA não vê a fila, então não sobe ninguém.

O KEDA olha a causa. Ele lê uma métrica externa, aqui a profundidade da fila SQS, e entrega esse número ao HPA. Na prática, você declara um ScaledObject e o KEDA cria e gerencia o HPA por você. Ele acrescenta duas coisas que o HPA não faz:

  • Scale-to-zero: o HPA tem no mínimo 1 réplica. O KEDA desliga o deployment quando não há trabalho e o religa quando a primeira mensagem chega.
  • Dezenas de fontes: SQS, Kafka, CloudWatch, Prometheus, cron e muitas outras, todas com o mesmo modelo de objeto.

Com scale-to-zero nos pods, o Karpenter consegue tirar os nós vazios. Sem trabalho, o cluster volta ao tamanho mínimo, e a conta acompanha.

A arquitetura

O cluster é o mesmo do artigo do Karpenter. Um node group pequeno e fixo, chamado system, roda os controllers: o KEDA e o Karpenter. Os pods da aplicação vão para nós que o Karpenter cria sob demanda, nas subnets privadas.

Arquitetura do KEDA no EKS: KEDA e Karpenter no node group system, nós Spot do Karpenter com os pods worker, fila SQS, IAM com Pod Identity e produtor de mensagens
Arquitetura do KEDA no EKS com a fila SQS e o Karpenter. Clique para ampliar.

O caminho de uma mensagem segue a numeração do desenho. O produtor envia para a fila. O KEDA lê a profundidade a cada 15 segundos e ajusta o HPA, com a meta de 10 mensagens por pod. Os pods que não cabem ficam pendentes, e o Karpenter cria nós para eles. Cada pod recebe uma mensagem, processa e apaga.

O módulo do KEDA com Terraform

O KEDA ganhou um módulo próprio na série, o terraform-eks-keda-modules. Ele instala o chart oficial no namespace keda e, quando recebe filas, cria a role do operator via Pod Identity. O ambiente do artigo só chama o módulo:

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

  cluster_name   = module.eks.name
  chart_version  = var.keda_chart_version   # "2.21.0"
  sqs_queue_arns = [aws_sqs_queue.jobs.arn]
  node_selector  = { "node-role" = "system" }

  depends_on = [module.addons]
}

O node_selector vale para os três componentes do chart: operator, metrics server e webhooks. Assim o KEDA fica nos nós fixos, longe dos nós que ele próprio faz o Karpenter criar e remover. Dentro do módulo, o helm_release é curto:

resource "helm_release" "this" {
  name             = "keda"
  repository       = "https://kedacore.github.io/charts"
  chart            = "keda"
  version          = var.chart_version
  namespace        = var.namespace
  create_namespace = true

  values = [yamlencode({
    nodeSelector = var.node_selector
    operator     = { replicaCount = var.operator_replicas }
  })]

  # a associação do Pod Identity precisa existir antes de o pod do operator nascer
  depends_on = [module.pod_identity]
}

Pod Identity para o KEDA ler a fila

Quem lê a fila é o operator do KEDA, não o pod da aplicação. Então a permissão de leitura fica numa role ligada à service account keda-operator, com o módulo de Pod Identity da série. A política só permite consultar os atributos da fila, que é o que o scaler usa para contar mensagens:

locals {
  operator_policy_json = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid      = "ReadQueueDepth"
      Effect   = "Allow"
      Action   = ["sqs:GetQueueAttributes", "sqs:GetQueueUrl"]
      Resource = var.sqs_queue_arns
    }]
  })
}

module "pod_identity" {
  source = "../terraform-eks-pod-identity-modules"
  count  = length(var.sqs_queue_arns) > 0 ? 1 : 0

  cluster_name = var.cluster_name
  role_name    = "KedaOperator-${var.cluster_name}"
  service_accounts = [
    { namespace = var.namespace, name = "keda-operator" },
  ]
  inline_policy_json = local.operator_policy_json
}

Um detalhe de Terraform apareceu aqui. A primeira versão montava a política com um data "aws_iam_policy_document". Como o módulo é chamado com depends_on, o Terraform só lê esse data source no apply. O count do módulo de Pod Identity, que depende de a política existir, ficava desconhecido, e o plan falhava com Invalid count argument. Com jsonencode, o Terraform sabe já no plan que o resultado nunca é nulo.

A documentação do KEDA descreve a autenticação na AWS pelo IRSA, com anotação na service account. Ela não fala do EKS Pod Identity. Olhando o código da versão 2.21.0, com o provider aws e sem roleArn, o KEDA usa a cadeia padrão de credenciais do AWS SDK, e essa cadeia já reconhece o Pod Identity. O laboratório confirmou: o operator recebeu as variáveis AWS_CONTAINER_CREDENTIALS_FULL_URI e AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE, sem nenhuma anotação.

A fila SQS e o consumidor

A fila de trabalho tem uma DLQ. Uma mensagem que falha cinco vezes vai para lá, em vez de voltar à fila para sempre e manter o KEDA escalando por causa dela:

resource "aws_sqs_queue" "jobs_dlq" {
  name                      = "jobs-dlq-${var.cluster_name}"
  message_retention_seconds = 1209600
  sqs_managed_sse_enabled   = true
}

resource "aws_sqs_queue" "jobs" {
  name                       = "jobs-${var.cluster_name}"
  visibility_timeout_seconds = 60
  receive_wait_time_seconds  = 20
  sqs_managed_sse_enabled    = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
    maxReceiveCount     = 5
  })
}

O consumidor tem a própria role, ligada à service account jobs/worker, com permissão de receber e apagar mensagens só dessa fila. O exemplo usa a imagem oficial do AWS CLI num loop: recebe uma mensagem, simula 5 segundos de trabalho e apaga. Cada pod pede 500m de CPU e só pode rodar em nós do Karpenter:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker
  namespace: jobs
spec:
  replicas: 0 # quem decide é o KEDA
  selector:
    matchLabels:
      app: worker
  template:
    metadata:
      labels:
        app: worker
    spec:
      serviceAccountName: worker
      nodeSelector:
        karpenter.sh/nodepool: default
      terminationGracePeriodSeconds: 60
      containers:
        - name: worker
          image: public.ecr.aws/aws-cli/aws-cli:2.38.6
          command: ["/bin/bash", "-c"]
          args:
            - |
              while true; do
                MSG=$(aws sqs receive-message --queue-url "$QUEUE_URL" \
                  --max-number-of-messages 1 --wait-time-seconds 20 \
                  --query 'Messages[0].[ReceiptHandle,Body]' --output text)
                if [ -n "$MSG" ] && [ "$MSG" != "None" ]; then
                  HANDLE=$(echo "$MSG" | cut -f1)
                  sleep "$WORK_SECONDS" # simula o processamento
                  aws sqs delete-message --queue-url "$QUEUE_URL" --receipt-handle "$HANDLE"
                fi
              done
          env:
            - name: QUEUE_URL
              value: ${QUEUE_URL}
            - name: AWS_REGION
              value: ${AWS_REGION}
            - name: WORK_SECONDS
              value: "5"
          resources:
            requests:
              cpu: 500m
              memory: 128Mi

TriggerAuthentication e ScaledObject

Dois objetos do KEDA ligam a fila ao deployment. A TriggerAuthentication diz como autenticar: provider aws, sem roleArn, ou seja, com as credenciais do próprio operator. O ScaledObject diz o que escalar e por qual métrica:

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: aws-pod-identity
  namespace: jobs
spec:
  podIdentity:
    provider: aws
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker
  namespace: jobs
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0   # fila vazia = nenhum pod
  maxReplicaCount: 20
  pollingInterval: 15  # segundos entre leituras da fila
  cooldownPeriod: 120  # espera depois da última mensagem antes de ir a zero
  triggers:
    - type: aws-sqs-queue
      authenticationRef:
        name: aws-pod-identity
      metadata:
        queueURL: ${QUEUE_URL}
        queueLength: "10"        # mensagens por pod
        activationQueueLength: "0"
        awsRegion: ${AWS_REGION}

O queueLength é a meta por pod. Com 1.000 mensagens e meta 10, o cálculo pede 100 réplicas, e o maxReplicaCount segura em 20. Por padrão, o scaler também conta as mensagens em processamento (scaleOnInFlight: true), o que evita derrubar pods que ainda estão trabalhando.

Os manifestos usam variáveis preenchidas com envsubst a partir dos outputs do Terraform. Liste as variáveis no comando: sem a lista, o envsubst também apagaria o $MSG e o $HANDLE do script do consumidor.

export CLUSTER_NAME=eks-blog-castilho AWS_REGION=us-east-1
export QUEUE_URL=$(terraform output -raw jobs_queue_url)

envsubst '$CLUSTER_NAME' < k8s/karpenter.yaml | kubectl apply -f -
envsubst '$QUEUE_URL $AWS_REGION' < k8s/worker.yaml | kubectl apply -f -
envsubst '$QUEUE_URL $AWS_REGION' < k8s/keda.yaml | kubectl apply -f -

terraform apply e o cluster no ar

terraform init
terraform plan  -var 'public_access_cidrs=["SEU_IP/32"]'   # 60 recursos
terraform apply -var 'public_access_cidrs=["SEU_IP/32"]'

aws eks update-kubeconfig --region us-east-1 --name eks-blog-castilho

O plan fecha em 60 recursos: os 51 do cluster com Karpenter e mais 9 novos (as duas filas, o chart do KEDA e as duas roles com política e associação). O apply levou 13 minutos e meio, quase todo gasto no control plane e no node group.

KEDA no EKS: cluster eks-blog-castilho ativo na versão 1.36 no console
Cluster eks-blog-castilho ativo na versão 1.36
Associações de Pod Identity do cluster: jobs/worker, keda/keda-operator e kube-system/karpenter
Três associações de Pod Identity: consumidor, KEDA e Karpenter, nenhuma com access key

Logo depois do apply, o keda-operator aparece com uma reinicialização. É esperado: na primeira subida, ele gera os certificados do webhook e se reinicia de propósito. O log diz isso com todas as letras: Secrets have been updated; exiting so pod can be restarted.

Com os manifestos aplicados e a fila vazia, o ScaledObject fica READY=True e ACTIVE=False, e o KEDA cria o HPA keda-hpa-worker. Sem erro de credencial no log do operator, a leitura da fila pelo Pod Identity está funcionando:

kubectl get scaledobject,hpa -n jobs

NAME                          MIN   MAX   READY   ACTIVE   TRIGGERS        AUTHENTICATIONS
scaledobject.keda.sh/worker   0     20    True    False    aws-sqs-queue   aws-pod-identity

NAME                                                  TARGETS              MINPODS   MAXPODS   REPLICAS
horizontalpodautoscaler.autoscaling/keda-hpa-worker   <unknown>/10 (avg)   1         20        0

Testando a escala com 1.000 mensagens

O teste envia 1.000 mensagens com SendMessageBatch, em lotes de 10. Um segundo script grava, a cada 10 segundos, a profundidade da fila, as réplicas, os pods pendentes e os nós do Karpenter. Os dois estão no repositório, na pasta do artigo.

./monitorar.sh > linha-do-tempo.csv &
./enviar-mensagens.sh 1000

kubectl get scaledobject,hpa,pods -n jobs -w
kubectl get nodes -l karpenter.sh/nodepool=default \
  -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type

O fluxo abaixo resume o que aconteceu, com os tempos reais contados a partir do início do envio:

Fluxo da escala do KEDA no EKS no laboratório: produtor envia, KEDA lê a fila, HPA sobe réplicas, Karpenter cria nós Spot, pods consomem, fila vazia, réplicas a zero e nós removidos
Como foi a escala no laboratório, passo a passo. Clique para ampliar.

Em cerca de 25 segundos, o KEDA viu a fila e ligou o primeiro pod. Em menos de dois minutos, eram 20 pods rodando. Para caber todos, o Karpenter criou 5 nós, todos Spot, de três tipos diferentes. Ele escolheu cada instância pelo que os pods pendentes pediam, e não por uma lista fixa:

Cinco instâncias Spot criadas pelo Karpenter durante o teste do KEDA, de tipos c7i-flex, c7a e c8i-flex
Os 5 nós Spot que o Karpenter criou para os 20 pods

Com 20 pods, a fila caiu num ritmo de cerca de 180 mensagens por minuto. O gráfico tem a linha do tempo completa, com as amostras do script:

Linha do tempo do teste do KEDA no EKS: mensagens na fila, pods desejados e rodando, e nós criados pelo Karpenter
Fila, pods e nós ao longo do teste. Clique para ampliar.

O CloudWatch da fila conta a mesma história. As mensagens visíveis sobem até 901 e caem a zero, enquanto as apagadas ficam em torno de 180 por minuto:

Métricas da fila jobs no CloudWatch: mensagens visíveis subindo a 901 e caindo a zero, mensagens apagadas e leituras vazias
Métricas da fila no CloudWatch durante o teste

A fila zerou cerca de 7 minutos e 45 segundos depois do início. Dois minutos depois, passado o cooldownPeriod, o KEDA levou as réplicas a zero. Os nós saíram um por um, e o cluster voltou aos dois nós fixos perto dos 14 minutos. No fim, as três filas do laboratório estavam vazias:

Filas SQS do laboratório: jobs, jobs-dlq e a fila de interrupção do Karpenter, todas com SSE-SQS
Fila de trabalho, DLQ e a fila de interrupção do Karpenter, zeradas no fim do teste

O que o laboratório ensinou

  • A descida é mais lenta que a subida, e de propósito. A subida levou 2 minutos. A descida levou 6: 2 de cooldown, 1 de saída dos pods e 3 de consolidação. Encurtar o cooldownPeriod economiza nós, mas aumenta o risco de desligar tudo entre duas rajadas de mensagens.
  • Pod “a zero” não sai na hora. Depois que o KEDA pediu zero réplicas, os pods continuaram rodando por cerca de 1 minuto. É o terminationGracePeriodSeconds: 60 somado ao long polling de 20 segundos: o pod termina a mensagem atual antes de sair. Bom para não perder trabalho; ajuste o grace ao tempo real de processamento.
  • Leitura vazia também é cobrada. No cooldown, os 20 pods ficaram em long polling numa fila vazia, e o CloudWatch mostrou um pico de NumberOfEmptyReceives. Cada uma dessas chamadas é uma requisição do SQS. O long polling de 20 segundos já reduz bastante; sem ele, o pico seria muito maior.
  • Visibilidade maior que o processamento. A mensagem some da fila por 60 segundos depois de recebida. Se o processamento passar disso, ela reaparece e outro pod a pega de novo. A regra é visibilidade maior que o tempo máximo de processamento, com folga.
  • O Karpenter não espera a fila esvaziar para consolidar. Com a fila ainda cheia, ele tirou um nó que tinha sobrado subutilizado (5 para 4). Com o budget de 10%, os nós saíram um de cada vez no fim.
  • Limite no NodePool. O maxReplicaCount limita os pods, e o limits.cpu do NodePool limita os nós. Use os dois. Sem eles, uma fila que cresce sem parar vira uma conta que cresce sem parar.

Custos e limpeza

  • KEDA: sem custo próprio; os pods dele rodam no node group fixo.
  • SQS: cobrado por requisição, incluindo as leituras vazias. No teste, ficou em centavos.
  • Nós do Karpenter: só existem enquanto há trabalho. No teste, 5 instâncias Spot por cerca de 12 minutos.
  • Cluster base: control plane, NAT Gateway e os dois nós fixos, como no primeiro artigo da série. É a maior parte da conta do laboratório.

Na limpeza, apague primeiro os objetos do Kubernetes e espere o Karpenter devolver os nós. Só depois rode o destroy, para não deixar instância órfã fora do controle do Terraform:

kubectl delete scaledobject worker -n jobs
kubectl delete triggerauthentication aws-pod-identity -n jobs
kubectl delete deployment worker -n jobs
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

O KEDA no EKS fecha o ciclo que o Karpenter começou. O Karpenter escala os nós, o KEDA escala os pods, e a fila decide os dois. Com scale-to-zero, um consumidor que fica parado a maior parte do dia não ocupa nó nenhum enquanto não há trabalho.

O ponto que mais merece atenção não é o Terraform, que se resume a um chart e uma role. É acertar os tempos: queueLength, cooldownPeriod, visibilidade da mensagem e grace period do pod precisam conversar entre si. No próximo artigo da série, o KEDA escala uma aplicação HTTP pelas requisições que chegam ao ALB, e não por fila.

Referências oficiais

Interessado em saber mais sobre artigos relacionados a Amazon EKS CLIQUE AQUI

🚀 Vamos nos conectar?

Não perca nenhuma oportunidade! Cadastre-se nas minhas redes e no canal do YouTube para receber conteúdos de TI, Cloud, Azure, AWS, Kubernetes e DevOps em primeira mão.

Dica: no Facebook, todos os artigos do blog são publicados automaticamente. Vale a pena curtir!


💬 Dúvidas ou problemas?

Com o intuito de ajudar a comunidade, caso você tenha dúvidas ou encontre problemas na execução dos comandos deste artigo, deixe um comentário abaixo. Responderei o mais breve possível!

Muito obrigado pela visita e até o próximo post!

Jefferson Castilho Especialista em Cloud & DevOps.

Este guia técnico é exclusivo do Blog do Castilho. Explore mais conteúdos sobre Cloud e DevOps.

Deixe um comentário

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

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

Continuar lendo