diff --git a/deploy/lab/k8s/keycloak-cluster.yaml b/deploy/lab/k8s/keycloak-cluster.yaml new file mode 100644 index 0000000..591800b --- /dev/null +++ b/deploy/lab/k8s/keycloak-cluster.yaml @@ -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