feat: deploy Keycloak multi-node cluster with PostgreSQL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
001efd624a
commit
6dce35ec83
@@ -0,0 +1,277 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user