Cinco da manhã do 5º dia útil. O tópico pagamentos saiu de 5 msg/s para 500 msg/s, tem algumas centenas de milhares de mensagens empilhadas esperando processamento, e o seu consumidor continua com um único Pod — o mesmo de ontem, o mesmo do mês passado.
Você abre o Grafana esperando encontrar um incêndio e não encontra nada. O HPA está lá, verdinho, sem nada a declarar: CPU em 40%, memória estável, target de 70% intocado. Do ponto de vista dele, está tudo perfeito.
E está mesmo. O problema é que ninguém fez a pergunta certa. Esse Pod já está no teto do que ele consegue processar — o gargalo é I/O, é o banco do outro lado, é o tamanho do batch — então CPU e memória simplesmente não sobem. O sinal que denuncia o problema não está dentro do Pod, está no Kafka. É o lag.
Em 90% dos casos que eu tenho visto em pequenos, médios e grandes projetos, o HPA é configurado com CPU e Memória e ponto final. Não está errado: para uma API HTTP costuma ser exatamente o que você quer. Mas para um worker que consome fila, é o mesmo que olhar a temperatura do motor para saber se o tanque está vazio — o instrumento funciona, só não é o instrumento daquela pergunta.
Neste artigo quero te mostrar como sair dessa situação: como o HPA busca métricas, por que existem três endpoints diferentes para isso, e como o KEDA usa o terceiro deles para escalar seu consumidor pelo lag do tópico — inclusive de zero a N.
Como o HPA funciona?
O HPA coleta as métricas através de um add-on chamado Metrics Server. Esse add-on coleta métricas dos kubelets em cada node e as expõe no API Server pelo endpoint /apis/metrics.k8s.io. O HPA faz uma consulta periódica a este endpoint para capturar os valores de CPU e Memória e decidir se deve aumentar ou diminuir a quantidade de pods.
Além do endpoint metrics.k8s.io, o HPA ainda pode usar outros dois para capturar outros tipos de métricas: custom.metrics.k8s.io e external.metrics.k8s.io. Vou tentar fazer uma comparação dos três tipos para você entender de forma simples:
- endpoint
metrics.k8s.io: No spec do HPA, quando você coloca otype: Resource(outype: ContainerResource, que mira o consumo de um container específico dentro do Pod), ele usa este endpoint, que coleta exclusivamente informações de CPU e Memória. - endpoint
custom.metrics.k8s.io: Quando você usatype: Podsoutype: Object, o HPA usa este endpoint. Aqui entram métricas que estão associadas a algum objeto do k8s — por exemplo, você poderia coletar uma métricahttp_requests_per_secondexposta no endpoint/metricsdo seu Pod. Vale um detalhe importante aqui: o HPA não faz scrape do seu Pod. Quem coleta essa métrica e a traduz para o contrato do k8s é um adapter, sendo oPrometheus Adaptero mais comum. - endpoint
external.metrics.k8s.io: Quando usadotype: External, suas métricas não estão associadas a recursos no k8s, por exemplo, o lag de um tópico do Kafka. Assim como no caso anterior, também é necessário um adapter — e é exatamente aí que o KEDA entra, como veremos adiante.
Note que você pode tomar decisões diferentes conforme o tipo usado no HPA: não apenas CPU e Memória, mas também fazer scaling dos seus Pods baseado no tamanho do lag de um tópico do Kafka.
Por que CPU e Memória não são suficientes?
Vamos voltar ao tópico pagamentos e colocar números nele. Média de 5 msg/s durante o mês inteiro, 500 msg/s no 5º dia útil. Não quero entrar em detalhes de Kafka aqui, então assuma que cada Pod do consumidor dá conta de 50 msg/s — e que esse limite vem de I/O, não de CPU.
Agora faça a conta: chegam 500 msg/s, um Pod processa 50 msg/s, sobram 450 msg/s se acumulando no tópico a cada segundo. Em uma hora são mais de 1,6 milhão de mensagens de atraso, enquanto o gráfico de CPU segue uma linha reta e o HPA segue sem fazer nada. Essa diferença entre o que chega e o que é consumido é o que chamamos de lag do consumidor, e é justamente esse número — e não CPU/Memória — que reflete se sua aplicação está dando conta do recado.
A resposta aqui é que precisamos usar o external.metrics.k8s.io para fazer scaling baseado no lag do tópico, assim podemos colocar 10 Pods x 50 msg/s = 500 msg/s, que daria conta do nosso pico no 5º dia útil.
Esse é um exemplo real que vi acontecer em um projeto em que trabalhei recentemente, mas existem muitos outros casos em que você talvez já tenha notado que CPU e Memória não vão ajudar muito — na verdade podem até atrapalhar.
Como implementar um adapter para external.metrics.k8s.io
O K8s API Aggregation Layer é responsável por redirecionar requisições que não são nativas do binário do API Server para endpoints externos, então você precisaria implementar uma aplicação que retorne o contrato exigido pelo K8s, que chamamos de adapter. Mas calma, não precisa sair implementando nada: meu conselho para você é usar o KEDA (https://keda.sh/).
O KEDA é um projeto graduado na CNCF que implementa exatamente esse adapter para você, e consegue extrair métricas de diversas fontes, veja aqui: https://keda.sh/docs/2.20/scalers/. Kafka é uma delas, então você poderia fazer scaling dos seus Pods baseado no lag do seu consumidor.
KEDA na prática
Novamente, a melhor forma de explicar algo é com exemplo prático. Abaixo vou mostrar um exemplo de como fazer scaling de um deploy baseado no lag do tópico.
Em poucas palavras, KEDA é um operator que usa um CRD ScaledObject, onde você define quais as regras de scale do seu deploy. Internamente ele configura um HPA que irá usar type: External apontando para o próprio KEDA, pois é ele quem vai coletar as métricas do Kafka para você. O fluxo completo fica assim:
KEDA Operator ──── consulta o lag ────> Kafka
│
└──> expõe a métrica em external.metrics.k8s.io
(keda-operator-metrics-apiserver)
▲
│ consulta periódica
│
HPA (keda-hpa-<nome-do-scaledobject>) ────> Deployment
Repare na divisão de trabalho, que na minha opinião é o ponto que mais confunde quem está começando: o KEDA cuida do 0 ↔ 1 e o HPA cuida do 1 ↔ N. Não é o KEDA que substitui o HPA, ele complementa.
- Primeiro você precisa instalar o KEDA: https://keda.sh/docs/2.20/deploy/#installing (Helm é a opção que eu geralmente uso).
- Vamos assumir que você tem uma aplicação chamada
processador-pagamentos. É ela quem estará no nossoScaledObjectabaixo:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: processador-pagamentos-so
namespace: default
spec:
scaleTargetRef:
name: processador-pagamentos
pollingInterval: 30
cooldownPeriod: 300
minReplicaCount: 0
maxReplicaCount: 10
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker.kafka.svc.cluster.local:9092
consumerGroup: processador-pagamentos-consumer
topic: pagamentos
lagThreshold: "50"
activationLagThreshold: "1"
offsetResetPolicy: latest
O que faz o scale acima? O scaleTargetRef define qual o nome do nosso deploy, já a seção triggers define quando fazer scale in/out. Dentro de triggers, você tem os dados de conexão ao Kafka e os dados que o KEDA usa para scaling: lagThreshold e activationLagThreshold. Os outros três campos do spec valem uma menção rápida, porque os defaults nem sempre são o que você quer: pollingInterval é de quanto em quanto tempo o KEDA vai perguntar o lag ao Kafka, cooldownPeriod é quanto tempo ele espera depois do último trigger ativo antes de voltar para zero, e maxReplicaCount tem default 100 — quase sempre um número que você prefere definir explicitamente.
Aqui entra uma feature que o KEDA nos entrega e que não está disponível usando apenas HPA: o Activation. Com HPA o mínimo de Pods na prática é 1 — existe um feature gate HPAScaleToZero que permite zero, mas ele continua em alpha e desabilitado por padrão na maioria dos clusters. Ou seja, mesmo que nenhuma mensagem esteja chegando, você paga o custo de pelo menos 1 Pod.
O KEDA introduz o conceito de “ativação”: um Deployment gerenciado por um ScaledObject com minReplicaCount: 0 fica com zero réplicas até que o trigger seja ativado. Sendo assim o KEDA possibilita scaling 0 <-> N, já o HPA 1 <-> N. A vantagem é clara, redução de custos: para um worker que consome mensagens do Kafka apenas 1 vez por dia, não faz sentido deixar ele ligado o dia todo. Dito isso, o parâmetro activationLagThreshold diz ao operator do KEDA qual o lag que deve ativar o deploy, ou seja, sair de zero para 1, já o lagThreshold é o parâmetro que será usado no HPA, para fazer scaling.
Vou tentar explicar um pouco melhor o lagThreshold no HPA. Ele não é um limite, é um alvo de lag por réplica. O HPA aplica a fórmula:
réplicas = ceil(lag_total / lagThreshold)
Então com um lag total de 100 e lagThreshold: "50", o HPA sobe para 2 réplicas. Se o lag for para 500, ele vai para 10. É por isso que o valor do lagThreshold é, na prática, a resposta para a pergunta “quantas mensagens acumuladas eu aceito que cada Pod tenha na frente dele?”.
Três detalhes que costumam pegar as pessoas
O número de partições é o seu teto real. O scaler de Kafka do KEDA não escala além da quantidade de partições do tópico (comportamento controlado pelo allowIdleConsumers, que por padrão é false). Isso faz todo sentido, porque dentro de um consumer group cada partição é consumida por no máximo um consumidor — Pods além disso ficariam ociosos. No nosso exemplo, os 10 Pods só vão existir de fato se o tópico pagamentos tiver pelo menos 10 partições. Se você configurar maxReplicaCount: 50 em um tópico de 10 partições, o scaling vai parar em 10 e você vai ficar procurando o motivo no lugar errado.
Não crie um HPA seu para o mesmo deploy. O KEDA já cria um, e dois controllers disputando o mesmo scaleTargetRef vão brigar entre si. Se você está migrando de um HPA existente para o KEDA, apague o HPA antigo. Para conferir o que o KEDA criou:
kubectl get hpa
# NAME REFERENCE TARGETS
# keda-hpa-processador-pagamentos-so Deployment/processador-pagamentos 12/50 (avg)
Cuidado com o flapping. Como o lag oscila bastante, é comum ver Pods subindo e descendo com frequência demais. Quem controla isso é a janela de estabilização do próprio HPA (o behavior, com 300s de default para scale down), e você consegue ajustá-la pelo KEDA através do bloco advanced.horizontalPodAutoscalerConfig.behavior no ScaledObject.
Em Kafka de verdade você vai precisar de autenticação. O exemplo acima assume um broker sem SASL/TLS para não poluir o YAML. Em qualquer ambiente real, as credenciais entram por um CRD separado, o TriggerAuthentication, referenciado no trigger via authenticationRef.
Conclusão
Com as incontáveis ferramentas que usamos em nossos projetos atuais, o uso de HPA apenas com CPU e Memória quase sempre não é suficiente. Por isso o uso de métricas externas para tomar decisões de scaling é muito importante, e o KEDA se propõe exatamente a isso. Eu uso KEDA em meus projetos e inclusive sou Contributor, pois acredito muito no projeto e sempre trouxe grandes ganhos pra mim.