The relay as several replicas on Kubernetes

nfltr connects machines through a relay that every machine dials out to: agents connect out to it, and visitors, tunnels and hubs (the Claude Code sessions that drive nfltr orch agents) reach those agents through it. On nfltr.xyz the relay is one process on one VM, and it stays that way. One process is one failure domain and one machine's capacity, so the question for people who run their own relay, and for a scale-out we may need later, is whether the relay holds up as several replicas behind one Kubernetes Service, with replicas killed, partitioned and restarted under load.

At first it did not. Ten paths still assumed one process, from an unauthenticated replica-to-replica entry to TCP sessions that crossed replicas at 5.5 MB/s. After those were fixed, a 6 hour fault soak failed: a partitioned replica took 62.4 s to recover, over the 50 s bound, because agent records in Redis outlived the sessions they named. The fix lets the newest session of an agent's credential win; a second 6 hour run passed, with replica kills recovering in 0 s instead of about 40. Everything here ran on kind, a Kubernetes cluster in containers on one laptop; no production traffic ran on it. Below: what assumed one process, how a visit crosses replicas, both runs, the defect, and what is still not good enough.

What assumed one process

The relay already had a multi-replica mode: Redis holds a record of which replica each agent is connected to, and a replica that gets a visit for an agent held elsewhere forwards it there. We deployed three Redis-backed replicas on three kind nodes and measured each path that crosses replicas. Most worked; these did not:

PathWhat we foundKind
Replica-to-replica entriesAdmitted any caller. The shared peer secret was sent and never checked, and the internal dispatcher port trusted an agent id header.security
TCP and end-to-end encrypted sessions to an agent on another replicaEvery session waited 1.5 s before dialing the agent's replica: one 8 MiB session moved 5.5 MB/s across replicas against 1294 MB/s on one replica.latency
The connection pool between replicasClosed a connection that was still connecting, under other sessions' streams: 8 concurrent cross-replica sessions failed in 5 of 10 runs. Ending one session closed the shared connection under every other one.correctness
A TCP agent's recordWritten once with a TTL and never renewed: 41 % of cross-replica TCP visits failed with "agent not connected" once it lapsed.correctness
Site hostsChecked that the serving agent was on the same process: 503 on two of three replicas.availability
Mailbox long-pollsWoken per process, so a pull on another replica waited out its 25 s bound. Without SQL each replica had its own in-memory mailbox.correctness
ShutdownDeleted every agent's record at once and closed the peer entries immediately, so other replicas got 404s and refusals until each agent reconnected.availability
ReadinessFollowed Redis: a Redis blip took every replica out of the Service, although each could still serve its own agents.availability
Browser sessionsA signing secret generated per replica when unset: a cookie from one replica was rejected by the others.correctness
Redis restarted emptyEvery agent's record gone until that agent's next message: about 40 s of cross-replica failures, 714 failed visits in one restart.availability
Measured on three Redis-backed replicas on kind and with an in-process benchmark of the cross-replica path. Rate limits, the connected-agents cap and WireGuard stay per replica and are documented as such.

Evidence: the cross-replica path, before and after the fixes

Setup
2026-10-09: three Redis-backed relay replicas on three kind nodes, deployed with the relay's Helm chart, on one laptop (Apple M-series, 10 CPUs); throughput from an in-process benchmark of the cross-replica path against the same-replica path.
Numbers
One 8 MiB TCP session across replicas: 5.5 MB/s before, 1222 MB/s after, against 1294 MB/s on one replica; eight sessions 1922 against 2742 MB/s. 8 concurrent cross-replica sessions failed in 5 of 10 runs before the pool fix; 41 % of cross-replica TCP visits failed once a record lapsed; 714 failed visits in one empty Redis restart.
Reproduce
(a) No released command runs several replicas: nfltr relay serve is one process. The same-replica side of the throughput comparison is public, in today's code: in github.com/nfltr/relay-core, go test -run '^$' -bench BenchmarkTCPTunnelThroughput -benchmem ./core/session/. (b) Our check, in the closed nfltr repository: make kind-deploy-relay-multinode-test deploys the same three-replica shape on kind ($0, Docker), and make check-helm-relay-scaleout checks the chart's multi-replica settings offline. The cross-replica benchmark is in the closed relay-server repository.
Limits
Loopback inside one machine: no network latency between replicas, so throughput is CPU-bound. The failure rates are from single runs except the pool test (10 runs).

How a visit crosses replicas

An agent holds one connection to whichever replica the Service gave it. That replica writes the agent's record to Redis and renews it while the connection lives, TCP agents included now. A visit can land on any replica. If the agent is elsewhere, the replica forwards the visit to the agent's replica over a peer entry that requires the shared peer secret; a replica in Kubernetes mode without that secret does not start. The header that marks a request as coming from a peer is set by the forwarding replica and stripped before anything reaches the agent. The agent's replica repeats every check that needs the agent's own stream, such as a protected share or a site host mapping. Mailbox wakeups travel over Redis pub/sub, and a multi-replica relay without SQL refuses mailboxes instead of keeping one per process.

After the fixes, the cross-replica path moves 1222 MB/s for one upload session against 1294 MB/s on one replica, and 1922 against 2742 MB/s with eight sessions. It is still one gRPC stream per session; at that speed it is not what limits a relay whose links to the internet are far slower.

Shutdown is now ordered. Endpoints drop the pod first. Then each agent is let go at a random moment in the first half of the shutdown window and redials another replica, and its record is removed only if it still names this replica. The peer entries stay open until the last agent has left. Readiness no longer follows Redis: when Redis is down a replica keeps serving its own agents and refuses cross-replica routing (fail closed). Each replica also keeps a small expiring marker key in Redis; when the marker is gone, Redis lost its data, and the replica rewrites its agents' records.

The soak

Unit tests cover each fix. To see the whole thing hold up over hours we wrote a soak tool and a script that brings up a fresh kind cluster for it:

  • The cluster: three relay replicas on three kind nodes, deployed with the relay's Helm chart, Redis without persistence, TCP tunnels on.
  • The load: 52 agents (40 HTTP, 12 TCP) in one process on the host, and one visit per agent per second through the Service's node port: an HTTP request, or a 4 KiB echo through a TCP tunnel. About two thirds of HTTP visits cross replicas.
  • The faults, in rotation every 20 minutes: a rolling restart of the deployment; a forced delete of one replica; a delete of the Redis pod, which comes back empty; and a 30 s network partition of one replica. Our first partitions did nothing: kind's default network plugin does not enforce NetworkPolicy. The tool now drops the replica's traffic with iptables on its node.
  • The samples: every replica's metrics and Redis's key count every 30 s, each failed visit with its error and the replicas involved, the agents' reconnects, every replica's log for the whole run, and goroutine dumps two minutes in and at the end.
  • A check on the check: every visit must be answered by the relay, which says so in a response header. In an early short run another program held the test port for a minute; the tool now refuses a port that is not the relay and fails any run another server answered.

The run is judged against fixed bounds, written down before the long runs:

ObjectiveBound
Steady availability (visits outside every fault and its recovery)≥ 99.9 %
Steady p99 visit latency≤ 500 ms
Failed visits per agent per fault≤ 30
Recovery: last failed visit after a fault ends, every agent answering again≤ 50 s
Leaks per replica, net of the agents it holdsgoroutines and file descriptors ≤ 1.25× plus a margin, memory ≤ 1.5× + 32 MiB, metric series ≤ 1.25× + 200
Idle CPU per replica after the load≤ 0.05 cores
"Net of agents" matters because faults move agents between replicas. Measured per agent: 9 goroutines, 1.3 file descriptors and 55 metric series.

The first 6 hour run failed

The first long run went overnight, on the relay version that v1.0.695 ships. Between faults it was fine: steady availability 99.9991 % for HTTP and 100 % for TCP, p99 223 ms, over about 1.1 million visits. The faults were another matter:

FaultRecoveryFailed visits
Rolling restart ×59.2–10.1 s37–50
Replica killed ×439.3–43.9 s64–150
Redis restarted empty ×49.9–14.4 s469–604
Replica partitioned ×46.7–62.4 s24–276
First 6 hour run, a fault every 20 minutes. One partition took 62.4 s to recover, over the 50 s bound.

Evidence: the first 6 hour soak breached its recovery bound

Setup
2026-10-09 to 10, overnight: kind v0.31 (Kubernetes v1.35) on OrbStack, on one Apple M-series laptop (10 CPUs, 15.7 GiB); three relay replicas at the relay version v1.0.695 ships, Redis without persistence, 52 agents (40 HTTP, 12 TCP), one visit per agent per second; 17 faults, one every 20 minutes. One run.
Numbers
About 1.1 million visits; steady availability 99.9991 % (HTTP) and 100 % (TCP), steady p99 223 ms. Recovery: rolling restart 9.2–10.1 s, replica kill 39.3–43.9 s, empty Redis 9.9–14.4 s, partition 6.7–62.4 s (bound 50 s). A replica's goroutines 185 to 336, a judge error, not a leak.
Reproduce
(a) No released binary or public repository runs this soak; the chart and the soak tool are not public. (b) Our check, in the closed nfltr repository: SOAK_DURATION=6h make soak-relay-kind (Docker and kind, $0; a fault every 20 minutes by default; each run makes and deletes its own cluster). It runs the current relay, which has the fixes; reproducing the breach needs the relay from before them.
Limits
One run, on one machine: kind nodes share the host's CPU and kernel, and there is no real network latency or loss. The goroutine bound was judged wrongly in this run (see below).

Two bounds were breached. The first was real: one partition took 62.4 s to recover, and every replica kill sat near 40 s, just under the bound. The second was ours: one replica's goroutines went from 185 to 336 in 80 minutes. That was not a leak. The run's tool was built before a fix to its own sampler, which looked up the connected-agents metric by the wrong name, so every replica read zero agents and the goroutine count was not netted. That replica had simply taken over agents from the others, its last sample fell inside a rolling restart, and the three replicas' total stayed flat for six hours. The goroutine dumps showed no stack growing beyond the agents' own connections. The judge now uses only steady samples and nets goroutines, file descriptors and series by the agents each replica holds; the bounds did not change.

The defect: stale agent records

We reran partitions and kills on fresh clusters with every replica's log followed and each failure recorded with its replicas. The pattern was the same in both faults.

A partitioned or killed replica does not say goodbye. Its agents notice through their gRPC keepalive, 40 s after the traffic stops, and redial; the Service sends them to another replica. That replica refused them with "already connected to another pod", because Redis still named the old replica, until the old record expired. That was the 40 s of every replica kill: not the agents' redial backoff (they redialled in under a second) and not endpoint propagation, just a stale record.

After a partition it was worse. The old replica could not see that its agents had gone, so it kept renewing their records to itself, and other replicas forwarded visits back to it. It served them into dead streams until its own transport keepalive closed them, which with the chart's settings at the time was 60 s plus 20 s. That was the 62 s breach. Records that expired during a partition came back only with each agent's next message, which left 404s on the other replicas for up to 27 s.

The fix makes a record name its session: the session's start time and a hash derived from the agent's credential. The newest session of a credential wins. The replica an agent redials admits it although the record names another replica, and the old replica's next renewal finds the newer record, writes nothing and ends its old session. Another credential, or a record written by an older relay, is still refused. Records lost with Redis are rewritten by every session on a replica at once when Redis takes writes again, not one by one as agents speak. We rejected an ownership check per request (a Redis read for every locally served visit) and a takeover message between replicas (HTTP agents publish no presence events, and the renewal already sees the newer record).

Two smaller problems turned up on the way. The chart sent agent heartbeats every 45 s, exactly the agents' own 45 s dead-relay threshold, so idle agents redialled; the chart now uses the relay's defaults (15 s heartbeats, 30 s keepalive pings closed 10 s later). And the first version of the fix read each replica's own internal relay agent, which runs on every replica under one name, as a newer session of itself; a short run caught that, and the next version exempts it. One short run also breached because the judge charged any failure within five minutes of a fault to that fault; a fault's recovery now ends at the first 60 s without a failed visit, and later failures count against steady availability.

The 6 hour run after the fixes passed

Three short runs passed with the fixes, then a second 6 hour run with the same faults every 20 minutes passed with no breach:

FaultRecovery, beforeRecovery, after
Replica killed39–44 s0 s (all four)
Redis restarted empty10–14 s3–5 s
Replica partitionedup to 62 s2–11 s
Rolling restartabout 10 sabout 10 s
Second 6 hour run: 858,515 HTTP and 257,256 TCP visits, steady availability 99.999 % for both, steady p99 218 ms (HTTP) and 219 ms (TCP).

Evidence: the 6 hour soak passed after the fixes

Setup
2026-10-10, the same machine, cluster shape, agents and fault rotation as the first run, with the session fixes and the corrected judge. Three short runs before it; one 6 hour run.
Numbers
858,515 HTTP and 257,256 TCP visits; steady availability 99.999 %; steady p99 218 ms (HTTP) and 219 ms (TCP). Recovery: replica kill 0 s (all four), empty Redis 3–5 s, partition 2–11 s, rolling restart about 10 s. Goroutines net of agents 388 and 383 (first and second half), Redis 69 to 70 keys, idle 0.002 cores or less per replica.
Reproduce
(a) No released binary or public repository runs this soak. (b) Our check, in the closed nfltr repository: SOAK_DURATION=6h make soak-relay-kind; make soak-relay-kind-short is a 25-minute version with the same faults. Both write a summary.json judged against the bounds above.
Limits
One long run on kind on one laptop. Not in this run: end-to-end encrypted shares, site hosts, mailboxes and clock skew. Nothing here was measured on a real cluster or with production traffic.

Netted by agents, the three replicas' goroutines averaged 388 over the first half and 383 over the second. File descriptors and memory were flat, Redis kept 69 to 70 keys, and each replica idled at 0.002 cores or less afterwards. The logs show the fix at work: 33 agents admitted on a replica while another still held their old session, and 14 re-announcements after Redis restarts and partitions.

What remains

  • A replica that dies silently costs up to about 40 s for the agents it held, because that is how long their keepalive takes to notice. A shorter client keepalive needs a client release and a measurement against every relay version in use first.
  • A rolling restart fails about 10 s of in-flight visits per replica: a visit already sent to an agent's stream when its replica lets the agent go fails after about 4 s, roughly 15 visits per replica at this load. The fix is make-before-break, where the relay asks the agent to connect elsewhere first and only then ends the old stream; it needs a client release.
  • Visits that reach the old replica of an agent that moved still go into the dead stream until that replica's next renewal (at most 22.5 s for HTTP, 15 s for TCP) or the re-announcement after a partition heals.
  • Redis without persistence loses share codes on a restart. Run Redis with persistence, or SQL.
  • Not in the soak yet: end-to-end encrypted shares, site hosts and mailboxes (they need SQL and wildcard DNS in the cluster; their cross-replica paths have unit tests), and clock skew.

Since this post (from the soak report the same evening): make-before-break drain landed, and in 20-minute runs a rolling restart then cost 0 and 1 failed visits; the soak now also drives end-to-end encrypted shares, a site host and mailboxes with Postgres in the cluster, and 6 agents running as pods; the clock-skew faults are skipped on these arm64 nodes. Replicas later stopped forwarding to each other over gRPC: link: why relay replicas stopped talking gRPC.

The Helm chart for running your own

The soaks deploy the relay's Helm chart, which gained what several replicas need. It generates the peer secret and the session signing secret and keeps both across upgrades, or takes an existing secret. With more than one replica it adds a PodDisruptionBudget of one unavailable pod and a preferred anti-affinity that spreads replicas over nodes. A preStop pause lets endpoints drop a pod before it drains, an optional autoscaler can follow connected agents per pod, and an optional client IP session affinity keeps a busy agent's extra connections on one replica. The chart's heartbeat and keepalive defaults are now the relay's, and an offline check fails when any of these go missing from the rendered chart.

A multi-replica relay needs Kubernetes mode, Redis with persistence, SQL for mailboxes, browser sessions and site hosts, an S3 artifact store (the chart refuses to render a shared filesystem one), and TLS terminated outside the pods, since in-pod ACME would order one certificate per replica. Rate limits stay per replica, so N replicas allow N times the configured rate. The chart and the soak are tested on kind only.

nfltr.xyz stays on one VM

The hosted relay at nfltr.xyz is one process on one VM, as described in nfltr.xyz now runs on nfltr, and this work does not change that. The first round of fixes shipped in v1.0.695 and changed little for one process: agents now leave in the first half of the shutdown window, and readiness no longer looks at a Redis it does not have. The session fixes are merged and ship with the next release; on one replica they change nothing observable, because admitting a newer session across replicas needs another replica. For a single relay of your own, nfltr relay serve is still the simpler path: see Run your own relay.

Is it for you?

Fits if you plan to run the relay as several replicas on Kubernetes and want to know what it takes and what failures cost: the chart, Redis with persistence, SQL, an S3 artifact store and TLS terminated outside the pods, and the measured recovery times above.

Not ready for production use as several replicas: the chart and the soaks are tested on kind on one laptop only, with no real network between replicas. A replica that dies silently still costs its agents up to about 40 s. Rate limits are per replica. Running your own relay is a beta (part 1), and nfltr.xyz is one VM. The chart, the soak and the relay server are closed source; the relay's session core and client SDK have been public since 2026-10-29 (what is open).

Try next one relay first: nfltr relay serve, from Run your own relay. For the multi-replica shape, tell us what you need; the chart and soak are in the closed nfltr repository. How replicas talk to each other now is in the link series; how the relay compares with frp and others on one process is in A faster, leaner relay; where this soak sits among our other evidence on scale is in How we build reliable Go software.

← All posts