Redis and Valkey on Kubernetes: StatefulSets, Operators, and What Breaks the Java Client

Last updated
August 20, 2026

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 — Map, Lock, Queue — 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 is derived from Redisson's source at version 4.7.0 rather than from 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 StatefulSet — 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 failed primaries or drifting slot assignments.

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, while valkey-operator and valkey-resources cover operator-managed clusters. If you need cluster mode — sharded, multi-primary with hash slots — the plain valkey chart is not the one; use the operator charts, 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

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 popular deployments. It manages Valkey or Redis through a unified Cluster CRD and an InstanceSet workload that tracks each Pod's role, and supports true sharded topologies with OpsRequest-driven Day-2 operations. Other options include the Hyperspike Valkey operator. 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.

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"]
        args:                       # each Pod announces its OWN stable FQDN
        - |
          exec 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 topology stays valid across restarts and rescheduling.

Once all six Pods are running, bootstrap the cluster:

PODS=$(for i in 0 1 2 3 4 5; do
  echo "valkey-$i.valkey-headless.data.svc.cluster.local:6379"
done)

kubectl exec -n data valkey-0 -- valkey-cli --cluster create $PODS \
  --cluster-replicas 1 --cluster-yes

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, the entire three-shard cluster is a single resource:

apiVersion: apps.kubeblocks.io/v1
kind: Cluster
metadata: { name: redis-sharding, namespace: data }
spec:
  terminationPolicy: Delete
  shardings:
  - name: 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       # per-Pod service advertising reachable endpoints
        podService: true             # per-shard, not cluster-level
        serviceType: NodePort        # NodePort or LoadBalancer for outside reach
      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. Redisson connects to it exactly as it would to the StatefulSet cluster above.

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 dependency — org.redisson:redisson:4.7.0 or newer — and configure cluster mode:

Config config = new Config();
config.useClusterServers()
    // ONE headless Service DNS name. Not two. Not a ClusterIP. See below.
    .addNodeAddress("redis://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:
    - "redis://valkey-headless.data.svc.cluster.local:6379"
  scanInterval: 5000
  readMode: "SLAVE"
  subscriptionMode: "SLAVE"

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 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. It fails differently, and worse.

The mechanism is that a ClusterIP load-balances. Each scan tick lands on an arbitrary backend Pod with no affinity across ticks, and whichever Pod answers becomes the sole authority on topology for that tick. A Pod that has just been rescheduled and has not yet rejoined the cluster reports a partial view — some peers still in HANDSHAKE, some primaries with no slots assigned yet. Redisson's slot-change handler applies that partial 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.

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. A participant in issue #2034 — a six-node cluster throwing WriteRedisConnectionException and RedisTimeoutException after Pod kills — described the setup as a deliberate design: "We used kubernetes Service in front of them, which give us a stable IP address ... This Service provides load balancing. Clients call the service, and their requests are balanced across the redis instances that are members of the Service." That is exactly right for a stateless backend and exactly wrong for a sharded one.

The chain above is traced through parsePartitions() and checkSlotsChange() in Redisson 4.7.0 — it describes what the current code does with a load-balanced seed, rather than a maintainer-confirmed diagnosis of that specific report.

scanInterval, and a Documentation Error

The configuration documentation gives scanInterval a default of 1000 ms. The code has said 5000 for at least seven years, unchanged across every release from 3.10.5 to 4.7.0. 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

A GitHub issue search for kubernetes in the Redisson repository returns around seventy reports. The first two patterns below are heavily represented in them; the other two are configuration defaults that happen to bite under Kubernetes' restart behavior.

Stale Pod IPs and "Unable to unfreeze entry"

The most commonly reported one. 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

The reporter of #2034 put the diagnosis plainly: when Pods die and Kubernetes brings them back, "the nodes will have new ip addresses" while Redisson is "still looking at the old ip addresses". The same pattern appears in #5416 ("The older IP's are getting cached"), #5078, where a Sentinel-mode client logged it "forever until restart the application", and #2728. Several were fixed on the Sentinel path in 3.17.4 and 3.18.0. 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. Issue #6507 describes it exactly: "the Redis node hostname is announced in CLUSTER NODES ... this hostname is the pod's name. Pods cannot be reached directly by the hostname" — producing NXDOMAIN against every node.

Redisson 3.51.0 added 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:

masterConnectionMinimumIdleSize: 4
masterConnectionPoolSize: 32
slaveConnectionMinimumIdleSize: 4
slaveConnectionPoolSize: 32
subscriptionConnectionPoolSize: 25

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:

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 externally routable address. KubeBlocks' redis-advertised NodePort or LoadBalancer service does 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.
// These classes moved package in Redisson 4.0.0. The docs still say api.
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 package change breaks silently in YAML, where the fully-qualified class name is the type tag: on 4.x, !<org.redisson.api.HostPortNatMapper> will not load. It is a documented breaking change, but the configuration docs still list the old package in four places.

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. Both are worth understanding before you rely on RLock in a cluster you deploy weekly.

The Failover Hazard

Acquiring an RLock writes the lock entry to the primary. Replication is asynchronous, so a naive lock returns to the caller as soon as the primary accepts the write — before it has reached any replica. If the primary then fails over to a replica that never received the write, the new primary has no record of the lock and a second client can acquire it. Two clients then believe they hold the same lock. Redis's own documentation is blunt about this: "By doing so we can't implement our safety property of mutual exclusion, because Redis replication is asynchronous."

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. Since 3.12.5, an RLock's writes — acquisition, release, and watchdog renewal — are synchronized through WAIT. Read methods such as isLocked() are ordinary reads. Since 3.17.0 and 3.22.0 respectively, the behavior is governed by two settings:

checkLockSyncedSlaves: true    # default
slavesSyncTimeout: 1000        # default, milliseconds

Two things to know about what that actually guarantees. First, acquisition fails only when zero replicas acknowledge — with three replicas and one ack, the lock is kept. Second, WAIT does not make the store strongly consistent — Redis's documentation says so in those words, and adds that the failover machinery underneath it is "just a best-effort attempt so it is possible to still lose a write synchronously replicated to multiple replicas." The window narrows substantially. It does not close.

The Single-Server Trap

This is the sharpest Kubernetes-specific gotcha in this section. The replica-synchronization check is skipped entirely in single-server mode — Redisson's syncedEval falls through to an ordinary write when the config is a single-server config.

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

A lock acquired without an explicit lease time is kept alive by Redisson's watchdog, which renews it every lockWatchdogTimeout / 3 — by default every 10 seconds against a 30-second timeout. If the holder dies, renewal stops and the lock expires when its remaining TTL runs out.

Now put that next to Kubernetes' own default. terminationGracePeriodSeconds is also 30 seconds: SIGTERM, then 30 seconds, then SIGKILL. The two windows are the same length and they stack.

A Pod that is killed mid-critical-section leaves its lock held for up to 30 more seconds while the replacement Pod blocks on lock(). Worst case across a rolling restart, that is close to a minute of stall on a lock nobody holds.

Two things do not help as much as people expect. redisson.shutdown() does not release held locks — it stops the write-behind service and the connection manager, and the renewal task explicitly returns early once shutdown has begun, so the key simply lingers until its TTL expires. And terminationGracePeriodSeconds is not a JVM shutdown-hook budget: a finally-block unlock() only runs if the JVM reaches it before SIGKILL.

What does help is releasing locks explicitly on the shutdown path — a preStop hook or @PreDestroy that unlocks before shutdown() — and shortening lockWatchdogTimeout if orphan windows hurt more than renewal traffic. Each renewal is a synchronized write with a WAIT round-trip, so that trade is real.

When You Need a Fencing Token

Martin Kleppmann's distinction is the one to apply: if a lock failure means duplicated work, that is an efficiency lock and the machinery above is adequate. If it means corrupted data, it is a correctness lock and no lock service alone is sufficient, because the holder can be paused — by GC, by CPU throttling on a Pod at its limit — past its own lease and not know it.

The fix is a fencing token: a monotonically increasing number handed out with the lock, which the guarded resource checks and rejects if it has gone backwards. Redis's current documentation now recommends this directly. Redisson provides it as RFencedLock (since 3.19.0), whose lockAndGetToken() returns the token for the downstream service to validate.

Relatedly, RedissonRedLock is deprecated — it was deprecated in the same 3.12.5 release that introduced the WAIT-based synchronization, and it is superseded by RLock with replica checking and by RFencedLock. If you are carrying a Redlock implementation forward on the assumption that it is the safe option, it is not the current guidance.

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 StatefulSet replaces 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            # ~5 min before the other probes engage
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.

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:

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. Redisson separately marks a replica as failed after ongoing connection errors within a check interval that defaults to three minutes, 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 Service load-balances across backend Pods, so each topology scan lands on an arbitrary node and a Pod with an incomplete cluster view can cause Redisson to discard correct slot mappings. Cluster mode needs to address each shard individually, which requires per-node DNS — that is what clusterIP: None provides.

What Is the Default scanInterval in Redisson?

5000 milliseconds. Redisson's configuration documentation states 1000, but the source has used 5000 across every release checked from 3.10.5 through 4.7.0. 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. 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 explicitly in a finally block, or unlock from a preStop hook before shutting the client down.

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.

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 but does not operate one. 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.