# Keycloak multi-node cluster with PostgreSQL. # # Goal of this manifest: two Keycloak pods on two different nodes must discover # each other and form one Infinispan cluster. Keycloak 26 discovers peers through # the database (jdbc-ping) rather than multicast, writing to a JGROUPS_PING table, # but the cluster traffic itself runs over TCP 7800 between the pods. Those are # two separate mechanisms, which is why "registered in the DB but not clustered" # is a real failure mode — and one that a single node cannot reproduce. # # kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml # kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s # # Secrets are plain here. Proper secret handling is roadmap item 11; keeping it # visible for now is deliberate so the gap is obvious rather than forgotten. apiVersion: v1 kind: Namespace metadata: name: keycloak-lab --- apiVersion: v1 kind: Secret metadata: name: keycloak-lab-secrets namespace: keycloak-lab type: Opaque stringData: POSTGRES_PASSWORD: lab-postgres-change-me KC_BOOTSTRAP_ADMIN_PASSWORD: lab-admin-change-me --- # PostgreSQL. local-path binds the volume to whichever node the pod lands on, so # the database is effectively pinned to one node. That is not a flaw here: it is # what makes "the database node dies" a meaningful experiment later. apiVersion: v1 kind: PersistentVolumeClaim metadata: name: postgres-data namespace: keycloak-lab spec: accessModes: [ReadWriteOnce] storageClassName: local-path resources: requests: storage: 5Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: postgres namespace: keycloak-lab spec: replicas: 1 strategy: type: Recreate # RWO volume cannot be mounted by two pods at once selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine ports: - containerPort: 5432 name: postgres env: - name: POSTGRES_DB value: keycloak - name: POSTGRES_USER value: keycloak - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: keycloak-lab-secrets key: POSTGRES_PASSWORD # The image refuses to initialise into a non-empty mount, and # local-path volumes are clean, but this keeps the data one level # down so a lost+found or similar never blocks initdb. - name: PGDATA value: /var/lib/postgresql/data/pgdata volumeMounts: - name: data mountPath: /var/lib/postgresql/data readinessProbe: exec: command: ["sh", "-c", "pg_isready -U keycloak -d keycloak"] initialDelaySeconds: 10 periodSeconds: 5 resources: requests: memory: 192Mi cpu: 50m limits: memory: 512Mi volumes: - name: data persistentVolumeClaim: claimName: postgres-data --- apiVersion: v1 kind: Service metadata: name: postgres namespace: keycloak-lab spec: selector: app: postgres ports: - port: 5432 targetPort: postgres --- # Keycloak. A StatefulSet rather than a Deployment so each pod keeps a stable # name (keycloak-0, keycloak-1); cluster membership is far easier to read in # logs and in the JGROUPS_PING table when the identities do not churn. apiVersion: apps/v1 kind: StatefulSet metadata: name: keycloak namespace: keycloak-lab spec: serviceName: keycloak-headless replicas: 2 podManagementPolicy: Parallel # both pods start together, so they race to # register — which is the interesting case selector: matchLabels: app: keycloak template: metadata: labels: app: keycloak spec: # One pod per node. Two pods on one node would share a kernel and make the # 7800 blocking experiment meaningless. topologySpreadConstraints: - maxSkew: 1 topologyKey: kubernetes.io/hostname whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: app: keycloak containers: - name: keycloak image: quay.io/keycloak/keycloak:26.7.0 # "start", not "start-dev". Dev mode forces cache=local and there is # no cluster to form at all. args: ["start"] ports: - containerPort: 8080 name: http - containerPort: 9000 name: management - containerPort: 7800 name: jgroups env: - name: KC_DB value: postgres - name: KC_DB_URL value: jdbc:postgresql://postgres:5432/keycloak - name: KC_DB_USERNAME value: keycloak - name: KC_DB_PASSWORD valueFrom: secretKeyRef: name: keycloak-lab-secrets key: POSTGRES_PASSWORD # Settings confirmed by the two-hop header measurement. # KC_HOSTNAME carries the full external URL, which pins scheme and # host for issuer and redirect URLs regardless of headers. # KC_PROXY_HEADERS is the separate opt-in that lets the forwarded # client address through — the same kind of switch as Spring's # forward-headers-strategy. See docs/two-hop-proxy-header-contract.md. - name: KC_HOSTNAME value: https://auth.hyeonworks.com - name: KC_HOSTNAME_STRICT value: "true" - name: KC_PROXY_HEADERS value: xforwarded - name: KC_HTTP_ENABLED value: "true" - name: KC_HEALTH_ENABLED value: "true" - name: KC_METRICS_ENABLED value: "true" # Without an explicit cap the JVM sizes its heap from the container # limit and this lab has roughly 3.8GB of guest headroom in total. - name: JAVA_OPTS_KC_HEAP value: "-Xms256m -Xmx512m" - name: KC_BOOTSTRAP_ADMIN_USERNAME value: admin - name: KC_BOOTSTRAP_ADMIN_PASSWORD valueFrom: secretKeyRef: name: keycloak-lab-secrets key: KC_BOOTSTRAP_ADMIN_PASSWORD # Keycloak serves health and metrics on the management port (9000), # not on 8080, since version 25. startupProbe: httpGet: path: /health/started port: management periodSeconds: 10 failureThreshold: 60 # first boot runs an implicit build readinessProbe: httpGet: path: /health/ready port: management periodSeconds: 10 livenessProbe: httpGet: path: /health/live port: management periodSeconds: 30 resources: requests: memory: 640Mi cpu: 100m limits: memory: 900Mi --- # Headless service. Not required for jdbc-ping discovery, which goes through the # database, but it gives each pod a stable DNS name for direct inspection. apiVersion: v1 kind: Service metadata: name: keycloak-headless namespace: keycloak-lab spec: clusterIP: None selector: app: keycloak ports: - port: 8080 targetPort: http name: http - port: 9000 targetPort: management name: management --- apiVersion: v1 kind: Service metadata: name: keycloak namespace: keycloak-lab spec: selector: app: keycloak ports: - port: 8080 targetPort: http name: http --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: keycloak namespace: keycloak-lab spec: ingressClassName: traefik rules: - host: auth.hyeonworks.com http: paths: - path: / pathType: Prefix backend: service: name: keycloak port: number: 8080