fix(storagenodeset): stop giving the shared storage-node ServiceAccount an ownerReference - #451
Draft
boddumanohar wants to merge 3 commits into
Draft
fix(storagenodeset): stop giving the shared storage-node ServiceAccount an ownerReference#451boddumanohar wants to merge 3 commits into
boddumanohar wants to merge 3 commits into
Conversation
…-heal after SA recreation The storage-node-ds DaemonSet relied on Kubernetes' default auto-injected ServiceAccount token, which defaults to a ~1 hour expiration (kubelet refreshes at ~80% of that, so ~48 minutes). We hit this directly this session: the simplyblock-storage-node-sa ServiceAccount got deleted and recreated (exact trigger not conclusively root-caused — suspected informer-cache resync race under rapid CRD schema updates), and every already-running pod's mounted token stayed bound to the old, now-nonexistent ServiceAccount UID. The API server correctly rejects those with 401 Unauthorized, and nothing self-heals until kubelet's next refresh — in practice this read as a long, manual-intervention outage (had to force-delete pods to get fresh tokens). Mount an explicit short-lived (10 minute) projected ServiceAccountToken volume instead of relying on the default auto-injected one, with AutomountServiceAccountToken: false on the pod spec to avoid a duplicate mount at the same default path. This doesn't prevent a ServiceAccount recreation event, but bounds its blast radius to a few minutes of self-healing via kubelet's normal refresh cycle instead of requiring manual pod deletion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
boddumanohar
marked this pull request as draft
August 21, 2026 13:55
… to self-heal after SA recreation" This reverts commit d66e29d. Reproduced the underlying mechanism live: it's deterministic Kubernetes garbage-collector behavior, not a rare race, and it isn't specific to the ServiceAccount's token. Shortening the token TTL only shrinks the blast radius of one symptom; it leaves the actual hole (ownerReference-based GC-cascade deletion of shared RBAC objects) open. Replacing this with a fix that removes the hole structurally instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt an ownerReference reconcileRBAC set a controller ownerReference from a single StorageNodeSet onto simplyblock-storage-node-sa, but the SA is shared by every StorageNodeSet in the namespace, not owned 1:1 by any one of them. That made whichever StorageNodeSet reconciled most recently the sole GC-relevant owner, and deleting -- or even briefly delete-recreating -- that one StorageNodeSet let kube-controller-manager's garbage collector cascade-delete the SA out from under every other StorageNodeSet's already-running pods. A pod's mounted token is checked against the SA's live UID on every API call, so once the SA is swapped for a fresh one, that check fails immediately (401) and doesn't recover until kubelet's next token refresh (default ~48min) -- previously required a manual force-delete of the affected pods. Reproduced the mechanism live on a real cluster: recreating a parent object under the same name gets an orphaned owner-referenced child deleted by GC in well under a second, even when the repoint is attempted in the very next API call. There's no reconcile fast enough to win that race, so the only real fix is to stop making the SA's lifecycle GC's responsibility in the first place. Replace the ownerReference with a plain "simplyblock.io/managed-by" label (also applied to the ClusterRole/ClusterRoleBinding for consistency, though those never carried an ownerReference to begin with). Labels carry no GC semantics, so the SA is now only ever removed by an explicit `kubectl delete` -- never as fallout from any single StorageNodeSet's lifecycle. This replaces the short-lived-token mitigation from the previous commit, which only shrank the blast radius of the symptom without addressing why the SA was ever recreated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
simplyblock-storage-node-saServiceAccount is ever deleted and recreated, every already-running pod's mounted token stays bound to the old, now-nonexistent ServiceAccount UID. The API server correctly rejects those with 401 Unauthorized, and nothing self-heals until kubelet's next token refresh (default ~48min) — in practice a long, manual-intervention outage (had to force-delete pods to get fresh tokens).reconcileRBACgave the SA acontroller: trueownerReference to a singleStorageNodeSet. The SA is shared by everyStorageNodeSetin the namespace, not owned 1:1 by any one of them, so whichever one reconciled last became the sole owner GC cares about. Deleting thatStorageNodeSet— including as an ordinary part of cluster expansion, adding one and then removing it — letkube-controller-manager's garbage collector cascade-delete the SA out from under every otherStorageNodeSet's already-running pods.ClusterRole/ClusterRoleBinding, which never carried one) with a plainsimplyblock.io/managed-bylabel instead. Labels carry no GC semantics, so the SA is now only ever removed by an explicitkubectl delete— never as fallout from any singleStorageNodeSet's lifecycle.Root cause (confirmed, reproduced)
reconcileRBACbuilds a fresh, ownerlessServiceAccountstruct on every reconcile and unconditionally overwrites itsownerReferencesto point at whicheverStorageNodeSetis currently reconciling. With more than oneStorageNodeSetsharing a namespace, ownership of the single shared SA just flip-flops to whichever reconciled most recently — there is no coordination between them.StorageNodeSet(e.g. for cluster expansion) — it reconciles almost immediately and becomes the SA's current owner — then delete it. Once its finalizer is removed and the object is actually gone, the garbage collector sees the SA's soleownerReferencesentry pointing at a UID that no longer exists and deletes the SA. It has no way to know any otherStorageNodeSetstill depends on it, since only the deleted one was ever recorded as owner.ownerReferencesto the new UID in the very next command was already too late.ownerReferencesmatch by UID, not name, so GC doesn't care that an object with the same name exists again.webappapi/tasks-runnercalling the storage-node's Flask API) is TLS-authenticated and stays reachable — this SA/token is unrelated to it. What breaks is the storage-node pod's own outbound calls to the Kubernetes API server (simplyblock_web/api/internal/storage_node/kubernetes.pyinsbcli, using the pod's own SA token): creating/deleting the SPDK device-prep Jobs, creating/deleting the spdk-proxy pod, and polling Node cordon/MachineConfigPool status. Once the SA token is invalid those calls return 401, so every request that tries to actually start, stop, or health-check a node's SPDK backend fails at that point — effectively freezing node lifecycle operations across the whole cluster, since every storage-node pod shares this one SA.StorageNodeSet, GC has nothing to cascade-delete here, so neither deleting aStorageNodeSetnor delete-recreating one can ever take the SA down with it again, by construction rather than by winning a race.What still isn't identified: whether anything besides deliberate cluster-expansion delete/recreate triggers this in practice (e.g. deploy tooling bouncing CRs during a CRD/schema migration). That's a separate investigation — this PR closes the hole regardless of what triggers it.
Test plan
go build ./...passesgo test ./...passes (full suite, including envtest-backed controller tests)golangci-lint run ./internal/utils/... ./internal/controller/... --new-from-rev=5aa711c(branch base) — 0 new issuesTestStorageNodeSetReconcileServiceAccountHasOwnerReference→TestStorageNodeSetReconcileServiceAccountIsLabeledNotOwned, asserting the SA carries no ownerReference and does carry thesimplyblock.io/managed-bylabelreconcileRBAC's ownerReference shape — confirms the SA-recreation scenario this PR fixes is real and near-deterministic, not hypotheticalStorageNodeSetin the same namespace, delete it, confirm the shared SA survives and every otherStorageNodeSet's running pods are unaffected) — not yet re-verified against this standalone PRDeployment note
This change does not touch the DaemonSet's pod template — no rolling restart of storage-node pods is triggered by this fix.
🤖 Generated with Claude Code
https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7