Redis and Valkey on Kubernetes: StatefulSets, Operators, and What Breaks the Java Client
Running Redis on Kubernetes is now routine, and running Valkey on Kubernetes increasingly so. Getting a Java application to survive either is less routine, and the reasons are specific rather than general. Valkey and Redis clusters gossip node addresses among members, and clients follow MOVED/ASK redirects to whichever node owns a key's hash slot. On Kubernetes those addresses belong to Pods that are rescheduled, renamed and re-IP'd as a matter of routine operation. The client's model of the cluster and the cluster's actual location drift apart, and the drift shows up as connection errors that look like network problems and are not.
Redisson is the Java client this guide uses throughout. It gives you distributed, thread-safe Java objects backed by the cluster, and its connection pooling, topology discovery and retry handling are the layer absorbing most of what Kubernetes does to a datastore underneath you.
So this guide covers deploying the cluster, then the part that is usually skipped: exactly how Redisson rediscovers a cluster whose Pods have moved, the configuration choice that silently decides whether it can do that at all, and what happens to a distributed lock when the primary holding it fails over.
Most of the client-side detail below comes from Redisson's source code rather than its documentation, because the documentation does not cover it — and in two places contradicts the code. Those are flagged where they come up.
Choosing a Kubernetes Deployment Strategy: Operators, Helm, or StatefulSets
The first step is selecting your Kubernetes deployment strategy. There are three viable methods, and the right one depends on how much Day-2 automation you want.
The StatefulSet
A raw StatefulSet gives you full control over a cluster with zero dependencies. You define the Pods, a headless Service for stable DNS, and bootstrap the cluster with valkey-cli --cluster create.
This approach is transparent and reproducible. It also means your team owns resharding, failover recovery, scaling, and backups. A StatefulSet is the best option for learning the mechanics or for small-footprint clusters. The choice of workload object follows directly from whether the thing you are deploying is stateless or stateful: Deployments suit interchangeable Pods, StatefulSets suit Pods with stable identity and attached storage.
Helm Charts
A Helm chart packages the workload — plus services, config, and probes — into a versioned, parameterized release. You get repeatable installs and easy upgrades, but a chart only deploys a database; it doesn't operate one. No controller is watching for drifting slot assignments or resharding the cluster; the most a chart adds is Sentinel for primary failover, covered below.
If you previously deployed the Bitnami Valkey chart, you're likely aware of the changes that took effect in late August 2025. Bitnami moved its maintained container images behind a commercial subscription (Bitnami Secure Images) and archived the old free images to a separate, frozen bitnamilegacy namespace. The charts themselves still pull; what broke is the image reference inside them, so the failure surfaces as an ImagePullBackOff on the Pod rather than an error from helm.
The Valkey project now maintains community charts at valkey-io/valkey-helm, letting you pin chart and image versions in values.yaml and upgrade on your own schedule. The repository ships three: valkey covers standalone and primary–replica topologies, valkey-operator installs the Valkey project's own operator, and valkey-resources creates clusters for that operator to manage. If you need cluster mode — sharded, multi-primary with hash slots — the plain valkey chart is not the one; use the operator, the Bitnami Secure Images valkey-cluster chart, or a StatefulSet.
Here's a shell script to get you started with the standalone / primary–replica chart — not cluster mode:
helm repo add valkey https://valkey.io/valkey-helm/
helm repo update
helm install valkey valkey/valkey -n data --create-namespace
Out of the box that command installs the stable chart and gives you a single standalone Pod. The chart's 1.0 release candidate, which requires Valkey 9.0 or later, adds Sentinel for automatic failover. Sentinel needs replication, persistent storage and a Sentinel password, and Redisson needs authentication on the data nodes too (explained below), so the install grows to this:
helm install valkey valkey/valkey -n data --create-namespace --version 1.0.0-rc.1 \
--set replica.enabled=true \
--set persistence.enabled=true --set persistence.size=5Gi \
--set sentinel.enabled=true --set sentinel.password=change-me \
--set auth.enabled=true \
--set 'auth.aclUsers.default.permissions=~* &* +@all' \
--set auth.aclUsers.default.password=change-me-too
This install and the StatefulSet in the next section both create a StatefulSet named valkey and a Service named valkey-headless, so try them in separate namespaces. With Sentinel turned on, the plain valkey Service is, in the chart's own words, "not a write endpoint": it spreads connections over every Pod, so some writes land on replicas and fail with READONLY. Clients either ask Sentinel where the primary is or go through the chart's optional HAProxy endpoint. In Redisson, use Sentinel mode:
Config config = new Config();
config.setPassword(valkeyPassword); // requires auth.enabled on the chart
config.useSentinelServers()
.setMasterName("mymaster") // the chart's default sentinel.masterSet
.addSentinelAddress("valkey://valkey-sentinel.data.svc.cluster.local:26379")
.setSentinelUsername("sentinel") // the chart turns off Sentinel's default user
.setSentinelPassword(sentinelPassword);
Two details in that snippet are easy to miss. The chart replaces Sentinel's default user with one named sentinel, so the username has to be set along with the password. And Redisson sends Sentinel credentials only when a password for the data nodes is configured, so with the chart's data-node authentication left off, every Sentinel lookup fails with NOAUTH. HAProxy works with single-server mode, but like any single-server setup it switches off the lock protection described under The Single-Server Trap below.
Kubernetes Operators
In a full-scale production environment, Kubernetes Operators are the more compelling option. An operator runs a controller that builds your specified cluster and automatically handles failover, online scaling, slot rebalancing, backup/restore, and rolling config changes.
KubeBlocks, an open-source operator from ApeCloud, is one of the more widely used. It manages Redis and Valkey through a unified Cluster CRD and an InstanceSet workload that tracks each Pod's role, with OpsRequest-driven Day-2 operations. Its sharded topologies come from the Redis addon; the newer Valkey addon covers standalone and Sentinel-managed replication, with no cluster mode.
The Valkey project also has its own operator, installed from the same Helm repository as the chart. It manages cluster mode only, through a ValkeyCluster resource, and its README says plainly that it is "not ready for production use" and that its API may still change. The Hyperspike Valkey operator is another option, though it has not published a release since October 2025. Operators add a control-plane component to learn and maintain, but for production clusters that scale or need to handle failovers, they pay for themselves.
Building a Valkey Cluster via StatefulSet
The best way to understand how clients interact with cluster topology is to build one from the ground up. The manifest below creates a six-node cluster (three primaries, three replicas) behind a headless Service. This demo runs without authentication and disables protected mode so peers and clients can connect from outside the loopback interface. In production you would set a password or ACLs instead, which disables protected mode as a side effect; set masterauth (and masteruser with ACLs) as well, or replicas cannot authenticate to their primary.
apiVersion: v1
kind: ConfigMap
metadata: { name: valkey-conf, namespace: data }
data:
valkey.conf: |
cluster-enabled yes
cluster-config-file /data/nodes.conf
cluster-node-timeout 5000
cluster-preferred-endpoint-type hostname
appendonly yes
# AOF/RDB persist on the mounted PVC
dir /data
# No auth in this demo, so protected mode must be off or peers/clients
# connecting from non-loopback are refused. In production set a password
# (requirepass/ACLs) instead — that disables protected mode too.
protected-mode no
---
apiVersion: v1
kind: Service
metadata: { name: valkey-headless, namespace: data }
spec:
clusterIP: None # headless: each Pod gets a stable DNS name
selector: { app: valkey }
ports: [{ port: 6379, name: client }, { port: 16379, name: gossip }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: valkey, namespace: data }
spec:
serviceName: valkey-headless
replicas: 6
selector: { matchLabels: { app: valkey } }
template:
metadata: { labels: { app: valkey } }
spec:
containers:
- name: valkey
image: valkey/valkey:8.1
env:
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: NAMESPACE
valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
command: ["sh", "-c"] # replaces the image ENTRYPOINT, so call it below
args: # each Pod announces its OWN stable FQDN
- |
exec docker-entrypoint.sh valkey-server /conf/valkey.conf \
--cluster-announce-hostname \
"${POD_NAME}.valkey-headless.${NAMESPACE}.svc.cluster.local"
ports: [{ containerPort: 6379 }, { containerPort: 16379 }]
volumeMounts:
- { name: conf, mountPath: /conf }
- { name: data, mountPath: /data }
volumes:
- { name: conf, configMap: { name: valkey-conf } }
volumeClaimTemplates:
- metadata: { name: data }
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: 8Gi } } }
The hostname configuration depends on two settings working together. In the ConfigMap, cluster-preferred-endpoint-type hostname tells nodes to return DNS names rather than ephemeral Pod IPs in CLUSTER SLOTS and MOVED/ASK redirects. But a node has no hostname to advertise unless you give it one, so each Pod must also set cluster-announce-hostname to its own FQDN. Since that value differs per Pod, it is built from the Downward API in the container args — yielding, for example, valkey-0.valkey-headless.data.svc.cluster.local. With both in place, the addresses clients receive stay valid across restarts and rescheduling. Setting command replaces the image's entrypoint, which is what normally drops the server from root to the valkey user, so the args call docker-entrypoint.sh explicitly to keep that step.
Once all six Pods are Ready, bootstrap the cluster. The address list is built inside the Pod, so the command works the same from bash, zsh or any other local shell:
kubectl exec -n data valkey-0 -- sh -c 'valkey-cli --cluster create \
$(for i in 0 1 2 3 4 5; do echo "valkey-$i.valkey-headless.data.svc.cluster.local:6379"; done) \
--cluster-replicas 1 --cluster-yes'
Hostname announcement changes what clients are told. It does not change how the nodes find each other: the cluster bus still records every peer by IP in nodes.conf. A single rescheduled Pod rejoins on its own, because it still knows where its peers are and they pick up its new address when it contacts them. If every Pod comes back on a new IP at once, after a cluster-wide outage or a restore from backup, every recorded address is stale. In a test of this configuration on Valkey 8.1 and 9.0, with all six Pods back on new IPs, the cluster still reported cluster_state:fail 90 seconds later. Introducing the nodes to each other again by their current IPs brings it back:
kubectl exec -n data valkey-0 -- sh -c 'for i in 0 1 2 3 4 5; do
valkey-cli cluster meet \
"$(getent hosts valkey-$i.valkey-headless.data.svc.cluster.local | cut -d" " -f1)" 6379
done'
CLUSTER MEET accepts only an IP address, hence the getent lookup. Run it once every Pod is Ready: the headless Service publishes DNS records only for ready Pods, so the lookup comes back empty for a node that is still loading its data. Each node recognizes its peers by node ID and updates their addresses, and in the same test the cluster was back to cluster_state:ok within a minute. This is the kind of Day-2 recovery an operator is meant to automate; whichever route you take, rehearse a full reschedule before you rely on it. The Java client loses every remembered address at the same moment, which is the case the seed-address rule below exists for.
Streamlining Operations: Managing Clusters with Kubernetes Operators
While the StatefulSet is good for understanding cluster mechanics, an operator removes the manual bootstrap and the Day-2 toil. With KubeBlocks installed, a three-shard Redis cluster is a single resource. The seed Service at the top is not part of KubeBlocks' own example; the paragraphs after the manifest explain why it is there:
apiVersion: apps.kubeblocks.io/v1
kind: Cluster
metadata: { name: redis-sharding, namespace: data }
spec:
terminationPolicy: Delete
services:
- name: seed # one headless Service across every shard
serviceName: seed # created as redis-sharding-seed
componentSelector: shard # the sharding name below
spec:
clusterIP: None
ports: [{ name: redis, port: 6379, targetPort: 6379 }]
shardings:
- name: shard
shardingDef: redis-cluster # one shared password for every shard
shards: 3 # >= 3 primaries; reshard by changing this number
template:
name: redis
componentDef: redis-cluster-7
serviceVersion: 7.2.4
replicas: 2 # one primary + one replica per shard
services:
- name: redis-advertised # one Service per Pod, advertising a reachable endpoint
podService: true
serviceType: NodePort # for clients outside Kubernetes; see below
volumeClaimTemplates:
- name: data
spec:
accessModes: ["ReadWriteOnce"]
resources: { requests: { storage: 20Gi } }
KubeBlocks provisions the Pods, forms the cluster, assigns the hash slots, and labels each Pod's role. You never issue valkey-cli --cluster create. Resharding is declarative: raise shards, or submit a HorizontalScaling OpsRequest, and the operator provisions the new shard for you.
KubeBlocks gives each shard its own headless Service, which resolves only to that shard's Pods. The seed Service, selected on the whole sharding, resolves to all of them, so it is the one hostname to give Redisson: redis://redis-sharding-seed.data.svc.cluster.local:6379. KubeBlocks also turns on a password for the default user. With shardingDef set, every shard shares it, and it is stored in the Secret redis-sharding-shard-default under the key password; pass it to Redisson with config.setPassword(...).
What the nodes announce follows the redis-advertised Service. With it, each node announces its Kubernetes node's IP and its own NodePort. That serves a client outside Kubernetes, provided it can route to the nodes' addresses, and it survives Pod moves because every Kubernetes node forwards every NodePort. Without it, the addon announces per-Pod FQDNs, as the StatefulSet above does, which is the better fit when every client runs inside the cluster. A LoadBalancer per Pod is a different Service in the addon, redis-lb-advertised; setting redis-advertised to LoadBalancer still announces node addresses.
The Valkey project's operator describes the same shape of cluster in a few lines. It is pre-release software, so treat this as something to try rather than to deploy:
apiVersion: valkey.io/v1alpha1
kind: ValkeyCluster
metadata: { name: my-cluster, namespace: data }
spec:
shards: 3
replicas: 1
networking:
discovery:
preferredEndpointType: Hostname # default is IP
preferredEndpointType: Hostname is the line that matters to Java clients. Left alone, the operator announces Pod IPs, which leaves Redisson no announced hostnames to re-resolve. Hostname makes each node announce its per-Pod FQDN under the operator's headless Service, as the StatefulSet above does. The operator also warns that combining TLS with IP announcement tends to break certificate name checks for clients that redial announced addresses. The headless Service is named valkey- plus the cluster name, so the Redisson seed here is valkey://valkey-my-cluster.data.svc.cluster.local:6379.
Connecting the Java Client: The Seed Address Rule
This is where Kubernetes deployments most often go wrong, and the mechanism behind it is not documented.
Add the org.redisson:redisson dependency at its current release and configure cluster mode:
Config config = new Config();
config.useClusterServers()
// ONE headless Service DNS name. Not two. Not a ClusterIP. See below.
.addNodeAddress("valkey://valkey-headless.data.svc.cluster.local:6379")
.setScanInterval(5000)
.setReadMode(ReadMode.SLAVE)
.setSubscriptionMode(SubscriptionMode.SLAVE);
RedissonClient redisson = Redisson.create(config);
And the equivalent YAML:
clusterServersConfig:
nodeAddresses:
- "valkey://valkey-headless.data.svc.cluster.local:6379"
scanInterval: 5000
readMode: "SLAVE"
subscriptionMode: "SLAVE"
Redisson treats the valkey:// and redis:// schemes the same way; use whichever matches the server you run.
If the Valkey Pods and your application Pods are spread over availability zones, SLAVE sends each read to a replica in any zone. On Valkey 8.0 and later, Redisson's AZ_AFFINITY read modes keep reads in the client's own zone, with each side taking its zone from the node's topology.kubernetes.io/zone label. AZ affinity for Valkey in Java covers the setup on both sides.
Why Exactly One Seed Address
Seeding a client with several addresses for redundancy is such standard practice that it barely reads as a decision. In Redisson's cluster mode on Kubernetes, it is the wrong one, and the cost is much higher than the benefit.
When ClusterConnectionManager starts, it records the seed hostname for later re-resolution under exactly one condition — the seed list holds a single entry and that entry is not an IP literal:
// ClusterConnectionManager.doConnect()
if (cfg.getNodeAddresses().size() == 1 && !addr.isIP()) {
configEndpointHostName = addr.getHost();
// ...also captures configEndpointUsername / configEndpointPassword
}
That field is what the periodic topology scan branches on. When it is set, each scan tick re-resolves the seed hostname and uses the returned addresses as candidates to query. When it is null — which is to say, whenever you configured two or more seeds — the scan instead walks the master and replica addresses it learned during the previous scan.
Those remembered addresses are always IP literals, because Redisson resolves every address in the CLUSTER NODES reply to an IP before storing it. And feeding an IP back into the resolver short-circuits immediately, returning the same IP without issuing a DNS query. The seed is never re-resolved again.
The consequence is not a race or a delay. After a mass Pod reschedule, every remembered address is dead, the scan exhausts its candidate list, logs Can't update cluster state using nodes: [...]. A new attempt will be made., and reschedules itself to try the same dead IPs again. It never recovers, because in this configuration cfg.getNodeAddresses() is never read again after startup. The seed list becomes dead configuration for the entire lifetime of the client.
Be precise about the scope of that, because there is a second DNS path. When nodes announce hostnames — as the manifest above makes them — Redisson re-resolves those announced names on every scan, so an ordinary rolling restart recovers fine as long as at least one remembered address still answers. Seed re-resolution is what saves you when none of them does.
So the second seed address buys a marginally better chance of a successful initial connect, and in exchange gives up the only recovery path that survives losing every remembered address at once. A headless Service returns A records for every ready Pod, so a single headless Service name gives you the redundancy you wanted at startup and keeps re-resolution alive.
The resolved address list is a set of candidates to ask, not the topology itself. Redisson connects to the first one that answers and treats that node's CLUSTER NODES reply as authoritative. What the headless Service actually guarantees is that as long as one Pod is ready, the scan has a live node to ask. That is precisely the property the multi-seed configuration loses.
Why Not a ClusterIP Service
A single ClusterIP Service name satisfies the one-hostname rule, so re-resolution stays enabled, but it gives the scan one candidate where a headless name gives it every ready Pod.
A ClusterIP name resolves to one virtual IP, and kube-proxy picks a backend Pod for each new connection. Whichever node answers is the sole authority on topology for that tick with either kind of Service, because a headless name's records also come back in varying order and Redisson asks the first address that answers. The difference shows when that node has nothing useful to say. A Pod that has restarted but not yet rejoined returns an empty node list; with a headless seed, Redisson moves on to the next address in the same tick, while through a ClusterIP the tick ends there. A node that has rejoined only partly is a problem for both: it reports some peers still in HANDSHAKE or some primaries with no slots, and Redisson's slot-change handler applies that view without a coverage guard, removing slot mappings it had correct a second earlier. It surfaces in the logs as N slots removed, followed by routing failures for exactly those slots until a later tick asks a node with the full view.
Cluster mode needs to address each shard individually, so it needs per-node DNS. That is what clusterIP: None gives you. A load-balancing Service in front of a sharded cluster is a category error, and it is worth checking for, because a ClusterIP Service is the default thing Kubernetes gives you when you are not thinking about it.
It is also a comfortable mistake to make, because the reasoning behind it is sound for most workloads: put a Service in front of the Pods, get one stable address, and let it spread requests across the members. That is right for a stateless backend and wrong for a sharded datastore. The symptom is a burst of RedisNodeNotFoundException ("Node for slot: N hasn't been discovered yet") for slots that were routing fine a moment earlier, which reads like a discovery bug, so the Service is rarely the first suspect.
scanInterval, and a Documentation Error
The configuration documentation gives scanInterval a default of 1000 ms. The code says 5000. If you have been assuming a one-second topology scan because the docs said so, you have been getting five. Set it explicitly.
A lower value shortens the window in which stale routing produces errors, at the cost of extra discovery traffic — but only down to a floor you do not control. Redisson resolves through Netty's DNS resolver with caching that honors the record TTL, and CoreDNS serves A records with a default TTL of 5 seconds. The effective refresh granularity is therefore roughly max(scanInterval, DNS TTL). Dropping scanInterval to 500 ms buys nothing for IP discovery; if recovery latency genuinely matters, the lever is the ttl directive in your CoreDNS ConfigMap. Note also that you cannot disable the scan — scanInterval of 0 or less throws at startup.
One interaction worth knowing when you tune this: the scan walks its candidate addresses sequentially, and each failed attempt is bounded by connectTimeout — 10 seconds by default. On a large headless Service where every Pod is unreachable, a single tick can take N × 10 seconds before it gives up and reschedules.
Pair whatever you choose with cluster-node-timeout on the server side, so the cluster itself fails over promptly rather than leaving the client to time out against a primary nobody has demoted.
dnsMonitoringInterval Does Nothing in Cluster Mode
This one costs people real time. dnsMonitoringInterval is inherited by ClusterServersConfig through the config class hierarchy, so useClusterServers().setDnsMonitoringInterval(1000) compiles cleanly, appears in your config, and does absolutely nothing.
Redisson's DNS monitor is started from MasterSlaveConnectionManager.doConnect(). ClusterConnectionManager overrides doConnect() and never calls it. The documentation lists the setting under Single, Sentinel, Multi Sentinel, Master/Slave and Proxy modes, and omits it from Cluster — technically correct, and easy to miss when the setter is right there on the builder.
In cluster mode, topology refresh is scanInterval plus the single-hostname rule above. That is the whole mechanism.
Worth knowing for the non-cluster modes too: the DNS monitor short-circuits when the configured address is an IP literal. Hard-coding a Pod IP in Sentinel or single-server mode does not merely skip a nicety — it guarantees you never pick up a rescheduled Pod.
Four Failure Modes Specific to Kubernetes
The first two failure modes below come from how the client learns and keeps node addresses; the other two are configuration defaults that happen to bite under Kubernetes' restart behavior.
Stale Pod IPs and "Unable to unfreeze entry"
The one you are most likely to hit. A node is frozen with freezeReason=MANAGER or RECONNECT and the client retries a dead IP indefinitely:
Unable to unfreeze entry: ClientConnectionsEntry{... freezeReason=MANAGER,
client=[addr=redis://10.252.232.175:6379], nodeType=SLAVE, ...} attempt: 3 of 4
Caused by: RedisConnectionException: Unable to connect to Redis server:
10.252.232.175/10.252.232.175:6379
Kubernetes brought the Pod back on a new IP, and the client is still retrying the old one. Nothing crashes, which makes this easy to miss: for a replica, as in the log above, reads move to the nodes that still answer and the line keeps repeating; for a primary, commands for its slots fail until the topology scan picks up the new address. In cluster mode, the durable answer is the seed-address rule above: with one headless-Service hostname the scan re-resolves and moves on; with two seeds it cannot.
Announced Hostnames That Don't Resolve
The mirror image, and the reason the manifest earlier sets cluster-announce-hostname to a headless-Service FQDN rather than letting the Pod announce its bare name. A bare Pod name such as valkey-0 has no DNS record of its own in Kubernetes, so every node the client learns about from CLUSTER NODES fails to resolve with NXDOMAIN.
Redisson has a fallback: when an announced hostname cannot be resolved, the cluster manager uses the announced IP instead. That turns a hard failure into a degraded one, which is an improvement and not a fix — you are back to IPs that go stale. Announce a resolvable FQDN and the problem does not arise.
checkSlotsCoverage During a Rolling Restart
checkSlotsCoverage defaults to true, and at startup Redisson verifies that all 16,384 hash slots are accounted for. If they are not, it shuts itself down and throws:
RedisConnectionException: Not all slots covered! Only 10923 slots are available.
Set checkSlotsCoverage = false to avoid this check.
On Kubernetes this fires more often than you would expect, because application Pods and cluster Pods restart together. An app Pod that comes up while the cluster is still converging — during a rolling restart, or on first deploy — hits an incompletely-formed cluster and fails to start. It is retried — Redisson.create() wraps the connect path in retryAttempts + 1 attempts, five by default, with the retry delay between them — but that buys a few seconds, nowhere near long enough for a cluster to converge.
The right fix is ordering, not disabling the check. Gate application startup on cluster readiness with an init container that waits for cluster_state:ok, rather than turning off a check that exists to stop you routing into a partially-formed cluster. Note the check runs only at startup — it does not guard the periodic scan.
Connection Storms from Pool Defaults
Redisson maintains separate pools per node, split between primaries and replicas. The defaults are 64 maximum and 24 minimum idle for each. Across a six-node cluster that is 144 connections opened eagerly by a single application Pod before it serves a request — and Kubernetes starts your application Pods in parallel. Ten replicas is 1,440 connections arriving at a cluster that may itself have just restarted.
A more conservative starting point, under clusterServersConfig:
masterConnectionMinimumIdleSize: 4 # default 24
masterConnectionPoolSize: 32 # default 64
slaveConnectionMinimumIdleSize: 4 # default 24
slaveConnectionPoolSize: 32 # default 64
subscriptionConnectionPoolSize: 25 # default 50
The minimum idle size matters more than the maximum here, because it is what gets established at startup regardless of load. For retries and reconnection, Redisson's defaults are already jittered — retryAttempts: 4 with equal jitter on a 1s base capped at 2s, and reconnection on a 100ms base capped at 10s — which is what stops a primary failover from turning into a synchronized reconnect wave. If you are overriding these, keep the jitter; backoff without jitter moves the wave rather than breaking it up. The fault tolerance and recovery docs cover the full set; these are the defaults, spelled out, and they also go under clusterServersConfig:
retryAttempts: 4
retryDelay: !<org.redisson.config.EqualJitterDelay> { baseDelay: PT1S, maxDelay: PT2S }
reconnectionDelay: !<org.redisson.config.EqualJitterDelay> { baseDelay: PT0.1S, maxDelay: PT10S }
timeout: 3000
connectTimeout: 10000
Reaching the Cluster from Outside Kubernetes
When the client runs outside the cluster — a developer laptop, a different cluster, anything behind NAT — the internal addresses the nodes gossip will not resolve. Running the client in-cluster is always preferable, but there are two ways to make a sharded cluster reachable from outside.
Server-side: have each node advertise an address the client can reach. KubeBlocks' per-Pod redis-advertised NodePort Services (or redis-lb-advertised LoadBalancers) do this, so the topology a client discovers already points at reachable endpoints.
Client-side: when the cluster only knows its internal addresses, natMapper rewrites them:
// NOTE: org.redisson.config, NOT org.redisson.api, which is
// where the configuration docs list these classes.
HostPortNatMapper natMapper = new HostPortNatMapper();
natMapper.setHostsPortMap(Map.of(
// Keys are the resolved IP:port Redisson actually sees — NOT the announced
// FQDN. Redisson maps addresses after resolution, so an FQDN key never matches.
"10.244.1.17:6379", "node1.example.com:31000",
"10.244.2.31:6379", "node2.example.com:31001"
// ...one entry per node — primaries and replicas alike
));
Config config = new Config();
config.useClusterServers()
.addNodeAddress("redis://node1.example.com:31000")
.setNatMapper(natMapper);
The wrong package (org.redisson.api) is easiest to hit in YAML, where the fully-qualified class name is the type tag: !<org.redisson.api.HostPortNatMapper> fails when the configuration loads, not when you build. The configuration docs give org.redisson.api in every section that mentions these classes.
The map needs an entry for every node, because Redisson applies it to each address it learns from CLUSTER NODES; a node with no entry is unreachable. Use HostPortNatMapper when the external port differs (NodePort) and HostNatMapper when only the host changes, as with a per-node LoadBalancer.
And note what those keys are on Kubernetes: Pod IPs. The map is keyed on exactly the values that change every time a Pod is rescheduled, which makes client-side NAT mapping fragile here and is the strongest argument for doing it server-side with per-Pod advertised endpoints instead.
The seed rule still applies out here. The sample above seeds one external hostname, so re-resolution stays enabled; list several external seeds and you forfeit it exactly as you would in-cluster.
Locks Across a Failover and a Rolling Restart
Kubernetes makes two things routine that distributed locks handle poorly: primaries failing over, and lock-holding processes being killed on a schedule. How Redisson's locks behave across a failover in general, including what its replica check does and does not guarantee and when you need a fencing token, is covered in how to use Redis locks in Java. This section covers the parts Kubernetes changes.
The Failover Hazard
Acquiring an RLock writes to the primary, and replication is asynchronous. If the primary fails over before the write reaches a replica, the new primary has no record of the lock and a second client can acquire it. On Kubernetes this is not a thought experiment: draining a node fails over whatever primaries were running on it.
Redisson narrows the window by default, synchronizing each lock write through WAIT under the top-level checkLockSyncedSlaves setting. The check has two gaps that Kubernetes makes routine. It is skipped while the primary has no connected replica, which is the state of a shard whose replica Pod is restarting, and, as the next section explains, it never runs in single-server mode.
The Single-Server Trap
This is the sharpest Kubernetes-specific gotcha in this section. Redisson's syncedEval falls through to an ordinary write whenever the config is a single-server config, so the replica check never runs.
Pointing Redisson at one Kubernetes Service DNS name is an extremely common pattern, and it looks like a reasonable simplification. It silently turns off a protection that is on by default, with no warning and no log line. The skip is keyed on single-server config specifically, so any multi-node mode — useClusterServers(), useSentinelServers(), useReplicatedServers() or useMasterSlaveServers() — keeps the check active.
The Watchdog and the Grace Period
Redisson's watchdog renews a lock taken without a lease time every lockWatchdogTimeout / 3, by default every 10 seconds against a 30-second timeout. When the holder dies, renewal stops and the lock lingers until that TTL runs out.
Kubernetes' terminationGracePeriodSeconds also defaults to 30 seconds (SIGTERM, then 30 seconds, then SIGKILL), and the two windows stack. A Pod killed mid-critical-section leaves its lock held for up to 30 more seconds while the replacement Pod blocks on lock(); across a rolling restart, the worst case is close to a minute of stall on a lock nobody holds.
redisson.shutdown() does not help: it has no lock-release path, and the renewal task stops once shutdown begins, so the key lingers until its TTL expires. Nor is the grace period a budget for JVM shutdown hooks; a finally-block unlock() runs only if the JVM reaches it before SIGKILL.
What helps is letting lock holders finish: on SIGTERM, stop taking new work, give the threads holding locks time to unlock in their own finally blocks, and only then call shutdown(). An RLock belongs to the thread that acquired it, so calling unlock() from a preStop handler or @PreDestroy method throws IllegalMonitorStateException; forceUnlock() skips that check and is safe only once the holder has stopped working. A shorter lockWatchdogTimeout shrinks the orphan window at the cost of more renewal traffic, since each renewal is a synchronized write with a WAIT round-trip.
One more pause to plan for: a Pod running at its CPU limit is throttled, which can stall a lock holder past its lease just as a long GC pause can, and the holder will not know. Where a lock guards writes that must not happen twice, use RFencedLock and have the guarded resource check its token.
Health Checks for Valkey and Redis on Kubernetes
Kubernetes container probes and Redisson's client-side validation cover different failure modes, and you want both.
Kubernetes Probes
On the Kubernetes side you want liveness, readiness and startup probes, so the kubelet restarts genuinely dead nodes and keeps loading ones out of rotation:
startupProbe: # tolerate slow AOF/RDB loads on restart
exec: { command: ["sh", "-c", "valkey-cli ping | grep -q PONG"] }
periodSeconds: 5
failureThreshold: 60 # allow up to ~5 min to load before a restart
readinessProbe: # keep still-loading nodes out of rotation
exec: { command: ["sh", "-c", "valkey-cli ping | grep -q PONG"] }
periodSeconds: 10
livenessProbe: # conservative TCP check, no mid-failover kills
tcpSocket: { port: 6379 }
periodSeconds: 15
The grep -q PONG is the important part. A node replaying its AOF answers -LOADING, and an auth-enabled node answers NOAUTH — both are successful exits that a bare exit-code check would pass. The startupProbe gives replay time to finish before liveness and readiness engage, so a slow-loading node is not killed mid-load, and the conservative TCP liveness check avoids restarting a node mid-failover. With authentication enabled, set REDISCLI_AUTH in the container environment so the probes can authenticate. That is the correct name for the valkey/valkey:8.1 image above; Valkey 9.0 renamed it VALKEYCLI_AUTH and kept the old name as a fallback, so setting the new one on 8.x is silently ignored.
Readiness matters more than usual here, because a headless Service's DNS records include only ready Pods. A correct readiness probe is what keeps a loading node out of the candidate list that the client's topology scan resolves. The same applies to the per-Pod names nodes announce: a node's FQDN resolves only while it is ready, so clients reach it once it can serve.
Redisson Client-Side Connection Validation
Kubernetes probes tell the cluster which nodes are healthy. They say nothing about whether the connections your client is holding are still alive, which is a separate problem with its own setting, under clusterServersConfig:
pingConnectionInterval: 30000 # default: ping each pooled connection every 30s
This detects connections that have gone silently dead — the common outcome when a Pod disappears without closing its sockets. It is on by default at 30 seconds; setting it to 0 disables it, which on Kubernetes is a bad trade. Separately, Redisson's failed-node detector (FailedConnectionDetector by default) marks a replica as failed when connection errors continue through its check interval, three minutes by default, and then retries it every three seconds.
Frequently Asked Questions
Why Does Redisson Keep Connecting to Old Pod IPs After a Restart?
Almost always because the client was configured with two or more seed addresses. Redisson only re-resolves its seed hostname during a topology scan when the seed list contains exactly one entry and that entry is not an IP literal. With multiple seeds, the scan falls back to probing the addresses it learned from the previous CLUSTER NODES reply, which are stored as IPs and never re-resolved. Configure a single headless Service DNS name instead.
Should I Use a Headless Service or a ClusterIP Service for Redis on Kubernetes?
Headless, for cluster mode. A ClusterIP name resolves to a single virtual IP, so each topology scan gets one candidate and cannot move on when the Pod behind it has nothing useful to report; a headless name resolves to every ready Pod. Cluster mode also needs per-node DNS names for the addresses nodes announce, which only a headless Service (clusterIP: None) provides.
What Is the Default scanInterval in Redisson?
5000 milliseconds. Redisson's configuration documentation states 1000, but the source code default is 5000. Set it explicitly rather than relying on the documented value. It cannot be set to 0 or less — that throws at startup.
Does dnsMonitoringInterval Work in Redisson Cluster Mode?
No. The setter is inherited from a shared base config class so it compiles and can be set, but the DNS monitor is started only from the master/slave connection manager's connect path, and the cluster connection manager overrides that method without calling it. In cluster mode, topology refresh comes from scanInterval combined with the single-seed-hostname rule.
What Happens to a Redisson Lock When the Redis Primary Fails Over?
Because replication is asynchronous, a lock written to the primary may not have reached the replica that gets promoted, in which case a second client can acquire the same lock. Redisson narrows this by synchronizing lock writes through WAIT, enabled by default via checkLockSyncedSlaves. Acquisition fails only if no replica acknowledges within slavesSyncTimeout, and the check is skipped while the primary has no connected replica. This reduces the window substantially but does not eliminate it — for correctness-critical locks, use RFencedLock and validate the token at the guarded resource.
Does redisson.shutdown() Release Locks Held by the Application?
No. Shutdown stops the write-behind service and the connection manager; it contains no lock-release path, and the watchdog renewal task returns early once shutdown has begun. A lock held at shutdown lingers until its remaining TTL expires — up to lockWatchdogTimeout, 30 seconds by default. Release locks in a finally block on the thread that holds them, and give those threads time to finish on SIGTERM before shutting the client down; calling unlock() from another thread, such as a preStop handler, throws IllegalMonitorStateException.
Why Does My Application Fail to Start with "Not all slots covered"?
Redisson verifies at startup that all 16,384 hash slots are assigned, and throws if they are not. On Kubernetes this usually means the application Pod started while the cluster was still converging, during a rolling restart or a first deploy. Gate application startup on cluster readiness with an init container that waits for cluster_state:ok, rather than disabling checkSlotsCoverage — the check exists to stop you routing into a partially-formed cluster.
Can Redisson Connect to a Cluster Managed by the Valkey Operator?
Yes. Seed Redisson with the headless Service the operator creates, named valkey- followed by the ValkeyCluster's name, and set networking.discovery.preferredEndpointType to Hostname so nodes announce per-Pod DNS names instead of Pod IPs. The operator's README describes it as not ready for production use, so test failover and a full reschedule before relying on it.
Should I Use a StatefulSet, a Helm Chart, or an Operator?
A StatefulSet for learning the mechanics or for small clusters where you accept owning resharding and failover recovery. A Helm chart for repeatable installs, understanding that a chart deploys a database and at most adds Sentinel for primary failover; it does not manage slots or resharding. An operator for production clusters that scale or fail over, where a controller handling slot rebalancing, backups and rolling config changes repays the control-plane component you have to run.
Next Steps
For the cluster concepts underneath all of this, see Redis Cluster and consistent hashing; for the failure mode a botched failover produces, split brain. If you are running primary–replica rather than sharded, Redis Sentinel covers that path. On the client side, connecting to a cluster in Java and using locks in Java go deeper than the summaries here, and upgrading a Valkey cluster with zero downtime is the operation this configuration exists to survive. Full settings are in the Redisson configuration documentation.
Redisson gives Java applications distributed locks, caches, queues and collections over Valkey and Redis, with topology discovery, jittered reconnection and replica-synchronized locks configured by default. Redisson PRO adds advanced caching, data partitioning and the Reliable Queue — try it for free.