Apache Kafka on Kubernetes (Strimzi Operator)

Running Kafka as a StatefulSet on Kubernetes — broker identity, PVC binding, failover, and the operational sequences (pod rescheduling, rolling restarts, scaling) that a plain stateless Deployment never has to deal with.

0/0 checks

Architecture

graph TD
    subgraph K8S["Kubernetes (GKE)"]
        STRIMZI["Strimzi Operator<br/>manages Kafka cluster lifecycle"]
        subgraph KAFKA["Kafka Cluster (StatefulSet)"]
            B0["kafka-0 (broker)<br/>leader for partitions: 0,3,6"]
            B1["kafka-1 (broker)<br/>leader for partitions: 1,4,7"]
            B2["kafka-2 (broker)<br/>leader for partitions: 2,5,8"]
        end
        subgraph ZK["ZooKeeper (or KRaft)"]
            Z0["zookeeper-0"]
            Z1["zookeeper-1"]
            Z2["zookeeper-2"]
        end
        STRIMZI --> KAFKA & ZK
        PVC_B0["PVC: data-kafka-0<br/>500Gi"]
        PVC_B1["PVC: data-kafka-1<br/>500Gi"]
        PVC_B2["PVC: data-kafka-2<br/>500Gi"]
        B0 --> PVC_B0
        B1 --> PVC_B1
        B2 --> PVC_B2
    end
    PROD["Producer"] --> B0 & B1 & B2
    B0 & B1 & B2 --> CONS["Consumer Groups"]

kafka-1's pod gets deleted and Kubernetes reschedules it. Does it come back as "kafka-1" attached to PVC data-kafka-1, or as a fresh, differently-named pod with an empty volume?


Broker Pod Rescheduling & PVC Reattachment

Kafka brokers run as a StatefulSet, not a Deployment — that distinction is the whole reason Kafka works on Kubernetes at all. A Deployment's pods are interchangeable; a StatefulSet's aren't. Each pod gets a stable, ordinal-indexed name (kafka-0, kafka-1, kafka-2) and a stable network identity via a headless Service, and each ordinal owns its own PVC from volumeClaimTemplatesdata-kafka-1 belongs to kafka-1 for the life of the StatefulSet, not the life of any one pod.

That matters because it's what makes a rescheduled broker safe: the replacement pod keeps the same name, the same DNS entry, and reattaches to the same PVC — it comes back as the same broker with its own data still on disk, not a fresh empty node that has to be re-added to the cluster from scratch.

sequenceDiagram
    participant Node as K8s Node (kafka-1)
    participant Sched as StatefulSet Controller
    participant PVC as PVC: data-kafka-1
    participant B1 as kafka-1 (new pod)
    participant Cluster as Rest of cluster

    Note over Node: Node fails / pod evicted (OOM, drain, node loss)
    Node--xSched: kafka-1 pod terminated
    Sched->>Sched: notices desired replica count violated
    Sched->>B1: schedule replacement pod, same name "kafka-1"
    B1->>PVC: mounts existing PVC data-kafka-1 (not a new volume)
    Note over B1: local segments on disk are intact — no full re-sync needed
    B1->>Cluster: rejoins as follower via existing headless-service DNS name
    Cluster->>B1: leader sends only what kafka-1 missed while down
    B1->>Cluster: caught up, rejoins ISR

Two things have to be true for this to work in practice: the storage class must support the volume reattaching wherever the new pod lands (a ReadWriteOnce cloud block volume works; anything tied to one specific node would not survive a node loss), and terminationGracePeriodSeconds needs to be generous enough for Kafka to flush and close segments cleanly rather than being SIGKILLed mid-write.

Walk through the sequence step by step:

1. Steady state. kafka-1 is running, mounted to PVC data-kafka-1, reachable at kafka-1.kafka-headless.svc via the headless Service.
2. Node failure or eviction. The underlying node dies, is drained, or the pod is OOM-killed. Kubernetes marks the pod terminated.
3. StatefulSet controller reacts. It notices the observed replica count no longer matches desired, and schedules a replacement pod with the exact same ordinal name: kafka-1.
4. Same PVC, same identity. The new pod claims the existing data-kafka-1 PVC — StatefulSets bind PVCs by ordinal, not by pod UID — and gets the same DNS name. To the rest of the cluster, kafka-1 just came back, not a new broker.
5. Catch-up, not full resync. Because the on-disk log segments survived, kafka-1 only fetches what it missed since going down, not the whole partition from scratch. Once caught up, it rejoins the ISR.

Why does a rescheduled Kafka broker pod only need to fetch what it missed, instead of re-replicating its whole partition from scratch?


Topic Replication and ISR

graph TD
    TOPIC["Topic: orders<br/>partitions: 9<br/>replication.factor: 3"]

    P0["Partition 0<br/>Leader: kafka-0"] --> R0A["Replica: kafka-1 (ISR)"]
    P0 --> R0B["Replica: kafka-2 (ISR)"]

    P1["Partition 1<br/>Leader: kafka-1"] --> R1A["Replica: kafka-0 (ISR)"]
    P1 --> R1B["Replica: kafka-2 (ISR)"]

ISR (In-Sync Replicas): Replicas that are fully caught up with the leader (within replica.lag.time.max.ms = 10000ms). A replica falls out of ISR if it falls behind.

min.insync.replicas: Minimum ISR count required for a write to succeed. With replication.factor=3 and min.insync.replicas=2: a write succeeds if at least 2 replicas (including leader) acknowledge it. One broker can fail with zero data loss.

# Topic config
min.insync.replicas: 2
replication.factor: 3
# Producer config for guaranteed delivery
acks: all             # wait for all ISR replicas to confirm
retries: 2147483647   # retry indefinitely
enable.idempotence: true  # exactly-once semantics

With replication.factor=3 and min.insync.replicas=2, how many brokers holding a partition's replicas can be down before an acks=all producer starts failing writes to it?


Partition Leader Election (Failover)

sequenceDiagram
    participant ZK2 as ZooKeeper
    participant B0_2 as kafka-0 (was leader)
    participant B1_2 as kafka-1 (ISR replica)
    participant CONT as Strimzi Operator

    Note over B0_2: kafka-0 crashes (node failure or OOM)
    B0_2--xZK2: ZooKeeper session expires (6s default)
    ZK2->>ZK2: partition 0 has no leader
    ZK2->>B1_2: Elect kafka-1 as partition 0 leader
    B1_2->>B1_2: Becomes leader for partition 0
    Note over B1_2: Producers/consumers reconnect to new leader

    Note over CONT: kafka-0 recovers
    CONT->>B0_2: kafka-0 starts as follower
    B0_2->>B1_2: Fetch from leader, catch up
    B0_2->>ZK2: Joins ISR after catching up

Step through the same failover as discrete stages:

1. Stable. kafka-0 is leader for partition 0; kafka-1 is an ISR replica, fully caught up.
2. kafka-0 crashes. Node failure or OOM kill. Its ZooKeeper session expires after the default 6s timeout.
3. New leader elected. ZooKeeper sees partition 0 has no leader and elects kafka-1 — the ISR replica — as the new leader. Producers and consumers reconnect to it.
4. kafka-0 recovers. The Strimzi operator brings kafka-0 back up. It rejoins the cluster as a follower, not automatically as leader again.
5. Catch-up and rejoin. kafka-0 fetches from the new leader (kafka-1) until caught up, then rejoins the ISR.

Unclean leader election: If ALL ISR replicas are down, Kafka can optionally elect an out-of-sync replica (unclean.leader.election.enable=true). Never enable in production — guarantees data loss.

Every ISR replica for a partition is down, and unclean.leader.election.enable=true. Kafka elects an out-of-sync replica as the new leader. Is any data lost?


Strimzi Kafka Cluster YAML

apiVersion: kafka.strimzi.io/v1beta2
kind: Kafka
metadata:
  name: my-kafka
spec:
  kafka:
    version: 3.6.0
    replicas: 3
    listeners:
      - name: plain
        port: 9092
        type: internal
        tls: false
      - name: tls
        port: 9093
        type: internal
        tls: true
    config:
      offsets.topic.replication.factor: 3
      transaction.state.log.replication.factor: 3
      transaction.state.log.min.isr: 2
      default.replication.factor: 3
      min.insync.replicas: 2
      log.retention.hours: 168       # 7 days retention
      log.segment.bytes: 1073741824  # 1GB segments
    storage:
      type: persistent-claim
      size: 500Gi
      class: premium-rwo             # GKE SSD storage class
    resources:
      requests:
        memory: 8Gi
        cpu: 2
      limits:
        memory: 16Gi
        cpu: 4
    jvmOptions:
      -Xms: 4096m
      -Xmx: 4096m

  zookeeper:
    replicas: 3
    storage:
      type: persistent-claim
      size: 50Gi
      class: premium-rwo

Rolling Restarts

Any change to the Kafka custom resource that requires a broker restart — a version bump, a JVM option, a value in the spec.kafka.config block — triggers a rolling restart, not a full-cluster bounce. Strimzi restarts one broker pod at a time, in ordinal order, and waits for that broker to rejoin the ISR for every partition it holds before touching the next one.

That one-at-a-time discipline is what keeps min.insync.replicas satisfied throughout the whole operation: with replication.factor=3 and min.insync.replicas=2, taking down one broker at a time still leaves 2 in the ISR for every partition, so acks=all producers keep working uninterrupted. Restarting two brokers concurrently could drop a partition's ISR below min.insync.replicas and start failing writes mid-rollout — exactly why Strimzi enforces the sequential order instead of restarting pods in parallel.

1. Change applied. A version bump or config change lands on the Kafka resource. The Strimzi operator diffs it against the running cluster and determines a restart is required.
2. kafka-0 restarts. Its pod is terminated and recreated on the new config. While it's down, kafka-1 and kafka-2 keep serving traffic for any partition kafka-0 led.
3. Wait for ISR. The operator waits until kafka-0 has rejoined the ISR for every partition it's a replica of before moving on — it does not restart the next broker on a fixed timer.
4. kafka-1, then kafka-2. The same restart-then-wait-for-ISR sequence repeats for each remaining broker, one at a time, in order.
5. Done. All three brokers are on the new config/version, and at no point did more than one broker's partitions drop out of full ISR at once.

With replication.factor=3 and min.insync.replicas=2, why does Strimzi restart brokers one at a time instead of all at once?


Scaling the Broker StatefulSet

Changing spec.kafka.replicas scales the StatefulSet, but "add a broker" and "remove a broker" aren't mirror images of each other — one is close to free, the other requires manual data movement first.

Bumping replicas higher creates a new ordinal pod (e.g. kafka-3) with a brand-new PVC provisioned automatically from volumeClaimTemplates. The new broker joins the cluster empty. It won't lead or replicate anything until you explicitly reassign some partitions onto it with kafka-reassign-partitions.sh — simply appearing in the cluster doesn't rebalance existing data onto it.
Lowering replicas deletes the highest-ordinal pod and its PVC outright. If that broker is still a leader or replica for any partition, deleting it before moving that data off is a straight capacity/durability loss — any partition relying on it drops a replica, and any partition it uniquely led goes into leader election under duress. Always run a partition reassignment moving every replica off the broker being removed before lowering replicas, never after.

You lower spec.kafka.replicas from 4 to 3 without reassigning any partitions off kafka-3 first. What happens to the data kafka-3 was holding?


Consumer Groups and Lag

graph LR
    TOPIC2["Topic: orders<br/>9 partitions"] --> CG1["Consumer Group: payments<br/>3 consumers<br/>3 partitions each"]
    TOPIC2 --> CG2["Consumer Group: analytics<br/>1 consumer<br/>9 partitions"]
# Check consumer group lag
kubectl exec -it kafka-0 -- kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe --group payments

# TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
# orders  0          1500            1500            0
# orders  1          1480            1520            40   <- lagging!
# orders  2          1600            1600            0

# Alert if lag > threshold
# Prometheus: kafka_consumer_group_lag > 1000

Consumer group "payments" (3 consumers) and consumer group "analytics" (1 consumer) both read the same 9-partition "orders" topic. Are these two groups splitting the topic's data between them, or each getting their own full copy?


Backups

Kafka doesn't have a built-in backup mechanism. Options:

  1. MirrorMaker 2 — replicate topics to another cluster (GKE → GCS via Kafka Connect)
  2. Kafka Connect S3 Sink — stream all messages to GCS/S3
  3. Volume snapshots — snapshot PVCs (consistent only if broker is stopped first)
Replicates whole topics, live, to a second Kafka cluster. Gives you a hot standby cluster you can fail over to, not just a static copy — closest thing to a real DR story here, at the cost of running (and paying for) a second cluster continuously.
A sink connector streams every message to object storage as it arrives. Cheap, durable, and good for long-term/compliance retention past Kafka's own retention.ms — but restoring from it means replaying flat files back into topics, not just pointing brokers at existing PVCs.
Snapshotting the PVCs directly is the fastest to set up, but it's only consistent if the broker is stopped first — snapshotting a live broker's PVC can capture a segment mid-write. Fine for periodic full-cluster disaster recovery; not something to restore a single topic from without care.

Why is a PVC snapshot taken while the broker is still running risky, compared to one taken after the broker is stopped?

# Kafka Connect S3 Sink (backup all topics to GCS)
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnector
spec:
  class: io.confluent.connect.s3.S3SinkConnector
  config:
    topics: ".*"
    s3.bucket.name: my-kafka-backup
    s3.region: us-central1
    flush.size: 1000
    rotate.interval.ms: 3600000  # rotate files every hour