The Keycloak Upgrade Ledger

Every upgrade we have rehearsed, with the environment stated and the clock running.

2026-08-26 · run 2026-08-26-k8s-infinispan-rolling-update

Kubernetes / Infinispan: the cache cluster partitions during a rolling update

Upgrade
26.0.0 → 26.7.1
Scale
master realm + lab realm (1 user)
Database
PostgreSQL 16, single pod (StatefulSet), in-cluster
Topology
Deployment replicas: 2, strategy.maxSurge=1, maxUnavailable=0
Host
Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 32 GB RAM, 240 GB NVMe
Condition
Kubernetes and Infinispan

Summary

First Kubernetes run of the lab. A 2-replica Keycloak Deployment on k3d with embedded Infinispan was rolled 26.0.0 → 26.7.1 under maxUnavailable=0. Four findings:

  1. The cache stack changed inside major 26. 26.0.0 hardcodes stack="udp" (multicast) in cache-ispn.xml, which cannot cluster in Kubernetes; 26.7.1 drops the hardcode and defaults to jdbc-ping (database discovery). Asking 26.0.0 for jdbc-ping fails with ISPN000540: No such JGroups stack 'jdbc-ping'. The kubernetes stack (DNS_PING) works on both but is deprecated in 26.7.1 — it is what this run used, so the version was the only variable.

  2. Embedded Infinispan clusters and shares sessions across pods (26.0.0): with KC_CACHE_STACK=kubernetes + -Djgroups.dns.query=<headless FQDN>, two pods form a 2-member cluster, and a refresh token issued by pod A is accepted by pod B.

  3. The schema migration is not what breaks a rolling update. During the rollout the new 26.7.1 pod migrated the shared Postgres under the two still- serving 26.0.0 pods, and the old pods logged no schema errors — the 26.0.0 → 26.7.1 changesets are additive. migration_model reached 26.7.1 and the rollout completed cleanly.

  4. The cache cluster partitions during the rollout (the finding). Infinispan 15.0.8 (26.0.0) and 16.0.12 (26.7.1) — JGroups 5.3.10 vs 5.5.5 — refuse to form a mixed cluster. The TCP handshake between an old and a new pod fails with JGRP000006 ... cookie sent by ... does not match own cookie; terminating connection. Old pods stay in their 2-member cluster; each new pod starts a 1-member cluster; they only merge once the old pods are gone. Sessions are therefore not shared between old and new pods during the transition — an in-flight user session lands on whichever pod owns it, and a request routed to the other side of the partition finds nothing.

Environment

FieldValue
Keycloak from → to26.0.0 → 26.7.1
Distribution / start modequay.io/keycloak/keycloak official image, start w/ external DB
DatabasePostgreSQL 16, single pod (StatefulSet), in-cluster
Cacheembedded Infinispan (KC_CACHE=ispn), KC_CACHE_STACK=kubernetes (DNS_PING)
Clusterk3d kclab (k3s v1.35.5), 1 server node
TopologyDeployment replicas: 2, strategy.maxSurge=1, maxUnavailable=0
HostHetzner CCX33 (fsn1) — 8 vCPU dedicated, 32 GB RAM, 240 GB NVMe
JVM heap-Xms256m -Xmx1g
Datasetmaster realm + lab realm (1 user)
Seedingadmin REST API (create realm + user)

Procedure

# on the box: k3d + kubectl (NOT installed on the laptop)
k3d cluster create kclab --k3s-arg --disable=traefik@server:* --k3s-arg --disable=servicelb@server:*
kubectl apply -f lab/k8s/                 # namespace, postgres StatefulSet, keycloak Deployment

# seed, then rolling update
kubectl -n keycloak set image deploy/keycloak keycloak=quay.io/keycloak/keycloak:26.7.1
kubectl -n keycloak rollout status deploy/keycloak --timeout=180s

Manifests are kept with the lab's Kubernetes fixtures. The initial 26.0.0 run failed with ISPN000540: No such JGroups stack 'jdbc-ping' — the stack 26.7.1 defaults to did not exist in 26.0.0, which is finding #1.

Results

Timeline of the rolling update (from pod logs)

tevent
19:31:34first new 26.7.1 pod starts, forms a 1-member cluster (v=16.0.12)
19:31:44old 26.0.0 pods still in their 2-member cluster (v=15.0.8)
19:32:13–14repeated JGRP000006 ... cookie ... does not match own cookie between old and new pods
19:32:15second new pod starts, also a 1-member cluster
19:32:36the two new pods form a 2-member cluster (v=16.0.12) — old pods gone
endmigration_model 26.7.1, both pods ready, cross-pod session sharing restored

The partition, in the log

WARN [org.jgroups.protocols.TCP] JGRP000006: 10.42.0.13:7800: failed accepting
  connection from peer Socket[addr=/10.42.0.15 ...]:
  BaseServer.TcpConnection.readPeerAddress(): cookie sent by ... does not match
  own cookie; terminating connection

The old cluster (JGroups 5.3.10) and the new pod (JGroups 5.5.5) never merge. DNS_PING hands each new pod the old pods' addresses, the handshake fails, and the new pod continues as a cluster of one until the old pods are terminated.

What this teaches

What is still not tested

Verification

ClaimEvidence
26.0.0 lacks jdbc-pingISPN000540: No such JGroups stack 'jdbc-ping'
2 pods form a cluster and share sessions (26.0.0)ISPN000094 ... (2) [...], cross-pod refresh OK ☑
Rolling update completes, no old-pod schema errorsmigration_model 26.7.1, no PSQLException in old logs ☑
Cache partitions during the rolloutJGRP000006 cookie ... does not match
Cluster heals after rolloutfinal view (2) [... v=16.0.12 ...], cross-pod refresh OK ☑

← All runs