Running Kafka Locally
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”การรัน Kafka 4.x บนเครื่องคือการสตาร์ท broker ตัวเดียวในโหมด KRaft พร้อม บังคับ replication-factor env var สามตัวเป็น 1 เพื่อให้ internal coordinator topic ของตัวมันเองสร้างได้จริงบน broker เดียว — ตั้งผิดแม้ตัวเดียวใน 3 ตัว อาการที่ได้ไม่ใช่ error ที่ชัดเจน แต่คือ client ที่นั่งเฉย ๆ ไม่ตอบสนอง
คำสั่งเดียวของ Docker และทำไม env var 3 ตัวถึงสำคัญกว่าที่คิด
หัวข้อที่มีชื่อว่า “คำสั่งเดียวของ Docker และทำไม env var 3 ตัวถึงสำคัญกว่าที่คิด”# Start a single-node Kafka 4.x broker in KRaft modedocker run -d --name kafka -p 9092:9092 apache/kafka:4.3.1คำสั่งเปล่านั้นทำงานได้โดยไม่ต้องตั้งค่าอะไรเลย เพราะ default ของ image เองตั้ง offsets.topic.replication.factor, transaction.state.log.replication.factor, และ share.coordinator.state.topic.replication.factor เป็น 1 ให้อยู่แล้วสำหรับ container แบบ combined single-node แต่ compose file ที่เริ่ม override KAFKA_* env var ตัวอื่นใดก็ตาม (probe ของคอร์สนี้เองก็ทำแบบนี้ เพื่อ publish listener ตัวที่สอง) จะสลับ KafkaDockerWrapper ของ image ไปเป็นโหมด generate config จาก env ทั้งหมด — และการสลับนั้นจะไม่เอา default ที่เป็นมิตรของ single-node กลับมาใช้ ตั้งแต่จุดนั้นคุณต้องตั้งเองทั้งสามตัว
# section: brokeroffsets.topic.replication.factor=1transaction.state.log.replication.factor=1share.coordinator.state.topic.replication.factor=1พิสูจน์: ลืมตัวเดียว แล้วดูว่าเกิดอะไรขึ้นจริง
หัวข้อที่มีชื่อว่า “พิสูจน์: ลืมตัวเดียว แล้วดูว่าเกิดอะไรขึ้นจริง”เพื่อดู failure ที่แท้จริง บทนี้สตาร์ท broker ที่ตั้ง offsets กับ transaction RF env var เป็น 1 แต่ share.coordinator.state.topic.replication.factor ปล่อยไว้ที่ default (3) — ความผิดพลาดบรรทัดเดียวใน compose file ที่ดูเหมือนถูกต้องทุกอย่าง
$ docker exec broker kafka-console-share-consumer.sh --bootstrap-server localhost:9092 \ --topic share-test --timeout-ms 10000[... รอ 10 วินาที ไม่มี output อะไรเลย ...]Processed a total of 0 messagesไม่มีอะไรในนั้นดูเหมือน config error เลย — ไม่มี exception ไม่มี stack trace มีแค่ความเงียบจนกว่า timeout ของ client เองจะยอมแพ้ สาเหตุจริงอยู่ใน log ของ broker ไม่ใช่ของ client
[...] INFO Sent auto-creation request for Set(__share_group_state) to the active controller. (kafka.server.DefaultAutoTopicCreationManager)[...] WARN Auto topic creation failed for __share_group_state with error 'INVALID_REPLICATION_FACTOR': Unable to replicate the partition 3 time(s): The target replication factor of 3 cannot be reached because only 1 broker(s) are registered or some brokers have all their log directories cordoned. (kafka.server.DefaultAutoTopicCreationManager)[... ซ้ำทุก ~2-4 วินาที จนกว่า client จะยอมแพ้ ...]broker retry การ auto-create __share_group_state ที่ replication.factor=3 กับ cluster ที่มี broker เดียวไปเรื่อย ๆ พังแบบเดิมทุกครั้ง ตลอดไป — share group ไม่มีทาง up ได้ ในขณะที่ client ไม่เห็น error อะไรเลย มีแค่การค้าง group แบบ classic/consumer-protocol ธรรมดา บน broker เดียวกันทำงานปกติสมบูรณ์ใน run เดียวกันนั้น เพราะ __consumer_offsets ถูกตั้งเป็น RF=1 อย่างถูกต้อง — นี่คือความล้มเหลวระดับ per-coordinator ไม่ใช่ทั้ง broker ซึ่งเป็นเหตุผลที่มันพลาดง่ายมากใน compose file ที่ “ส่วนใหญ่ทำงานได้”
apache/kafka เทียบกับ apache/kafka-native วัดจริง
หัวข้อที่มีชื่อว่า “apache/kafka เทียบกับ apache/kafka-native วัดจริง”Apache ยังเผยแพร่ apache/kafka-native ซึ่งเป็น broker ตัวเดียวกันที่ compile ด้วย GraalVM สตาร์ทด้วย compose แบบ single-node KRaft เดียวกันเป๊ะบน Mac เครื่องนี้ วัดจาก docker inspect .State.StartedAt ถึง log line Kafka Server started ของ broker เอง
apache/kafka:4.3.1 container start -> "Kafka Server started" ~1.97s (JVM: class loading + JIT warmup)apache/kafka-native:4.3.1 container start -> "Kafka Server started" ~0.28s (compile แบบ ahead-of-time ไม่ต้อง startup JVM)เร็วกว่าประมาณ 7 เท่า ในการ ready ครั้งแรกบนเครื่องนี้ — เป็นตัวเลขจริงจาก run เดียวในเครื่อง ไม่ใช่การ claim แบบ benchmark log line แรก ๆ ของ container เองอธิบายเหตุผล และมี caveat จริงมาด้วย
WARNING: THIS IS AN EXPERIMENTAL DOCKER IMAGE RECOMMENDED FOR LOCAL TESTING AND DEVELOPMENT PURPOSES.[...] INFO Starting Kafka server in the native mode. (kafka.docker.KafkaDockerWrapper$)caveat ที่ใหญ่กว่าและไม่มีเอกสารบอกจนกว่าจะลองเอง: /opt/kafka/ ของ apache/kafka-native มีไฟล์เดียวเป๊ะ คือ binary ของ server ที่ compile แล้ว — ไม่มี bin/kafka-topics.sh ไม่มี kafka-console-producer.sh ไม่มี kafka-consumer-groups.sh ไม่มีอะไรเลย
$ docker exec kafka-native-broker ls -la /opt/kafka/total 129484-rwxr-xr-x 1 appuser appuser 132565240 kafka.Kafkadrwxrwxr-x 1 appuser appuser configdrwxr-xr-x 2 appuser appuser logsการพิสูจน์ผ่าน CLI ทุกอันในคอร์สนี้ (kafka-topics.sh --describe, kafka-consumer-groups.sh, kafka-dump-log.sh) ต้องใช้ script bin/ ของ distribution แบบ JVM ซึ่ง apache/kafka-native ไม่มีติดมาเลย — คุณจะต้องรัน CLI จาก container apache/kafka แยกต่างหาก, ติดตั้งบน host, หรือใช้ client library แทน apache/kafka-native มีแค่ broker process เท่านั้น
flowchart LR
subgraph jvm["apache/kafka (JVM)"]
j1["kafka.Kafka (JVM)"] --- j2["bin/*.sh CLI tools"]
end
subgraph native["apache/kafka-native (GraalVM)"]
n1["kafka.Kafka (native binary)"]
end
jvm -->|"~1.97s to ready (this Mac)"| ready1["broker ready"]
native -->|"~0.28s to ready (this Mac)"| ready2["broker ready, no CLI tools included"] Listener: PLAINTEXT, PLAINTEXT_HOST, CONTROLLER
หัวข้อที่มีชื่อว่า “Listener: PLAINTEXT, PLAINTEXT_HOST, CONTROLLER”probe compose ของคอร์สนี้เอง (และ scratch compose ของบทนี้) ประกาศ listener สามตัว แต่ละตัวมีหน้าที่ต่างกัน — PLAINTEXT สำหรับ client ที่อยู่ใน Docker network เดียวกันอยู่แล้ว, PLAINTEXT_HOST publish ออกสู่ host สำหรับ client ที่รันนอก container, และ CONTROLLER สำหรับ traffic quorum ของ KRaft เองระหว่าง broker/controller role ซึ่ง client ทั่วไปไม่เคยต่อเข้าไปเลย
# section: brokerlisteners=PLAINTEXT://:9092,PLAINTEXT_HOST://:39092,CONTROLLER://:39093advertised.listeners=PLAINTEXT://localhost:9092,PLAINTEXT_HOST://localhost:39092controller.listener.names=CONTROLLERlisteners คือที่ broker bind; advertised.listeners คือ address ที่มันส่งกลับให้ client ใน metadata response สำหรับทุก connection หลังจาก ตัวแรก — และถ้าตั้งค่าตัวที่สองผิด จะได้ getaddrinfo ENOTFOUND จริงจาก bootstrap connection ที่เชื่อมต่อได้ปกติ ซึ่งพิสูจน์ครบวงจรไว้แล้วใน Brokers, Topics & Partitions — จะไม่พูดซ้ำที่นี่
healthcheck ที่ใช้งานได้จริง
หัวข้อที่มีชื่อว่า “healthcheck ที่ใช้งานได้จริง”healthcheck ที่ดูเหมือนตรงไปตรงมาคือการรัน tool ตัวเดียวกับที่ broker เองมีติดมา
/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server "$BOOTSTRAP" | head -1ดูสมบูรณ์และมักจะเป็นแบบนั้นจริง — จนกว่า compose file จะเปิด JMX สำหรับ metrics ด้วย (บทถัดไป ทำแบบนี้เป๊ะ) scratch compose ของบทนี้เองตั้ง JMX_PORT/KAFKA_JMX_OPTS เป็น env var ระดับ container และใช้คำสั่งด้านบนตรง ๆ เป็น Docker healthcheck: ทุก run พังหมด
$ docker exec broker kafka-broker-api-versions.sh --bootstrap-server localhost:9092Error: JMX connector server communication error: service:jmx:rmi://<container-id>:39094jdk.internal.agent.AgentConfigurationError: java.rmi.server.ExportException: Port already in use: 39094; nested exception is: java.net.BindException: Address in use[...]kafka-broker-api-versions.sh สตาร์ท JVM สั้น ๆ ของตัวเองเพื่อรัน admin call — และ JVM นั้น inherit KAFKA_JMX_OPTS เดียวกันที่ container ตั้งไว้สำหรับ broker เอง เลยพยายาม bind JMX port เดียวกัน กับที่ broker process ที่รันอยู่ถืออยู่แล้ว docker inspect health log เองแสดงผลลัพธ์จริง: FailingStreak ไต่ขึ้นทุก retry 5 วินาที container ค้างที่ health: starting ตลอดไป วิธีแก้คือถอด env var สองตัวนั้นออกสำหรับ process ของ healthcheck เอง ไม่ใช่สำหรับ broker
env -u JMX_PORT -u KAFKA_JMX_OPTS /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server "$BOOTSTRAP" | head -1localhost:9092 (id: 1 rack: null isFenced: false) -> (เอาไปใส่เป็น compose healthcheck.test ใหม่ container เดียวกันเข้าสถานะ healthy ในการเช็กครั้งถัดไปทันที
kafka-storage.sh format กับ cluster.id
หัวข้อที่มีชื่อว่า “kafka-storage.sh format กับ cluster.id”image ของ Docker auto-format storage ตอนสตาร์ทครั้งแรก แต่ไม่ได้ generate UUID ใหม่ให้เว้นแต่คุณจะบอกมัน — script configureDefaults ของมันเองฝัง UUID ไว้แล้ว
$ docker run --rm --entrypoint sh apache/kafka:4.3.1 -c \ 'grep -n CLUSTER_ID /etc/kafka/docker/configureDefaults'# Replace CLUSTER_ID with a unique base64 UUID using "bin/kafka-storage.sh random-uuid" ["CLUSTER_ID"]="5L6g3nShT-eMCtK--X86sw"ยืนยันกับ container อิสระสองตัว (apache/kafka:4.3.1 กับ apache/kafka-native:4.3.1) ที่สตาร์ทโดยไม่ตั้ง CLUSTER_ID เลย — ทั้งคู่ format ด้วย cluster.id=5L6g3nShT-eMCtK--X86sw ตัวเดียวกันเป๊ะ ใน meta.properties ของตัวเอง ไม่มีปัญหาสำหรับ broker เดี่ยวที่ใช้แล้วทิ้ง แต่เป็นปัญหาจริงถ้าคุณเขียนสคริปต์ทำ multi-broker KRaft cluster แล้วคิดว่าแต่ละ container จะได้ ID สุ่มของตัวเอง — มันจะไม่ได้ เว้นแต่คุณจะ generate แล้วส่งเข้าไปเอง
/opt/kafka/bin/kafka-storage.sh random-uuidEwfKmPniREOD__fBjwaROQเรียกสองครั้ง ได้ค่าต่างกันสองค่า — พิสูจน์ว่า random-uuid สุ่มจริง ต่างจาก default ที่เงียบ ๆ ของ container เอง ลำดับการ bootstrap แบบ manual (ไม่ใช้ Docker) ใช้ค่านี้เป๊ะ
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"bin/kafka-storage.sh format --standalone -t "$KAFKA_CLUSTER_ID" -c config/server.propertiesbin/kafka-server-start.sh config/server.propertiesdata volume เทียบกับ ephemeral — default คือ ephemeral
หัวข้อที่มีชื่อว่า “data volume เทียบกับ ephemeral — default คือ ephemeral”image ประกาศ named volume ที่ /var/lib/kafka/data ซึ่งดูเหมือนเป็นที่ข้อมูลของคุณอยู่ มันไม่ใช่ เว้นแต่คุณจะตั้ง KAFKA_LOG_DIRS ให้ชี้ไปที่นั่นด้วย ถ้าไม่ override อะไร log.dirs จริงที่ broker ใช้คือ default ในตัวของ Kafka เอง
$ docker exec broker cat /tmp/kafka-logs/meta.propertiescluster.id=5L6g3nShT-eMCtK--X86swdirectory.id=O4H39-bgwrYSQm9VydOgxgnode.id=1version=1
$ docker inspect broker --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}'/etc/kafka/secrets/mnt/shared/config/var/lib/kafka/data/tmp/kafka-logs — ที่ข้อมูลจริงอยู่ — ไม่อยู่ ใน mount list นั้น /var/lib/kafka/data เป็น volume จริง แต่ไม่มีอะไรเขียนลงไปเลย docker compose down -v หรือแค่ docker rm โดยไม่มี -v ก็ทำให้ทุก topic ทุก offset ทุกอย่างหายหมด — โดย default บน official image โดยไม่มีคำเตือนใด ๆ สำหรับอะไรที่มากกว่าการทดสอบแบบใช้แล้วทิ้งในเครื่อง ให้ตั้ง KAFKA_LOG_DIRS=/var/lib/kafka/data ตรง ๆ (หรือ bind-mount path ของคุณเอง) เพื่อให้ volume ที่ประกาศไว้เป็นตัวที่ถูกใช้งานจริง
flowchart TD start["docker run apache/kafka (no KAFKA_LOG_DIRS)"] --> logdirs["effective log.dirs = /tmp/kafka-logs (Kafka's own default)"] logdirs --> notmounted["NOT one of the container's mounted volumes"] declared["Image declares a volume at /var/lib/kafka/data"] --> unused["...but nothing writes there unless you set KAFKA_LOG_DIRS yourself"] notmounted --> gone["docker compose down -v (or plain rm) -> all data lost"]
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”🐛 หา bug ในโค้ดนี้
services:broker: image: apache/kafka:4.3.1 environment: JMX_PORT: '9999' KAFKA_JMX_OPTS: >- -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.rmi.port=9999 healthcheck: # AI comment: "Reuse the broker's own CLI tool to verify it's alive" test: ['CMD-SHELL', '/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092'] interval: 5s retries: 24เฉลย
นี่คือ compose fragment ที่ดูสมเหตุสมผลและพบเห็นบ่อย — เปิด JMX สำหรับ metrics แล้ว healthcheck ด้วย admin CLI ของ broker เอง ซึ่งพิสูจน์ได้จริงว่า broker ตอบ request จริง ๆ bug ที่พิสูจน์ไว้ด้านบนกับ combination เป๊ะนี้: kafka-broker-api-versions.sh สตาร์ท JVM ของตัวเอง ซึ่ง inherit KAFKA_JMX_OPTS จาก container environment แล้วพยายาม bind port 9999 — port เดียวกับที่ broker process ที่รันอยู่ถืออยู่แล้ว healthcheck ทุกครั้ง exit ไม่เป็นศูนย์ด้วย ExportException: Port already in use จริง และ container ค้างที่ health: starting (แล้วเป็น unhealthy) ตลอดไป ทั้งที่ broker เองทำงานปกติสมบูรณ์และให้บริการ traffic จริง วิธีแก้คือใส่ env -u JMX_PORT -u KAFKA_JMX_OPTS นำหน้าคำสั่ง healthcheck โดยเฉพาะ — เปิด JMX ให้ broker แต่ไม่ใช่ให้ทุก short-lived CLI process ที่ container ดันรันด้วย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”container apache/kafka ใหม่แท้จริงแล้วคือสองสิ่งอิสระที่ซ้อนกันอยู่: พิธีกรรม bootstrap (format log directory ครั้งเดียว พร้อม cluster ID ก่อนที่อะไรจะสตาร์ทได้ — พิสูจน์ด้านบน image ทำสิ่งนี้ให้คุณด้วย ID default ที่ตายตัวเว้นแต่จะ override) และ พื้นผิว config runtime (KAFKA_* env var ทุกตัวที่คุณเพิ่ม) คำสั่งเปล่า docker run ทำงานได้เพราะบังเอิญมี default ที่ดีบนชั้นที่สอง ไม่ใช่เพราะชั้นแรกเป็น optional — ทันทีที่คุณแตะพื้นผิว config runtime คุณรับผิดชอบส่วนที่ default เคยครอบคลุมเงียบ ๆ รวมถึง replication factor ของ coordinator ทั้งสามตัว
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”apache/kafka(JVM) เทียบกับapache/kafka-native(GraalVM) — native สตาร์ทเร็วกว่าประมาณ 7 เท่าบนเครื่องนี้ (พิสูจน์ด้านบน) และมี memory footprint เล็กกว่าตามแบบฉบับ binary ที่ compile แบบ AOT แลกกับ ไม่มี CLI tool ติดมาเลย และคำเตือน “experimental, for local dev” ตรง ๆ จาก image เอง ใช้ JVM สำหรับอะไรก็ตามที่ต้อง inspect ด้วยkafka-*.sh; native น่าสนใจเฉพาะตอนที่ startup latency สำคัญกว่า tooling ใน container (เช่น broker ชั่วคราวสำหรับ test ระดับ app ใน CI รันคู่กับ container tool ที่เป็น JVM)- Storage ที่ auto-format ด้วย cluster ID default เทียบกับสร้างเอง — default สะดวกจริงสำหรับ broker เดี่ยวที่ใช้แล้วทิ้ง และผิดจริงทันทีที่คุณเขียนสคริปต์มากกว่าหนึ่ง container ที่คาดหวัง identity อิสระของตัวเอง
- Storage แบบ ephemeral ใน
/tmpเทียบกับlog.dirsที่ bind-mount/volume จริง — ephemeral ถูกต้องเป๊ะสำหรับ probe ของคอร์สนี้เอง (ใช้แล้วทิ้งต่อบท) และผิดเป๊ะสำหรับอะไรก็ตามที่คุณอยากให้รอดจาก restart image ไม่ได้บังคับให้คุณเลือกถูก default ของมันเลือก ephemeral ให้เงียบ ๆ
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
docker run apache/kafkaเปล่า ๆ กับ compose file ของฉันที่มี env var เพิ่มเติมทำงานเหมือนกันข้างใต้” ไม่ — พิสูจน์ด้านบน: การเพิ่มKAFKA_*env var ที่ไม่เกี่ยวกันแม้ตัวเดียว (เช่น เพื่อ publish listener ตัวที่สอง) สลับ wrapper ไปโหมด env-driven เต็มรูปแบบ ซึ่งไม่เอา default RF=1 ที่เป็นมิตรของคำสั่งเปล่ากลับมาใช้ - “การลืม replication factor ของ coordinator ตัวใดตัวหนึ่งจะได้ config error ตอน startup” ไม่ — พิสูจน์ด้านบน: broker สตาร์ทได้ปกติดี; failure จะโผล่มาตอนที่ client พยายามใช้ coordinator ตัวนั้นจริง ๆ เท่านั้น (ในที่นี้คือ share group) เป็นการค้างแบบเงียบ ๆ บวก warning
INVALID_REPLICATION_FACTORที่ซ้ำใน log ของ broker เอง ไม่ใช่ของ client - “
apache/kafka-nativeเป็นแค่ตัวแทนที่เร็วกว่าของapache/kafka” ไม่ — พิสูจน์ด้านบน: มันมีแค่ binary ของ broker เท่านั้น ไม่มี CLI toolbin/*.shเลย การพิสูจน์ที่ใช้ CLI ทุกอันในคอร์สนี้ต้องใช้ image แบบ JVM หรือ client จากภายนอก - “volume
/var/lib/kafka/dataที่ image ประกาศไว้แปลว่าข้อมูลของฉันถูก persist อัตโนมัติ” ไม่ — พิสูจน์ด้านบน: volume มีอยู่จริง แต่log.dirsdefault จริงของ broker (/tmp/kafka-logs) ไม่ใช่ volume นั้น ไม่มีอะไรถูกเขียนลงไปเลยเว้นแต่คุณจะตั้งKAFKA_LOG_DIRSเอง