Running Kafka Locally
The idea in one sentence
Section titled “The idea in one sentence”Running Kafka 4.x locally means a single broker in KRaft mode, started with three replication-factor env vars forced to 1 so its own internal coordinator topics can actually be created on one broker — get any of the three wrong and the symptom is not a clean error, it is a client that just sits there.
The Docker one-liner, and why three env vars matter more than it looks
Section titled “The Docker one-liner, and why three env vars matter more than it looks”# Start a single-node Kafka 4.x broker in KRaft modedocker run -d --name kafka -p 9092:9092 apache/kafka:4.3.1That bare command works with zero configuration because the image’s own defaults already set offsets.topic.replication.factor, transaction.state.log.replication.factor, and share.coordinator.state.topic.replication.factor to 1 for a combined single-node container. A compose file that starts overriding any other KAFKA_* env var (this course’s own probe does, to publish a second listener) switches the image’s KafkaDockerWrapper into fully env-driven config generation — and that switch does not re-apply the friendly single-node defaults. From that point on you own all three explicitly:
# section: brokeroffsets.topic.replication.factor=1transaction.state.log.replication.factor=1share.coordinator.state.topic.replication.factor=1Proof: leave one out, and watch what actually happens
Section titled “Proof: leave one out, and watch what actually happens”To see the real failure, this lesson started a broker with the offsets and transaction RF env vars set to 1 but share.coordinator.state.topic.replication.factor left at its default (3) — a single-line mistake in an otherwise-correct compose file:
$ docker exec broker kafka-console-share-consumer.sh --bootstrap-server localhost:9092 \ --topic share-test --timeout-ms 10000[... 10 seconds pass, no output at all ...]Processed a total of 0 messagesNothing about that looks like a config error — no exception, no stack trace, just silence until the client’s own timeout gives up. The real cause is in the broker’s log, not the client’s:
[...] 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)[... repeats every ~2-4s until the client gives up ...]The broker keeps retrying the auto-creation of __share_group_state at replication.factor=3 against a cluster with exactly one broker, failing the same way every time, forever — the share group can never come up, and the client sees no error at all, just a hang. A regular classic/consumer-protocol group against the same broker works completely normally in the same run, because __consumer_offsets was correctly set to RF=1 — this is a per-coordinator failure, not a whole-broker failure, which is exactly what makes it easy to miss in a compose file that “mostly works.”
apache/kafka vs apache/kafka-native, measured
Section titled “apache/kafka vs apache/kafka-native, measured”Apache also publishes apache/kafka-native, a GraalVM-compiled build of the same broker. Started with an identical single-node KRaft compose on this Mac, from docker inspect .State.StartedAt to the broker’s own Kafka Server started log line:
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 (ahead-of-time compiled, no JVM startup)Roughly 7x faster to first-ready on this machine — a real, single-run local number, not a benchmark claim. The container’s own first log lines explain why, and also carry the real 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$)The bigger, undocumented-until-you-try-it caveat: apache/kafka-native’s /opt/kafka/ contains exactly one file, the compiled server binary — no bin/kafka-topics.sh, no kafka-console-producer.sh, no kafka-consumer-groups.sh, nothing:
$ 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 logsEvery CLI proof in this course (kafka-topics.sh --describe, kafka-consumer-groups.sh, kafka-dump-log.sh) needs the JVM distribution’s bin/ scripts, which apache/kafka-native does not ship at all — you would run the CLI from a separate apache/kafka container, a host install, or a client library instead. apache/kafka-native is the broker process only.
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"] Listeners: PLAINTEXT, PLAINTEXT_HOST, CONTROLLER
Section titled “Listeners: PLAINTEXT, PLAINTEXT_HOST, CONTROLLER”This course’s own probe compose (and this lesson’s scratch compose) declares three listeners with three different jobs — PLAINTEXT for clients already inside the Docker network, PLAINTEXT_HOST published to the host for clients running outside the container, and CONTROLLER for KRaft’s own quorum traffic between broker/controller roles, which regular clients never connect to at all:
# section: brokerlisteners=PLAINTEXT://:9092,PLAINTEXT_HOST://:39092,CONTROLLER://:39093advertised.listeners=PLAINTEXT://localhost:9092,PLAINTEXT_HOST://localhost:39092controller.listener.names=CONTROLLERlisteners is where the broker binds; advertised.listeners is the address it hands back to clients in metadata responses for every connection after the first one — and getting the second value wrong produces a real getaddrinfo ENOTFOUND from a working bootstrap connection, already proven end-to-end in Brokers, Topics & Partitions — not repeated here.
A healthcheck that actually works
Section titled “A healthcheck that actually works”The obvious healthcheck is running the same tool the broker itself ships:
/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server "$BOOTSTRAP" | head -1That looks complete, and it usually is — until a compose file also enables JMX for metrics (the next lesson does exactly this). This lesson’s own scratch compose set JMX_PORT/KAFKA_JMX_OPTS as container-wide env vars and used the command above, verbatim, as the Docker healthcheck:. Every single run failed:
$ 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 starts its own short-lived JVM to run the admin call — and that JVM inherits the same KAFKA_JMX_OPTS the container sets for the broker itself, so it tries to bind the identical JMX port the running broker process already holds. docker inspect’s own health log showed the real consequence: FailingStreak climbing on every 5-second retry, the container stuck at health: starting indefinitely. The fix is to strip those two env vars for the healthcheck’s own process, not for the 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) -> (Re-applied as the compose healthcheck.test, the same container went healthy on the very next check.
kafka-storage.sh format and cluster.id
Section titled “kafka-storage.sh format and cluster.id”The Docker image auto-formats storage on first start, but it does not generate a fresh UUID unless you tell it to — its own configureDefaults script bakes one in:
$ 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"Confirmed against two independent containers (apache/kafka:4.3.1 and apache/kafka-native:4.3.1) started with no CLUSTER_ID set — both formatted with the exact same cluster.id=5L6g3nShT-eMCtK--X86sw in their meta.properties. Harmless for one standalone broker you throw away; a real problem if you ever script a multi-broker KRaft cluster and assume each container gets its own random ID — they will not, unless you generate and pass one explicitly:
/opt/kafka/bin/kafka-storage.sh random-uuidEwfKmPniREOD__fBjwaROQTwo calls, two different values — proving random-uuid genuinely randomizes, unlike the container’s own silent default. The manual (non-Docker) bootstrap sequence uses exactly this value:
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 vs. ephemeral — the default is ephemeral
Section titled “Data volume vs. ephemeral — the default is ephemeral”The image declares a named volume at /var/lib/kafka/data, which looks like where your data lives. It is not, unless you also set KAFKA_LOG_DIRS to point there. With no override, the broker’s real, effective log.dirs is Kafka’s own built-in default:
$ 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 — where the real data lives — is not in that mount list. /var/lib/kafka/data is a volume, but nothing is writing to it. docker compose down -v, or just docker rm without -v, loses every topic, every offset, everything — by default, on the official image, with no warning. For anything beyond a disposable local test, set KAFKA_LOG_DIRS=/var/lib/kafka/data explicitly (or bind-mount your own path) so the declared volume is the one actually in use.
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"]
Reading AI-generated code
Section titled “Reading AI-generated code”🐛 Spot the 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: 24Show the answer
This is a plausible, common-looking compose fragment — enable JMX for metrics, then healthcheck with the broker’s own admin CLI, which genuinely does prove the broker answers requests. The bug, proven above on this exact combination: kafka-broker-api-versions.sh starts its own JVM, which inherits KAFKA_JMX_OPTS from the container environment and tries to bind port 9999 — the same port the long-running broker process already holds. Every healthcheck invocation exits non-zero with a real ExportException: Port already in use, and the container sits at health: starting (then unhealthy) forever, even though the broker itself is completely fine and serving real traffic. The fix is env -u JMX_PORT -u KAFKA_JMX_OPTS prepended to the healthcheck command specifically — enable JMX for the broker, not for every short-lived CLI process the container also happens to run.
Mental Model
Section titled “Mental Model”A fresh apache/kafka container is really two independent things layered together: a bootstrap ritual (format a log directory once, with a cluster ID, before anything can start — proven above, the image does this for you with a fixed default ID unless you override it) and a runtime config surface (every KAFKA_* env var you add). The bare docker run one-liner works by accident of good defaults on the second layer, not because the first layer is optional — the instant you touch the runtime config surface at all, you inherit responsibility for the pieces the defaults were quietly covering, including all three coordinator replication factors.
Trade-offs
Section titled “Trade-offs”apache/kafka(JVM) vsapache/kafka-native(GraalVM) — native starts ~7x faster on this machine (proven above) and has a smaller memory footprint typical of AOT-compiled binaries, at the direct cost of zero bundled CLI tools and an explicit “experimental, for local dev” warning from the image itself. Use JVM for anything you need to inspect withkafka-*.sh; native is attractive only where startup latency matters more than in-container tooling (e.g. ephemeral CI broker for an app-level test, run alongside a JVM-based tool container).- Auto-formatted storage with a default cluster ID vs a manually generated one — the default is genuinely convenient for a single disposable broker, and genuinely wrong the moment you script more than one container expecting independent identities.
- Ephemeral
/tmpstorage vs a real bind-mounted/volumedlog.dirs— ephemeral is exactly right for this course’s own probes (throwaway per lesson), and exactly wrong for anything you want to survive a restart; the image does not make you choose correctly, its default silently chooses ephemeral for you.
Common Misconceptions
Section titled “Common Misconceptions”- “A bare
docker run apache/kafkaone-liner and my own compose file with extra env vars behave the same way underneath.” No — proven above: adding even one unrelatedKAFKA_*env var (e.g. to publish a second listener) switches the wrapper into fully env-driven mode, which does not re-apply the bare command’s friendly RF=1 defaults. - “Missing a coordinator replication factor throws a config error at startup.” No — proven above: the broker starts fine; the failure only surfaces when a client actually tries to use that specific coordinator (here, a share group), as a silent hang plus a repeating
INVALID_REPLICATION_FACTORwarning in the broker’s own logs, not the client’s. - “
apache/kafka-nativeis just a faster drop-in replacement forapache/kafka.” No — proven above: it ships the broker binary only, with nobin/*.shCLI tools at all; every CLI-based proof in this course needs the JVM image or an external client. - “The image’s declared
/var/lib/kafka/datavolume means my data is automatically persisted.” No — proven above: the volume exists, but the broker’s reallog.dirsdefault (/tmp/kafka-logs) is not that volume; nothing gets written there unless you setKAFKA_LOG_DIRSyourself.