Skip to content

docs(kafka): deploy Kafka on node-local disks with pre-bound PVs - #845

Merged
chideat merged 10 commits into
mainfrom
docs/kafka-local-pv-hostpath-deployment
Sep 9, 2026
Merged

docs(kafka): deploy Kafka on node-local disks with pre-bound PVs#845
chideat merged 10 commits into
mainfrom
docs/kafka-local-pv-hostpath-deployment

Conversation

@chideat

@chideat chideat commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a deployment guide for running Kafka on node-local storage (hostPath / local disks).

docs/en/solutions/ecosystem/kafka/Deploy_Kafka_with_Local_HostPath_Storage.md

Applies to Alauda Streaming Service for Kafka 4.3. KRaft mode with KafkaNodePool.

Why

Two failures reported from the field with local-disk Kafka:

1. A broker reschedules and comes up on an empty or foreign log directory.
Root cause is a hostPath PV without nodeAffinity — kubelet resolves the same path on whatever node the pod landed on. local PVs cannot do this, because the API server requires nodeAffinity on them.

2. PVs cross-bind, so broker-0 lands on broker-1's disk.
Not caused by restarting pods — a bound PVC never changes its PV. It happens when PVCs are recreated: uninstall/reinstall, a PVC deleted by hand, a pool or cluster rename, or node-ID reassignment. Three fresh PVCs then race three Retained PVs, matched only on storage class + capacity + access mode.

The fix

PV-side claimRef pre-binding. The two configuration-level alternatives have no effect: Kafka.spec.kafka.storage is ignored in favour of KafkaNodePool.spec.storage, and per-broker storage.overrides is ignored. storage.selector applies one selector to the whole pool, so it narrows candidates rather than assigning them.

Tested on a live cluster

Run end to end on ACP v4.3 with three worker nodes, twice — once with hostPath PVs, once with local PVs — plus a separate two-instance single-replica test.

Test Result
Delete all broker pods at once Bindings and data preserved, full ISR
Delete cluster, keep PVCs Bindings preserved; cluster ID must be restored
Delete PVCs and recreate Cross-binding reproduced — brokers 1 and 2 swapped, topic 1000 → 0 messages
claimRef recovery All 1000 messages restored
Destructive cycle with claimRef Mapping held, one kafka-log* per disk
Two single-replica instances One instance's PVC bound the other's PV

Testing corrected several things the first draft got wrong:

  • The wrong-disk failure is usually silent. log.dirs is <mount>/kafka-log<ownNodeId>, so a broker on a foreign disk never reads the other broker's meta.properties — it formats an empty log beside the orphaned data and reports Running 1/1. Two of three brokers failed silently.
  • The original verification step could not detect it. A reformatted broker writes a node.id matching itself. Replaced with counting kafka-log* directories per disk.
  • deleteClaim: false does not make reinstall safe. The KRaft cluster ID lives in Kafka.status.clusterId; deleting the resource loses it and every broker rejects its retained disk. Added a record-and-restore procedure, including the reconcile trigger the status patch needs to survive.
  • hostPath gets no fsGroup from kubelet, so Kafka's UID 1001 cannot write a root:root 0755 directory. With local it works at default privileges. Concrete reason to prefer local.
  • Pod template settings belong on the KafkaNodePool — a pool template replaces Kafka.spec.kafka.template.pod entirely.

Single-replica mode is covered too: RF=1 has no fault tolerance, and the cross-binding risk moves to the boundary between instances sharing a StorageClass. That case fails loudly (every single-replica instance is node 0, so it finds a foreign kafka-log0 and rejects the cluster ID) and the victim's data survives.

Notes for reviewers

  • Not tested: node failure and disk replacement.
  • Not verified: whether the instance creation form exposes class / selector on the node pool. Flagged as an open item.
  • id: and docs/zh/ are produced by CI on push to main. check-translation-integrity.mjs --since passes; the file is 44KB, under the 60KB chunking threshold.
  • Remaining strimzi.io/* strings are API identifiers (CRD group, labels, annotations) that appear verbatim in user-authored YAML.

Tracked by ECO-933.

🤖 Generated with Claude Code

Adds a deployment guide for running Kafka on node-local storage under the
Strimzi 0.48 operator line (ACP 4.3, KRaft, KafkaNodePool).

Covers the two field failures with local storage:

- A broker reschedules and comes up on an empty or foreign log directory.
  Root cause is a hostPath PV without nodeAffinity; local PVs cannot do
  this because the API server requires nodeAffinity on them.

- PVs cross-bind so broker-0 lands on broker-1's disk. This is not caused
  by restarting pods -- a bound PVC never changes its PV -- but by PVCs
  being recreated (uninstall/reinstall, PVC deletion, pool or cluster
  rename, node-ID reassignment), where matching is first-come-first-serve
  on class + capacity + accessMode.

The fix is PV-side claimRef pre-binding, since the two configuration-level
alternatives are gone on this operator line: Kafka.spec.kafka.storage is
ignored in favour of KafkaNodePool.spec.storage, and per-broker
storage.overrides has been ignored since Strimzi 0.46.0.

Verification commands in the guide have not been run against a live
cluster; the doc says so.
Tested the procedure end to end on an ACP 4.3 cluster (operator 0.48.0 /
v4.3.3, Kafka 4.2.0, three worker nodes). Several claims were wrong.

Confirmed as written:

- Deleting all broker pods simultaneously does not reshuffle storage.
  Every pod returned to its original node, bindings unchanged, all 1000
  test messages intact with full ISR.
- Cross-binding on PVC recreation is real. Recreating the PVCs swapped
  brokers 1 and 2 onto each other's disks.
- The claimRef pre-binding fix works, and the released-PV recovery
  command in the guide works verbatim.

Corrected:

- The wrong-disk failure had its emphasis inverted. log.dirs is
  <mount>/kafka-log<ownNodeId>, so a broker on a foreign disk never reads
  the other broker's meta.properties -- it formats an empty log beside the
  orphaned data and reports Running 1/1. Two of three brokers failed
  silently; the topic went from 1000 messages to 0 while every byte was
  still on disk. The Invalid cluster.id crash is the exception, hitting
  only a broker that kept its own disk.
- Verification check 4 could not detect a wrong binding: a reformatted
  broker writes a node.id matching itself. Replaced with a count of
  kafka-log* directories per disk, which is the real signal.
- deleteClaim:false does not make uninstall/reinstall safe. The KRaft
  cluster ID lives only in Kafka.status.clusterId, so deleting the Kafka
  resource loses it and every broker rejects its retained disk. Added the
  record-and-restore procedure, including the reconcile trigger the status
  patch needs to survive.
- Wrong-binding recovery must identify each disk's true owner by directory
  size, not meta.properties.
- hostPath PVs get no fsGroup treatment from kubelet, so Kafka's UID 1001
  cannot write a kubelet-created root:root 0755 directory. Another concrete
  reason to prefer local.
- Pod template settings must go on the KafkaNodePool; a pool template
  replaces Kafka.spec.kafka.template.pod entirely and a securityContext
  placed there is silently dropped.

Single-node/non-HA mode and node-failure/disk-replacement remain untested.
- Drop "with no replication from peers" from the recovery result. Only the
  final 1000/1000 count was measured; whether partitions re-replicated
  while the mis-bound brokers ran empty was not checked.
- The KRaft cluster ID also falls back to each pool's status.clusterId,
  so "lives only in Kafka.status.clusterId" was wrong. The operational
  conclusion is unchanged: deleting those resources loses it.
- Say plainly that the exercise ran on hostPath PVs. Binding, claimRef and
  recovery behaviour carries over to local; the local-specific claims
  (API-enforced nodeAffinity, fsGroup applied) are from the Kubernetes
  docs and were not under test.
…claims

Repeated the full test on the same ACP 4.3 cluster using local PVs on
/cpaas/kafka-lpv, with directories created on the nodes beforehand. Results
matched the hostPath run everywhere it matters, and the two local-specific
claims that were previously only cited from the Kubernetes docs are now
measured:

- The API server rejects a local PV with no nodeAffinity:
  "spec.nodeAffinity: Required value: Local volume requires node affinity".
- kubelet applies fsGroup to local volumes. Starting from the same
  root:root 0755 directory that made hostPath crash-loop, it became
  drwxrwsr-x on mount and the cluster came up first time at default
  privileges -- no runAsUser override.

Repeated on local, unchanged from the hostPath run: deleting all broker pods
at once preserves bindings and data; PVC recreation cross-binds (brokers 1
and 2 swapped again, two of three silently formatted empty logs beside the
real data, topic 1000 -> 0); claimRef recovery restores all 1000 messages;
and the same destructive cycle with claimRef in place holds the mapping with
one kafka-log* directory per disk.

Also, from doing the recovery twice: restoring status.clusterId is a race
against the operator and can need several attempts, so the procedure is now
a loop that verifies the ConfigMap before restarting brokers. Added the
cleanup step for the orphaned empty log directories.

Initial binding was not name-ordered in either run (c/b/a then c/a/b),
which is the clearest evidence that a PVC name has no bearing on which PV
it receives.
Covers the last outstanding requirement. Verified on the same ACP 4.3
cluster with two single-replica instances in separate namespaces, each
holding different data.

- States plainly that RF=1 has no fault tolerance: one copy, one disk, one
  node. Node down means the cluster is down and its data unreachable; disk
  lost means data lost. Worth being blunt about for a log-queue use case.
- Gives the manifest: one pool, one replica, combined roles, every
  replication factor at 1, no anti-affinity needed.
- The cross-binding risk does not vanish, it moves between instances. Any
  number of single-replica instances sharing one no-provisioner
  StorageClass draw from the same pool of unreserved PVs, and a PVC has no
  notion of which instance a PV belongs to. Demonstrated: with s1's pod
  scheduled onto s2's node, s1's PVC bound s2's PV.
- The failure mode differs from the multi-broker case, and is safer. Every
  single-replica instance is node 0, so its log directory is always
  kafka-log0 -- a broker landing on another instance's disk does find a
  kafka-log0, reads a foreign cluster.id and crash-loops instead of
  formatting. The victim's data survived intact. Multi-broker
  cross-binding loses data quietly; single-replica theft is an outage that
  preserves data.
- Pod restart, cluster-ID loss on CR deletion, and claimRef recovery all
  behave identically; both instances recovered their own data (500 and
  300 messages).
- Applicable versions are v4.1, v4.2, v4.3 and v4.4, not 4.3 alone. Updated
  the frontmatter ProductsVersion and the applicability note. Testing was
  done on v4.3, and the doc now says that rather than implying all four
  were exercised.
- Use the product name "Alauda Streaming Service for Kafka" throughout,
  replacing "Alauda Kafka Operator".
- Remove upstream community version numbers (0.48 / 0.46 / 0.25). Where a
  behaviour was described as changing at a community release, it is now
  stated as applying to every version this document covers.
- Since the doc now spans four operator versions, note that
  spec.kafka.version must be one the installed operator supports rather
  than presenting 4.2.0 as fixed.
- Drop the internal Java class names from the naming table. The naming
  rule is the actionable part; a reader cannot verify the implementation.

Remaining strimzi.io references are API identifiers -- CRD group, labels
and annotations that appear verbatim in user-authored YAML.
Previous commit conflated the two. Alauda Streaming Service for Kafka is
4.3; v4.1 through v4.4 are the Alauda Container Platform versions it runs
on.

- Applicability now reads: Alauda Streaming Service for Kafka 4.3, running
  on ACP v4.1, v4.2, v4.3 or v4.4.
- ProductsVersion frontmatter back to 4.3, which is the product this
  document is about.
- Test-environment statements say ACP v4.3, so they cannot be read as a
  service version distinction. The verified cluster was ACP v4.3.2.
- Dropped the note telling readers to pick a Kafka version their operator
  supports; with a single service version, the 4.2.0 in the examples is
  simply correct.
- Replaced the remaining "this operator line" phrasing with the product
  name.
The applicability note now names only the product this document is about:
Alauda Streaming Service for Kafka 4.3. Which ACP versions it runs on is
not listed here.

Test-environment statements in the body still say which cluster a given
result came from; those describe where a measurement was taken, not a
compatibility range.
Two problems with the previous version.

It was written against the raw Kafka and KafkaNodePool resources rather
than the RdsKafka instance API that users actually create. Retested through
that API, which changes the procedure materially:

- A highly available instance is three brokers AND three controllers, in
  separate pools with separate storage. Six volumes, not three.
- spec.storage is broker storage; spec.controller.storage is controller
  storage. Brokers take node IDs 0-2, controllers 3-5.
- Claim names embed a hash generated per instance -- two instances on the
  same cluster carried cb42e1 and a28da4. It cannot be predicted, so
  volumes cannot be created up front. The procedure is now two-phase:
  create the instance, read the claim names, then create volumes reserved
  for exactly those names. Verified: claims sit Pending, then bind to the
  reserved volume as soon as it appears, and pods start on the right nodes.
- The instance storage block exposes only class, size and deleteClaim.
  There is no selector, so that fallback is gone entirely.

New prerequisite, found the hard way. A StorageClass must be granted to the
project or the pvc-validator.cpaas.io webhook denies every claim:

  StorageClass "kafka-local" is not allowed in project "demo"

The failure is indirect -- no pods, no claims, nothing on the instance --
and visible only in the Kafka resource conditions. Earlier testing missed
it because those namespaces had no project label. Documented with the
project.cpaas.io label and a troubleshooting entry.

Second problem: this is a solution document that led with several screens
of failure analysis. Reordered so the deployment procedure comes first and
the reasoning follows in "Why This Design". Added the manual cleanup
section after the procedure, since Retain means volumes and on-disk data
are never reclaimed and the host directories are the step people forget.

Also notes that bash and zsh index arrays differently, which silently
produced an off-by-one node mapping while preparing this.
Matches every other document in the kafka solutions directory.
@chideat
chideat merged commit f40682f into main Sep 9, 2026
1 check passed
@chideat
chideat deleted the docs/kafka-local-pv-hostpath-deployment branch September 9, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant