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.

By Updated

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.x addresses 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.

Screenshot to comeredis-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 field
The node map: each line rewrites where one node is dialed, never what it is called.

Or 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….

Screenshot to comeredis-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 pane
A cross-slot MGET is refused before it is sent, and the message says how to fix it.

Tools 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 UNLINK batch stays inside one slot and the cluster never sees a cross-slot delete.
  • Profiler. It runs MONITOR on 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 PUBLISH everywhere. Sharded channels go to the master that owns them: enter #orders and the viewer runs SSUBSCRIBE orders on the owner of slot 105. Rows carry a node column.
Screenshot to comeredis-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 master
A sharded channel is watched on the master that owns it, and every row names that node.

Cluster 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.

Screenshot to comeredis-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 state
Cluster Topology: what each node announces, what SQLly dials, and a step-by-step trace per node.

TLS 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.