Skip to content

[CORE-149029] Fix broker restart loop: trigger on CR intent change, not live-pod drift - #269

Open
dobrerazvan wants to merge 9 commits into
masterfrom
CORE-149029-fix
Open

[CORE-149029] Fix broker restart loop: trigger on CR intent change, not live-pod drift#269
dobrerazvan wants to merge 9 commits into
masterfrom
CORE-149029-fix

Conversation

@dobrerazvan

@dobrerazvan dobrerazvan commented Jul 8, 2026

Copy link
Copy Markdown

Description

Problem: Kafka broker pods restart in a loop (~8s cycle) whenever an admission controller mutates the live pod — most concretely ScaleOps VPA rewriting container requests (e.g. kafka cpu 1→392m), but also any webhook appending preferred affinity terms.

Root cause: handleRollingUpgrade used patch.DefaultPatchMaker.Calculate(currentPod, desiredPod) — a three-way strategic merge that includes the live pod in the change-detection half. Per apimachinery strategicpatch/patch.go, deltaMap = diffMaps(current, modified, {IgnoreDeletions: true}): a value change to a field koperator declares (like requests.cpu) is always reverted → non-empty patch → rolling upgrade → pod deleted → admission controller re-mutates → loop. The last-applied annotation only governs deletions; it cannot protect value changes to fields koperator still declares.

Fix — intent-aware two-way merge (podSpecIntentChanged):

Compare original (last-applied annotation on the current pod) vs modified (freshly generated desired pod). The live pod is deliberately not consulted.

  • External mutation, CR unchanged → original == modified → no restart; live value (VPA tuning, webhook affinity) is preserved.
  • CR edit → original != modified → one restart; pod is recreated with the CR value and then settles.

This subsumes the earlier preferred-affinity strip approach (ignorePreferredAffinities) and is strictly better: the old approach also silently swallowed intentional soft-affinity edits made through the CR.

This matches how built-in controllers work — ReplicaSet, Deployment, and StatefulSet all decide from recorded intent (replica count / pod-template-hash / controller-revision-hash), never from a live-pod spec diff. koperator is a hand-rolled StatefulSet for Kafka; the bug was that it diffed the live pod instead of recorded intent.

Cleanup included:

  • Removed annotation backfill dead code — the annotation is always set by the pod creation path, so backfill on update was unreachable.
  • Removed redundant toleration merge in handleRollingUpgrade — coalescing current-pod tolerations into the desired spec is unnecessary with intent-aware diffing.

Security note: The old live-pod diff incidentally reverted out-of-band tampering (e.g. kubectl patch image swap). The intent diff does not — consistent with RS/Deployment/StatefulSet, none of which revert live-pod drift. Tamper detection should be an explicit feature (RBAC, admission policy, image digest pinning), not coupled to the reconcile diff.

Type of Change

  • Bug Fix

Checklist

  • I have read the contributing guidelines
  • Existing issues have been referenced (where applicable)
  • I have verified this change is not present in other open pull requests
  • Functionality is documented
  • All code style checks pass
  • New code contribution is covered by automated tests
  • All new and existing tests pass

dobrerazvan and others added 8 commits August 18, 2026 12:15
PreferredDuringSchedulingIgnoredDuringExecution has no patchMergeKey in
the Kubernetes API types, so the three-way strategic merge patch treats
it as an atomic list. Any term injected by an admission controller (e.g.
ScaleOps) is removed on every reconcile because the list in the running
pod no longer matches the desired spec.

Replace the previous sync-copy approach (which corrupted the
last-applied annotation and silently suppressed intentional CR changes)
with a CalculateOption that strips preferred affinities from both sides
of the diff before the merge is computed. The diff becomes blind to
those terms, so admission-controller additions are never fought.

Also adds annotation backfill for broker pods that pre-date the
banzaicloud.com/last-applied annotation: without it the three-way merge
degenerates to a two-way diff, producing false resource diffs. Both
behaviours are gated on the new spec.admissionWebhooksEnabled field.

12 unit/integration tests added covering deletePreferredAffinities and
the full diff path via patch.DefaultPatchMaker.Calculate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itionally

ignorePreferredAffinities is a reconciler correctness fix, not a feature
toggle. The atomic-list issue affects any external actor that touches
preferredDuringSchedulingIgnoredDuringExecution, not just ScaleOps. The
toleration merge already follows this precedent unconditionally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y current code

Every pod Koperator creates goes through the creation path which calls
SetLastAppliedAnnotation before r.Create. The annotation is therefore always
present on pods managed by a current build; the backfill is dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… param

- Extract affinityKeyPod/AntiPod/Node constants to satisfy goconst
- Change baseKafkaPod() to take no arguments to satisfy unparam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d drift

The preferred-affinity strip fixed one class of admission-controller churn
but not the loop seen in practice: an autoscaler (ScaleOps VPA) rewrites the
broker pod's resource requests after admission, koperator's diff reverts them,
and the pod is deleted/recreated every reconcile. The strategic three-way
merge reverts any value change to a field koperator declares (deltaMap =
diff(current, modified)), so resources — and any other webhook-touched field —
keep looping. The strip also silently swallowed intentional soft-affinity
edits made through the CR.

Replace the affinity strip with a generic, intent-aware comparison:
handleRollingUpgrade now triggers a rolling upgrade iff koperator's own desired
spec differs from what it last applied — the last-applied annotation vs the
freshly generated desired pod (two-way strategic merge). The live pod is not
consulted, so mutations by any admission controller (autoscalers, webhooks,
node lifecycle controller) never cause a restart, while intentional CR edits
(resources and soft affinities alike) always do, settling after one restart.

- pkg/resources/kafka/util.go: add podSpecIntentChanged(); remove
  ignorePreferredAffinities()/deletePreferredAffinities() (subsumed)
- pkg/resources/kafka/kafka.go: switch on !intentChanged instead of
  patchResult.IsEmpty(); isPodTainted() case unchanged and still ordered
  first, so the shredder park->restart flow (TaintedBrokersSelector on the
  live pod) is unaffected
- pkg/resources/kafka/util_test.go: TestPodSpecIntentChanged (resource/affinity
  drift ignored; CR edits honored) and TestParkedBrokerRestartsIndependentOfIntent

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dobrerazvan dobrerazvan changed the title [CORE-149029] Fix preferred affinity restart loop caused by atomic list merge semantics [CORE-149029] Fix broker restart loop: trigger on CR intent change, not live-pod drift Aug 18, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants