Skip to content

fix(storagenodeset): stop giving the shared storage-node ServiceAccount an ownerReference - #451

Draft
boddumanohar wants to merge 3 commits into
mainfrom
fix/storage-node-sa-token-expiration
Draft

fix(storagenodeset): stop giving the shared storage-node ServiceAccount an ownerReference#451
boddumanohar wants to merge 3 commits into
mainfrom
fix/storage-node-sa-token-expiration

Conversation

@boddumanohar

@boddumanohar boddumanohar commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

  • If the simplyblock-storage-node-sa ServiceAccount 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).
  • Root cause: reconcileRBAC gave the SA a controller: true ownerReference to a single StorageNodeSet. The SA is shared by every StorageNodeSet in the namespace, not owned 1:1 by any one of them, so whichever one reconciled last became the sole owner GC cares about. Deleting that StorageNodeSet — including as an ordinary part of cluster expansion, adding one and then removing it — let kube-controller-manager's garbage collector cascade-delete the SA out from under every other StorageNodeSet's already-running pods.
  • Fix: drop the ownerReference entirely and mark the SA (and, for consistency, the ClusterRole/ClusterRoleBinding, which never carried one) with a plain simplyblock.io/managed-by label instead. 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.

Root cause (confirmed, reproduced)

  1. reconcileRBAC builds a fresh, ownerless ServiceAccount struct on every reconcile and unconditionally overwrites its ownerReferences to point at whichever StorageNodeSet is currently reconciling. With more than one StorageNodeSet sharing a namespace, ownership of the single shared SA just flip-flops to whichever reconciled most recently — there is no coordination between them.
  2. Deterministic trigger, no race required: add a second 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 sole ownerReferences entry pointing at a UID that no longer exists and deletes the SA. It has no way to know any other StorageNodeSet still depends on it, since only the deleted one was ever recorded as owner.
  3. Separately confirmed the underlying GC mechanism is close to instantaneous, live on a real cluster, with a disposable ConfigMap/ServiceAccount pair carrying the same ownerReference shape: recreating the parent object under the same name still got the orphaned child deleted by GC in well under a second — even patching the child's ownerReferences to the new UID in the very next command was already too late. ownerReferences match by UID, not name, so GC doesn't care that an object with the same name exists again.
  4. Blast radius: the control-plane→storage-node HTTP channel (webappapi/tasks-runner calling 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.py in sbcli, 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.
  5. There is no reconcile fast enough to win this race reactively once triggered — the actual fix has to be structural: stop making the SA's lifecycle GC's responsibility at all. With no ownerReference pointing at any StorageNodeSet, GC has nothing to cascade-delete here, so neither deleting a StorageNodeSet nor 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 ./... passes
  • go test ./... passes (full suite, including envtest-backed controller tests)
  • golangci-lint run ./internal/utils/... ./internal/controller/... --new-from-rev=5aa711c (branch base) — 0 new issues
  • Updated TestStorageNodeSetReconcileServiceAccountHasOwnerReferenceTestStorageNodeSetReconcileServiceAccountIsLabeledNotOwned, asserting the SA carries no ownerReference and does carry the simplyblock.io/managed-by label
  • Reproduced the underlying GC-orphan mechanism live on a real cluster with a synthetic ConfigMap/ServiceAccount pair mirroring reconcileRBAC's ownerReference shape — confirms the SA-recreation scenario this PR fixes is real and near-deterministic, not hypothetical
  • Full live end-to-end verification against this exact change (add a second StorageNodeSet in the same namespace, delete it, confirm the shared SA survives and every other StorageNodeSet's running pods are unaffected) — not yet re-verified against this standalone PR

Deployment 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

…-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
boddumanohar marked this pull request as draft August 21, 2026 13:55
boddumanohar and others added 2 commits August 21, 2026 17:00
… 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>
@boddumanohar boddumanohar changed the title fix(storagenodeset): shorten storage-node-sa token expiration to self-heal after SA recreation fix(storagenodeset): stop giving the shared storage-node ServiceAccount an ownerReference Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant