I broke a three-server Nomad control plane by changing one line in a config template. Not a wrong line — it held the same address as the two lines above it, which I changed at the same time and which caused no trouble at all. The difference is that in serf/memberlist, one of those three lines is not a setting. It is the node's name.
Causing the outage took a single playbook run. The cleanup took three days, and the cleanup is the part worth reading: three obvious repairs do not work, one of them by moving the fault rather than removing it, and the one that does had to happen on all three servers inside the same window.
The setup
The lab is a Nomad + Consul control plane: three servers (nc-srv-1,
nc-srv-2, nc-srv-3) running as Debian LXC guests on a single
Proxmox host at the main site, region global, datacenter dc1,
Nomad 2.0.4, TLS and ACLs on, everything provisioned by Ansible. Two Nomad clients run the
workloads: Prometheus, Grafana, a whoami service, node-exporter, a CI runner.
Ten more machines sit at a second, remote site and want to join as Nomad clients. That is
where the whole problem starts, and it is embarrassingly mundane: both sites use
192.168.1.0/24. 192.168.1.211 is a worker at the remote
site, and it is also a Proxmox guest at the main site. The same address means two different
machines depending on where you are standing. Telling them apart took a different SSH host
key, a different OS version and a Proxmox MAC prefix — I had to check, because that
ambiguity once pulled a virtual machine into an inventory of physical ones.
The workaround was an overlay network (Tailscale): every node gets one stable address that is unique and means the same thing from either site. So the obvious move was to make the cluster advertise its overlay addresses. That obvious move is what broke it.
The failure: one variable, three addresses
The Ansible template for nomad.hcl had this. Read the third line inside the
block.
{% if nomad_network | default(cluster_network | default('lan')) == 'tailscale' %}
{% set adv = hostvars[inventory_hostname].ts_ip %}
{% else %}{% set adv = ansible_host %}{% endif %}
advertise {
http = "{{ adv }}"
rpc = "{{ adv }}"
serf = "{{ adv }}" # this is the line that broke the cluster
}
One variable, three addresses, one playbook run across all three live servers. Within seconds the journals filled up with this:
memberlist: Conflicting address for nc-srv-3.global.
Mine: 192.168.1.223:4648 Theirs: <overlay addr>:4648
Port 4648 is serf — the gossip layer, not Raft, not the RPC. Nomad lost its leader,
scheduling stopped, and the cluster spent the next few minutes arguing with itself about
who nc-srv-3 was. The rollback was a re-run of the same playbook without the
override: configs rewritten at 18:58:25, agents restarted at
18:58:28, :29, :29. A leader came back. I wrote it
off as a bad afternoon and moved on.
The scar you cannot see for two days
Two days later I ran a full audit of the cluster, expecting to find nothing interesting about that incident. Instead:
nomad server members → seen from srv-1 and srv-2: nc-srv-3 = <overlay addr>
seen from srv-3 itself : nc-srv-3 = 192.168.1.223
nomad operator raft → leader nc-srv-2; peer nc-srv-3 = <overlay addr>:4647
/etc/nomad.d/nomad.hcl on srv-3 → advertise { http/rpc/serf = "192.168.1.223" }
consul members → nc-srv-3 = 192.168.1.223 (correct)
The rollback had rewritten the file on disk. It had not moved memberlist an inch. The
cluster held two contradictory beliefs about the same node, and logged the disagreement
roughly once a minute — about 1,450 Conflicting address lines in 24
hours in the journal of nc-srv-2 alone:
nc-srv-2: Conflicting address for nc-srv-3.global.
Mine: <overlay addr>:4648 Theirs: 192.168.1.223:4648
The mechanism is simple once you accept the premise. In memberlist, whoever claims a name
first keeps the address bound to it and rejects corrections. And the state does not only
live in RAM: /opt/nomad/server/serf/snapshot — about 1.2 kB, a cache of member
addresses — still listed the overlay address as the last known entry for that node.
Its mtime was the process start time, unchanged for two days. A naive stop/start
re-injects it.
Three consequences, and I had anticipated exactly zero of them:
-
The quorum silently depended on the overlay daemon. Raft was routing to
a peer that lives on the same physical host, through a WireGuard tunnel, because that is
the address it had. If
tailscaledstopped inside that container, quorum dropped to 2/3 with no margin. Nothing in the config said so. -
The drift had already reached the data plane.
GET /v1/agent/serverson a client returned an overlay address that client had never had in any configuration file. A client'sserverslist is only a seed: the server heartbeat replaces it, using each peer's advertised RPC address, which is propagated as the serf tagrpc_addr. -
Any further address reconfiguration on that node was a silent no-op.
While the conflict stands, the other two servers ignore every tag update coming from the
conflicted node. I could edit
nomad.hcl, restart the agent, confirm the file on disk was exactly right, and see nothing at all change innomad server members. This is the consequence that would have cost me a full day of "why does Nomad ignore its own config file".
Three repairs that did not work
1. force-leave on a running node
nomad server force-leave nc-srv-3.global from the other two, agent still up.
This does nothing durable: the node is still gossiping, and it re-announces the stale
address out of its own serf snapshot within a gossip round — evicted and back before the
command output finishes printing.
2. Cleaning the drifted server alone
Better idea: take the node out of the conversation first. Stop Nomad on the drifted server
(quorum stays at 2/3, never at risk), run force-leave from the other two
while it is absent, back up and empty its serf snapshot, start it again.
It half-worked, which is the worst outcome. The node now saw itself correctly at
192.168.1.223. And immediately started logging:
serf: Node name conflicts with another node at <overlay addr>:4648
The stale entry had not been destroyed. It had moved. The other two servers still held it in their own memberlist and re-seeded it, by gossip, into the node I had just cleaned. I had spent thirty minutes carefully relocating the bug.
3. Cleaning them one at a time, in sequence
This one I did not have to run to the end. As long as one live member holds the entry, gossip pushes it back into whichever node was just purged — the same failure, mirrored. There is no rolling version of this operation — which is the whole lesson compressed into one sentence: a serf address change requires a simultaneous stop, never a rolling one.
What worked: a short simultaneous window
Stop all three, purge all three membership caches while nothing is alive to re-propagate, start all three.
# on all three servers, in parallel — the window opens here
systemctl stop nomad
F=/opt/nomad/server/serf/snapshot
cp "$F" "$F.bak-$(date +%F-%H%M%S)"
: > "$F" # membership address cache only
# /opt/nomad/server/raft is NOT touched
systemctl start nomad
Measured outcome:
- A leader was back within seconds of the restart, without any manual peer surgery.
-
Three voters, zero overlay addresses in
nomad operator raft list-peers, andjournalctl -u nomad --since "-1min" | grep -c "Conflicting address"→ 0. -
Roughly 1 to 2 minutes with no leader: no new scheduling, no
nomad job run. No allocation was interrupted — the running containers never stopped, and Consul was not touched at all.
What was never touched: /opt/nomad/server/raft, 24 MB of replicated
log. Nomad runs Raft protocol 3, where a peer's identity is its ID, not
its address, so the log came through the whole episode intact. That is not the same as
Raft having absorbed the change: the address it used to reach that peer was the frozen
serf tag, handed to it by the layer that was broken. The broken layer was gossip, one
directory over.
In that data directory, serf/snapshot is a cache and raft/ is
the truth. Emptying the first costs you a rejoin hint. Emptying the second costs you the
cluster.
The real fix is a schema change, not a repair
Purging the caches restored the state. It does nothing to stop me from doing it again next month. The actual fix is to stop coupling two things that were only accidentally the same value.
| Setting | What it really is | Value here | Changeable on a live server? |
|---|---|---|---|
advertise.serf |
The member's identity in the gossip pool | LAN, pinned | Never |
advertise.rpc / .http |
Pure transport, propagated as the serf tag rpc_addr |
overlay | Freely |
client.network_interface |
The address services and allocations are published under | overlay | Freely, but only for future allocations |
So the template splits them, and deliberately does not offer a switch for the first one:
{# serf = the member's identity in the gossip. NEVER change it on a live server.
rpc/http = transport: free to change, propagated via the serf tag rpc_addr. #}
{% set serf_adv = ansible_host %}
{% if nomad_advertise_rpc_network | default('lan') == 'tailscale' %}
{% set rpc_adv = hostvars[inventory_hostname].ts_ip %}
{% else %}
{% set rpc_adv = ansible_host %}
{% endif %}
advertise {
http = "{{ rpc_adv }}"
rpc = "{{ rpc_adv }}"
serf = "{{ serf_adv }}"
}
Deployed one server at a time, leader last, waiting for failure tolerance to return to 1
and three voters before moving to the next. Zero conflicts at every step. Afterwards,
nomad server members shows Addr in 192.168.1.x — serf,
untouched — while rpc_addr is on the overlay. That asymmetry looks like a bug
in a dashboard and is exactly the intended result.
The servers became reachable from the remote site without the gossip layer ever noticing, which is what let the 10 remote workers join. Twelve workers ready, Raft 3/3, jobs intact. One line of difference between a broken cluster and a two-site one.
Consul stayed entirely on the local LAN, on purpose. Its LAN gossip is tuned for a sub-10 ms datacenter; the cross-site round-trip here measures 57 / 87 / 322 ms with a mean deviation of 49 ms. Nomad's serf shrugs at that. Consul's would flap the remote nodes in and out of the pool forever. Stretching one Consul datacenter across both sites would have been the exact symmetric version of the mistake I had just made.
A third address, one day later
There is a punchline. The day after the servers went multi-site, Prometheus reported
no route to host on all ten remote workers. The advertise block
only concerns the agent itself. Services and allocations register under the address
of the interface Nomad fingerprints, by default the first routable one — the LAN.
So the workers were publishing themselves as 192.168.1.93:9100, an address
that from the other site points at a different machine, or at nothing.
client { network_interface = "tailscale0" } fixes it, with a corollary that
cost me another twenty minutes: an allocation registers once, at creation. After
the config change the old allocations kept their old addresses. It takes
nomad job stop -purge then run to recreate them. That is the
classic "but I already fixed it, why is it not applying".
Three settings, all holding IP addresses, all in the same file, answering three different questions: who am I, how do you reach me, under what address are my services published. None of them inherits from the others.
One more finding: MinQuorum was 0
Unrelated to the drift, found in the same audit: both autopilots ran with
MinQuorum = 0 and CleanupDeadServers = true. That pair lets
autopilot reap dead servers with no floor at all — three voters to two to one, each removal
individually reasonable, the aggregate a single point of failure that nobody chose.
nomad operator autopilot set-config -min-quorum=3
consul operator autopilot set-config -min-quorum=3
Five minutes of work, and not free: at three servers, a floor of three also stops autopilot from cleaning up a server that is genuinely dead, so replacing one becomes a manual step. I will take that trade. It is the difference between "clean up dead servers" and "clean up dead servers, but never take the cluster below three".
The rule I wrote into the repo
In a gossip/memberlist system, the address is part of the identity. Not a property of the identity — part of it. Everything else follows:
- Never change a gossip advertise address on a live server. Not carefully, not one at a time, not with a rollback ready.
- If an entry has already drifted: stop everywhere, purge the membership cache everywhere, start everywhere. One live holder is enough to undo the whole repair, and the failure mode when it goes wrong is that the entry appears to move rather than disappear.
- Separate who I am from how you reach me in the config schema before it matters. The day the second has to change, the first must not be dragged along with it — and a single template variable feeding both guarantees that it will be.
And the real root cause is duller than any of this: two sites on the same
/24. The overlay network is a workaround, an effective one, but still a
workaround. Renumbering the remote site to 192.168.2.0/24 deletes the entire
class of problem — the drift, the wrong-machine SSH, the fingerprinted addresses that mean
two things. That is the next change, and it is three fields in a router admin page.