Kubernetes7 min de leitura

10 pods para 5 partições: a conta que o Kafka não fecha

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.

flowchart LR subgraph T[Topico pedidos - 5 particoes] P0[Particao 0] P1[Particao 1] P2[Particao 2] P3[Particao 3] P4[Particao 4] end P0 --> A[Pod 1] P1 --> B[Pod 2] P2 --> C[Pod 3] P3 --> D[Pod 4] P4 --> E[Pod 5] F[Pods 6 a 10
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:

  1. A carga aumenta e a fila acumulada cresce.
  2. O HPA vê a fila crescendo e sobe um pod.
  3. O pod novo entra no grupo e dispara um rebalance.
  4. O consumo para por alguns segundos e a fila cresce mais ainda.
  5. O HPA vê a fila maior e sobe outro pod.
  6. Volta para o passo 3.
flowchart TD A[Fila acumulada cresce] --> B[HPA sobe um pod] B --> C[Pod novo entra no grupo] C --> D[Rebalance: consumo para] D --> A

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

← Voltar aos Artigos