10 pods para 5 partições: a conta que o Kafka não fecha
Introdução
Sexta-feira de manhã, um worker começou a escalar sozinho.
Chegava carga, o Kubernetes subia um pod novo. O consumo dava uma travada, a fila de mensagens acumuladas crescia, e ele subia mais um pod. E mais um. Até bater no teto de 10.
A primeira reação foi olhar o pod: memória, CPU, log de erro. Estava tudo normal. Nenhum pod caindo, nenhuma exceção, nada de anormal nas métricas da máquina.
O problema não estava no Kubernetes. Estava no tópico do Kafka, em um número que ninguém tinha olhado.
Antes de tudo: três palavras
Se você não trabalha com Kafka no dia a dia, três conceitos resolvem o artigo inteiro.
Tópico é o lugar onde as mensagens ficam. Pense em uma caixa de entrada compartilhada: um sistema coloca mensagens lá, outro sistema tira.
Partição é uma divisão dentro dessa caixa. Um tópico com 5 partições é uma caixa dividida em 5 filas independentes. As mensagens chegam distribuídas entre elas.
Consumer group é o time que lê as mensagens. Se você tem 10 cópias da sua aplicação rodando e todas pertencem ao mesmo grupo, o Kafka enxerga isso como um time de 10 leitores trabalhando na mesma caixa.
E tem o HPA (Horizontal Pod Autoscaler), que é o mecanismo do Kubernetes que cria e remove cópias da sua aplicação conforme a carga aumenta ou diminui. Cada cópia é um pod.
Os dois arquivos que contam a história
Primeiro, a configuração de escala do worker:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: worker-pedidos
spec:
minReplicas: 3
maxReplicas: 10
Depois, o tópico no MSK:
kafka-topics.sh --describe \
--topic pedidos \
--bootstrap-server $BROKER
Topic: pedidos PartitionCount: 5 ReplicationFactor: 3
Cinco partições. Dez pods.
Aí está o problema inteiro, em dois números que moram em arquivos diferentes e ninguém compara.
A regra que explica tudo
Dentro de um consumer group, cada partição é entregue a um único consumidor.
Não é uma configuração que dá para mudar. É como o Kafka garante que a mesma mensagem não seja processada duas vezes ao mesmo tempo por duas cópias da sua aplicação.
A consequência é direta: se o tópico tem 5 partições, no máximo 5 pods conseguem consumir. O sexto pod em diante sobe, fica saudável, aparece verde no kubectl get pods e não recebe nenhuma partição. Ele só ocupa memória do cluster.
sem particao
sem trabalho]
É por isso que a partição, e não o pod, define quantas coisas acontecem ao mesmo tempo. Subir mais réplica só ajuda enquanto sobrar partição para distribuir.
O ciclo que se alimenta sozinho
O pod ocioso já seria desperdício. Mas o comportamento que estávamos vendo era pior que isso: o worker escalava, o consumo piorava, e ele escalava de novo.
O culpado é o rebalance.
Toda vez que um pod entra ou sai do consumer group, o Kafka precisa redistribuir as partições entre quem ficou. Durante essa redistribuição, o consumo para. São segundos, às vezes menos, mas para.
Agora junte as peças:
- A carga aumenta e a fila acumulada cresce.
- O HPA vê a fila crescendo e sobe um pod.
- O pod novo entra no grupo e dispara um rebalance.
- O consumo para por alguns segundos e a fila cresce mais ainda.
- O HPA vê a fila maior e sobe outro pod.
- Volta para o passo 3.
A partir de um certo ponto, o worker não estava mais reagindo à carga real. Estava reagindo à própria escala. Cada tentativa de melhorar piorava um pouco, e o gráfico só subia.
A correção
Uma linha:
spec:
minReplicas: 3
maxReplicas: 5 # igual ao numero de particoes do topico
Com o teto igual ao número de partições, os pods inúteis deixaram de subir. Sem pod entrando, sem rebalance. Sem rebalance, o consumo parou de travar. E sem as travadas, a fila voltou a diminuir sozinha.
O gráfico estabilizou em minutos.
Três perguntas antes de mexer no HPA
Quando um consumer Kafka parece lento, vale responder isso antes de abrir o YAML da escala.
1. Quantas partições o tópico tem?
Esse número é o seu teto real de paralelismo. Colocar maxReplicas acima dele não aumenta vazão nenhuma, só gasta recurso do cluster e aumenta a chance de rebalance.
2. Os pods que já existem estão realmente ocupados?
Se os 5 pods estão com CPU baixa e a fila continua crescendo, o gargalo não é quantidade de consumidor. Normalmente está no que a aplicação faz com a mensagem: uma chamada HTTP lenta, uma query pesada, um lock no banco. Mais pods não consertam isso.
3. O que está sendo usado como métrica de escala?
Escalar pelo tamanho da fila parece natural, mas é justamente essa métrica que fecha o ciclo descrito acima. Se for usar, vale combinar com uma janela de estabilização mais longa, para o HPA não reagir a cada oscilação:
behavior:
scaleUp:
stabilizationWindowSeconds: 300
O detalhe que pega muita gente
Se o teto é o número de partições, a saída óbvia para ganhar vazão é aumentar as partições. E dá para fazer isso, com dois avisos.
Partição só aumenta, nunca diminui. Não existe comando para voltar atrás. Se você criar 50 partições em um tópico que precisava de 8, a única saída é criar um tópico novo e migrar tudo.
Aumentar partição mexe na ordem das mensagens. O Kafka garante ordem dentro de uma partição, e decide a partição a partir da chave da mensagem. Como essa decisão leva em conta a quantidade de partições, mudar esse número faz a mesma chave passar a cair em outro lugar.
Na prática: se o seu sistema depende de processar os eventos de um mesmo pedido na ordem em que aconteceram, os eventos antigos vão estar em uma partição e os novos em outra. Por um tempo, a ordem deixa de valer.
Para muitos casos isso é irrelevante. Para um fluxo de estados, como "pedido criado, pago e cancelado", é o tipo de coisa que aparece em produção no pior horário possível.
Conclusão
No fim, o problema não estava no YAML. Estava no modelo mental.
Escalar uma API HTTP é intuitivo: chegou mais requisição, sobe mais réplica, o balanceador distribui. Cada pod novo entra e ajuda no mesmo instante.
Um consumer Kafka não funciona assim. O trabalho já vem dividido em partições antes de qualquer pod existir, e o pod só pega o pedaço que sobra. Se não sobra pedaço, o pod não serve para nada. Pior: ele ainda atrapalha quem estava trabalhando, por causa do rebalance.
Então, antes de subir o teto do HPA, olhe o tópico. A resposta costuma estar lá.
Tem dúvidas ou quer aprofundar algum ponto? Me encontra no LinkedIn ou no GitHub.
Gostou do conteúdo? Me segue no @getcaramelo.dev
Caramelo