A cloud-native, Rust-based Kafka-compatible broker backed by object storage (S3, GCS, Azure) with embedded Raft consensus.
Kafkaesque speaks the Kafka wire protocol, so standard Kafka clients work without modification. It uses object storage for durability and embedded Raft for coordination — no Zookeeper, no external metadata service, no local-disk reliability requirement.
- Architecture
- Quick Start
- Workspace layout
- Configuration
- Health & monitoring
- Durability contract
- Security
- Deployment
- Cargo features
- Supported APIs
- Client compatibility
- Testing
- Fuzzing
- Development
- License
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Kafka Client │ │ Kafka Client │ │ Kafka Client │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌────────────▼────────────┐
│ Kafkaesque Broker(s) │
└────────────┬────────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌─────────▼──────┐ ┌───▼────────┐ ┌▼──────────────┐
│ Raft Layer │ │ SlateDB │ │ Object Store │
│ (Metadata & │ │ (LSM, hot │ │ (S3, GCS, │
│ Coordination) │ │ data) │ │ Azure) │
└────────────────┘ └────────────┘ └───────────────┘
Key features:
- Kafka compatible — works with librdkafka, Java, sarama, kcat, …
- Object-store native — durability comes from S3/GCS/Azure; brokers are effectively stateless.
- Embedded Raft — no Zookeeper. Brokers coordinate leader election and metadata over an authenticated internal Raft channel.
- Single binary — zero external dependencies at runtime.
- LSM-tree storage — built on SlateDB for efficient writes against object stores.
Building from source needs the Rust toolchain pinned in
rust-toolchain.toml (Rust 1.91, edition 2024).
rustup picks it up automatically. Running under Docker needs only Docker,
but there is no published image yet — you build it locally (see below).
CLUSTER_PROFILE=development cargo run --release -p kafkaesque-bin --bin kafkaesqueThe broker listens on localhost:9092 for Kafka traffic and localhost:8080
for health and metrics. Data is stored under /tmp/kafkaesque-data.
CLUSTER_PROFILE=development opts into an unauthenticated Raft control
plane for local use. Outside the development profile the broker refuses to
start unless RAFT_CLUSTER_SECRET is set — see Security.
No image is published to a registry yet, so build one first:
docker build -f Dockerfile.minimal -t kafkaesque:latest .docker run --rm -p 9092:9092 -p 8080:8080 \
-e CLUSTER_PROFILE=development \
-e OBJECT_STORE_TYPE=local \
-e DATA_PATH=/data \
-v /tmp/kafkaesque-data:/data \
kafkaesque:latestecho "hello" | kcat -P -b localhost:9092 -t demo
kcat -C -b localhost:9092 -t demo -eThe repository is a Cargo workspace with three crates:
| Crate | Purpose |
|---|---|
kafkaesque-protocol (in crates/kafkaesque-protocol/) |
Runtime-independent Kafka wire-protocol layer — parser, encoder, types, constants, wire-error. No tokio/slatedb/openraft, so it can be reused on its own. |
kafkaesque (root crate) |
The umbrella: cluster coordination, server, runtime, telemetry. Re-exports the protocol layer at the same module paths it used to live at, so existing use kafkaesque::{parser, encode, types, ...} paths keep working. |
kafkaesque-bin (in crates/kafkaesque-bin/) |
The production broker binary. Build via cargo build -p kafkaesque-bin --bin kafkaesque. |
MockCoordinator and other test helpers live behind the
test-utilities feature on the umbrella crate
Configuration is handled entirely via environment variables. Common knobs:
| Category | Variable | Default | Description |
|---|---|---|---|
| Core | BROKER_ID |
0 |
Unique broker ID; must be persistent across restarts. |
HOST |
0.0.0.0 |
Bind address for the Kafka listener. | |
PORT |
9092 |
Kafka protocol port. | |
ADVERTISED_HOST |
(value of HOST) |
Hostname returned to clients in Metadata responses. Set this when the broker is reachable at a different address than it binds to (NAT, load balancer, Kubernetes). |
|
CLUSTER_ID |
kafkaesque |
Logical cluster identifier. | |
| Storage | OBJECT_STORE_TYPE |
local |
local, s3, gcs, or azure. |
DATA_PATH |
/tmp/kafkaesque-data |
Path or object-store prefix for data. | |
| Raft | RAFT_PEERS |
(empty) | Peer list, e.g. 0=host:port,1=host:port. |
RAFT_LISTEN_ADDR |
127.0.0.1:9093 |
Internal Raft RPC address. | |
| Security | CLUSTER_PROFILE |
production |
Deployment profile. Only development permits running without Raft auth. |
RAFT_CLUSTER_SECRET |
(required outside dev) | Shared HMAC key authenticating all Raft RPC traffic. Identical on every node. | |
SASL_ENABLED |
false |
Enable SASL auth on the Kafka listener (needs --features sasl). |
|
SASL_USERS_FILE |
Path to the SASL user/credential file. | ||
TLS_ENABLED |
false |
Enable TLS on the Kafka listener (needs --features tls). |
|
TLS_CERT_PATH / TLS_KEY_PATH |
PEM cert and key for TLS termination. | ||
| S3 | S3_BUCKET |
AWS S3 bucket name. | |
AWS_REGION |
AWS region (e.g. us-east-1). |
||
| GCS | GCS_BUCKET |
Google Cloud Storage bucket name. | |
GOOGLE_APPLICATION_CREDENTIALS |
Path to the GCP service-account JSON. | ||
| Azure | AZURE_STORAGE_ACCOUNT |
Azure storage account name. | |
AZURE_CONTAINER |
Azure blob container name. | ||
| Health | HEALTH_PORT |
8080 |
Port for /health, /ready, /live, /metrics. Set to 0 to disable. |
METRICS_AUTH_TOKEN |
When set, /metrics requires Authorization: Bearer <token>. |
||
| Telemetry | LOG_FORMAT |
pretty |
pretty or json (for log aggregators). |
RUST_LOG |
info |
Standard tracing-subscriber filter. |
|
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 |
OTLP collector endpoint. Requires --features otel. |
|
OTEL_SERVICE_NAME |
kafkaesque-kafka-broker |
Service name attached to exported spans. |
This is a curated subset; see src/cluster/config.rs
for the full set of tunables (thread pools, cache TTLs, retention, flush
windows, ACLs, …).
Each broker runs a lightweight HTTP server on HEALTH_PORT (default 8080,
bound to HOST) exposing:
| Endpoint | Purpose | Notes |
|---|---|---|
GET /health (/healthz) |
Liveness | Always 200 while the process is running. Use as the Kubernetes liveness probe. |
GET /live (/livez) |
Liveness | Alias of /health. |
GET /ready (/readyz) |
Readiness | 503 when the broker is in zombie mode (fenced / not serving); 200 otherwise. Use as the readiness probe. |
GET /metrics |
Prometheus metrics | Text exposition format. Gated by METRICS_AUTH_TOKEN when set. |
Set HEALTH_PORT=0 to disable the health server entirely.
Kafkaesque does not replicate between brokers. Each partition is stored once, in the object store, and has exactly one live owner at a time. Durability is the backing bucket's job (S3-class storage replicates underneath you), not a follower set's.
Concretely, if you are used to Kafka:
| Kafka concept | Here |
|---|---|
replication.factor |
Only 1 (or -1, "broker default") is accepted. CreateTopics with replication_factor > 1 is rejected with INVALID_REPLICATION_FACTOR. |
| ISR (in-sync replicas) | Metadata reports replicas: [leader] and isr: [leader] to satisfy the wire format. There is no follower set to shrink or expand. |
min.insync.replicas |
Not implemented; has no effect. |
acks=all |
Identical to acks=1. It waits for one durable object-store write — it does not wait for peer brokers. |
| Broker loss | The partition's lease expires and another broker takes ownership, reading the same object-store data. Availability, not durability, is what failover buys you. |
This is a deliberate design (the same shape as other object-store-backed
Kafka implementations), not a missing feature — but it means your bucket's
durability is your data's durability. Use a bucket with cross-AZ or
cross-region replication if you need more than one failure domain. A single
local OBJECT_STORE_TYPE=local directory has exactly one.
The broker refuses replication_factor > 1 rather than accepting it and
reporting a one-node ISR, so a client cannot ask for quorum durability and be
told it got it.
Kafkaesque honors the standard Kafka acks semantics. Every produce
request opts into one of three durability levels by setting acks:
acks |
Behavior | What can be lost on a hard kill |
|---|---|---|
0 |
Fire-and-forget. Broker replies immediately and writes asynchronously. | Up to ~100 ms of unacked records (one SlateDB flush window). |
1 |
Broker waits for the WAL to fsync before acking. | Nothing the broker has acked. |
all |
Same as acks=1 for a single broker; identical durability across replicas because the data plane is the object store, not peer brokers. |
Nothing the broker has acked. |
Beyond the acks flag, two additional internal writes are always
durable, regardless of the producer's choice:
- Idempotent producer state. When a record batch carries a
producer_id, the broker persists the producer's last sequence number and base offset in the same SlateDB write batch as the records themselves, withawait_durable: true. This ensures the exactly-once dedup window survives crashes — an idle producer whose in-memory state was evicted cannot have a fresh-looking sequence-0 batch admitted as new after a restart. - Leader-epoch fencing token, log-start offset, and snapshot
pointer. All written with
await_durable: true.
PartitionManager::shutdown flushes every owned partition store
before releasing its lease and unregistering the broker. SIGTERM /
SIGINT trigger this path; a hard kill (SIGKILL, OOM-kill) does not. On
a hard kill, only the acks=0 window can lose acknowledged data.
tests/durability_contract_props.rs drives a randomized produce
schedule (varying batch sizes and acks levels) interleaved with
randomized "crashes" — re-opening the SlateDB instance against the
same backing object store without first running graceful shutdown.
The test asserts that every offset returned by append_batch_durable
is still readable after the crash. Run with:
cargo test --test durability_contract_propsThe Raft RPC port accepts cluster-membership changes and coordination
commands, so it must never be exposed unauthenticated. The broker is
secure by default: startup fails unless RAFT_CLUSTER_SECRET is set to a
non-empty value, or you explicitly opt out with
CLUSTER_PROFILE=development for local work.
# Generate once, distribute the same value to every node:
export RAFT_CLUSTER_SECRET="$(openssl rand -base64 32)"For the public Kafka listener, enable SASL/PLAIN and TLS at compile time:
cargo build --release --features sasl,tlsThe Raft port and the Kafka port are secured independently, and it is worth
being explicit about what each default gives you. Outside
CLUSTER_PROFILE=development the broker refuses to start without
RAFT_CLUSTER_SECRET, RAFT_JOIN_TOKEN, and ACL_ENABLED — so
authorization is always on. What is not required is authentication on
the Kafka listener: a broker with ACLs but no SASL sees every client as the
single principal User:ANONYMOUS. That is a deliberate allowance for
internal-only clusters sitting behind a network boundary, but it means ACLs
can only express "everyone" until SASL is turned on.
To run a listener with real per-client principals, set all of the following
(TLS is not optional here — the broker rejects SASL_ENABLED=true without
SASL_REQUIRE_TLS=true outside the development profile, because PLAIN puts
the password on the wire):
# Authentication
SASL_ENABLED=true \
SASL_REQUIRED=true \
SASL_USERS_FILE=/etc/kafkaesque/sasl/users.json \
SASL_REQUIRE_TLS=true \
# Transport
TLS_ENABLED=true \
TLS_CERT_PATH=/etc/kafkaesque/tls/tls.crt \
TLS_KEY_PATH=/etc/kafkaesque/tls/tls.key \
# Authorization — drop the ANONYMOUS bootstrap super-user once SASL is on
ACL_ENABLED=true \
ACL_DENY_BY_DEFAULT=true \
KAFKAESQUE_SUPER_USERS="User:admin" \
# Control plane
RAFT_CLUSTER_SECRET="$SHARED_SECRET" \
RAFT_JOIN_TOKEN="$JOIN_TOKEN" \
kafkaesqueSASL_REQUIRED=true rejects unauthenticated connections outright; leave it
false only during a migration window while existing clients are cut over.
On Kubernetes the Helm chart exposes this as the sasl and tls value
blocks — see values-production.yaml, which turns both on. ACL bindings
can be managed over the wire or seeded from a bootstrap file; see
Managing ACLs.
ACL enforcement and administration are both supported over the wire.
Every request path authorizes against the replicated ACL state machine
(ACL_ENABLED=true, ACL_DENY_BY_DEFAULT=true), including the
Describe/Read/Write/Alter operations on topics, groups, and cluster
resources. Standard Kafka admin tooling can manage bindings via
DescribeAcls / CreateAcls / DeleteAcls (API keys 29–31, versions
0–1) — kafka-acls.sh and the Java AdminClient speak these APIs.
What is also supported:
- Seeding by file. Point
KAFKAESQUE_ACL_BOOTSTRAP_FILEat a JSON array of ACL bindings; the leader writes them through Raft on startup so they replicate to every node. Re-applying the same file is idempotent (CreateAclsdeduplicates), so this composes with config management. - Super-users bypass ACL checks entirely via
KAFKAESQUE_SUPER_USERS(e.g.User:admin). On a cluster without SASL the only available principal isUser:ANONYMOUS.
Supported resource types on the wire today are Topic, Group, and Cluster;
pattern types are Literal and Prefixed. Unsupported Kafka resource types
(TransactionalId, User, DelegationToken) and Match patterns are rejected
with INVALID_REQUEST.
For a 3-node cluster, give each broker a unique BROKER_ID and the full
RAFT_PEERS list:
BROKER_ID=0 \
RAFT_LISTEN_ADDR=0.0.0.0:9093 \
RAFT_PEERS="0=node0:9093,1=node1:9093,2=node2:9093" \
RAFT_CLUSTER_SECRET="$SHARED_SECRET" \
OBJECT_STORE_TYPE=s3 \
S3_BUCKET=my-kafka-data \
cargo run --release -p kafkaesque-bin --bin kafkaesqueRaw manifests are in deploy/kubernetes/:
kubectl apply -k deploy/kubernetes/This deploys a 3-broker StatefulSet. Replace the placeholder secrets in
secret.yaml before production use — see
deploy/kubernetes/README.md.
A chart with dev and production value presets is in
deploy/helm/kafkaesque/:
helm install kafkaesque ./deploy/helm/kafkaesque -n kafkaesque --create-namespace \
-f deploy/helm/kafkaesque/values-production.yaml \
--set raftAuth.clusterSecret="$(openssl rand -base64 32)"See deploy/helm/kafkaesque/README.md for
the full values reference.
Modules under deploy/terraform/ provision the object
store and IAM/identity wiring for AWS, GCP, and Azure (e.g. an S3
bucket plus an IRSA role for EKS). See
deploy/terraform/README.md.
| Feature | Pulls in | Purpose |
|---|---|---|
sasl |
(no extra deps) | SASL/PLAIN and SCRAM-SHA-256 authentication. |
tls |
tokio-rustls, rustls, rustls-pemfile |
TLS termination on the Kafka listener. |
otel |
opentelemetry, opentelemetry-otlp, tracing-opentelemetry |
OTLP span export over gRPC. |
loom |
loom |
Loom-based concurrency tests (dev only). |
test-utilities |
(no extra deps) | Exposes MockCoordinator and other helpers for integration tests. |
Kafkaesque implements the core Kafka protocol used by modern clients:
- Produce / Fetch — basic messaging.
- Metadata — topic and partition discovery.
- OffsetForLeaderEpoch — log-truncation fencing for consumer rebalances (KIP-320).
- Consumer groups —
JoinGroup,SyncGroup,Heartbeat,LeaveGroup,OffsetCommit,OffsetFetch,DescribeGroups,ListGroups,DeleteGroups,FindCoordinator. - Admin —
CreateTopics,DeleteTopics,CreatePartitions,DescribeConfigs,AlterConfigs,IncrementalAlterConfigs,InitProducerId,DescribeAcls,CreateAcls,DeleteAcls. - API negotiation —
ApiVersions. - Auth —
SaslHandshake,SaslAuthenticate(PLAIN, SCRAM-SHA-256; requires--features sasl).
Exact version ranges are advertised over ApiVersions and locked to the
CHANGELOG via tests/changelog_contract_tests.rs. See
Client compatibility for what that means for
librdkafka, Java, and kcat.
Not yet supported, called out because the wire protocol makes them look available:
- Broker-to-broker replication.
replication_factor > 1is rejected; see Durability contract. - Log compaction.
cleanup.policy=compact(andcompact,delete) is rejected withINVALID_CONFIG— there is no cleaner, so accepting it would promise key-collapsing that never happens. Usecleanup.policy=deletewithretention.ms. - Incremental fetch sessions (KIP-227). Fetch v7+ is supported for its
other fields (
log_start_offset, leader-epoch fencing, rack-aware replica selection). Responses always carrysession_id = 0, which is the spec's "sessionless" signal telling clients to keep sending full fetches; asession_idthe broker never issued is answered withFETCH_SESSION_ID_NOT_FOUND. - Transactions / EOS.
transactional_idis rejected at produce and atInitProducerId. RecordBatch attribute bit 4 (transactional) and bit 5 (control) are refused on the produce path. - Static membership (KIP-345).
group.instance.idrequires JoinGroup v5+ / Heartbeat v3+ / SyncGroup v3+, which are not advertised. - KIP-848 consumer group protocol. ApiKeys 68/69 are absent; clients that support it fall back to the classic protocol.
- Idempotent-producer deduplication beyond the per-session
producer_id.
Kafkaesque speaks the Kafka wire protocol. That is not the same claim
as “every Kafka client feature works.” Clients that honor ApiVersions
(the default for librdkafka with api.version.request=true, and for
modern Java kafka-clients) negotiate min(client_max, broker_max) and
do not fail hard when the broker advertises a lower ceiling. Clients that
disable version negotiation, or that require APIs we do not advertise
(static membership, transactions, KIP-848), will get UnsupportedVersion
(35) or a typed produce/admin error — the connection stays open.
| Client | How | Notes |
|---|---|---|
librdkafka via rdkafka 0.38 |
tests/rdkafka_e2e.rs (dedicated CI job) |
Produce, consume with group.id, group offset commit, idempotent produce, admin. Uses ApiVersions negotiation against the advertised ceilings. |
| kcat | scripts/run-e2e.sh, scripts/run-cluster-e2e.sh |
Produce/consume smoke and multi-broker failover. |
Java kafka-clients |
java-smoke/ + scripts/run-java-smoke.sh (CI java-client-matrix) |
Produce → consume → OffsetCommit with classic RangeAssignor. Do not enable group.instance.id, transactions, or KIP-848. |
| API | Advertised | Why it stops there |
|---|---|---|
| OffsetCommit | v0–v6 | v5 drops retention; v6 leader_epoch ignored. groupInstanceId is v7+ (refused). |
| OffsetFetch | v0–v5 | v5 response committed_leader_epoch (-1). Request body same as v2 (no require_stable; that is v7+). Flexible / multi-group v6+ refused. |
| JoinGroup | v0–v4 | v4 MemberIdRequired (KIP-394). v5 static membership refused. |
| SyncGroup | v0–v3 | v3 group_instance_id parsed and ignored. |
| Heartbeat | v0–v3 | v3 group_instance_id parsed and ignored. |
| LeaveGroup | v0–v3 | v3 batch leave supported; instance ids ignored. |
| FindCoordinator | v0–v2 | v2 error_message. Flexible / multi-key v3+ refused. |
A client that forces JoinGroup v5 (or any version above the advertised
max) receives UnsupportedVersion without the broker closing the socket.
The contract is locked by tests/client_compatibility_matrix_tests.rs.
Compressed RecordBatches (gzip / snappy / lz4 / zstd) are accepted when
the payload decompresses and the inner record count matches the header
records_count. Undefined codec ids, undecompressible payloads, and
header/body count mismatches are rejected. Transactional and control
attribute bits are rejected even when the CRC is valid.
# Unit + integration tests (mirrors CI's split):
cargo test --lib
cargo test --tests
# Doc tests:
cargo test --doc
# End-to-end (requires kcat):
./scripts/run-e2e.sh # single-node
./scripts/run-cluster-e2e.sh # 3-node, simulates failover
./scripts/run-k8s-e2e.sh # in-cluster
# All CI checks (fmt, clippy, tests, audit, deny):
make ciThe fuzz/ directory holds cargo-fuzz
targets covering every attacker-reachable parser, decoder, and validator —
Kafka request parsers, the connection frame reader, RecordBatch header
validation, SCRAM, postcard decoders for cluster RPC, and the identifier
validators.
cargo install cargo-fuzz
cargo +nightly fuzz list
cargo +nightly fuzz run api_produce -- -max_total_time=60See fuzz/README.md for the full target catalogue,
reproduction workflow, and corpus / coverage management.
- Format:
cargo fmt --all - Lint:
cargo clippy --all-targets -- -D warnings - Benchmarks:
cargo bench(Criterion; results intarget/criterion/) - Audit:
make audit(RustSec advisories) - Deny:
make deny(license / banned-crate policy indeny.toml)
Apache-2.0