fix: re-introduce cluster members whose addresses changed after a full restart - #333
fix: re-introduce cluster members whose addresses changed after a full restart#333matka12 wants to merge 2 commits into
Conversation
…l restart When every pod restarts at the same time (full Kubernetes cluster restart, node pool replacement) with persistence enabled, each node comes back with a new pod IP while its persisted nodes.conf still lists the peers' old IPs. With no surviving member to gossip the new addresses, every node keeps dialing dead addresses, all peers stay fail?/fail, and the cluster never re-forms without a manual CLUSTER MEET (valkey-io#275). The operator already knows both sides: the live address of every member (ValkeyNode pod IPs) and each node's stale view (CLUSTER NODES). Add a heal phase that detects known-but-failing peers whose node ID belongs to a live member at a different address and re-introduces them with CLUSTER MEET. The handshake carries the member's node ID, so the viewer rebinds the existing entry to the new address and gossip propagates it. Also guard forgetStaleNodes against this state: it matches failing entries to ValkeyNodes by pod IP, so a live member at a changed address looked like a dead node and could be issued CLUSTER FORGET - banning it from rejoining for a minute and fighting the recovery. Verified on a 3-shard/1-replica cluster with persistence: deleting all six pods simultaneously previously left the cluster in cluster_state:fail indefinitely; with this change the operator re-forms it in ~20s (StaleAddressesHealed event, 30 peer links) with data intact. Fixes valkey-io#275 Signed-off-by: Matan David <matan.david@eon.io>
|
|
this is the right fix for the all-restart case, and it gets the two things that are easy to get wrong: sourcing the MEET target from the live cluster view ( one optional thing: on a full N-node restart every node's table lists every other peer as stale, so this issues on the order of N² MEETs before it settles. and where this sits relative to #296: the phase exists because nodes announce pod IPs, so |
On a full N-node restart every node's table lists every peer as stale, so the heal issued ~N^2 MEETs before settling. One successful MEET per moved member is enough: the handshake also registers the viewer's current address on the target, and gossip carries both new addresses to the remaining nodes. Re-verified on the 3-shard/1-replica k3d repro: deleting all six pods now heals with 6 MEETs (was 30), still ~20s to Ready with data intact. Signed-off-by: Matan David <matan.david@eon.io>
|
Thanks @melancholictheory — good call on the N² MEETs. Pushed 5c88340: the heal now dedupes by the moved member's node ID, issuing one MEET per member instead of one per (viewer, peer) pair. A failed MEET doesn't mark the member as met, so another viewer retries it on the same pass. The first viewer in iteration order lists all moved peers, so the resulting MEET graph is effectively a star from that viewer — connected, and gossip converges the rest. Re-verified on the k3d repro (3 shards / 1 replica, persistence, delete all 6 pods): 6 MEETs (was 30), still ~20s to Agreed on #296: this phase is the reactive fix for IP-announcement clusters, hostname announcement is the preventive one — and if hostname mode lands, the detector's |
|
star from the first viewer is the clean way to do it, and 30 -> 6 on the repro is the payoff. one edge to keep in mind: if that first viewer is itself unreachable on a given pass, no star forms that round, but since an absent viewer just means the next reconcile picks another one, it self-heals on the requeue rather than getting stuck. so the only cost is an extra pass in that case, which is fine. nice turnaround. |
|
Thanks! Small note on that edge: it's actually cheaper than an extra pass. An unreachable viewer never makes it into the scraped state at all ( |
|
Thanks for raising this @matka12. I want to validate it works properly with reproduction steps. You mention the upgrade from 0.3.0 -> 0.4.0, perhaps you could provide that? Would we then expect the issue to be resolved with an upgrade from 0.3.0 -> |
|
Sure — two repros, both on a scratch cluster (I used k3d, kind works the same). The minimal one first since it isolates the bug from the upgrade mechanics: Repro A — minimal (any operator version, ~2 min)helm repo add valkey https://valkey.io/valkey-helm/
helm install valkey-operator valkey/valkey-operator --version 0.4.0 \
-n valkey-operator-system --create-namespace
kubectl apply -f - <<'YAML'
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata:
name: test
spec:
image: valkey/valkey:9.1.0
shards: 3
replicas: 1
persistence:
size: 1Gi
config:
appendonly: "yes"
YAML
# wait for state: Ready, then write a marker key
kubectl exec valkey-test-0-0-0 -c server -- valkey-cli -c set canary survives
# the trigger: every pod restarts at once, every pod IP changes
kubectl delete pod --allUnpatched result: pods come back Ready, but every With this PR's image: same kill, the controller logs Repro B — the 0.3.0 → 0.4.0 upgrade as triggerSame as A but install # per UPGRADE.md — CRDs first
kubectl apply --server-side -f https://raw.githubusercontent.com/valkey-io/valkey-operator/v0.4.0/config/crd/bases/valkey.io_valkeyclusters.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/valkey-io/valkey-operator/v0.4.0/config/crd/bases/valkey.io_valkeynodes.yaml
helm upgrade valkey-operator valkey/valkey-operator --version 0.4.0 -n valkey-operator-systemThe v0.4.0 controller adds the system-users auth env ( Your question — 0.3.0 → this PR directlyYes, resolved. The simultaneous roll itself still happens (the pod-template change is inherent to the version bump), but the controller doing the rolling is the patched one, so the moment the pods are back it detects the stale pairs and re-MEETs them — the cluster re-forms on its own in seconds instead of wedging. Data on the PVCs (AOF) is untouched throughout. That was exactly my original failure case: this bug turned a routine operator upgrade on a persistent production cluster into a manual-recovery incident. |
| idx := strings.LastIndex(fields[1], ":") | ||
| if idx == -1 { | ||
| continue | ||
| } | ||
| if address := fields[1][:idx]; address != peer.Address { |
There was a problem hiding this comment.
Bracketed IPv6 peers are falsely treated as moved
CLUSTER NODES represents IPv6 endpoints as [host]:port@bus-port, but slicing at the final colon leaves brackets in the extracted address ([fd00::2]). Kubernetes reports the live Pod IP as fd00::2, so a reachable peer carrying fail, fail?, or noaddr is incorrectly classified as having changed address. The reconciler then issues an unnecessary CLUSTER MEET and requeues, which can repeatedly interfere with normal recovery for IPv6-enabled clusters. Parse and normalize the endpoint with IPv6-aware host/port handling before comparing it with peer.Address.
Artifacts
Focused Go test source for bracketed IPv6 stale-address classification
- Temporary package-local Go test executed against current HEAD; it constructs a live IPv6 peer and standard bracketed CLUSTER NODES entry for each failure flag, demonstrating the exact method input.
Focused Go test output showing all bracketed IPv6 failure states selected as stale
- Captured output from the focused Go test command, including command, working directory, exit code, parsed and live addresses, and one stale pair for fail, fail?, and noaddr; the takeaway is that current HEAD produces false stale detections for current IPv6 peers.
|
I was able to repo it and get it to work locally, so thank you! It would be great to have an e2e test for this, but I don't want to block the PR any further since I'd like to get it out for 0.5. Can you raise an issue for that and get one created once this is merged in please? Could you also take a look at the AI reviewer comments to see if its applicable? |
Summary
Fixes #275 — a persistent
ValkeyClusternever re-forms after a full restart that changes every pod IP; it stays inReconcilinguntil someone manually runsCLUSTER MEET.Root cause: each node's persisted
nodes.conflists its peers' old pod IPs. A single restarted member is re-discovered through the survivors, but when all members restart at once there is no surviving gossip path — every node keeps dialing dead addresses and all peers stayfail?/failforever. The operator's existing MEET phase only handles isolated nodes (cluster_known_nodes <= 1); these nodes know all their peers (at dead addresses), so nothing heals them.Changes
ClusterState.FindStaleAddressPeers()(internal/valkey/clusterstate.go): detects peers flaggedfail/fail?/noaddrin a node's table whose node ID belongs to a live, reachable member at a different address.fail?is included deliberately — pfail→fail promotion needs gossip between a majority of primaries, which is exactly what a cluster-wide address change breaks.healStaleAddressPeers()(internal/controller/valkeycluster_controller.go): new reconcile phase, runs afterpromoteOrphanedReplicasand beforeforgetStaleNodes. For each stale pair it issuesCLUSTER MEET <live-ip> <port>from the viewer; the handshake carries the member's node ID, so the viewer rebinds the existing entry to the new address and gossip propagates from there. Emits aStaleAddressesHealedevent and requeues.forgetStaleNodesguard: it matches failing entries to ValkeyNodes by pod IP, so a live member at a changed address looked like a dead node and could receiveCLUSTER FORGET— a 60-second rejoin ban that actively fights the recovery. Entries whose ID matches a live scraped node are now skipped.Verification
Unit tests for the detector (full-restart, dead-peer, transient-pfail, gossip-in-progress, pending-node cases) plus live verification on a 3-shard / 1-replica cluster with persistence (k3d,
valkey/valkey:9.1.0):kubectl delete pod --all(all 6 pods, IPs change)cluster_state:fail, stuckReconciling13+ min, needs manualCLUSTER MEETcluster_state:ok+Readyin ~20s, no interventionStaleAddressesHealed: Re-introduced 30 peer link(s) whose addresses changedThe same failure is triggered by the 0.3.0 → 0.4.0 operator upgrade itself: the new auth env vars update all ValkeyNode StatefulSets in one reconcile pass, restarting every pod simultaneously — so this also makes operator upgrades on persistent clusters recover automatically.
make testpasses (90 controller specs, valkey + api packages).