Redis Cluster behind SSH, NAT and Docker: the node map
How SQLly routes Redis Cluster commands by slot, rewrites announced node addresses with a node map, and opens per-node SSH forwards behind a bastion.
The short version
A Redis Cluster tells clients where its nodes are. Behind NAT, Docker or a bastion, those addresses are often wrong.
SQLly now routes every command to the node that owns its key's slot and follows
MOVED and ASK. For clusters whose announced addresses your machine
can't reach, a node map rewrites them, and each node gets its own SSH forward. The Cluster
Topology… view traces each node step by step, so you can see which hop fails.
How a cluster routes
A Redis Cluster splits the keyspace into 16,384 hash slots. Each key belongs to one slot, which
is CRC16 of the key, or of the part between { and } when there is a
hash tag, modulo 16384. Each master owns a range of slots. Ask the wrong node about a key and it
replies MOVED 7515 10.0.0.6:7002: that slot lives over there. While a slot is being
migrated, a node may reply ASK instead, which means "try that node for this one
command". You can check a slot yourself:
CLUSTER KEYSLOT user:2:profile
# (integer) 7544
CLUSTER KEYSLOT user:{42}:profile
# (integer) 8000
CLUSTER KEYSLOT user:{42}:cart
# (integer) 8000
A cluster client keeps a map of slots to nodes, sends each command to the owner, and refreshes the map when a redirect says it is stale. SQLly does exactly that. When a script saw redirects, for example right after a failover, the Messages pane says so:
Cluster: 3 MOVED/ASK redirects followed while this script ran (slots moved between nodes; the client's slot map is now up to date)
One redirect after a failover is normal. A steady stream of them usually means a reshard is in progress.
Why announced addresses break clients
The slot map lists node addresses, and those come from the nodes themselves: each one announces the address it was configured with, or its interface address. Inside the cluster's network that works. From your laptop it often doesn't:
- Docker. Nodes announce container IPs like
172.18.0.4:6379, and your host can only reach the published ports. - NAT and private subnets. Nodes announce
10.0.0.xaddresses that only exist on the far side. - SSH bastions. You forward local ports to the nodes, but the cluster still hands out the private addresses. The client follows them and connects nowhere.
The bastion case is the one described in a reported Redis Insight issue, #6537: a cluster reached through locally forwarded SSH ports fails with "Failed to refresh slots cache". The seed connection works. The first slot refresh then names addresses the client can't dial, and nothing else does. Any cluster client has this problem unless it can translate addresses, so that is what we built.
The node map: announced => dial
The connection editor's Cluster node map field takes one line per node, in the
form announced host:port => dial host:port. The announced address still names the
node everywhere: in the slot map, in redirects, and as the TLS server name. Only the socket goes
to the dial address. That split matters. Rewriting the slot map itself would break redirects
(which name the announced address) and TLS verification (the certificate was issued for the
announced name, not for 127.0.0.1).
Worked example: three masters through local forwards
Say a cluster has three masters that announce 10.0.0.5:7001,
10.0.0.6:7002 and 10.0.0.7:7003, and only bastion.example.com
can reach them. You forward one local port per node:
ssh -N \
-L 17001:10.0.0.5:7001 \
-L 17002:10.0.0.6:7002 \
-L 17003:10.0.0.7:7003 \
[email protected]
Then in SQLly:
Server: 127.0.0.1:17001
Cluster mode: Redis Cluster
Cluster node map: 10.0.0.5:7001 => 127.0.0.1:17001
10.0.0.6:7002 => 127.0.0.1:17002
10.0.0.7:7003 => 127.0.0.1:17003
Now a GET user:2:profile that hashes to a slot on 10.0.0.6:7002 goes out
through 127.0.0.1:17002. A MOVED … 10.0.0.7:7003 after a failover goes
through 127.0.0.1:17003. Add lines for the replicas too if you want the topology Trace
to reach them.
redis-cluster-editor-node-map.webp — the Redis connection editor with a seed host in Server, Cluster mode set to Redis Cluster, and three announced => dial lines in the Cluster node map fieldOr let SQLly open the forwards
You don't have to run ssh -L at all. Turn on "Connect through an SSH tunnel" with the
bastion, put a node's real address in Server, and SQLly opens a separate local forward for every
node the cluster names, through the same hops. This works for SOCKS and HTTP proxies too.
Because the bastion can reach 10.0.0.x, no node map is needed. If you have one,
it is applied first, so it can also rewrite what the bastion dials. Each forward gets the tunnel
keepalive (every 30 seconds of silence by default, set with Keepalive (seconds)). A forward
whose SSH session ended is dialed again on next use, and the run says Tunnel
re-established. A command is never re-run for you. Two transports can't do this: a
Kubernetes port-forward carries one service port, so it reaches only the seed node, and relay
routes don't support cluster mode yet.
What the cluster refuses, and why
Some commands can't run as typed on a cluster. Sending them anyway gets you
CROSSSLOT or a cursor that means nothing on the next node. SQLly refuses them before
they are sent, with a message that names the rule:
MGET user:1:profile user:2:profile
# MGET names keys in 2 different hash slots (7544, 12348); a cluster runs a multi-key
# command only when every key maps to the same slot — give the keys a common {hash tag}
# such as user:{42}:a and user:{42}:b
SELECT 2
# SELECT 2 is refused: a cluster has one logical database (0)
KEYS user:*
# KEYS would block every master while it walks the whole keyspace; on a cluster use the
# key browser or SQLLY.KEYS <pattern>, which scans each master in pages
SCAN 0
# a SCAN cursor belongs to one node, so on a cluster it cannot be continued from the editor;
# use SQLLY.KEYS <pattern> [limit [cursor]], which walks every master and returns a cursor
# that continues across them
MULTI
# MULTI is not available on a cluster connection: each command is routed to its own slot's
# master, so a transaction cannot stay on one node; run the transaction with redis-cli -c
# against the node that owns its hash-tagged keys
The hash-tagged version runs, because both keys land in slot 8000:
MGET user:{42}:profile user:{42}:cart
Refusing MULTI is the trade-off we argued about most. We could pin a transaction to
the node of its first key. But the editor routes each line separately, and a transaction that
quietly runs half on one node would be worse than no transaction. Copy to Database… and
MOVE are refused the same way, because there is no other database to copy or move
to; the Copy to Database… message points you to Copy to Connection….
redis-cluster-refusal-cross-slot.webp — a query tab on the cluster connection after running MGET user:1:profile user:2:profile, with the refusal naming slots 7544 and 12348 and the hash-tag fix in the Messages paneTools that cover every master
A tool that talks to one node shows you one node's share of the data, so each tool now fans out:
- Key browser. It walks every master in turn, up to its usual budget. The note row says how many keys it scanned across how many masters, and Scan more continues on the node where it stopped, using a per-node cursor.
- Memory analyzer. The sample is split between masters in proportion to each one's key count, and the totals are summed.
- Bulk delete. Matched keys are grouped by hash slot, so every
UNLINKbatch stays inside one slot and the cluster never sees a cross-slot delete. - Profiler. It runs
MONITORon every master at once and merges the streams into one table with a node column. - Pub/Sub viewer. Keyspace notifications are per node, so they are subscribed on every master. Ordinary channels are subscribed once, since a cluster delivers
PUBLISHeverywhere. Sharded channels go to the master that owns them: enter#ordersand the viewer runsSSUBSCRIBE orderson the owner of slot 105. Rows carry a node column.
redis-cluster-pubsub-sharded-node-column.webp — the Pub/Sub viewer on the cluster connection subscribed to #orders, with SPUBLISH messages arriving and the node column naming the owning masterCluster Topology and Trace
Right-click a database and choose Cluster Topology… (or run Redis Cluster
Topology… from the palette). The view lists every node from CLUSTER SHARDS, or
CLUSTER NODES before 7.0, with its role, slots, health, replication offset and link
state. It also shows the address the node announces, its announced hostname, the address SQLly
dials, and the TLS server name it sends. The footer checks that all 16,384 slots are covered and
names any node that is unhealthy or remapped.
Trace runs the same staged check as Test Connection against each node: name
lookup, tunnel or proxy with the node map, TCP, TLS, sign-in, protocol and ping, database, and
cluster state. You get one row per step, and the first failing row tells you what to fix: a
wrong node map line, a blocked port, a TLS name mismatch or an ACL problem. Both reports are
also available in a query tab as the read-only SQLLY.TOPOLOGY and
SQLLY.TRACE.
redis-cluster-topology-trace.webp — the Cluster Topology view for a six-node cluster with remapped dial addresses, the footer confirming 16,384 slots covered, and Trace rows for one node from name lookup to cluster stateTLS names per node
TLS on a cluster gets tricky when nodes announce hostnames, or when the address you dial isn't
the name on the certificate. A reported issue,
#5722, describes an incomplete
cluster topology with TLS and hostnames. In SQLly the TLS check for each node runs against the
address that node announces, not the local forward. So a node reached through
127.0.0.1:17002 is still verified against the address it announces, and the topology
view shows the TLS name sent, so a mismatch is visible.
Entra on a cluster: rebuild, don't patch
Azure Redis tokens last about an hour, and Azure drops a connection whose token has expired.
On a single server SQLly sends AUTH on the live connection before the token runs out.
That isn't enough on a cluster. The client library signs each node connection in with the
password the routed client was built with, and reuses that password when it reconnects to a node
later. Re-authenticating the open node connections would last only until the next reconnect.
So when a fresh token arrives, SQLly builds a new routed connection with it and swaps it in.
Commands already running finish on the old one. Every node, including one that reconnects an
hour later, signs in with the current token.
Limits
- Nothing has been tested live against ElastiCache cluster mode, MemoryDB or Azure Managed Redis yet. The cluster code has opt-in live tests against a local three-master, three-replica cluster.
- The Profiler doesn't re-authenticate Entra mid-capture. Start it again when the token expires.
- Kubernetes port-forwards reach only the seed node, and relay routes don't support cluster mode.
The full reference is the Redis Cluster docs page, and the release overview covers the rest of the Redis work. Download SQLly and point it at a seed node.