ข้ามไปยังเนื้อหา

KRaft กับ Cluster

Kafka cluster ต้องมีที่เก็บ metadata ของ cluster ว่า broker ตัวไหน lead partition ไหน, มี topic อะไรบ้าง, ใครอยู่ใน ISR และใน Kafka 4.x งานนี้ทำโดย KRaft ที่เป็น controller quorum แบบ Raft ที่ built-in เข้ามา โดย ZooKeeper ถูกถอดออกหมดแล้ว

ตลอดประวัติศาสตร์ส่วนใหญ่ของ Kafka ใช้ ensemble ของ ZooKeeper ภายนอกเก็บ metadata ของ cluster เป็นระบบแยกต่างหากที่ต้อง deploy, secure, tune และทำความเข้าใจ แถมกลายเป็นคอขวดด้าน scalability เมื่อ cluster โตจนมี partition เยอะ KRaft (Kafka Raft, KIP-500) ย้ายการจัดการ metadata เข้ามาไว้ใน Kafka เอง ตั้งแต่ Kafka 4.0 KRaft เป็น mode เดียว ZooKeeper mode ถูกถอดออกทั้งหมด และ flag --zookeeper ของ CLI ก็หายไปแล้ว

KRaft เอาปรัชญาของ Kafka เองมาใช้กับการประสานงาน cluster โดยเก็บ metadata เป็น log ที่ replicate ได้ broker กลุ่มหนึ่งทำหน้าที่เป็น controller รวมกันเป็น quorum โดยมีตัวหนึ่งเป็น leader ที่ถูกเลือก ทุกการเปลี่ยน metadata ไม่ว่าจะ create topic, เลือก partition leader ใหม่ หรือ update ISR ล้วนเป็น event ที่ถูก append เข้า metadata log ภายใน (topic __cluster_metadata, partition 0) และ replicate ไปยัง controller ตัวอื่นผ่าน protocol แบบ Raft

ผลลัพธ์คือความ consistent และความเร็ว broker ตาม metadata ทันด้วยวิธีเดียวกับที่ consumer ตามข้อมูลทัน คืออ่าน log จาก offset การ failover ของ leader และการกระจาย metadata เร็วกว่าโมเดล watch สมัย ZooKeeper มาก

flowchart TB
  subgraph quorum["Controller quorum (KRaft)"]
    lead["Active controller (leader)"] -->|replicate metadata log| f1["Controller (follower)"]
    lead -->|replicate metadata log| f2["Controller (follower)"]
  end
  lead -->|metadata updates| brk["Brokers (data plane)"]
  brk --> cli["Clients discover leaders via bootstrap-server"]
KRaft: controller quorum ที่ replicate metadata log

บน single-broker container ของคอร์สนี้เอง (process.roles=broker,controller — node แบบ combined ทำทั้งสองบทบาท) kafka-metadata-quorum.sh คุยกับ quorum ได้ตรง ๆ

scripts/kraft-and-the-cluster/describe-single.sh
echo "=== describe --status ==="
/opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server $BOOTSTRAP describe --status
echo "=== describe --replication ==="
/opt/kafka/bin/kafka-metadata-quorum.sh --bootstrap-server $BOOTSTRAP describe --replication

output จริงไม่ได้แก้ไข

=== describe --status ===
ClusterId: 5L6g3nShT-eMCtK--X86sw
LeaderId: 1
LeaderEpoch: 1
HighWatermark: 185
MaxFollowerLag: 0
MaxFollowerLagTimeMs: 0
CurrentVoters: [{"id": 1, "endpoints": ["CONTROLLER://localhost:29093"]}]
CurrentObservers: []
=== describe --replication ===
NodeId DirectoryId LogEndOffset Lag LastFetchTimestamp LastCaughtUpTimestamp Status
1 AAAAAAAAAAAAAAAAAAAAAA 187 0 1790636449483 1790636449483 Leader

voter ตัวเดียว ตัวมันเอง เป็น Leader ตรงตามที่ combined broker+controller node ตัวเดียวควรจะเป็น มันคือ quorum ที่มีสมาชิกคนเดียว MaxFollowerLag/MaxFollowerLagTimeMs เลยเป็น 0 เสมอ และ CurrentVoters มีแค่ตัวมันเอง

เพื่อดู quorum จริงที่มี follower มีการนำ compose 3-node ชั่วคราว ขึ้นมา (ทั้งสามตัว process.roles=broker,controller ไม่ publish host port เลย เป็น scratch stack ของคอร์สนี้เอง ปิดทันทีหลังพิสูจน์เสร็จ) แล้วรันคำสั่งเดียวกันเป๊ะ

# kafka-metadata-quorum.sh --bootstrap-server broker1:9092 describe --status
ClusterId: 5L6g3nShT-eMCtK--X86sw
LeaderId: 2
LeaderEpoch: 1
HighWatermark: 48
MaxFollowerLag: 0
MaxFollowerLagTimeMs: 182
CurrentVoters: [{"id": 1, "endpoints": ["CONTROLLER://broker1:9093"]}, {"id": 2, "endpoints": ["CONTROLLER://broker2:9093"]}, {"id": 3, "endpoints": ["CONTROLLER://broker3:9093"]}]
CurrentObservers: []
# kafka-metadata-quorum.sh --bootstrap-server broker1:9092 describe --replication
NodeId DirectoryId LogEndOffset Lag LastFetchTimestamp LastCaughtUpTimestamp Status
2 AAAAAAAAAAAAAAAAAAAAAA 50 0 1790636489291 1790636489291 Leader
1 AAAAAAAAAAAAAAAAAAAAAA 50 0 1790636489121 1790636489121 Follower
3 AAAAAAAAAAAAAAAAAAAAAA 50 0 1790636489121 1790636489121 Follower

node 2 ชนะการเลือกตั้งและขึ้น Leader ส่วน node 1 กับ 3 ขึ้น Follower และ MaxFollowerLagTimeMs: 182 เป็นตัวเลขจริงที่ไม่ใช่ศูนย์ในรอบนี้ คือเวลาตั้งแต่ follower ที่ช้าที่สุดตามทันครั้งล่าสุด บน cluster 3 node ที่ idle บนเครื่องนี้ นี่คือข้อมูลรูปแบบเดียวกับที่ kafka-topics.sh --describe รายงาน Leader/Isr/high-watermark ให้ partition ของข้อมูล — พิสูจน์แล้วใน Brokers, Topics & Partitions — เพราะ metadata partition ก็ถูก replicate ด้วยกลไกแบบ Raft เดียวกันเป๊ะที่ KRaft ถูกสร้างมาเพื่อ generalize

metadata log เป็น Kafka log จริง ไฟล์ segment/index แบบเดียวกับ partition อื่น ๆ (ดู The Log Abstraction) แค่มี decoder พิเศษ kafka-dump-log.sh --cluster-metadata-decoder อ่าน raw segment __cluster_metadata-0 แล้ว print record ที่มี type จริง ๆ

scripts/kraft-and-the-cluster/dump-metadata-log.sh
# Redirect the full dump to a file first (this log grows for as long as the
# broker runs), then grep the first real example of each record type --
# keeps output bounded no matter how large the log has grown.
/opt/kafka/bin/kafka-dump-log.sh --cluster-metadata-decoder \
--files /tmp/kafka-logs/__cluster_metadata-0/00000000000000000000.log --print-data-log \
> /tmp/meta-dump.txt 2>&1
for t in LeaderChange FEATURE_LEVEL_RECORD CONFIG_RECORD REGISTER_CONTROLLER_RECORD \
REGISTER_BROKER_RECORD BROKER_REGISTRATION_CHANGE_RECORD NO_OP_RECORD \
TOPIC_RECORD PARTITION_RECORD; do
grep -m 1 "$t" /tmp/meta-dump.txt
done

output จริง — ตัวอย่างแรกจริง ๆ ของแต่ละ record type ที่เจอใน metadata log ของคอร์สนี้เอง

| offset: 0 ... LeaderChange: {"version":0,"leaderId":1,"voters":[{"voterId":1}],"grantingVoters":[{"voterId":1}]}
| offset: 2 ... payload: {"type":"FEATURE_LEVEL_RECORD","version":0,"data":{"name":"metadata.version","featureLevel":30}}
| offset: 8 ... payload: {"type":"CONFIG_RECORD","version":0,"data":{"resourceType":4,"resourceName":"","name":"min.insync.replicas","value":"1"}}
| offset: 10 ... payload: {"type":"REGISTER_CONTROLLER_RECORD","version":0,"data":{"controllerId":1,"incarnationId":"SW2n63rERL2UUhHUFJ3VkQ","zkMigrationReady":false,"endPoints":[{"name":"CONTROLLER","host":"localhost","port":9093,"securityProtocol":0}], ...}}
| offset: 11 ... payload: {"type":"REGISTER_BROKER_RECORD","version":4,"data":{"brokerId":1,"incarnationId":"riTwzbFkR2qHiCWR29Fbzw","brokerEpoch":11,"endPoints":[{"name":"PLAINTEXT","host":"localhost","port":9092, ...},{"name":"PLAINTEXT_HOST","host":"localhost","port":19092, ...}], ..., "fenced":true, ...}}
| offset: 12 ... payload: {"type":"BROKER_REGISTRATION_CHANGE_RECORD","version":0,"data":{"brokerId":1,"brokerEpoch":11,"fenced":-1}}
| offset: 14 ... payload: {"type":"NO_OP_RECORD","version":0,"data":{}}
| offset: 26 ... payload: {"type":"TOPIC_RECORD","version":0,"data":{"name":"consumers-consumer-groups-and-rebalancing-orders","topicId":"obGo8usiSIiFSzu8Acbl-w"}}
| offset: 27 ... payload: {"type":"PARTITION_RECORD","version":2,"data":{"partitionId":0,"topicId":"obGo8usiSIiFSzu8Acbl-w","replicas":[1],"isr":[1],"leader":1,"leaderEpoch":0,"partitionEpoch":0, ...}}

(ตัวอย่าง TOPIC_RECORD/PARTITION_RECORD มาจาก topic ของบทเรียนอื่นบน harness broker ที่ใช้ร่วมกันตัวเดียวกันนี้ — metadata log เก็บทุก topic ที่เคยถูกสร้างบนมัน ซึ่งเป็นภาพประกอบจริงของ “broker เดียว, metadata log เดียว, ทุกการเปลี่ยน cluster shape ที่เคยเกิดขึ้น” — พิสูจน์แล้วข้างบนตรงที่ record ตัวแรก ๆ ที่ offset 0-14 คือ bootstrap ของ broker ตัวนี้เอง ก่อนจะมี topic ไหนอยู่เลยด้วยซ้ำ)

node ทุกตัวข้างบนรัน process.roles=broker,controller — node แบบ combined ทำทั้งสองงาน เป็นรูปแบบเดียวที่ single-node และ 3-node proof ของบทนี้ใช้ (และเป็นรูปแบบที่ cluster เล็ก ๆ มักรันกัน) Kafka ยังรองรับ isolated controller ด้วย node ที่ process.roles=controller อย่างเดียว (ไม่เคยรับ client traffic หรือ host partition เลย) คู่กับ node แยกที่ process.roles=broker อย่างเดียว isolated controller แลกความง่ายในการดูแล (process เยอะขึ้นต้องรัน) กับการแยก blast-radius broker ที่โดน client load หนัก ๆ จะไม่แย่ง CPU/disk I/O ที่ controller quorum ต้องใช้เพื่อเลือก partition leader ให้ทันเวลา ซึ่งสำคัญขึ้นเรื่อย ๆ เมื่อ cluster และจำนวน partition โตขึ้น proof ของบทนี้ทั้งหมดเป็น combined mode ส่วนการเลือกระหว่างสองแบบเป็นการตัดสินใจเชิง operational sizing ที่อธิบายต่อใน Running Kafka Locally และโมดูล production

node ทุกตัวข้างบนถูก start ด้วย controller.quorum.voters=1@broker1:9093,2@broker2:9093,3@broker3:9093 แบบ static node ID และ address ของทุก voter ถูกตรึงไว้ตั้งแต่ start ยืนยันตรงจาก server.properties ของ container เอง นี่คือโมเดล KRaft ดั้งเดิม จะเปลี่ยน quorum membership ต้องแก้ config แล้ว restart ทุก node

KIP-853 เพิ่มทางเลือก quorum แบบ dynamic ยืนยันแล้วว่ามีจริงและใช้ได้บน Kafka 4.3.1 image ของคอร์สนี้เอง

  • controller.quorum.bootstrap.servers เป็น broker config key จริงที่มีเอกสารรองรับ (ยืนยันกับ official config reference ที่ cache ไว้ซึ่ง harness ของคอร์สนี้ใช้) เป็น list address สไตล์ bootstrap สำหรับ discover quorum ปัจจุบัน แทนที่จะเป็น voter set แบบตายตัวปิดตาย

  • kafka-metadata-quorum.sh เอง output ของ --help บน image นี้ list ไว้ 3 subcommand ไม่ใช่ 1 คือ describe, add-controller และ remove-controller command จริงที่มีอยู่จริงสำหรับเปลี่ยน quorum membership ตอน runtime (ผ่าน connection --bootstrap-controller) แทนที่จะแก้ controller.quorum.voters แล้ว restart controller ทุกตัว

ในชีวิตประจำวัน KRaft แทบมองไม่เห็นจากฝั่ง client คุณยังเชื่อมต่อด้วย --bootstrap-server, produce และ consume เหมือนเดิมเป๊ะ พิสูจน์แล้วตลอดคอร์สนี้ ที่เปลี่ยนคือ mental model กับงาน operations

  • ระบบเดียว ไม่ใช่สอง ไม่มี ZooKeeper ให้ deploy หรือ monitor broker และ controller เป็น process ของ Kafka cluster เล็ก ๆ รวมสองบทบาทไว้ด้วยกันได้เลย ตามที่ proof ทุกอันในบทนี้ใช้
  • recovery เร็วขึ้น การ failover ของ controller และการกระจาย metadata เร็วขึ้น ทำให้ leadership ของ partition ลงตัวเร็วขึ้นหลัง broker ตาย
  • default สมัยใหม่ Kafka 4.x ยังปล่อย KIP-848 next-generation consumer rebalance protocol เป็น GA (banner ของมันขึ้นมาเองโดยไม่ต้องเรียก ตั้งแต่ proof แรกของคอร์สนี้ — ดู What Is Kafka) และเสริมความแข็งแรงให้ transaction (KIP-890) ทั้งสองอย่างจะพูดถึงต่อในคอร์สนี้

🐛 หา bug ในโค้ดนี้

scripts/kraft-and-the-cluster/list-topics-old-style.sh
kafka-topics.sh --zookeeper zk1:2181,zk2:2181,zk3:2181 --list
kafka-topics.sh --zookeeper zk1:2181,zk2:2181,zk3:2181 \
--create --topic events --partitions 6 --replication-factor 3
เฉลย

นี่เป็น pattern ที่ AI assistant สร้างบ่อยมาก มาจาก training data ก่อนยุค 4.0 ตรง ๆ ที่ --zookeeper เป็นวิธีปกติในการรัน admin command บน Kafka 4.x ยืนยันตรงกับ broker ของคอร์สนี้เอง ไม่มี ZooKeeper process รันอยู่เลย (KRaft ถอดมันออกไปแล้ว พิสูจน์แล้วข้างบนว่าทุก metadata operation ในบทนี้ผ่าน --bootstrap-server กับ __cluster_metadata log แทน) รันสคริปต์นี้ตามที่เขียนไว้จะพังทันที kafka-topics.sh บน 4.x ไม่รับ flag --zookeeper เลยด้วยซ้ำ วิธีแก้คือสลับตรง ๆ เป็น --bootstrap-server localhost:9092 (หรือ bootstrap address จริง) ซึ่งคุยกับ broker แล้ว broker ก็ resolve request นั้นกับ metadata ที่ KRaft จัดการอยู่เอง ไม่ต้องมี connection string ของ ZooKeeper และไม่ต้องมี ensemble ของ ZooKeeper ให้รันค้างไว้ที่ไหนเลย

ก่อนมี KRaft คำถาม “cluster หน้าตาเป็นยังไงตอนนี้” ต้องไปถามคนละระบบ (ZooKeeper) กับระบบที่ทำงานจริง (broker) สองระบบที่ต้องตรงกันเอง และอาจเพี้ยนจากกันได้เวลามี partition หรือ bug mental model ของ KRaft ยุบทั้งหมดนี้เป็นไอเดียเดียวที่ใช้อยู่ทุกที่ในคอร์สนี้แล้ว รูปร่างของ cluster ก็แค่ log อีกอันหนึ่ง controller quorum คือ Kafka cluster จิ๋ว ๆ (พิสูจน์แล้วข้างบน มัน Leader, Follower, high watermark, replication lag คำศัพท์เดียวกับ data partition ทุกอย่าง) ที่มีงานเดียวคือ replicate log ของ “อะไรเปลี่ยนไปบ้างเกี่ยวกับ cluster” broker ทุกตัวคือ consumer ของ log นั้น ไม่มี source of truth ที่สองให้หลุดไม่ตรงกันอีกต่อไป เพราะไม่มีระบบที่สองอีกแล้ว

  • Combined node (broker+controller) — process น้อยลงให้รันและ monitor (proof ทุกอันในบทนี้ใช้แบบนี้) แลกกับ load ของ client traffic ของ broker ต้องแชร์ CPU/disk กับหน้าที่ controller ของตัวมันเอง
  • Isolated controller — แยก blast-radius ดีกว่าบน cluster ใหญ่ แลกกับต้องรันและ monitor กองทัพ node ที่ทำหน้าที่ controller อย่างเดียวแยกต่างหาก
  • controller.quorum.voters แบบ static — เรียบง่าย ชัดเจน และเป็นสิ่งที่ proof ทุกอันในบทนี้ใช้ เปลี่ยน membership ต้องแก้ config แล้ว restart controller ทุกตัว
  • Dynamic quorum (KIP-853) — พิสูจน์แล้วข้างบนว่ามีอยู่จริงเป็น tooling ใน 4.3.1 (add-controller/remove-controller, controller.quorum.bootstrap.servers) ผลตอบแทนคือเปลี่ยน membership ได้โดยไม่ต้อง restart ทั้ง quorum แลกกับ model bootstrap-discovery ที่ไม่ชัดเจนเท่า operational ซับซ้อนกว่าต้องทำความเข้าใจ
  • “Kafka 4.x ยังต้องใช้ ZooKeeper อยู่ดี เว้นแต่จะเลือกใช้ KRaft” ผิด พิสูจน์แล้วข้างบนและ KIP-500 บอกตรง ๆ ว่าตั้งแต่ Kafka 4.0 KRaft เป็น mode เดียว ZooKeeper mode ถูกถอดออก ไม่ใช่แค่ deprecate
  • “kafka-metadata.sh คือ tool สำหรับดู KRaft state” ผิด เช็คตรง ๆ กับ /opt/kafka/bin/ บน image 4.3.1 ของคอร์สนี้เอง script นั้นไม่มีอยู่ tool จริงคือ kafka-metadata-quorum.sh กับ kafka-metadata-shell.sh
  • “controller quorum กับ data replication (leader/ISR) เป็นกลไกคนละเรื่องกันไม่เกี่ยวข้องกัน” ผิด พิสูจน์แล้วข้างบน describe --replication บน metadata partition รายงาน Leader/Follower/lag รูปแบบเดียวกับที่ kafka-topics.sh --describe รายงานให้ data partition เป๊ะ เพราะ controller quorum ของ KRaft คือ Kafka partition ที่ replicate ด้วย Raft แค่บรรทุก cluster metadata แทน record ของแอป
  • “ทุก Kafka 4.x cluster ต้องรัน isolated controller node” ผิด proof จริงทุกอันในบทนี้ (single-node และ 3-node) ใช้ node แบบ combined process.roles=broker,controller isolated controller เป็นทางเลือกที่มีให้สำหรับ cluster ใหญ่กว่า ไม่ใช่ข้อบังคับ
อะไรมาแทน ZooKeeper สำหรับเก็บ metadata ของ cluster ใน Kafka 4.x
KRaft เก็บ metadata ของ cluster ยังไง
จาก proof 3-node quorum จริงข้างบน describe --status กับ describe --replication แสดงอะไร
การเช็ค /opt/kafka/bin/ บน Kafka 4.3.1 image ของคอร์สนี้เอง พิสูจน์อะไรเกี่ยวกับ kafka-metadata.sh
บทนี้พิสูจน์อะไรว่ามีจริงและ ship มาแล้วใน Kafka 4.3.1 สำหรับ KIP-853 dynamic quorum reconfiguration
คุณเห็น tutorial ใช้ --zookeeper localhost:2181 กับ kafka-topics.sh บน Kafka 4.x แปลว่าอะไร