Chi decide dove sta ogni partizione, ora che ZooKeeper non c'è più
Un cluster Kafka deve sapere in ogni istante quali broker sono vivi, chi è leader di ogni partizione, quali topic esistono e quali ACL valgono. Fino alla serie 3.x questo registro viveva in un sistema esterno, Apache ZooKeeper, da installare, monitorare e aggiornare a parte. Kafka 4.0 ha rimosso quella dipendenza: la documentazione di upgrade dice senza ambiguità che «Kafka 4.0 only supports KRaft mode - ZooKeeper mode has been removed». Se hai ancora un cluster con ZooKeeper, per passare alla 4.x devi prima migrarlo, e il percorso passa da una release ponte che non è quella che molti ricordano: la 3.9.
In questo articolo capisci come KRaft tiene i metadati, come si dimensiona il quorum dei controller, come si configura un cluster da zero e come si esegue la migrazione. Tutti i fatti di configurazione vengono dalla documentazione di Kafka 4.4 (la pagina «KRaft») e dalla 3.9 per la migrazione, consultate il 2026-11-12; i comportamenti del consenso li dimostra un piccolo modello in Java 21 che ho compilato ed eseguito. Il modello non è Kafka: non scambia messaggi di rete, non ha epoche che crescono né snapshot. Serve a rendere visibile una regola, quella per cui un record confermato sopravvive alla morte del leader.
Perché togliere ZooKeeper: il motivo secondo chi ha scritto la proposta
La proposta originale, KIP-500, motiva la scelta con due argomenti concreti. Il primo è la divergenza di stato: il controller teneva in memoria una copia dei metadati e la confrontava con ZooKeeper tramite «watch», che segnalano che qualcosa è cambiato ma non che cosa. Il secondo è il costo del cambio di controller: chi subentrava doveva ricaricare lo stato. In KRaft i controller seguono tutti lo stesso log, quindi chi subentra ha già lo stato (la KIP parla di «hot standbys»). La stessa proposta aggiunge una promessa di scalabilità (più partizioni con meno CPU), ma è un obiettivo di progetto, non una misura: nessuna cifra di partizioni massime o di secondi di failover è riportata qui, perché non ho una fonte che la dimostri.
Se hai letto altrove cifre precise (un numero massimo di partizioni per ZooKeeper, i secondi di un failover, una riduzione percentuale citata da un caso aziendale), trattale come non verificate: nella documentazione ufficiale non compaiono, e per questo non le ripeto. Il vantaggio operativo che puoi affermare è invece strutturale: un sistema da gestire invece di due, un solo ciclo di upgrade, e lo stato del cluster contenuto in un solo log invece che sparso tra Kafka e ZooKeeper.
Come funziona KRaft: i metadati sono un log, e i broker lo leggono
Il cuore di KRaft è un'idea che hai già incontrato nell'articolo sui fondamenti: se i dati sono un log, la coordinazione è un log. I metadati del cluster (topic, partizioni, leader, ISR, ACL, configurazioni) sono scritti come eventi in una partizione speciale, __cluster_metadata-0, replicata tra i nodi con il ruolo di controller tramite il protocollo di consenso Raft, descritto nel paper «In Search of an Understandable Consensus Algorithm». Tra i controller se ne elegge uno solo, il controller attivo, che riceve le richieste dei broker e scrive gli eventi; gli altri replicano e restano pronti a subentrare.
Un dettaglio che spesso si fraintende: i broker non ricevono i metadati in push. Secondo la KIP-500 i broker li prelevano dal controller attivo (con una richiesta analoga alla fetch dei consumatori), tengono traccia dell'ultimo offset ricevuto e chiedono solo le novità. Per questo un broker che riparte non deve ricaricare tutto lo stato. Gli eventi arrivano nello stesso ordine su tutti i nodi, perché sono l'ordine del log.
La regola che rende il consenso sicuro è la maggioranza. Un evento è committed quando sta nel log di più della metà dei controller; un candidato leader ottiene il voto di un nodo solo se il suo log è almeno aggiornato quanto quello del votante. Il modello seguente applica proprio queste due regole, e lo fa in due scene: nella prima un controller è rimasto indietro, nella seconda l'ultimo evento esiste solo sul leader che cade.
import java.util.*;
public class MetadataLog {
record Voce(int epoca, String evento) {}
static class Nodo {
final String nome; final List<Voce> log = new ArrayList<>(); boolean vivo = true;
Nodo(String nome) { this.nome = nome; }
int ultimaEpoca() { return log.isEmpty() ? 0 : log.get(log.size() - 1).epoca(); }
// Regola del voto di Raft: si vota solo chi ha un log almeno aggiornato quanto il proprio.
boolean votaPer(Nodo candidato) {
if (candidato.ultimaEpoca() != ultimaEpoca()) return candidato.ultimaEpoca() > ultimaEpoca();
return candidato.log.size() >= log.size();
}
}
// Una voce è committed quando sta nel log di una maggioranza dei nodi.
static int committed(List<Nodo> nodi) {
int[] lunghezze = nodi.stream().mapToInt(n -> n.log.size()).sorted().toArray();
return lunghezze[(nodi.size() - 1) / 2]; // la lunghezza raggiunta da almeno una maggioranza
}
static void elezione(List<Nodo> nodi, Nodo candidato) {
int voti = 1; // vota se stesso
for (Nodo n : nodi) if (n != candidato && n.vivo && n.votaPer(candidato)) voti++;
int maggioranza = nodi.size() / 2 + 1;
System.out.println(candidato.nome + " chiede voti: " + voti + "/" + maggioranza
+ (voti >= maggioranza ? " -> LEADER con " + candidato.log.size() + " voci" : " -> non eletto"));
}
static void scena(String titolo, int voceA, int voceB, int voceC, String... candidati) {
System.out.println("== " + titolo);
Nodo a = new Nodo("A"), b = new Nodo("B"), c = new Nodo("C");
int[] quante = {voceA, voceB, voceC};
Nodo[] tutti = {a, b, c};
for (int i = 0; i < 3; i++)
for (int k = 1; k <= quante[i]; k++) tutti[i].log.add(new Voce(1, "evento-" + k));
List<Nodo> nodi = List.of(a, b, c);
System.out.println("log A/B/C = " + voceA + "/" + voceB + "/" + voceC + ", committed = " + committed(nodi));
a.vivo = false; // il leader A cade
for (String chi : candidati) elezione(nodi, chi.equals("B") ? b : c);
int perse = a.log.size() - committed(nodi);
System.out.println("voci presenti solo su A e mai committed (perse): " + perse);
}
public static void main(String[] args) {
scena("C è rimasto indietro", 5, 5, 3, "C", "B");
scena("l'ultima voce era solo su A", 6, 5, 5, "C");
}
}
// Output:
// == C è rimasto indietro
// log A/B/C = 5/5/3, committed = 5
// C chiede voti: 1/2 -> non eletto
// B chiede voti: 2/2 -> LEADER con 5 voci
// voci presenti solo su A e mai committed (perse): 0
// == l'ultima voce era solo su A
// log A/B/C = 6/5/5, committed = 5
// C chiede voti: 2/2 -> LEADER con 5 voci
// voci presenti solo su A e mai committed (perse): 1
Nella prima scena A (il leader) e B hanno cinque eventi, C ne ha tre; committed vale 5 perché due nodi su tre li hanno. Quando A cade, C chiede i voti ma B glieli rifiuta, perché il suo log è più corto: C ottiene solo il proprio voto (1 su 2 richiesti) e non viene eletto. B invece ottiene il voto di C ed è eletto con tutti e cinque gli eventi. Nella seconda scena la sesta voce era solo su A: la regola dice che non era mai stata committed, e infatti il nuovo leader parte con cinque voci e quella va persa. Non è un difetto: a un client non è mai stato confermato che la voce fosse stata applicata. Il modello omette tutto ciò che in un broker vero è indispensabile: i numeri di epoca che crescono a ogni elezione, il rifiuto di leader obsoleti, le richieste di fetch con cui i follower si allineano, e gli snapshot che accorciano il log.
Quanti controller e dove metterli
Per tollerare N guasti simultanei servono 2N + 1 controller, come ricorda la sezione «Deploying Considerations» della documentazione: con 3 controller il cluster sopravvive a uno che cade, con 5 a due. La tabella sotto la calcola per dimensioni da 1 a 7.
public class Quorum {
public static void main(String[] args) {
System.out.println("controller maggioranza guasti tollerati");
for (int n = 1; n <= 7; n++) {
int maggioranza = n / 2 + 1; // più della metà dei nodi
int tollerati = n - maggioranza; // quanti possono cadere con la maggioranza viva
System.out.printf("%10d %11d %16d%n", n, maggioranza, tollerati);
}
}
}
// Output:
// controller maggioranza guasti tollerati
// 1 1 0
// 2 2 0
// 3 2 1
// 4 3 1
// 5 3 2
// 6 4 2
// 7 4 3
Guarda le righe 3 e 4: passare da tre a quattro controller non aumenta i guasti tollerati (resta 1) ma richiede una maggioranza più grande, quindi un numero pari di controller è un costo senza beneficio. Per la stessa ragione le dimensioni sensate sono 3 e 5 (quello che dice la pagina KRaft: «a Kafka admin will typically select 3 or 5 servers»). Un singolo controller, riga 1, tollera zero guasti: va bene per lo sviluppo. Quanto alla memoria, la documentazione stima che per un cluster tipico bastino 5 GB di RAM e 5 GB di disco per la directory del metadata log, una stima loro («we believe»), non una garanzia.
La scelta successiva è tra controller dedicati e combinati, quelli che fanno anche da broker (process.roles=broker,controller). La documentazione dice che i combinati sono più semplici per lo sviluppo, ma che con la modalità combinata il controller è meno isolato e non si possono aggiornare o scalare i controller separatamente dai broker, e la sconsiglia per i cluster critici. Per una produzione la strada è dunque: tre controller dedicati e N broker.
Configurare un cluster KRaft da zero
La differenza che sorprende chi viene da ZooKeeper è che l'archiviazione va formattata con un identificatore del cluster scelto da te, prima del primo avvio. La documentazione spiega perché: Kafka formattava prima in automatico le directory vuote, e questo poteva nascondere errori; se una maggioranza di controller potesse partire con un log vuoto, potrebbe essere eletto un leader senza dati già committed. Il cluster ID si genera una volta con kafka-storage.sh random-uuid e va riutilizzato per ogni nodo. Conservalo: ti serve per aggiungere nodi.
# Controller dedicato (schema da adattare, dalla documentazione Kafka 4.4)
process.roles=controller
node.id=1
listeners=CONTROLLER://controller1.example.com:9093
controller.listener.names=CONTROLLER
controller.quorum.bootstrap.servers=controller1.example.com:9093,controller2.example.com:9093,controller3.example.com:9093
log.dirs=/var/lib/kafka/metadata
# Broker "puro" (un altro file, su un'altra macchina)
# process.roles=broker
# node.id=4
# controller.quorum.bootstrap.servers=controller1.example.com:9093,controller2.example.com:9093,controller3.example.com:9093
# controller.listener.names=CONTROLLER
Le tre proprietà che definiscono i ruoli sono process.roles, node.id (sostituisce broker.id, e vive in un solo spazio di numeri per broker e controller) e controller.listener.names, il nome del listener su cui i controller parlano tra loro. controller.quorum.bootstrap.servers vale per ogni nodo, broker compresi: deve elencare i controller e serve a trovare il quorum, come bootstrap.servers per un client. Se la sbagli, il nodo non trova il controller attivo e non si unisce.
Attenzione a una differenza fra versioni. Il vecchio schema statico elenca tutti i controller in controller.quorum.voters (formato id@host:porta), che in KRaft versione 1 è dichiarato deprecato; con il quorum dinamico (KIP-853, attivo con kraft.version=1) si imposta invece controller.quorum.bootstrap.servers e si aggiungono o tolgono controller con kafka-metadata-quorum.sh add-controller e remove-controller. Per sapere in quale modalità sei, lancia kafka-features.sh --bootstrap-controller localhost:9093 describe: se kraft.version manca o vale 0 il quorum è statico.
# Schema da adattare (non eseguito): il cluster ID si genera una volta sola
CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
# Primo controller, da solo; gli altri si aggiungono poi dinamicamente
bin/kafka-storage.sh format --cluster-id "$CLUSTER_ID" --standalone \
--config config/controller.properties
# Nuovo broker in un cluster esistente: nessun flag in più
bin/kafka-storage.sh format --cluster-id "$CLUSTER_ID" \
--config config/server.properties
# Nuovo controller in un cluster esistente
bin/kafka-storage.sh format --cluster-id "$CLUSTER_ID" \
--config config/server.properties --no-initial-controllers
Il flag --standalone crea un primo controller che è l'unico votante; --initial-controllers permette invece di partire con più votanti, ma il valore deve essere identico su tutti. Un broker nuovo si formatta senza flag aggiuntivi, un nuovo controller con --no-initial-controllers. L'errore tipico è formattare un nodo con un cluster ID diverso da quello del cluster: il nodo parte, ma non è nello stesso cluster.
Migrare da ZooKeeper senza perdere il cluster
Il percorso ufficiale passa per la 3.9, l'ultima release ponte: la pagina KRaft di 4.4 dice che per migrare «you need to use a bridge release. The last bridge release is Kafka 3.9». Le istruzioni sono nella documentazione 3.9, con la versione dei broker portata a 3.9.1 e inter.broker.protocol.version impostato a 3.9. Il meccanismo prevede cinque stati, nell'ordine che segue:
- Fase iniziale: tutti i broker in modalità ZooKeeper, controller basato su ZooKeeper.
- Caricamento iniziale: un quorum KRaft appena creato legge i metadati da ZooKeeper.
- Fase ibrida: alcuni broker sono ancora ZooKeeper, ma il controller è KRaft.
- Dual-write: tutti i broker sono KRaft, ma il controller continua a scrivere anche su ZooKeeper.
- Finalizzazione: i metadati non vengono più scritti su ZooKeeper.
L'ordine non è intercambiabile e non è sempre reversibile. Dopo la finalizzazione non puoi tornare a ZooKeeper, lo dice la documentazione, e durante la migrazione non devi cambiare la versione dei metadati. Il dual-write esiste per darti un margine: finché non finalizzi, il vecchio sistema ha ancora una copia aggiornata. Non finalizzare finché non hai verificato che il cluster si comporta come atteso.
# Migrazione da ZooKeeper a KRaft (schema da adattare, dalla documentazione Kafka 3.9)
# 1. Controller KRaft: stesso cluster ID del cluster esistente
process.roles=controller
node.id=3000
controller.quorum.bootstrap.servers=localhost:9093
controller.listener.names=CONTROLLER
listeners=CONTROLLER://:9093
zookeeper.metadata.migration.enable=true
zookeeper.connect=localhost:2181
inter.broker.listener.name=PLAINTEXT
# 2. Broker ancora in modalità ZooKeeper, riavviati uno alla volta
broker.id=0
inter.broker.protocol.version=3.9
zookeeper.metadata.migration.enable=true
zookeeper.connect=localhost:2181
controller.quorum.bootstrap.servers=localhost:9093
controller.listener.names=CONTROLLER
# 3. Broker convertito in KRaft: broker.id diventa node.id, via le proprietà ZooKeeper
process.roles=broker
node.id=0
controller.quorum.bootstrap.servers=localhost:9093
controller.listener.names=CONTROLLER
Le righe che contano nel primo blocco sono zookeeper.metadata.migration.enable=true, che mette il controller in modalità migrazione, e zookeeper.connect, che gli permette di leggere da ZooKeeper; il controller va formattato con lo stesso cluster ID del cluster (si legge da meta.properties o da ZooKeeper). Nel secondo blocco i broker vengono riavviati uno alla volta con le stesse due proprietà più l'indirizzo del quorum: quando l'ultimo riparte, la migrazione inizia da sola e il controller registra «Completed migration of metadata from Zookeeper to KRaft». Nel terzo blocco ogni broker si converte, broker.id diventa node.id, si aggiunge process.roles=broker e spariscono le proprietà ZooKeeper; l'ID deve restare lo stesso, e i node.id dei controller non devono coincidere con nessun broker.id esistente. Infine si finalizza togliendo la proprietà di migrazione dai controller e riavviandoli uno alla volta. Se usi l'autorizzazione con AclAuthorizer, nel passaggio dei broker va sostituita con StandardAuthorizer.
Che cosa guardare per sapere che il quorum sta bene
Lo strumento è kafka-metadata-quorum.sh describe --status. Nella documentazione l'uscita mostra campi come LeaderId, LeaderEpoch, HighWatermark, MaxFollowerLag e l'elenco dei votanti correnti. Non ripeto un esempio con valori inventati: guardane la forma sulla pagina e confronta con il tuo cluster. Un MaxFollowerLag che cresce indica un controller che non tiene il passo; LeaderId assente indica che nessun controller è stato eletto, tipicamente perché la maggioranza non è raggiungibile (rete, listener, cluster ID).
Per ispezionare i metadati ci sono due strumenti ufficiali: kafka-dump-log.sh --cluster-metadata-decoder decodifica i segmenti e gli snapshot di __cluster_metadata-0, e kafka-metadata-shell.sh apre uno snapshot come un file system, con directory come brokers, topics e metadataQuorum. Attenzione: il file 00000000000000000000-0000000000.checkpoint non contiene metadati del cluster e non va usato con la shell.
Le differenze di configurazione, in una tabella
| Cosa | Con ZooKeeper (fino a 3.9) | Con KRaft (4.x) |
|---|---|---|
| Come i nodi trovano il coordinamento | zookeeper.connect | controller.quorum.bootstrap.servers (o controller.quorum.voters nel quorum statico) |
| Identità del nodo | broker.id | node.id |
| Ruolo | sempre broker | process.roles: broker, controller o entrambi |
| Listener tra controller | non esiste | controller.listener.names |
| Inizializzazione | automatica | kafka-storage.sh format con cluster ID esplicito |
| Dove stanno i metadati | znode di ZooKeeper | log __cluster_metadata |
| Versione del formato dei metadati | inter.broker.protocol.version | feature metadata.version |
La tabella è una mappa per chi converte un file server.properties. L'ultima riga merita una nota: in KRaft la versione dei metadati si alza con kafka-features.sh upgrade, solo dopo che tutti i nodi hanno il software nuovo, e non si cambia durante una migrazione. La stessa documentazione mostra come alzare le feature con upgrade --release-version 4.1 o, per il solo quorum, upgrade --feature kraft.version=1.
Che cosa sai fare adesso
Sai spiegare che in KRaft i metadati sono un log replicato con Raft e che i broker lo leggono a pezzi. Sai scegliere la dimensione del quorum (3 o 5, mai un numero pari) e preferire controller dedicati in produzione. Sai formattare un nodo con un cluster ID deciso da te, e distingui un quorum statico da uno dinamico con un comando. Sai che per migrare da ZooKeeper devi passare dalla 3.9 e che dopo la finalizzazione non si torna indietro. Il passo successivo è la scrittura affidabile verso quei broker, nell'articolo su producer e consumer avanzati; quando ti servirà dimensionare un cluster vero, torna all'articolo su sizing e disaster recovery. Se arrivi da un altro punto della serie, i collegamenti sono: il monitoraggio.
Fonti
- Apache Kafka 4.4, KRaft - ruoli, 3 o 5 controller, 2N + 1, formattazione, quorum statico e dinamico, strumenti di debug, release ponte 3.9, consultata il 2026-11-12
- Apache Kafka 3.9, KRaft (sezione ZooKeeper to KRaft Migration) - le fasi, le limitazioni e le configurazioni di migrazione, consultata il 2026-11-12
- Apache Kafka 4.4, Upgrading - Kafka 4.0 supporta solo KRaft; versione minima 3.3, consultata il 2026-11-12
- KIP-500: Replace ZooKeeper with a Self-Managed Metadata Quorum - motivazioni, metadati come log, broker che prelevano i metadati, consultata il 2026-11-12
- KIP-853: KRaft Controller Membership Changes - il quorum dinamico dei controller, consultata il 2026-11-12
- Ongaro, Ousterhout - In Search of an Understandable Consensus Algorithm (Raft) - commit a maggioranza e regola del voto, consultata il 2026-11-12
I due modelli Java sono stati compilati ed eseguiti con OpenJDK 21 (java Nome.java); gli output nei commenti sono quelli reali. I blocchi di configurazione e i comandi sono schemi da adattare tratti dalla documentazione e non sono stati eseguiti: non c'è un cluster Kafka in questo ambiente.

Commenti
Caricamento commenti...
Accedi per commentare