# Kafka em Escala: Tiered Storage, KRaft e Exactly-Once

Uma análise aprofundada da arquitetura interna do Apache Kafka — modelo de log/segmentos, eliminação do ZooKeeper via KRaft, offload de dados para S3 com Tiered Storage, semântica exactly-once e o que o Amazon MSK abstrai (e o que ele não abstrai). Um teardown técnico para quem precisa operar Kafka de verdade.

- URL: https://fernando.moretes.com/studies/kafka-tiered-storage-kraft-exactly-once

- Markdown: https://fernando.moretes.com/studies/kafka-tiered-storage-kraft-exactly-once/study.md?lang=pt

- Type: Teardown

- Company: Apache Kafka / Amazon MSK

- Domain: Streaming

- Date: 2024-06-01

- Tags: kafka, streaming, event-driven, amazon-msk, tiered-storage, kraft, exactly-once, data-platform

- Reading time: 10 min

---

Kafka não é uma fila. Essa distinção importa mais do que parece — ela define como você dimensiona, como você retém dados, como você garante entrega e, principalmente, onde você vai errar em produção. Neste teardown, desmonto a arquitetura interna do Kafka a partir das fontes primárias: o modelo de log imutável, a transição do ZooKeeper para o KRaft, o Tiered Storage com offload para S3, a semântica exactly-once com producers idempotentes e transações, e o que o Amazon MSK realmente gerencia — e o que ainda fica na sua conta.

## Ficha Técnica

- **Sistema:** Apache Kafka + Amazon MSK
- **Domínio:** Streaming de eventos / Plataforma de dados
- **Versão de referência:** Kafka 3.x (KRaft GA: 3.3+, Tiered Storage GA: 3.6+)
- **Escala típica (produção):** Milhões de mensagens/segundo; retenção de TB a PB por cluster
- **Stack principal:** Java/Scala (broker), Apache ZooKeeper (legado), KRaft (Raft interno), Amazon S3 (tiered storage via MSK)
- **MSK — disponibilidade:** Multi-AZ por padrão; SLA AWS de 99,9%
- **Mudança arquitetural chave:** Remoção do ZooKeeper (KIP-500/833); ZooKeeper deprecated no Kafka 3.5, removido no 4.0
- **Fontes primárias:** kafka.apache.org/documentation, KIP-405, docs.aws.amazon.com/msk

## O Problema Fundamental: Log, Não Fila

A abstração central do Kafka é o **log de partição**: uma sequência imutável, append-only, de registros ordenados por offset. Isso não é detalhe de implementação — é a decisão de design que explica tudo o mais. Filas tradicionais (SQS, RabbitMQ) deletam mensagens após o consumo; o Kafka retém. Isso permite múltiplos consumer groups lendo o mesmo dado de forma independente, reprocessamento determinístico e auditoria nativa. O preço é que **o broker precisa gerenciar retenção ativa**, e disco local vira gargalo em clusters com alto volume ou retenção longa.

Cada tópico é dividido em **partições**, cada partição replicada entre brokers conforme o fator de replicação configurado. O broker líder de cada partição aceita writes; os followers replicam de forma síncrona (se no ISR — In-Sync Replica set). Um producer escreve para o líder; o registro só é considerado committed quando todos os membros do ISR confirmam — esse é o contrato de durabilidade do Kafka. O parâmetro `acks=all` (ou `acks=-1`) junto com `min.insync.replicas` define o quórum mínimo de confirmação. Sem entender esse triângulo (líder, ISR, `acks`), qualquer discussão sobre exactly-once é prematura.

O modelo de segmentos é onde o Kafka ganha eficiência de I/O: cada partição no disco é uma sequência de **segmentos** (arquivos `.log` + índices de offset e timestamp). O broker só escreve no segmento ativo (tail); segmentos fechados são imutáveis. Isso permite `sendfile()` direto do filesystem cache para o socket do consumer — zero-copy — sem passar pelo heap da JVM. Em workloads de alto throughput, o Kafka frequentemente satura a rede antes do disco, justamente por esse design.

## KRaft: Por Que o ZooKeeper Precisava Ir

O ZooKeeper foi a escolha pragmática de 2011: um serviço de coordenação distribuída já maduro, que o Kafka usou para armazenar metadados de cluster (quais brokers estão vivos, quais são os líderes de partição, configurações de tópicos, ACLs). Funcionou por uma década, mas criou um **acoplamento arquitetural caro**: dois sistemas distribuídos para operar, dois modelos de segurança para configurar, e um gargalo de escala real — o ZooKeeper armazenava todos os metadados em memória, e clusters com centenas de milhares de partições começavam a sentir latência no controller election e na propagação de metadados.

O **KRaft** (Kafka Raft Metadata) resolve isso via KIP-500: os próprios brokers Kafka passam a executar um protocolo Raft interno para consenso de metadados. Um subconjunto de brokers (ou nodes dedicados de controller) forma o **quórum de controllers**; o controller ativo mantém um log de metadados replicado — o mesmo modelo de log/segmento do Kafka, aplicado a si mesmo. Isso elimina o ZooKeeper como dependência operacional e, mais importante, **remove o limite prático de partições por cluster**: benchmarks da Confluent mostraram recuperação de controller failure em segundos com KRaft vs. dezenas de segundos com ZooKeeper em clusters grandes.

A transição não foi trivial. O KIP-500 foi proposto em 2019; KRaft ficou em early access no Kafka 2.8 (2021), atingiu GA no 3.3 (2022) para novos clusters, e o suporte a migração de ZooKeeper para KRaft chegou no 3.5. O ZooKeeper foi oficialmente removido no Kafka 4.0 (2024). No Amazon MSK, clusters KRaft estão disponíveis a partir do Kafka 3.6 no MSK. **O ponto operacional crítico**: em modo KRaft, os controller nodes precisam de discos confiáveis e baixa latência de rede entre si — eles são o novo plano de controle do cluster. Em MSK, isso é abstraído; em clusters self-managed, é uma decisão de infraestrutura explícita.

## Arquitetura Kafka: Log, KRaft, Tiered Storage e Consumer Groups

Visão reconstruída da arquitetura interna do Kafka 3.6+ com KRaft e Tiered Storage habilitado no Amazon MSK. Mostra o fluxo de escrita (producer → líder → ISR), o offload de segmentos frios para S3, o plano de controle KRaft e o consumo via consumer groups.

### 👤 Producers

- Producer A idempotent + txn (external)
- Producer B acks=all (external)

### ⚙️ MSK Broker Plane (Multi-AZ)

- Broker 1 Partition Leader (AZ-a) (messaging)
- Broker 2 ISR Follower (AZ-b) (messaging)
- Broker 3 ISR Follower (AZ-c) (messaging)

### 🗂️ Local Storage (EBS/NVMe)

- Segmentos Ativos (hot — local disk) (storage)
- Segmentos Fechados (cold — candidatos a offload) (storage)

### ☁️ Tiered Storage (S3)

- Amazon S3 Segmentos Offloaded (retenção longa) (storage)
- Tiered Storage Plugin (RemoteLogManager) (compute)

### 🔐 KRaft Controller Quorum

- Controller 1 (active — AZ-a) (compute)
- Controller 2 (standby — AZ-b) (compute)
- Controller 3 (standby — AZ-c) (compute)

### 📦 Consumer Groups

- Consumer Group A (analytics — offset latest) (external)
- Consumer Group B (audit — offset earliest / S3 fetch) (external)
- Group Coordinator (broker-side) (messaging)

### Fluxos

- p1 -> b1: write (acks=all)
- p2 -> b1: write (idempotente)
- b1 -> b2: replicação ISR
- b1 -> b3: replicação ISR
- b1 -> seg_hot: append segmento ativo
- seg_hot -> seg_cold: segmento fechado (roll)
- seg_cold -> ts_plugin: offload trigger
- ts_plugin -> s3: upload segmento + índice
- seg_cold -> ts_plugin: delete local após offload
- kc1 -> b1: metadata / leader election
- kc1 -> kc2: Raft replication
- kc1 -> kc3: Raft replication
- cg1 -> b1: fetch (local)
- cg2 -> b1: fetch request
- b1 -> s3: fetch remoto (tiered)
- cg1 -> gc: heartbeat / rebalance
- cg2 -> gc: heartbeat / rebalance

## Tiered Storage: Separando Hot de Cold no Log

O KIP-405 (Tiered Storage) resolve um problema econômico real: **disco local em brokers Kafka é caro e não escala independentemente de compute**. Em clusters com retenção de dias ou semanas, a maior parte dos dados está fria — raramente lida, mas precisa estar disponível para reprocessamento ou auditoria. Manter isso em EBS ou NVMe local é desperdício.

O mecanismo funciona assim: o `RemoteLogManager` (RLM), componente introduzido pelo KIP-405, monitora os segmentos fechados de cada partição. Quando um segmento atinge o critério de offload (configurável por tempo ou tamanho), o RLM faz upload do arquivo `.log`, dos índices de offset e timestamp, e dos metadados para o storage remoto — no caso do MSK, Amazon S3. Após confirmação do upload, o segmento local pode ser deletado, liberando disco. O broker mantém um **mapa de metadados remotos** que indica quais offsets estão em S3 vs. local.

Do ponto de vista do consumer, o protocolo é transparente: ele faz um `FetchRequest` normal para o broker líder. Se o offset solicitado está em storage remoto, o broker busca o dado no S3, faz o cache localmente (configurável), e retorna ao consumer. O consumer não sabe que o dado veio de S3. Isso é elegante, mas tem implicações de latência: um fetch de dado frio tem latência de S3 (tipicamente dezenas de ms) vs. disco local ou page cache (sub-ms). **Para workloads de reprocessamento em lote, isso é aceitável; para consumidores que precisam de baixa latência em dados históricos, não é.**

No Amazon MSK, o Tiered Storage é habilitado por configuração no cluster e usa S3 gerenciado pela AWS — você não configura o bucket diretamente, mas pode usar S3 Lifecycle Policies para controlar custo de armazenamento de longo prazo. A limitação atual: Tiered Storage no MSK não suporta todos os tipos de instância, e a compactação de log (log compaction) tem restrições com tiered storage habilitado — segmentos compactados não são elegíveis para offload em certas versões. Isso é crítico para tópicos de changelog (Kafka Streams, CDC).

## Exactly-Once: Idempotência, Transações e o Custo Real

Exactly-once no Kafka é implementado em duas camadas complementares, e confundir as duas é um erro comum em entrevistas e em produção.

**Camada 1 — Producer Idempotente**: habilitado com `enable.idempotence=true` (default `true` desde Kafka 3.0). O broker atribui ao producer um `ProducerID` (PID) e rastreia o número de sequência por partição. Se o producer retransmite uma mensagem (por timeout ou falha de rede), o broker detecta o número de sequência duplicado e descarta — sem duplicatas no log. Isso garante **exactly-once por partição, por sessão de producer**. Custo: praticamente zero — é bookkeeping no broker, sem coordenação distribuída adicional.

**Camada 2 — Transações**: habilitado com `transactional.id` no producer. O producer coordena com um **Transaction Coordinator** (um broker especial eleito por hash do `transactional.id`) para abrir, commitar ou abortar uma transação que pode abranger múltiplas partições e múltiplos tópicos. O mecanismo usa um **two-phase commit interno**: o coordinator grava o estado da transação em um tópico interno (`__transaction_state`) antes de instruir os brokers a marcar as mensagens como committed. Consumers com `isolation.level=read_committed` só veem mensagens de transações commitadas — mensagens de transações abertas ou abortadas são filtradas.

O custo real das transações é não-trivial: latência adicional de round-trip para o Transaction Coordinator, overhead de escrita no `__transaction_state`, e **o risco de transações zumbis** — producers que falharam sem abortar a transação, deixando-a aberta até o `transaction.timeout.ms`. Em sistemas de alto throughput, transações muito grandes (muitas mensagens em uma transação) ou muito frequentes (uma transação por mensagem) degradam throughput significativamente. A recomendação prática: **batch suas transações por janela de tempo ou contagem de registros**, não por mensagem individual.

Um ponto frequentemente ignorado: exactly-once **end-to-end** (producer → broker → consumer → sink) requer que o sink também seja idempotente ou transacional. O Kafka garante exactly-once no log; se o consumer processa e grava em um banco sem idempotência, você ainda tem at-least-once no sistema como um todo. O Kafka Streams implementa exactly-once end-to-end usando transações Kafka para coordenar leitura, processamento e escrita atomicamente — mas isso só funciona quando source e sink são ambos Kafka.

## Consumer Groups, Rebalance e o Problema do Stop-the-World

O modelo de consumer group é o que permite Kafka escalar o consumo horizontalmente: cada partição é atribuída a exatamente um consumer dentro do grupo, e múltiplos grupos podem consumir o mesmo tópico independentemente. O **Group Coordinator** (um broker eleito por hash do `group.id`) gerencia o ciclo de vida do grupo: join, sincronização de assignment, heartbeats e detecção de falhas.

O problema clássico é o **rebalance**: sempre que um consumer entra, sai, ou para de enviar heartbeats dentro de `session.timeout.ms`, o coordinator dispara um rebalance. No protocolo original (Eager Rebalance), **todos os consumers do grupo param de consumir, revogam todas as partições, e aguardam o novo assignment**. Em grupos grandes com muitas partições, isso pode causar pausas de segundos a dezenas de segundos — um stop-the-world distribuído.

O Kafka 2.4 introduziu o **Cooperative Sticky Assignor** (KIP-429/KIP-482), que implementa rebalance incremental: apenas as partições que precisam ser movidas são revogadas; as demais continuam sendo consumidas durante o rebalance. Isso reduz drasticamente o impacto, mas requer que todos os consumers do grupo usem um assignor cooperativo — um detalhe de configuração que frequentemente é esquecido em migrações.

Outro ponto operacional crítico: `max.poll.interval.ms` define o tempo máximo entre duas chamadas a `poll()`. Se o processamento de um batch demora mais que esse intervalo (processamento pesado, chamadas externas lentas), o consumer é considerado morto pelo coordinator e um rebalance é disparado — mesmo que o consumer esteja vivo e processando. Isso cria um loop de rebalances em workloads com processamento variável. A solução não é aumentar `max.poll.interval.ms` indefinidamente (isso atrasa a detecção de falhas reais), mas **reduzir o tamanho do batch via `max.poll.records`** para garantir que o processamento caiba dentro do intervalo configurado. No Amazon MSK, esses parâmetros são configuráveis por consumer — o MSK não os gerencia; eles ficam inteiramente na sua aplicação.

## Trade-offs Arquiteturais: Decisões Chave no Design do Kafka

### KRaft vs. ZooKeeper

**Pros**
- Elimina dependência operacional do ZooKeeper
- Recovery de controller em segundos (vs. dezenas de segundos)
- Suporte a clusters com milhões de partições
- Modelo de segurança unificado

**Cons**
- Migração de clusters existentes requer procedimento específico (3.5+)
- Controller nodes precisam de discos confiáveis em self-managed
- Ecossistema de tooling ainda em adaptação (monitoramento, etc.)

**Verdict:** Adote KRaft para novos clusters. Sem exceções.

### Tiered Storage (S3) vs. Retenção Local

**Pros**
- Custo de armazenamento drasticamente menor para dados frios
- Escala de retenção independente de compute
- Transparente para consumers (sem mudança de protocolo)

**Cons**
- Latência de fetch para dados frios (dezenas de ms vs. sub-ms)
- Incompatibilidade com log compaction em certas versões
- Custo de API S3 (GET/PUT) pode surpreender em alta frequência de offload

**Verdict:** Ideal para retenção longa (>24h) com acesso esporádico a dados históricos. Evite em tópicos compactados.

### Exactly-Once Transactions vs. At-Least-Once + Idempotência no Sink

**Pros**
- Garantia nativa no broker; sem lógica de deduplicação no consumer
- Necessário para Kafka Streams EOS

**Cons**
- Latência adicional (round-trip ao Transaction Coordinator)
- Throughput reduzido se transações muito frequentes ou grandes
- Não garante EOS end-to-end se o sink não for idempotente

**Verdict:** Use transações onde o custo de duplicata é alto (financeiro, CDC). Para analytics, at-least-once + idempotência no sink é mais simples e mais rápido.

### Amazon MSK vs. Kafka Self-Managed (EC2/EKS)

**Pros**
- MSK: sem overhead de gestão de brokers, patches, ZK/KRaft
- MSK: integração nativa com IAM, VPC, CloudWatch, S3 (tiered)
- MSK: Multi-AZ por design; failover automático de broker

**Cons**
- MSK: menos controle sobre configurações de broker (algumas são fixas)
- MSK: custo mais alto que EC2 equivalente em escala muito grande
- Self-managed: flexibilidade total, mas overhead operacional real

**Verdict:** MSK para a maioria dos casos. Self-managed apenas se você tem equipe dedicada de plataforma e requisitos que o MSK não suporta.

## Leitura Well-Architected: Kafka / Amazon MSK

- **security**: O Kafka tem um modelo de segurança multidimensional: autenticação (SASL/PLAIN, SCRAM, mTLS, IAM no MSK), autorização (ACLs por recurso — tópico, grupo, cluster), e criptografia em trânsito (TLS) e em repouso (EBS encryption no MSK). O ponto crítico: ACLs no Kafka são aditivas e granulares — um producer mal configurado com permissão de `WRITE` em `*` (todos os tópicos) é um vetor de injeção de dados. No MSK com IAM auth, as políticas IAM substituem as ACLs internas para autenticação, mas as ACLs ainda podem coexistir.
- **reliability**: Confiabilidade no Kafka é função direta de três configurações: fator de replicação (≥3 em produção), `min.insync.replicas` (≥2, tipicamente RF-1), e `acks=all` no producer. O MSK gerencia failover de broker automaticamente, mas o tempo de eleição de líder ainda impacta producers durante o failover — `retries` e `retry.backoff.ms` no producer devem ser configurados para absorver esse intervalo. Com KRaft, o controller election é mais rápido, reduzindo a janela de indisponibilidade.
- **performance**: O Kafka é otimizado para throughput, não para latência de mensagem individual. O mecanismo de batching no producer (`linger.ms`, `batch.size`) é fundamental: aumentar `linger.ms` de 0 para 5-20ms pode multiplicar o throughput por 10x às custas de latência de ponta a ponta. Compressão (`snappy` ou `lz4`) reduz uso de rede e disco com custo de CPU mínimo em dados textuais/JSON. No MSK, o tipo de instância define o throughput máximo de rede — instâncias `kafka.m5.4xlarge` e superiores têm Enhanced Networking habilitado.
- **cost**: O custo do MSK tem três componentes: instâncias de broker (por hora), armazenamento EBS (por GB/mês), e transferência de dados (intra-AZ é gratuita; inter-AZ tem custo). Tiered Storage adiciona custo de S3 (armazenamento + API), mas tipicamente reduz o custo total ao permitir instâncias menores com menos EBS. O custo oculto mais comum: **retenção excessiva em tópicos de alto volume sem Tiered Storage** — times que configuram `retention.ms=7d` em tópicos de 100MB/s precisam de ~60TB de EBS por réplica.

> **O que eu faria diferente — e o que o mercado ainda erra:** Depois de operar e projetar sistemas sobre Kafka em contextos financeiros, há três padrões de erro que vejo repetidamente — e que eu abordaria de forma diferente.

**1. Tiered Storage como afterthought, não como design inicial.** A maioria dos times habilita Tiered Storage depois que o custo de EBS já está alto e a migração é dolorosa. Eu definiria a política de tiered storage na criação do tópico: `local.retention.ms` para hot tier (ex: 6-24h), `retention.ms` para o total (ex: 30d em S3). Isso é uma decisão de dia zero, não de dia 90.

**2. Exactly-once mal aplicado.** Vejo times habilitando `transactional.id` em todos os producers por "segurança", sem entender o custo. Em sistemas financeiros, exactly-once é necessário em fluxos de pagamento e reconciliação — não em telemetria ou logs de auditoria onde at-least-once + deduplicação no sink é mais eficiente. A decisão deve ser por fluxo, não por cluster.

**3. Consumer group design negligenciado.** Times criam um consumer group por serviço e colocam múltiplas responsabilidades nele. Eu separo consumer groups por responsabilidade de processamento — um grupo para processamento em tempo real (baixo `max.poll.records`, alta prioridade de SLA), outro para batch/analytics (alto `max.poll.records`, tolerante a lag). Isso permite tuning independente e evita que um consumer lento em processamento pesado cause rebalances que afetam o fluxo em tempo real.

O ponto mais importante que eu enfatizaria para qualquer time adotando Kafka: **o Kafka resolve o problema de transporte e retenção; ele não resolve o problema de contrato de dados**. Sem um schema registry (Confluent Schema Registry ou AWS Glue Schema Registry) com evolução de schema versionada, você vai quebrar consumers em produção em algum momento. Isso não é opcional em sistemas de produção sérios.

## Veredicto

O Apache Kafka é uma das arquiteturas de software mais bem projetadas dos últimos 15 anos — o modelo de log imutável é simples, correto e escala de formas que sistemas de fila tradicionais não conseguem. A transição para KRaft remove o último grande ponto de fragilidade operacional (o ZooKeeper) sem comprometer o modelo de consistência. O Tiered Storage resolve o problema econômico de retenção longa de forma elegante, com trade-off de latência bem definido e documentado.

O exactly-once é genuinamente útil e corretamente implementado no nível do broker — mas é frequentemente mal aplicado em produção, gerando overhead desnecessário ou, pior, uma falsa sensação de segurança quando o sink não é idempotente.

O Amazon MSK é a escolha certa para a maioria dos times: elimina o overhead operacional de gestão de brokers e integra nativamente com o ecossistema AWS (IAM, VPC, S3, CloudWatch). A limitação real do MSK não é técnica — é que ele abstrai o suficiente para que times menos experientes subestimem o que ainda é responsabilidade deles: tuning de producer/consumer, design de consumer groups, schema management, e monitoramento de consumer lag.

## Referências

- [Apache Kafka — Official Documentation](https://kafka.apache.org/documentation/)
- [KIP-405: Kafka Tiered Storage](https://cwiki.apache.org/confluence/display/KAFKA/KIP-405%3A+Kafka+Tiered+Storage)
- [Amazon MSK — AWS Documentation](https://docs.aws.amazon.com/msk/)
- [KIP-500: Replace ZooKeeper with a Self-Managed Metadata Quorum](https://cwiki.apache.org/confluence/display/KAFKA/KIP-500%3A+Replace+ZooKeeper+with+a+Self-Managed+Metadata+Quorum)
- [KIP-429: Kafka Consumer Incremental Rebalance Protocol](https://cwiki.apache.org/confluence/display/KAFKA/KIP-429%3A+Kafka+Consumer+Incremental+Rebalance+Protocol)
- [Kafka Exactly-Once Semantics — Confluent Blog](https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/)
- [Amazon MSK Tiered Storage — AWS Docs](https://docs.aws.amazon.com/msk/latest/developerguide/msk-tiered-storage.html)

## Fontes do caso

- [Apache Kafka — Documentation](https://kafka.apache.org/documentation/)
- [KIP-405: Kafka Tiered Storage](https://cwiki.apache.org/confluence/display/KAFKA/KIP-405%3A+Kafka+Tiered+Storage)
- [Amazon MSK — AWS docs](https://docs.aws.amazon.com/msk/)
