Conversation
…cord with it
The name worker-N.simplyblock-spdk-proxy.<namespace>.svc.cluster.local is a
per-pod record of a selectorless headless Service whose EndpointSlices this
operator writes by hand. It was addressed from pod.Status.PodIP, so the pod
replacement at the heart of every storage-node restart deleted the record, and
the control plane -- which RPCs that name a fixed five seconds after the pod is
created -- got NXDOMAIN. NXDOMAIN is the worst failure mode available here: its
RPC client raises a fatal socket.gaierror rather than the retryable connection
error a record pointing at a down proxy would give it, and cluster DNS caches
the denial for longer than the proxy is actually absent.
The contrast in the 20260906 resilient-failover run shows it was never cluster
DNS: worker-N.simplyblock-storage-node-api..., queried by the same client at the
same instant, always resolved, because that slice is filled from stable node
internal IPs. All 126 resolution failures were on *.simplyblock-spdk-proxy
names, in ten short bursts, each seconds after the SPDK pod for that exact RPC
port was replaced. Six restarts aborted that way; three nodes were reset to
OFFLINE.
The record's lifetime now belongs to the worker rather than to whichever pod
currently serves it. The spdk-proxy pod is host-networked, so its PodIP is the
worker's own node IP, and reconcileSpdkProxyEndpointSlices gathers
(nodeName, rpcPort) from the StorageNode CRs -- spec.workerNode plus
status.ports.rpc, both readable without a live pod -- and addresses them from
the Node. A ready pod still overrides that address, keeping the pod as ground
truth should it ever stop being host-networked and as the only source for a
worker whose CR has not reported a port yet. A slice is deleted only once its
port has left the set entirely.
StorageNodeSet.status.nodes[].hostname is deliberately not used as the node
name: one write path fills it from the Kubernetes node name and another
overwrites it with the control plane's short hostname, which on an FQDN-named
node is not the same string.
The Pod watch predicate passed phase transitions only, so the
ContainerStatuses[].Ready flip that makes a pod publishable fired no event at
all and republishing waited for the periodic requeue. It is now the named
spdkProxyPodPublishStateChanged, keyed on what the reconciler actually reads.
SpdkProxyEndpoint.PodIP becomes Address, since it no longer holds a pod IP, and
reconcileSpdkProxyEndpointSlices is split into an orchestrator and four helpers
to stay under the cyclomatic complexity limit.
Tests, all proven red against the unfixed tree first, as U-264 through U-266 in
the StorageNode test plan:
- TestReconcileSpdkProxyEndpointSlices_SurvivesSpdkPodReplacement, which
failed with "resolves to nothing ... slices present: []".
- TestReconcileSpdkProxyEndpointSlices_RemovedWorkerLosesRecord, the negative
counterpart: surviving a pod replacement must not mean surviving forever.
- TestSpdkProxyPodPublishStateChanged, red on all four dropped-update cases
under the old predicate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The behavioral change is well-scoped, appears correct, and is backed by targeted unit tests; only a minor documentation comment nit was found.
Pull request overview
This PR fixes spdk-proxy per-worker DNS instability during storage-node restarts by decoupling EndpointSlice publishing from the lifetime of the spdk-proxy pod object, preventing transient NXDOMAIN responses and making the record stable across pod replacement.
Changes:
- Publish spdk-proxy EndpointSlice addresses from StorageNode/Node data (stable across pod replacement), with ready pods overriding as ground truth when available.
- Improve the spdk-proxy pod watch predicate so reconciles trigger on readiness/address/scheduling changes (not only Pod phase transitions).
- Rename the published endpoint field from
PodIPtoAddress, and add unit tests + test-plan entries covering the regressions and negative case.
File summaries
| File | Description |
|---|---|
| operator/internal/utils/storage_nodeset_ds.go | Renames SpdkProxyEndpoint field to Address and uses it when building EndpointSlice endpoints. |
| operator/internal/utils/storage_nodeset_ds_test.go | Updates EndpointSlice builder tests to use Address instead of PodIP. |
| operator/internal/controller/simplyblockstoragenodeset_controller.go | Refactors and hardens spdk-proxy EndpointSlice reconcile logic; adds improved pod update predicate and deterministic endpoint ordering. |
| operator/internal/controller/simplyblockstoragenodeset_controller_unit_test.go | Adds regression/negative tests for surviving pod replacement, record deletion after worker removal, and the new predicate behavior. |
| operator/docs/tests/test-plan-storagenode.md | Documents the new regression/negative test scenarios and links them to the added tests. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+527
to
+531
| // SpdkProxyEndpoint describes one spdk-proxy instance that backs the headless | ||
| // spdk-proxy Service. Address is the worker's node IP rather than anything | ||
| // read off a pod: the spdk-proxy pod is host-networked, so the two are the | ||
| // same value, and taking it from the node keeps the endpoint publishable | ||
| // while the pod is being replaced. |
noctarius
force-pushed
the
main
branch
2 times, most recently
from
September 9, 2026 10:21
60dceb7 to
fbaabe4
Compare
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.
The bug
A storage-node restart aborts with a fatal name-resolution error:
worker-N.simplyblock-spdk-proxy.<namespace>.svc.cluster.localis a per-pod record of aselectorless headless Service whose EndpointSlices this operator writes by hand. It was
addressed from
pod.Status.PodIP, so the pod replacement at the heart of every storage-noderestart deleted the record, and the control plane — which RPCs that name a fixed five seconds
after "Pod created" — got NXDOMAIN.
NXDOMAIN is the worst failure mode available here. Its RPC client raises a fatal
socket.gaierrorrather than the retryable connection error a record still pointing at adown proxy would give it, and cluster DNS caches the denial for longer than the proxy is
actually absent.
Why it is this and not cluster DNS
From the
k8s_native_resilient_failover-20260906-112437run:*.simplyblock-spdk-proxynames. Zero on anythingelse.
worker-1.simplyblock-storage-node-api.simplyblock.svc.cluster.local:5000answered 200 at22:13:01,527 — same client pod, same node, same namespace, 20 seconds earlier — and never
once failed to resolve in twelve hours. That slice is filled from stable node internal
IPs, so it never goes away.
exact RPC port being replaced, and every burst matches an entry in
outage_log_20260906_112437.log.Impact in that run: six restart attempts aborted, and three nodes reset to OFFLINE
(22:13:23, 22:23:15, 22:23:18).
The fix
The record's lifetime now belongs to the worker rather than to whichever pod currently serves
it. The spdk-proxy pod is host-networked — its
PodIPis10.0.0.11, identical to thenode's own
InternalIP— so the operator was already publishing a node address; it was justreading it off an object with a pod's lifetime.
reconcileSpdkProxyEndpointSlicesnow gathers(nodeName, rpcPort)from the StorageNode CRs(
spec.workerNodeplusstatus.ports.rpc, both readable without a live pod) and addressesthem from the Node. A ready pod still overrides that address, which keeps the pod as
ground truth should it ever stop being host-networked, and as the only source for a worker
whose CR has not reported a port yet. A slice is deleted only once its port has left the set
entirely.
The property that matters: across a pod replacement the desired slice content is now
identical, so the reconcile writes nothing at all — no delete, no recreate, no DNS churn,
and no window in which a resolver can cache a denial.
One deliberate behavior change: the name resolves even when no proxy is listening. Callers
see a connection error instead of NXDOMAIN.
simplyblock-storage-node-apialready behavesthis way, so the pattern is established.
Secondary: the watch predicate
The Pod watch passed phase transitions only:
A pod reaches
Runningwhile a container is still unready, and the laterContainerStatuses[].Readyflip — the change that makesisSpdkProxyPodReadytrue — leavesthe phase alone, so it fired no event at all and republishing waited for the periodic
requeue. It is now the named
spdkProxyPodPublishStateChanged, keyed on what the reconcileractually reads.
Also
SpdkProxyEndpoint.PodIP→Address, since it no longer holds a pod IP.reconcileSpdkProxyEndpointSlicessplit into an orchestrator plus four helpers, to stayunder the
gocyclolimit.A trap worth knowing
StorageNodeSet.status.nodes[].hostnameis deliberately not used as the node name. Onewrite path fills it from the Kubernetes node name
(
simplyblockstoragenodeset_controller.go:1463) and another overwrites it with the controlplane's
sn.Status.Hostname(simplyblockstoragenodeset_storagenode.go:493) — the shortworker-1, not the FQDNworker-1.ocp.simplyblock.aithat is the real node name.getNodeInternalIPwould have failed on exactly the clusters in this incident.Tests
All three were proven red against the unfixed tree first, and are rows U-264 through U-266 in
test-plan-storagenode.md:..._SurvivesSpdkPodReplacementnode-a.simplyblock-spdk-proxy.ns.svc.cluster.local resolves to nothing: no endpoint for node-a on port 9001 (slices present: [])..._RemovedWorkerLosesRecordTestSpdkProxyPodPublishStateChangedGates:
go test ./... -shuffle=ongreen including envtest,make lintclean,make manifests generateproduces no drift, house-style table gate clean. No API types,markers, or RBAC changed.
Still open upstream (sbcli, not this repo)
Neither change removes the reason a two-second DNS gap became three nodes reset to OFFLINE:
set_opts_rpc.bdev_nvme_set_options()atstorage_node_ops.py:5933has no connectretries — it raised
MaxRetryError5 ms after its request with no retry warnings, whileevery neighboring RPC client rode out the identical flapping.
_restart_storage_node_implsleeps a fixed 5 s after "Pod created" and then RPCs the name;it should poll until the name resolves and the proxy answers.
🤖 Generated with Claude Code