Skip to content

Skip automatic rolling updates for a Kafka node - #229

Open
acgtun wants to merge 7 commits into
strimzi:mainfrom
acgtun:146-skip-rolling-update-for-single-kafka-node
Open

Skip automatic rolling updates for a Kafka node#229
acgtun wants to merge 7 commits into
strimzi:mainfrom
acgtun:146-skip-rolling-update-for-single-kafka-node

Conversation

@acgtun

@acgtun acgtun commented Jun 15, 2026

Copy link
Copy Markdown

Type of Change

  • New Proposal

Description

Adds proposal 146: an opt-in, per-node control that excludes a single Kafka node from operator-driven rolling, while the operator keeps reconciling every other node in the cluster.

Today the only pause control is strimzi.io/pause-reconciliation on the whole Kafka CR, which is cluster-wide and far too coarse: to protect one node you must freeze config, cert rotation, scaling, and rolling for the entire cluster. This proposal adds a scalpel — stop the operator from rolling one node, and only that node.

Highlights:

  • Two trigger surfaces: an ephemeral pod annotation (strimzi.io/skip-rolling-update) fast path and a durable KafkaNodePool.spec.skipRollingUpdate field.
  • The mechanism is working-set exclusion in KafkaRoller (not a RestartReasons filter), so a skipped node is never a roll candidate and the reconcile never waits on its readiness.
  • v1 scope is brokers only; controller skips are refused, with the quorum-safe controller path deferred to future work.
  • Behind a SkipNodeRollingUpdate feature gate, default off.
  • Covers availability math, security/RBAC, Drain Cleaner interaction, status, metrics, testing, and compatibility (including a downgrade data-loss warning).

Checklist

  • AI assistance was used to create this PR (see the Strimzi AI policy)
  • BEFORE MERGING: The next free sequence number was used for the proposal and all its assets (images, etc.)
  • BEFORE MERGING: The proposal index in README.md was updated

@acgtun
acgtun force-pushed the 146-skip-rolling-update-for-single-kafka-node branch from 37a3396 to 29f68d8 Compare June 15, 2026 02:35

@im-konge im-konge left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the proposal, I didn't go fully through it, but for now few points from me:

  • please have one sentence per line, it's easier to do reviews and comment exact sentence than pick up from few senteces on one line.
  • you are mentioning having API change in NodePools, annotation, then you are comparing what is better and not, then you have Feature Gate - you should pick one approach, say why you picked it and then you can have some rejected alternatives
  • also, I'm not sure you need Feature Gate for something that will not change default behavior, but it will be opt-in based on some configuration or something, but that's maybe just my feeling
  • speaking about v1 and API stuff is kind of confusing for me, as we already have v1 API, but here you mention some "v1 of the implementation"

And then something from my side. I respect our AI policy, but having a long text generated by AI is not good for the maintainers, as they have to go through a lot of text, that may not provide much or everything that is needed.

The points about long text, having one sentence per line etc. is actually something for those 2 other proposals you created.

@scholzj scholzj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the proposal.

I have some doubts about the usefulness. I think it would in most cases ultimately block the operator from rolling the other nodes and thus end up in the same state as the paused reconciliation.

However, I think if we trim down the implementation to something smaller and leaner, it would be a reasonable feature to add and I would be fine with it. It might not help that much in some situations, but it would add at least something.

I left bunch of comments, but I think the key would be to try to simplify it. Please think whether this really needs dedicated metrics or API in the .spec section. I think it does not and if we trim it down, I think this proposal would be fine. Let's start smaller, get the feature added and see how it works from there.


The cluster-wide pause is the wrong tool for the most common operational emergencies, which are almost always single-node problems. An operator needs to take one node out of operator-driven rolling — while the rest of the cluster keeps being managed — for many reasons, for example:

- A broker doing a long on-disk log recovery (`RECOVERY`) that exceeds the operation timeout, where a restart throws the in-progress recovery away.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be covered and the Roller should not roll brokers in recovery. Is that not the case for you? That would be IMHO a bug.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — you're right that a clean RECOVERY is already handled by 048 and the roller shouldn't roll a recovering broker. The loop here was the agent hanging and returning BrokerState(-1) instead of RECOVERY (#12513), so it fell through to force-restart. I've reframed the motivation: #12513 is the right fix for recovery and should land on its own; this lever covers the cases the heuristic can't see.

Comment on lines +43 to +45
1. The affected broker is in `RECOVERY`, so the Kafka Agent readiness endpoint fails and the pod is `0/1` NotReady.
2. `KafkaRoller` waits up to `STRIMZI_OPERATION_TIMEOUT_MS` (default `300000` ms) for readiness, does not get it, and takes its not-ready-pod path. It rolls (deletes/recreates) the still-recovering pod. The empty reasons list seen in the incident logs (`due to []`) reflects that the `podNeedsRestart` predicate produced no reasons — the restart was driven by the roller's internal not-ready / force-restart path, not by the predicate.
3. The reconcile times out again, surfaces as a `FatalProblem`, and the next periodic reconcile (`STRIMZI_FULL_RECONCILIATION_INTERVAL_MS`, default `120000` ms) repeats the whole thing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, this sounds like a bug. Can you reproduce it? The KAfka Agent running inside the broker should be providing the information about the recovery and the operator should not touch such a node. So maybe something changed in Kafka and this does not work anymore? (I assume you use some reasonably recent Kafka version) CC @tinaselenge

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a bug. It reproduces when the data volume is stuck: the agent stays reachable on TCP but never returns a broker-state response, and getBrokerState() has no request timeout (#12513), so it resolves to BrokerState(-1, null) and 048's == 2 guard doesn't fire. Kafka version was recent; the timeout fix is #12675. Moved to a short note in the motivation.

@tinaselenge tinaselenge Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KafkaRoller generally decides to roll unresponsive nodes when operator cannot reach through Admin API bu if we cannot determine whether it's in log recovery or not via KafkaAgent, I wouldn't expect the force-restart to happen. Unless if the node was in one of the stuck states ( "CrashLoopBackOff", "ImagePullBackOff", "ContainerCreating", "InvalidImageName"), and was outsided which would mean it wasn't doing recovery. If it was simply in not ready state, it shouldn't have been restarted. We should see the following logs in this case:

LOGGER.warnCr(reconciliation, "Failed to wait for the readiness of the pod {}. We will proceed and check if it needs to be rolled.", nodeRef.podName(), e.getCause());
...
await(isReady(namespace, nodeRef.podName()), operationTimeoutMs, e -> new FatalProblem("Error while waiting for non-restarted pod " + nodeRef.podName() + " to become ready", e));

FatalProblem should cause the reconciliation to fail, not restart non ready pod.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for taking the time to walk through the roller logic — your first sentence is exactly what happened: the restart came from the cannot-reach-through-Admin-API handling, not the stuck-state path. Tracing it through the code:

  • The pod was NotReady but not in any of the stuck states, so restartIfNecessary waited for readiness, timed out, and called getBrokerState(). The stuck volume meant the agent request failed rather than reporting RECOVERY, and getBrokerState() swallows the failure and returns BrokerState(-1, null) (per its javadoc), so isBrokerInRecovery() was false and execution fell through with the "We will check if KafkaRoller can do anything about it" warning.
  • In checkIfRestartOrReconfigureRequired, the bootstrap admin client initialized fine (the other brokers were healthy), but the per-broker probe — getConfig(nodeRef), which the code comments note is "sent to that specific broker" as proof it can respond — failed against the stuck broker with a ForceableProblem.
  • Once the backoff was exhausted, that ForceableProblem becomes a restart either way: inside checkIfRestartOrReconfigureRequired it sets needsRestart with CONFIG_CHANGE_REQUIRES_RESTART, and if it propagates instead, the outer catch (ForceableProblem) force-rolls with POD_FORCE_RESTART_ON_ERROR (subject to canRoll). (POD_UNRESPONSIVE is the sibling branch for when even the bootstrap client can't be created.)
  • The FatalProblem wait you quote is the else branch, reached only when the roller can talk to the broker and there's nothing to restart — an unreachable broker never gets there.

So I agree with your conclusion that there's no roller bug here beyond the agent-client one: with strimzi/strimzi-kafka-operator#12675 giving the agent request a timeout, a clean RECOVERY answer stops the roller exactly as 048 intends. The annotation is aimed at the cases where nothing the operator can read distinguishes "restart me" from "leave me alone" — which the unresponsive handling, by design, resolves as "restart".


This is an infinite restart loop. The intrinsic harm — independent of storage setup — is that every forced restart discards in-progress recovery: `LogLoader` starts over from scratch, so a long recovery can never finish and the cluster stays permanently under-replicated. In this particular incident it was made worse by storage topology: the restart rescheduled the pod onto a different Kubernetes node (host), and because the volumes were `ReadWriteOnce` the pod then hit `Multi-Attach` errors (the PersistentVolume was still attached to the prior host). That is an aggravating factor specific to RWO storage, not a precondition for the loop — the recovery-restart loop happens regardless.

Notably, the deployed operator already shipped proposal-048's recovery detection and it still did not prevent the loop. With the data volume stuck, the Kafka Agent stayed reachable on TCP but never returned a broker-state response, so `KafkaAgentClient.getBrokerState()` — which has no request timeout, [strimzi-kafka-operator#12513](https://github.com/strimzi/strimzi-kafka-operator/issues/12513) — hung and resolved to `BrokerState(-1, null)`. Because `-1 != 2` (`RECOVERY`), `KafkaRoller` fell through to force-restart as if 048 were absent. A timeout fix is tracked in [#12675](https://github.com/strimzi/strimzi-kafka-operator/pull/12675), but in the operator source reviewed here `KafkaAgentClient` still sets no request timeout, so the hang is reproducible — check its merge state before relying on it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I guess that actually answers the previous comments. But why does the Agent timeout?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The volume was stuck, so the agent never completed the broker-state read and getBrokerState() had no request timeout to bail out (#12513 / #12675). Folded into the reframed motivation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a problem we should investigate and fix with KafkaAgent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fully agree, and that's been done independently of this proposal: strimzi/strimzi-kafka-operator#12513 tracked it and strimzi/strimzi-kafka-operator#12675 (merged — thank you for the review there) bounds the HTTP timeouts in KafkaAgentClient. The proposal text frames that fix as landing on its own; the annotation is only for what the recovery heuristic can't decide, not a workaround for that bug.


Throughout this proposal, **"v1"** refers to the first implementation increment of this feature — not a Strimzi release version or a CRD API version (e.g. `v1beta2`). To give reviewers a smaller, clearly bounded first cut, v1 is intentionally narrow. Everything outside this list is either deferred (see Out of scope) or a later increment of the same design:

- **Brokers only; controllers refused in v1.** Broker skip is the motivating case and is quorum-safe by construction. In v1 a skip that targets a controller node (controller-only or combined broker+controller) is **refused** — the entry is ignored, a `.status` condition and warning event explain why, and the operator keeps managing that node normally. The whole controller machinery (refuse-by-default quorum math, the `force` exception, per-reconcile quorum re-evaluation) is **future work**, not v1. This keeps the most contentious surface out of the first review.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is just a language. But refused sounds confusing here as you cannot refuse it. I would stick with ignored.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — switched to "ignored" throughout. A controller node ID in the list is ignored and the operator keeps managing it.

- **Known limitation — combined-role deployments.** Because a combined broker+controller node *is* a controller, v1 refuses skips for it. In a fully **combined-mode** cluster (every node holds both roles, common on small KRaft deployments) this means v1 refuses **every** skip and the feature is effectively inert there; it is useful only where dedicated broker-only nodes exist. The motivating incident is covered only if the recovering broker is a broker-only node. This is an accepted v1 limitation; combined-node skips become available with the controller work.
- **Two surfaces, one meaning.** The ephemeral pod-annotation fast path (`strimzi.io/skip-rolling-update="true"`) and the durable `KafkaNodePool.spec` field. The pod annotation is what an operator reaches for mid-incident; the `spec` field is what survives pod recreation (and any declarative re-apply, GitOps included).
- **Remove the skipped node from the roller's working set *and* from the post-roll readiness stage.** This is the mechanism (see "KafkaRoller behavior"): the node is excluded from the set `KafkaRoller` iterates, so it is never a roll candidate. Excluding it from the roller is **necessary but not sufficient** — the reconcile pipeline runs a *separate* `KafkaReconciler.podsReady()` stage after rolling, which waits on the unfiltered `kafka.nodes()` set and would still time out on the skipped pod (see "Reconcile readiness stage and CR status"). v1 must exclude the skipped node from both. As a consequence *every* operator-driven roll of the node is suppressed at once — the not-ready/force-restart loop, config, cert-renewal, and version-upgrade rolls — and it keeps its current config/cert/version until unskipped. This is a deeper change than filtering restart reasons, and the part v1 strictly requires.
- **Behind a feature gate**, default off (see below).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this needs feature gate TBH. It is something the users would trigger by annotation. So maybe it is not needed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — dropped the feature gate. It's opt-in by annotation and doesn't change default behavior.


### Controller handling: refused in v1

Skipping a controller is more dangerous than skipping a broker, because controllers form the KRaft metadata quorum. Rather than ship a half-built quorum-safety story, **v1 refuses controller skips outright** and defers the full machinery:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really the case? Wouldn't it just essentially block the rolling update when rolling update is needed?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I was overselling this. The rewrite now says plainly that when the skipped broker is down, rolls of other brokers sharing an at-risk partition are deferred by the existing min-ISR check — exactly as any unsafe roll is today. The honest win over pause is narrower: non-rolling reconciliation continues cluster-wide and unaffected nodes still roll. Availability-math change moved to future work.

Comment on lines +202 to +209
### Drain Cleaner interaction

A skip does **not** make a node un-evictable. Strimzi Drain Cleaner intercepts pod eviction (e.g. from a node drain) and asks the operator to roll the pod gracefully; a skipped node is still a normal pod to the eviction path. So a drain can evict a skipped, recovering broker → the `StrimziPodSet` recreates it → recovery restarts from scratch — the exact harm the skip was meant to prevent, via a different door.

v1 guidance, at minimum:

- **Operationally exclude the skipped node from Drain Cleaner.** While a node is skipped, the operator documentation instructs disabling Drain Cleaner handling for that node (and cordoning the Kubernetes node so the cluster autoscaler / drain tooling does not evict it), so an eviction does not silently undo the skip.
- A first-class integration where Drain Cleaner itself honors the skip and refuses to evict a skipped node is noted as future work, not v1.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not really clear to me what exactly you suggest here. Also, keep in mind that the Drain Cleaner works through the manual rolling update. So it relates to the other part where I already raised an comment. That should be linked and thought through wjat we really want here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this was the interaction I'd hand-waved. Since Drain Cleaner evicts through the manual-rolling-update path, and skip deliberately doesn't suppress manual rolls, a drain can still move a skipped node. Made that explicit and linked it to the manual-rolling-update semantics: for now handled operationally (exclude from Drain Cleaner + cordon the host), with a first-class integration as future work.


New operator metrics (Prometheus, `strimzi_` prefix, labeled by cluster / node pool / node ID where applicable), to make the dangerous states (forgotten skip, quorum thinning, sole-replica-on-skipped-node) alertable rather than implicit.

v1:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, this sounds like something to trim down and reduce to log warnings, maybe a status condition. Alternatively, maybe Kube State Metrics can be used to monitor this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — dropped all dedicated metrics. Just a .status condition plus log warnings, and Kube State Metrics can alert off that. Metrics noted as future work only.

Comment on lines +220 to +222
A skip suppresses operator-*initiated* rolls only. It does **not** stop: the `StrimziPodSet` controller recreating the pod if it is deleted/evicted (which is why the durable `spec` path exists and Drain Cleaner must be handled — a recreated pod is skipped again on the next reconcile); kubelet liveness/readiness probes; Kubernetes host-level eviction/drain/scheduler actions (see Drain Cleaner); or reconciliation of *other* nodes.

It also does **not** stop operator-driven **scale-down**. `KafkaReconciler.scaleDown()` rewrites the `StrimziPodSet` to the desired pod set derived from `kafka.nodes()`, entirely outside `KafkaRoller`. If the skipped node's ID is removed from the desired set — by lowering the pool's `replicas` or via `strimzi.io/remove-node-ids` — its pod is deleted regardless of the skip (and, mid-recovery, that discards in-progress recovery). v1 does **not** intercept this: the skip lever governs *rolling*, not membership. To avoid silently undoing a skip, v1 documents that a skipped node must not be scaled down (the existing scale-down safety check in [049](https://github.com/strimzi/proposals/blob/main/049-prevent-broker-scale-down-if-it-contains-partition-replicas.md) already refuses removing a broker that still holds replicas, which covers the common case); a first-class "refuse scale-down of a skipped node" guard is noted as future work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about changes to the Pod's support resources?

  • Should services be updated?
  • Should PVCs be added / removed?
  • Should the Pod itself be updated in the StrimziPodSet (and recreated as updated next time) - e.g. when you change resources?
  • The configuration files in ConfigMaps and Secrets, etc.

I assume these would go as before?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — those go as before. Skip only suppresses rolling restarts; non-rolling reconciliation (Services, PVCs, ConfigMaps/Secrets, the SPS pod template) continues, the change just isn't applied to the running pod until it's next restarted or unskipped. Added a section saying this, plus a note that membership stages (KRaft register/unregister, scale-down) keep deriving membership from kafka.nodes() so a skipped-but-down node isn't treated as removed.


- **Whole-`Kafka`-CR pause (`strimzi.io/pause-reconciliation`).** This exists today and is exactly the pain this proposal addresses. It is too coarse: to protect one node you must freeze config, cert rotation, scaling, and rolling for the entire cluster, including all the healthy nodes you still want the operator to manage. It is an emergency sledgehammer, not a per-node control.

- **A durable node-pool *annotation* instead of a `spec` field.** Rejected on durability grounds (above): a pod annotation does not survive pod recreation, and any declarative re-apply (plain `kubectl apply`, or ArgoCD/Flux with self-heal) reverts an annotation not present in the manifest. The durable surface must be a `spec` field the user authors; the annotation survives only as the documented ephemeral fast path.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We treat annotations as durable. You can set it through GitOps, you just need to go through Git. So that is why I think this is the right way to go.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and this collapses the proposal's biggest section. Removed the "spec field is required for durability" argument — the durable surface is the KafkaNodePool annotation, set/removed via Git like any other field. The rejected-alternatives entry now just notes a pod annotation isn't durable because pods are recreated from the SPS template.

@Frawless Frawless left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with idea to having annotation on KafkaNodePools resources where user can specify broker IDs that will be excluded from the RU. It would be great to make a propsal smaller. In case there will be more maintainers that will agree on this approach, it will make the proposal much more easier to read.

Would be sufficient to have it effective on KNP level only in case it will make the implementation logic easier?

@acgtun

acgtun commented Jun 25, 2026

Copy link
Copy Markdown
Author

Thank you all for the thorough reviews — this was really helpful. The consistent message was that the proposal is too long and tries to do too much, and I completely agree. I've rewritten it around the minimal design you suggested and cut it down by roughly 3x (about 6500 to 2300 words).

Here's what changed:

  • One surface only: a single annotation on KafkaNodePool, strimzi.io/skip-rolling-update="[2,5]", reusing the existing node-ID annotation format. Dropped the pod annotation and the KafkaNodePool.spec field.
  • No feature gate — it's opt-in by annotation and doesn't change default behavior, so I agree it isn't needed.
  • No dedicated metrics — just a .status condition (parallel to ReconciliationPaused) plus log warnings; Kube State Metrics can alert off that if needed.
  • Brokers only; a node ID that resolves to a controller is ignored (kept managed), not "refused". Controller/quorum handling moved entirely to future work.
  • Reformatted to one sentence per line and dropped the "v1" wording that collided with the real API version.
  • Dropped the skip-vs-manual-rolling-update precedence machinery. Instead the semantics are now explicit: skip suppresses only automatic rolls; an explicit manual-rolling-update still rolls the node by design.
  • Reframed the motivation: the recovery loop is largely the #12513 agent-hang bug plus a 048 gap that should be fixed independently. The skip lever is now the deterministic human override for the cases the recovery heuristic can't cover, with a concrete example that survives the #12513 fix (a broker failing readiness on a degraded-but-alive disk, where a restart reschedules into a Multi-Attach crash loop on RWO storage).
  • Set the availability expectations honestly: I no longer claim "the rest of the cluster keeps rolling freely". When the skipped broker is down, rolls of other brokers sharing an at-risk partition are deferred by the existing min-ISR check, exactly as today. The real win over pause-reconciliation is that all non-rolling reconciliation (scaling, PVCs, config/cert generation, status) continues cluster-wide and unaffected nodes still roll.
  • Made the Drain Cleaner interaction explicit: because it evicts through the manual-rolling-update path, it can still move a skipped node, so it must be handled operationally while a node is skipped (first-class integration is future work).
  • Added a short Testing strategy section and a one-line security note (the KNP-annotation surface is intentionally tighter and more auditable than pod-edit).

I'll apply the same length and one-sentence-per-line treatment to the other two proposals as well. Thanks again for taking the time — please let me know if the trimmed version is closer to what you had in mind.

@tinaselenge tinaselenge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the proposal. To be honest, I'm not sure about the motivation. It sounds like the the main issue we are trying to fix here is rolling a node in recovery mode due to KafkaAgent being unresponsive. Then we probably should investigate and fix that so that it is handled automatically, rather than manually via annotation.

Do we know if there has been a demand from several users to skip a node from rolling rather than pausing reconciliations for other reasons? I only remember requests such as slowing down rolling or speeding up the rolling.


### Mechanism

A single predicate `isSkipped(nodeId)` is consulted at every gate that waits on node readiness; missing one would hang the reconcile, so the set is explicit:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where would this predicate be set? is KafkaReconciler going to remove this from the set that is passed to the KafkaRoller?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at every gate that waits on node readiness

So, where?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly as you describe — the change lives in KafkaReconciler, and KafkaRoller itself needs no modification. Concretely, KafkaReconciler resolves the KafkaNodePool annotations into a set of skipped node IDs once per reconciliation (validating pool membership and roles at that point), and the set is applied in the reconcile() chain:

  1. rollingUpdate() — today it passes the unfiltered kafka.nodes() to maybeRollKafka(); the skipped IDs are removed from that set, so the roller never sees the node and none of its internal force-restart paths can fire.
  2. podsReady() — it waits on every pod from kafka.nodes() with operationTimeoutMs; without filtering here, a NotReady skipped pod would time this stage out on every reconcile even though the roller ignored it.
  3. manualRollingUpdate() — deliberately not filtered, so an explicit strimzi.io/manual-rolling-update still rolls the node.

I also checked the later serviceEndpointsReady() / headlessServiceEndpointsReady() stages: they need no filtering, because Endpoints readiness needs only one ready address and the headless brokers service is created with publishNotReadyAddresses: true, so a single NotReady node doesn't block them.

Sorry this wasn't clear from the text — I'll replace the vague "every gate that waits on node readiness" wording with this concrete list in the next revision.

A single predicate `isSkipped(nodeId)` is consulted at every gate that waits on node readiness; missing one would hang the reconcile, so the set is explicit:

- the `KafkaRoller` working set — the node is never a roll candidate, so it never reaches the force-restart paths (decided inside the roller after `POD_UNRESPONSIVE` or in the `catch (ForceableProblem)` branch, which produce no `RestartReasons` — a reason filter would never catch them);
- `KafkaReconciler.podsReady()` and the endpoint-readiness stages, which otherwise wait on the unfiltered `kafka.nodes()` and would time out every cycle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this mean?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies — that sentence was too compressed to be useful. It's the second half of the answer above: KafkaReconciler.podsReady() runs right after rollingUpdate() and waits up to operationTimeoutMs for every pod in kafka.nodes() to be Ready. If the skip only filtered the roller's working set, a permanently NotReady skipped pod would still fail this stage every cycle and the reconciliation would never complete. So the same skipped set has to filter the pod list handed to ReconcilerUtils.podsReady(). I'll reword the sentence to say exactly that and to name the stages precisely instead of the vague "endpoint-readiness stages" (the endpoint stages themselves turned out to need no filtering — details in the thread above).

Two things the skip must **not** do:

- It must not make the node look removed: membership stages (KRaft register/unregister, scale-down) keep deriving membership from `kafka.nodes()`, which still includes the skipped node.
- It must not advance cluster-wide state past the held node: a cluster-wide Kafka version / `metadata.version` change is deferred while any node is skipped, since the skipped node stays on its old version and finalizing the upgrade without it could leave it unable to rejoin.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if kafka version is updated, it basically would pause the whole reconciliation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — not the whole reconciliation, but the version rollout itself would be held, and that's deliberate. Rolling every other node to the new version and then finalizing metadata.version while one node is held on the old version could leave that node unable to rejoin, which seemed worse than waiting. So: if a version change is pending while any node is skipped, the operator defers the upgrade rollout, logs a warning, and surfaces the conflict in the status condition; everything else (scaling, PVCs, cert/config generation, rolls for other reasons on other nodes) continues. This is also why the doc says a skip must not be held across an upgrade — the condition makes the conflict visible rather than silent.

If you think the alternative is better — let the binary rollout proceed on the other nodes and hold only the metadata.version bump — I'm happy to explore that; deferring the whole upgrade just seemed both safer and simpler for the first version. I'll make this behavior explicit in the doc either way.

### Controller nodes

The first version handles broker nodes only.
A node ID that resolves to a controller (controller-only or combined broker+controller) is ignored and kept managed, surfaced in status and logs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where would this check happen? We currently check process role of the node in 2 different ways, NodeRef contains desired role and KafkaRoller checks pod label.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for raising this — the two-sources subtlety is exactly the kind of thing the doc should pin down and didn't. My intent is to do the check at annotation-resolution time in KafkaReconciler, using the NodeRef desired roles, because that's the same level where the skip set is built and it works even when the pod doesn't exist. Since desired and actual roles can diverge during a role transition, I'd make it conservative: honor the skip only if the node is broker-only by desired role and, when the pod exists, its role labels don't claim controller; otherwise ignore the skip and log why. The reasoning: rolling one node too many during a transition is recoverable, while silently thinning the quorum is not. I'll spell this out in the proposal — and if you see a problem with keying off NodeRef at the reconciler level, I'd appreciate the correction.


The first version handles broker nodes only.
A node ID that resolves to a controller (controller-only or combined broker+controller) is ignored and kept managed, surfaced in status and logs.
Quorum-safe controller skipping is future work, because a skipped controller can thin the KRaft metadata quorum; in a fully combined-mode cluster every node is a controller, so the skip is inert there until then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see how this would be done safely for controllers or how it is useful for controllers as recovery process is very quick for controllers. Normally users would not need more than 3 or 5 nodes for the quorum, skipping even just one decreases the majority and puts the quorum safety at risk. I understand it is safer with brokers as there can be much larger number of brokers that rolling can continue safely while one broker is skipped.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both counts — controller recovery is fast so the motivation barely applies, and the quorum-majority risk is real. The proposal is brokers-only and a controller-resolving ID is ignored and kept managed. Since you and Paolo both flagged the future-work entry as well, I'll drop controller skipping from Future work entirely rather than leave it as a half-promise (see the line 125 thread).


## Future work

- **Controller skip support**: a quorum-safe admission check (refuse a skip that would drop the KRaft quorum below majority), an explicit override flag, per-reconcile re-evaluation, and deciding other-controller rolls from real `DescribeQuorum` state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said previously, I don't think this should be done for controllers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood, and agreed — I'll remove the controller-skip entry from Future work. The proposal will state that skipping applies to broker-only nodes, controller-resolving IDs are ignored and kept managed, and controller support is explicitly a non-goal rather than deferred work.

- **Controller skip support**: a quorum-safe admission check (refuse a skip that would drop the KRaft quorum below majority), an explicit override flag, per-reconcile re-evaluation, and deciding other-controller rolls from real `DescribeQuorum` state.
- **First-class Drain Cleaner integration** so a skipped node is automatically excluded from drain-driven evictions.
- **Auto-expiry of a skip** after a configurable duration, so a forgotten skip does not block cert rotation, config, or upgrades indefinitely.
- **Availability-math refinement** to subtract skipped brokers from the effective ISR when judging whether other nodes can roll.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why would you subtract it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thought was: a node is usually skipped because it's unhealthy or about to be worked on, so when deciding whether another broker can roll, counting the skipped node as a dependable ISR member is optimistic — it may drop out mid-roll. "Subtracting" it would treat it as unavailable in the safety math, which is conservative. But you're right to question it: the existing check already works from the live ISR reported by Kafka, which reflects reality well enough, and the extra pessimism has its own cost (blocking rolls that are fine when the skipped node is healthy). Since it's speculative and evidently more confusing than helpful, I'll drop this bullet.

- **First-class Drain Cleaner integration** so a skipped node is automatically excluded from drain-driven evictions.
- **Auto-expiry of a skip** after a configurable duration, so a forgotten skip does not block cert rotation, config, or upgrades indefinitely.
- **Availability-math refinement** to subtract skipped brokers from the effective ISR when judging whether other nodes can roll.
- **Dedicated metrics** if the status condition proves insufficient for alerting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be decided now whether it is sufficient or not.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair — a proposal shouldn't leave this open. Decision: the status condition plus log warnings is the observability surface for this feature; no dedicated metrics, unless the discussion on the line 96 thread lands the other way, in which case they get specced here rather than deferred. I'll remove the "if it proves insufficient" hedge either way.

Comment on lines +43 to +45
1. The affected broker is in `RECOVERY`, so the Kafka Agent readiness endpoint fails and the pod is `0/1` NotReady.
2. `KafkaRoller` waits up to `STRIMZI_OPERATION_TIMEOUT_MS` (default `300000` ms) for readiness, does not get it, and takes its not-ready-pod path. It rolls (deletes/recreates) the still-recovering pod. The empty reasons list seen in the incident logs (`due to []`) reflects that the `podNeedsRestart` predicate produced no reasons — the restart was driven by the roller's internal not-ready / force-restart path, not by the predicate.
3. The reconcile times out again, surfaces as a `FatalProblem`, and the next periodic reconcile (`STRIMZI_FULL_RECONCILIATION_INTERVAL_MS`, default `120000` ms) repeats the whole thing.

@tinaselenge tinaselenge Jun 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KafkaRoller generally decides to roll unresponsive nodes when operator cannot reach through Admin API bu if we cannot determine whether it's in log recovery or not via KafkaAgent, I wouldn't expect the force-restart to happen. Unless if the node was in one of the stuck states ( "CrashLoopBackOff", "ImagePullBackOff", "ContainerCreating", "InvalidImageName"), and was outsided which would mean it wasn't doing recovery. If it was simply in not ready state, it shouldn't have been restarted. We should see the following logs in this case:

LOGGER.warnCr(reconciliation, "Failed to wait for the readiness of the pod {}. We will proceed and check if it needs to be rolled.", nodeRef.podName(), e.getCause());
...
await(isReady(namespace, nodeRef.podName()), operationTimeoutMs, e -> new FatalProblem("Error while waiting for non-restarted pod " + nodeRef.podName() + " to become ready", e));

FatalProblem should cause the reconciliation to fail, not restart non ready pod.


This is an infinite restart loop. The intrinsic harm — independent of storage setup — is that every forced restart discards in-progress recovery: `LogLoader` starts over from scratch, so a long recovery can never finish and the cluster stays permanently under-replicated. In this particular incident it was made worse by storage topology: the restart rescheduled the pod onto a different Kubernetes node (host), and because the volumes were `ReadWriteOnce` the pod then hit `Multi-Attach` errors (the PersistentVolume was still attached to the prior host). That is an aggravating factor specific to RWO storage, not a precondition for the loop — the recovery-restart loop happens regardless.

Notably, the deployed operator already shipped proposal-048's recovery detection and it still did not prevent the loop. With the data volume stuck, the Kafka Agent stayed reachable on TCP but never returned a broker-state response, so `KafkaAgentClient.getBrokerState()` — which has no request timeout, [strimzi-kafka-operator#12513](https://github.com/strimzi/strimzi-kafka-operator/issues/12513) — hung and resolved to `BrokerState(-1, null)`. Because `-1 != 2` (`RECOVERY`), `KafkaRoller` fell through to force-restart as if 048 were absent. A timeout fix is tracked in [#12675](https://github.com/strimzi/strimzi-kafka-operator/pull/12675), but in the operator source reviewed here `KafkaAgentClient` still sets no request timeout, so the hang is reproducible — check its merge state before relying on it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a problem we should investigate and fix with KafkaAgent.

@ppatierno ppatierno left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some comments but this proposal needs more checks from the author and not just pushing what AI generates. Most of the times it is just a list of bullet points without any introduction and their meaning.

The feature began with a sharper incident: a broker stuck in a long on-disk log recovery, repeatedly force-restarted by the roller (`due to []` — no pending change), discarding in-progress recovery each time.
That recovery case should already be handled by proposal [048](https://github.com/strimzi/proposals/blob/main/048-avoid-broker-restarts-when-in-recovery.md): the roller reads the Kafka Agent broker state and, on `RECOVERY`, stops instead of force-restarting.
It looped only because the agent hung and returned `BrokerState(-1)` instead of `RECOVERY` ([#12513](https://github.com/strimzi/strimzi-kafka-operator/issues/12513), fix in [#12675](https://github.com/strimzi/strimzi-kafka-operator/pull/12675)), so 048's check fell through.
That part is a bug and the #12513 fix should land independently of this proposal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide a consistent link.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do — I'll make #12513 a proper markdown link, consistent with the references on the previous line. Thanks for catching it.


### Mechanism

A single predicate `isSkipped(nodeId)` is consulted at every gate that waits on node readiness; missing one would hang the reconcile, so the set is explicit:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at every gate that waits on node readiness

So, where?


### Controller nodes

The first version handles broker nodes only.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first version of what?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of this feature — the scope this proposal commits to implement. You're right that "first version" reads ambiguously next to API versioning; I'll reword it to "the initial implementation of this feature handles broker nodes only".


A `.status` condition on the `Kafka` CR (paralleling `ReconciliationPaused`) lists skipped node IDs with their start time, plus ignored controller IDs and rejected invalid IDs; per-node detail is mirrored on the owning `KafkaNodePool`.
Because `Ready` excludes skipped nodes, the condition also flags when a skipped node holds the sole in-sync replica of any partition, so an intentionally-skipped node cannot make the cluster look healthy while a partition is actually offline.
Entering/leaving the skipped state is logged and emitted as an event.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which event?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Kubernetes Event, published through the operator's existing event publisher the same way restart events are emitted today — with reasons named for entering/leaving the skip (e.g. RollingUpdateSkipEnabled / RollingUpdateSkipDisabled). The doc should have said that instead of the bare "an event"; I'll name the mechanism and the reasons explicitly.

Because `Ready` excludes skipped nodes, the condition also flags when a skipped node holds the sole in-sync replica of any partition, so an intentionally-skipped node cannot make the cluster look healthy while a partition is actually offline.
Entering/leaving the skipped state is logged and emitted as an event.

Disabling a node's self-healing requires edit access to its `KafkaNodePool` — a tighter, more auditable surface than pod-edit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this exactly mean?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's contrasting with the rejected pod-annotation design, but I agree it doesn't say so — I'll rewrite it. The intended meaning: with a pod annotation, anyone with pod-edit permission could silently disable a node's self-healing. With the KafkaNodePool annotation, doing that requires edit rights on the KNP custom resource — which in most deployments is GitOps-managed and reviewed, so the action leaves an audit trail and is gated by the same access controls as any other cluster-shape change.


## Future work

- **Controller skip support**: a quorum-safe admission check (refuse a skip that would drop the KRaft quorum below majority), an explicit override flag, per-reconcile re-evaluation, and deciding other-controller rolls from real `DescribeQuorum` state.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@acgtun

acgtun commented Jul 8, 2026

Copy link
Copy Markdown
Author

@tinaselenge replying to your review summary:

Thank you for reading it critically — it's a fair challenge and I'd rather answer it honestly than oversell. You're right that the incident that started this was the agent bug, and the right fix for that is automatic: strimzi/strimzi-kafka-operator#12675 is merged, and the proposal explicitly says recovery handling should not depend on this annotation.

The remaining motivation is the cases where no automatic heuristic can decide, because the deciding information isn't in anything the operator can read: a node held NotReady on purpose for live investigation, a known-bad host awaiting a hardware swap where a restart just reschedules the problem (or lands in a Multi-Attach crash loop on RWO storage), a degraded-but-alive disk where a restart makes things worse. In each of those, the operator's unresponsive-node handling is designed to restart — reasonably, for the common case — and the only counter-lever today is the cluster-wide pause, which we've had to use in production and which freezes cert rotation and scaling for every healthy node too.

On demand: the concrete evidence I can personally vouch for is our own production experience operating these clusters, and I won't claim broader demand I can't substantiate. If the maintainers feel that isn't enough to justify even a small opt-in lever, I'm happy to park this and open a discussion issue to gauge interest instead. My case for proceeding is that the surface is now very small (one annotation, a filter in KafkaReconciler, a status condition) and it composes with — rather than substitutes for — the automatic fixes. Either way, I appreciate the scrutiny; it has already made the proposal considerably better.

@acgtun

acgtun commented Jul 8, 2026

Copy link
Copy Markdown
Author

@ppatierno replying to your review summary:

You're right, and I'm sorry for the extra review burden that caused. Several of the questions in this round ("which event?", "first version of what?", the readiness-gates bullet) were things I should have caught before pushing — text that gestures at a mechanism without actually naming it, which wastes reviewers' time. For this round I've answered every inline comment against the operator source itself (the KafkaRoller/KafkaAgentClient walk-through on the line-45 thread is checked against current main), and I've rewritten the flagged sections so each bullet states its mechanism concretely, with enough introduction to be read standalone. I do use AI as a drafting tool within the project's policy, but the checking and the answers are my responsibility, and I'll hold every push to that standard.

@acgtun

acgtun commented Jul 8, 2026

Copy link
Copy Markdown
Author

Thank you @tinaselenge and @ppatierno for the careful second round — the questions were specific and caught real gaps, and I've tried to answer each one inline with the same specificity. I've pushed a revision (691abf5) with the changes that follow from them:

  • Name the exact integration points: skip set resolved in KafkaReconciler, filtering rollingUpdate()'s node set and podsReady(); KafkaRoller unchanged; manualRollingUpdate() deliberately unfiltered (and why the endpoint-readiness stages need no filtering).
  • Pin down role validation: NodeRef desired roles at annotation-resolution time, conservative when desired and actual roles diverge during a transition.
  • Remove controller skipping from Future work entirely — brokers only, stated as a non-goal under rejected alternatives.
  • Extend the status condition (now named RollingUpdateSkipped) to flag partitions that fall below min.insync.replicas (not just sole-ISR), and replace the "short-lived" claim with the actual mechanisms (start time in status, per-reconcile warnings).
  • Clarify the version-upgrade interaction: the upgrade rollout is deferred while a skip exists (deliberately), everything else keeps reconciling.
  • Drop the ambiguous availability-math future-work bullet; make the metrics position definite (status condition + KSM).
  • Wording/link fixes: consistent #12513 link, "initial implementation of this feature", named Kubernetes Event reasons, rewritten RBAC paragraph, documented multi-node shared-partition risk, concrete testing strategy.

Two open questions where I kept the smaller option for now and will gladly change based on your preference: whether auto-expiry of a skip should be in the first version rather than future work, and whether the two metrics (gauge of skipped nodes, counter of suppressed rolls) should be specced in this proposal.

@acgtun acgtun changed the title Skip operator-driven rolling for a single Kafka node Skip automatic rolling updates for a Kafka node Jul 8, 2026
acgtun added a commit to acgtun/strimzi-kafka-operator that referenced this pull request Jul 13, 2026
Implements proposal 146 (strimzi/proposals#229): an opt-in, per-node
control that excludes broker nodes from operator-driven rolling updates
through the strimzi.io/skip-rolling-update annotation on a
KafkaNodePool, while the operator keeps reconciling everything else.

- Resolve the annotation in KafkaReconciler at the start of the
  reconciliation: per-entry validation (pool membership, format),
  controller-resolving IDs ignored based on desired roles and the role
  labels of existing Pods
- Exclude skipped nodes from the working set passed to KafkaRoller in
  rollingUpdate() and from the Pods waited on in podsReady();
  manual-rolling-update is deliberately not filtered
- Defer a KRaft metadata version change while any node is skipped and
  surface it with a MetadataVersionChangeDeferred warning condition
- Add a RollingUpdateSkipped condition on the Kafka status (mirrored on
  the owning KafkaNodePool) and emit RollingUpdateSkipEnabled/Disabled
  Kubernetes Events

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
@acgtun
acgtun force-pushed the 146-skip-rolling-update-for-single-kafka-node branch from 4ef2f7d to e7650e3 Compare July 16, 2026 18:34
acgtun added 6 commits July 16, 2026 11:37
Add proposal 146: an opt-in, per-node control to exclude a single Kafka
node from operator-driven rolling while the rest of the cluster keeps
reconciling. Updates the README proposal index.

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
- podsReady()/endpoint-readiness must also exclude skipped nodes, else the
  reconcile times out on the skipped pod every cycle (working-set exclusion
  in KafkaRoller alone is insufficient)
- Specify the Kafka CR Ready contract for a skipped+NotReady node
- Call out combined-role/KRaft-combined limitation (all skips refused there)
- Note operator scale-down can still delete a skipped node (not governed by skip)
- Correct the FatalProblem/readiness-wait citations in KafkaRoller behavior
- Cover CA replacement (not just expiry) for a skipped node
- Filter skipped IDs out of the StrimziPodSet-level manual-rolling-update expansion

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
Trim the proposal to a single KafkaNodePool annotation
(strimzi.io/skip-rolling-update), drop the pod annotation, the spec
field, the feature gate, and dedicated metrics. Brokers only, with
controller IDs ignored. Reformat to one sentence per line, make the
automatic-vs-manual rolling semantics explicit, and set the
availability expectations honestly.

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
Lead the motivation with up-node cases where the value is real and add
the "cannot auto-distinguish stuck vs intentionally not-ready" argument.
Defer cluster-wide version/metadata changes while a node is skipped,
flag sole-ISR partitions in the status condition so Ready stays honest,
frame Drain Cleaner suppression as a principled deferral, and add the
"stop force-restarting not-ready pods" rejected alternative. Retitle to
"automatic" and trim throughout.

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
Name the concrete KafkaReconciler integration points, make controller
skipping an explicit non-goal, report two-level partition risk in the
status condition, scope the upgrade deferral, replace the short-lived
claim with the actual visibility mechanisms, and apply the wording and
link fixes from the round-2 review.

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
Merge the duplicated pause-is-too-coarse argument, compress the
incident story, drop two rejected alternatives nobody raised, and fold
the Drain Cleaner and auto-expiry follow-ups inline where they are
discussed.

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
@acgtun
acgtun force-pushed the 146-skip-rolling-update-for-single-kafka-node branch from 4e7a702 to df47ba5 Compare July 16, 2026 18:38

@tinaselenge tinaselenge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the comments. I've read through the updated proposal. The proposed implementation of how a node would be skipped from rolling kind of makes sense, but I'm still not convinced about the motivation.

The original motivating incident is now fixed by #12513. The remaining cases, degraded disk, hung mount, Multi-Attach on RWO storage are described only in general terms. It's not clear now that we have the fix, how this feature would still be useful in real scenarios.

I'd like to understand more concretely why pause-reconciliation is insufficient for these scenarios. The proposal says the skip annotation should be short-lived during manual intervention. But pause-reconciliation is also short-lived.

During that short window, rolling updates of other nodes may not even be scheduled since rolling doesn't happen all the time. So the value of per-node granularity over a cluster-wide pause seems to be small. And the skip doesn't protect against Kubernetes itself deleting the pod (eviction, liveness failure, node drain), so it's not a complete "don't touch this node" guarantee either.

Without stronger motivation, the complexity doesn't seem justified: annotation parsing with validation, role-checking logic, version upgrade deferral, a new status condition, the risk of a broker missing a CA renewal. I'm afraid this feels like a solution looking for a problem after the original problem was fixed.


The feature began with a sharper incident: a broker in a long on-disk log recovery was repeatedly force-restarted by the roller, discarding the in-progress recovery each time.
That turned out to be a bug: proposal [048](https://github.com/strimzi/proposals/blob/main/048-avoid-broker-restarts-when-in-recovery.md) makes the roller stop on `RECOVERY`, but the agent request failed and returned `BrokerState(-1)` ([#12513](https://github.com/strimzi/strimzi-kafka-operator/issues/12513), fixed in [#12675](https://github.com/strimzi/strimzi-kafka-operator/pull/12675)), so 048's check fell through and the failing per-broker Admin API probe escalated to a force-restart once the backoff was exhausted.
That fix lands independently of this proposal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
That fix lands independently of this proposal.
That fix has already landed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you — applied in dd4dbf3.

That turned out to be a bug: proposal [048](https://github.com/strimzi/proposals/blob/main/048-avoid-broker-restarts-when-in-recovery.md) makes the roller stop on `RECOVERY`, but the agent request failed and returned `BrokerState(-1)` ([#12513](https://github.com/strimzi/strimzi-kafka-operator/issues/12513), fixed in [#12675](https://github.com/strimzi/strimzi-kafka-operator/pull/12675)), so 048's check fell through and the failing per-broker Admin API probe escalated to a force-restart once the backoff was exhausted.
That fix lands independently of this proposal.

A human lever is still needed for the cases 048 cannot see: a node not-ready for a non-`RECOVERY` reason (a degraded-but-alive disk, a hung mount) where the agent reports nothing actionable and a force-restart only reschedules the pod into a crash loop (`Multi-Attach` on `ReadWriteOnce` storage).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think either in here or in the proposal section, it would be useful to explain more about these scenarios and how much skipping a node from rolling would exactly help, where reconciliation pause cannot.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pushing on this — the motivation is much stronger for it. Expanded in dd4dbf3. The concrete case: a broker on a host with a failing-but-alive disk, and the replacement depends on datacenter ops — days, not hours. During that window every reconcile force-restarts the NotReady broker, and on ReadWriteOnce storage the recreated pod can land in a Multi-Attach crash loop. Holding pause-reconciliation for days stops cert rotation, config, and scaling for the whole cluster; a skip holds one node while everything else keeps reconciling — the difference is duration.

I also added two more scenario classes (on-host storage operations, live forensics, human-gated staged rollout) and the precedent: StatefulSets expose this per-node hold as spec.updateStrategy.rollingUpdate.partition; StrimziPodSets have no equivalent.

@acgtun

acgtun commented Jul 20, 2026

Copy link
Copy Markdown
Author

Thank you for addressing the comments. I've read through the updated proposal. The proposed implementation of how a node would be skipped from rolling kind of makes sense, but I'm still not convinced about the motivation.

The original motivating incident is now fixed by #12513. The remaining cases, degraded disk, hung mount, Multi-Attach on RWO storage are described only in general terms. It's not clear now that we have the fix, how this feature would still be useful in real scenarios.

I'd like to understand more concretely why pause-reconciliation is insufficient for these scenarios. The proposal says the skip annotation should be short-lived during manual intervention. But pause-reconciliation is also short-lived.

During that short window, rolling updates of other nodes may not even be scheduled since rolling doesn't happen all the time. So the value of per-node granularity over a cluster-wide pause seems to be small. And the skip doesn't protect against Kubernetes itself deleting the pod (eviction, liveness failure, node drain), so it's not a complete "don't touch this node" guarantee either.

Without stronger motivation, the complexity doesn't seem justified: annotation parsing with validation, role-checking logic, version upgrade deferral, a new status condition, the risk of a broker missing a CA renewal. I'm afraid this feels like a solution looking for a problem after the original problem was fixed.

Thanks @tinaselenge for your comments and deep insight.
The main difference between pause-reconciliation and skip-a-single-node is duration. For pause-reconciilation is for a short time window, as you mentioned, pause for too long, will cause issues, for example, cert rotation. In some case, for example, they're a broker on a host waiting for a disk or hardware replacement, which depends on datacenter ops and takes days, in this case, we could skip the rolling upgrade for this single node.

@tinaselenge

Copy link
Copy Markdown
Contributor

For pause-reconciilation is for a short time window, as you mentioned, pause for too long, will cause issues, for example, cert rotation. In some case, for example, they're a broker on a host waiting for a disk or hardware replacement, which depends on datacenter ops and takes days, in this case, we could skip the rolling upgrade for this single node.

Maybe making that clearer in motivation section could help because this sentence here seems to suggest that skip node is also short window, so I wasn't sure how long we are talking about.

A skip is intended to be short-lived and human-supervised, and must not be held across an upgrade or CA renewal.

@acgtun

acgtun commented Jul 23, 2026

Copy link
Copy Markdown
Author

Maybe making that clearer in motivation section could help because this sentence here seems to suggest that skip node is also short window, so I wasn't sure how long we are talking about.

@tinaselenge You're right, and thank you for reading closely enough across rounds to catch this — the proposal text was wrong, not your reading. "Short-lived" should have been "bounded by the intervention", which can be days. The real constraint is events, not time: a skip must not be held across a version upgrade or CA renewal. Rewritten in dd4dbf3: no hard time limit, a version rollout is not initiated while a skip exists, and a standing warning covers the rest. The timescale is now in the motivation too.

The same push takes your complexity concern seriously rather than arguing with it: partition-risk reporting is cut (Kafka's UnderMinIsrPartitionCount / OfflinePartitionsCount already cover it — see the line-92 thread), per-cause conflict detection is cut, and the KafkaNodePool status mirror and start times are cut. What remains: one annotation, two node-set filters in KafkaReconciler, an upgrade interlock, a status condition, a warning, two Events. If that still doesn't clear the bar, I'd rather hear it on this smaller version — either way, the proposal is considerably better for the time you've put into it.

- Apply suggested wording: the #12675 fix has already landed
- Expand motivation with a concrete days-long hardware-swap scenario and
  the duration contrast with pause-reconciliation
- Add two further scenario classes (on-host storage operations, live
  forensics, human-gated staged rollout) and the StatefulSet
  updateStrategy partition precedent
- Replace the contradictory 'short-lived' wording: skip duration is
  bounded by events (version upgrade, CA renewal), not wall-clock time
- Trim the observability surface: drop operator-computed partition-risk
  reporting (Kafka metrics are authoritative), per-cause conflict
  detection, the KafkaNodePool status mirror, and per-node start times
- Add the Affected/not affected projects section from the template
- Tighten the bug backstory, controller rationale, and test wording

Signed-off-by: Haifeng Chen <haifengc@twitter.com>
@acgtun
acgtun force-pushed the 146-skip-rolling-update-for-single-kafka-node branch from dd4dbf3 to cce1105 Compare July 24, 2026 17:06
Comment on lines +6 to +7
"Automatic" rolling means a roll the operator decides on its own: config change, certificate renewal, version upgrade, or the not-ready / force-restart path.
It is distinct from an *explicit* roll a human triggers with `strimzi.io/manual-rolling-update`, which is deliberately **not** suppressed (see "Interaction with manual rolling update").

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be rephrased. Currently, the certificate renewal is the only thing the operator decides on its own. The rest are all user decisions. You change the config, you do the upgrade, etc. I think the intention is good here and not rolling in these cases makes sense. Just the wording is wrong.

```

The value reuses the node-ID format of the existing `strimzi.io/next-node-ids` / `strimzi.io/remove-node-ids` annotations (IDs and ranges, e.g. `[2,5]` or `[2,4-6]`).
It lives on the `KafkaNodePool` rather than the `Kafka` CR because node IDs are owned by the pool; it is set and removed by the user, so it survives pod recreation and GitOps re-apply.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting this on the NodePool rather then on the Kafka CR surely impacts the implementation (at this point I'm not sure if it makes the implementation easier or harder TBH). You should share some implementation details on how it will be read, stored, etc. by the KafkaRoller.

I also wonder if it is confusing to put it there. You are right that the NodePool owns the node IDs. But the setup where you put strimzi.io/skip-rolling-update="[5]" on a wrong node pool and it is ignored is strange. It also requires more complex validation (does this ID exist + does it belong to this node pool).

Comment on lines +95 to +97
Drain Cleaner is the awkward case: it is automated (so by that rule it *should* honor the skip) but it triggers rolls through the manual-rolling-update path, which the initial implementation of this feature cannot cleanly intercept.
So a drain can still move a skipped node; while a node is skipped, exclude it from Drain Cleaner and cordon its host.
A first-class Drain Cleaner integration that honors the skip can be added as a follow-up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would you drain a node with the Pod where you do some manual maintenance? I do not think this needs any special handling. You should just not drain the node and if you do, it will be rolled 🤷

Comment on lines +79 to +80
- It must not advance cluster-wide state past the held node: a cluster-wide Kafka version / `metadata.version` change is deferred while any node is skipped, since the skipped node stays on its old version and finalizing the upgrade without it could leave it unable to rejoin.
Only the upgrade rollout is held: all other reconciliation, including rolls of other nodes for other reasons, continues, and the deferral is logged and surfaced in the status condition.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, this is an expert option that you should use for some manual emergency handling. I do not think it should have any special treatment for upgrade or metadata version updates. If you do upgrade while the node is skipped, it either works or not (depending on the in-sync partitions). It should be your responsibility to not update metadata versions or upgrade Kafka when in some emergency state. So I think this should be (similar to the Drain Clener) a documentation thing.

This kind of per-node hold is an established control for stateful workloads on Kubernetes: StatefulSets expose an ordinal-based version of it as `spec.updateStrategy.rollingUpdate.partition`, bounding which pods a rollout may touch.
StrimziPodSets have no equivalent; this annotation supplies one.

The skip does **not** promise that the rest of the cluster keeps rolling freely: when a skipped broker is actually down, a roll of another broker sharing an at-risk partition is deferred by the existing min-ISR check, exactly as any unsafe roll is today.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bit misleading ... in most clusters, taking the node down would fail the rolling update the and cluster anyway because the partition replicas would be missing (with the typical min.insync.replicas 2 and replication factor 3, you cannot really take down a node easily).

This annotation really only ensures the cluster remains ready until the next rolling update where it fails. And if the node is unavailable for a long time, the state of the cluster might become more and more inconsistent.

This should be clearly documented here as well as in the docs if/once approved and implemented:

  • You should keep this as short as possible
  • You should avoid any changes to the cluster while the node rolling is skipped as they would not work well anyway

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.

6 participants