From 33878e8880ecce905d20e34aa579d3f34601197a Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 3 Sep 2026 17:26:45 +0900 Subject: [PATCH] docs: record multi-node cluster setup, rationale and formation evidence Co-Authored-By: Claude Opus 5 --- .../01-cluster-formed.txt | 56 +++ .../keycloak-multinode-cluster/README.md | 62 +++ docs/keycloak-multinode-cluster.md | 377 ++++++++++++++++++ 3 files changed, 495 insertions(+) create mode 100644 docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt create mode 100644 docs/evidence/keycloak-multinode-cluster/README.md create mode 100644 docs/keycloak-multinode-cluster.md diff --git a/docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt b/docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt new file mode 100644 index 0000000..7ccb359 --- /dev/null +++ b/docs/evidence/keycloak-multinode-cluster/01-cluster-formed.txt @@ -0,0 +1,56 @@ +수집 시각: 2026-09-03 17:24:54 KST +대상: Keycloak 26.7.0 × 2 + PostgreSQL 16, k3s 2노드 + +=== [1] 파드 배치 === + keycloak-0 1/1 10.42.1.18 kc-lab-2 + keycloak-1 1/1 10.42.0.16 kc-lab-1 + postgres-7b474b88c8-bw7b8 1/1 10.42.1.19 kc-lab-2 + +=== [2] 클러스터 뷰 로그 (Infinispan) === +-- keycloak-0 -- + 2026-09-03 08:18:23,359 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)] + 2026-09-03 08:18:23,433 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`, physical addresses are `[10.42.1.18:7800]` +-- keycloak-1 -- + 2026-09-03 08:18:23,269 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)] + 2026-09-03 08:18:23,282 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster + 2026-09-03 08:18:23,286 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster + +=== [3] JGROUPS_PING 테이블 구조 === + Table "public.jgroups_ping" + Column | Type | Collation | Nullable | Default + ----------------+------------------------+-----------+----------+--------- + address | character varying(200) | | not null | + name | character varying(200) | | | + cluster_name | character varying(200) | | not null | + ip | character varying(200) | | not null | + coord | boolean | | | + last_update | bigint | | | + coordinated_by | character varying(200) | | | + Indexes: + "constraint_jgroups_ping" PRIMARY KEY, btree (address) + + +=== [4] JGROUPS_PING 등록 내역 === + name | cluster_name | ip | coord + ------------------+--------------+-----------------+------- + keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f + keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t + (2 rows) + + +=== [5] 외부 접근 — OIDC discovery === + issuer https://auth.hyeonworks.com/realms/master + authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth + token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token + end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout + jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs + + ★ 전부 https. 첫 실험에서 확정한 KC_HOSTNAME + KC_PROXY_HEADERS 조합이 작동한다. + +=== [6] 자원 사용 === + keycloak-0 8m 594Mi + keycloak-1 9m 593Mi + postgres-7b474b88c8-bw7b8 3m 67Mi + --- 노드 --- + kc-lab-1 2248Mi (65%) + kc-lab-2 1447Mi (58%) diff --git a/docs/evidence/keycloak-multinode-cluster/README.md b/docs/evidence/keycloak-multinode-cluster/README.md new file mode 100644 index 0000000..a915eee --- /dev/null +++ b/docs/evidence/keycloak-multinode-cluster/README.md @@ -0,0 +1,62 @@ +# 증거 — Keycloak 멀티노드 클러스터 형성 + +`docs/keycloak-multinode-cluster.md`의 근거 자료. +**정상적으로 클러스터가 형성된 상태**에서 수집했으며, 이후 고장을 주입한 +뒤 이것과 대조한다. + +수집 시각: 2026-09-03 17:24 KST + +| 파일 | 내용 | +|---|---| +| `01-cluster-formed.txt` | 파드 배치·클러스터 뷰 로그·JGROUPS_PING·OIDC discovery·자원 | + +## 이 상태에서 확인된 것 + +**클러스터 뷰가 멤버 2를 보고한다** + +``` +ISPN000094: Received new cluster view for channel ISPN: + [keycloak-1-26938|1] (2) [keycloak-1-26938, keycloak-0-49501] +ISPN100000: Node keycloak-0-49501 joined the cluster +ISPN000079: physical addresses are [10.42.1.18:7800] +``` + +**디스커버리와 통신 경로가 한 테이블에 다 보인다** + +``` + name | cluster_name | ip | coord +------------------+--------------+-----------------+------- + keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f + keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t +``` + +`name`/`cluster_name`은 **DB 디스커버리**의 결과이고, `ip`의 `:7800`은 +**실제 통신 경로**다. 7800을 막으면 이 표는 그대로 채워지면서 클러스터 뷰만 +깨질 것으로 예상한다 — 다음 실험의 가설이다. + +`coord = t` 인 `keycloak-1`이 코디네이터다. + +**배치** — 서로 다른 노드에 하나씩. PostgreSQL은 `kc-lab-2`에 있으므로 +**그 노드를 죽이면 Keycloak 하나와 DB가 동시에 사라진다.** + +``` +keycloak-0 10.42.1.18 kc-lab-2 +keycloak-1 10.42.0.16 kc-lab-1 +postgres 10.42.1.19 kc-lab-2 +``` + +**issuer가 https로 발급된다** — 첫 실험(2홉 헤더 계약)의 결론이 적용된 결과다. + +## 재수집 + +```bash +kubectl -n keycloak-lab get pods -o wide +kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000' + +PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1) +kubectl -n keycloak-lab exec "$PG" -- \ + psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;" + +curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool +kubectl -n keycloak-lab top pods +``` diff --git a/docs/keycloak-multinode-cluster.md b/docs/keycloak-multinode-cluster.md new file mode 100644 index 0000000..d49dd13 --- /dev/null +++ b/docs/keycloak-multinode-cluster.md @@ -0,0 +1,377 @@ +# Keycloak 멀티노드 클러스터 — 구성과 형성 확인 + +로드맵 1번. 세션 저장소 실험 전부의 선행 인프라다. +브랜치 `feature/keycloak-multinode-cluster-jdbc-ping`. + +**결과 — 두 파드가 서로 다른 노드에서 하나의 Infinispan 클러스터를 이뤘다.** + +--- + +## 1. 무엇을 확인하려는가 + +Keycloak 26은 **디스커버리와 클러스터 통신을 서로 다른 경로로** 처리한다. + +| 단계 | 경로 | 실패하면 | +|---|---|---| +| **디스커버리** — 서로를 찾는다 | PostgreSQL의 `JGROUPS_PING` 테이블 | 상대의 존재 자체를 모른다 | +| **클러스터 통신** — 실제로 대화한다 | **TCP 7800** (파드 간 직접) | **DB에는 등록되는데 클러스터가 안 붙는다** | + +두 번째 줄이 이 실험대를 2노드로 만든 이유다. **단일 노드에서는 이 고장을 +재현할 수 없다** — 같은 커널 안에서는 막을 경계가 없기 때문이다. + +먼저 **정상적으로 붙는 상태**를 확보하고 실측값을 남긴다. 그래야 다음 실험에서 +깨뜨렸을 때 무엇이 달라졌는지 비교할 수 있다. + +--- + +## 2. 배포한 구성과 그 근거 + +매니페스트: [`deploy/lab/k8s/keycloak-cluster.yaml`](../deploy/lab/k8s/keycloak-cluster.yaml) + +### 2-1. 왜 StatefulSet인가 + +Deployment를 쓰면 파드 이름이 `keycloak-7d9f8b-x4k2p`처럼 매번 바뀐다. +StatefulSet은 **`keycloak-0`, `keycloak-1`로 고정**된다. + +```yaml +kind: StatefulSet +spec: + serviceName: keycloak-headless + replicas: 2 + podManagementPolicy: Parallel +``` + +**이 실험에서 이름 안정성이 중요한 이유** — 클러스터 멤버십을 읽는 곳이 두 +군데인데(Infinispan 로그, `JGROUPS_PING` 테이블) 이름이 계속 바뀌면 대조가 +어렵다. 실제로 Infinispan은 `keycloak-0-49501`처럼 **파드 이름 + 랜덤 접미사**를 +노드 식별자로 쓴다. + +**`podManagementPolicy: Parallel`** — 기본값 `OrderedReady`는 0번이 Ready가 된 +뒤에야 1번을 만든다. `Parallel`은 **동시에 시작**하므로 두 파드가 DB에 등록을 +경쟁하게 되고, 그것이 운영에서 실제로 일어나는 상황이다. + +### 2-2. 왜 `start`이고 `start-dev`가 아닌가 + +```yaml +args: ["start"] +``` + +`start-dev`는 **`cache=local`을 강제**한다. 클러스터가 아예 형성되지 않는다. +저장소의 `docker-compose.yml`이 `start-dev`를 쓰는 것은 단일 인스턴스 학습용이며, +이 실험대에서는 쓸 수 없다. + +`--optimized`는 붙이지 않았다. 붙이려면 사전 `build`가 필요하고, 없으면 +첫 기동에 **암묵적 build가 실행되어 60~90초**가 걸린다. 그래서 아래처럼 +`startupProbe`를 넉넉하게 준다. + +### 2-3. 노드당 하나씩 배치 + +```yaml +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: { app: keycloak } +``` + +**두 파드가 한 노드에 몰리면 7800 차단 실험이 무의미해진다.** 같은 커널 안의 +루프백 통신이라 막을 대상이 없기 때문이다. + +`ScheduleAnyway`를 고른 이유는 장애 실험 때문이다. `DoNotSchedule`이면 노드 +하나를 죽였을 때 남은 파드가 **배치되지 못하고 Pending에 머문다.** + +### 2-4. 헬스체크는 9000 포트다 + +```yaml +ports: + - { containerPort: 8080, name: http } + - { containerPort: 9000, name: management } + - { containerPort: 7800, name: jgroups } + +startupProbe: { httpGet: { path: /health/started, port: management }, failureThreshold: 60 } +readinessProbe:{ httpGet: { path: /health/ready, port: management } } +livenessProbe: { httpGet: { path: /health/live, port: management } } +``` + +**Keycloak 25부터 health와 metrics가 8080이 아니라 관리 포트 9000으로 옮겨졌다.** +8080으로 프로브를 걸면 404가 나고 파드가 영원히 Ready가 되지 않는다. + +`KC_HEALTH_ENABLED=true`를 켜야 엔드포인트가 노출된다. + +`startupProbe`의 `failureThreshold: 60` × `periodSeconds: 10` = **최대 10분**을 +기다린다. 첫 기동의 암묵적 build 때문이다. 이게 없으면 liveness가 먼저 발동해 +**재시작 루프**에 빠진다. + +### 2-5. 환경변수 — 첫 실험에서 확정한 값 + +```yaml +- { 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" } +``` + +[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md)에서 +측정으로 확정한 조합이다. + +| 설정 | 역할 | +|---|---| +| `KC_HOSTNAME`에 **전체 URL** | 스킴·호스트를 **고정**한다. 헤더와 무관하게 `iss`가 https로 발급된다 | +| `KC_HOSTNAME_STRICT=true` | Host 헤더를 믿지 않는다. 조작으로 흐름을 돌릴 여지를 없앤다 | +| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 forwarded 헤더에서 가져온다 | +| `KC_HTTP_ENABLED=true` | 앞단이 TLS를 끊었으므로 평문 HTTP를 받는다 | + +**이 실험을 먼저 하지 않았다면** 지금 `iss`가 `http://10.42.x.x`로 나왔을 것이고, +원인을 세션 쪽에서 찾느라 헤맸을 것이다. + +### 2-6. 힙 상한 + +```yaml +- { name: JAVA_OPTS_KC_HEAP, value: "-Xms256m -Xmx512m" } +resources: + requests: { memory: 640Mi, cpu: 100m } + limits: { memory: 900Mi } +``` + +Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다. 이 실험대의 게스트 여유가 +약 3.8GB이므로 명시적으로 잡는다. 실측 결과 **파드당 약 590Mi**로 안정됐다. + +### 2-7. PostgreSQL — 볼륨이 노드에 고정된다 + +```yaml +storageClassName: local-path +strategy: + type: Recreate +env: + - { name: PGDATA, value: /var/lib/postgresql/data/pgdata } +``` + +k3s 기본 `local-path` 프로비저너는 **파드가 배치된 노드의 로컬 디스크**에 +볼륨을 만든다. 따라서 PostgreSQL은 그 노드에 묶인다. + +**이것은 결함이 아니라 실험 조건이다.** 나중에 "데이터베이스가 있는 노드가 +죽으면" 시나리오가 그래서 의미를 갖는다. + +- `strategy: Recreate` — RWO 볼륨은 두 파드가 동시에 마운트할 수 없다. + 기본값 `RollingUpdate`면 새 파드가 볼륨을 못 잡고 멈춘다 +- `PGDATA`를 한 단계 아래로 — 마운트 지점에 `lost+found` 같은 것이 있으면 + `initdb`가 거부한다 + +### 2-8. 헤드리스 서비스는 왜 두는가 + +```yaml +kind: Service +metadata: { name: keycloak-headless } +spec: + clusterIP: None +``` + +**jdbc-ping 디스커버리에는 필요 없다.** DB로 서로를 찾기 때문이다. +개별 파드에 안정된 DNS 이름으로 접근해 상태를 조회하기 위해 둔다. + +--- + +## 3. 실행한 명령 + +### 3-1. 브랜치와 정리 + +```bash +# 워크스테이션 +cd ~/workspace/keycloak-pattern +git checkout -b feature/keycloak-multinode-cluster-jdbc-ping +git merge --no-edit develop-keycloak-session-store + +# lab host — 끝난 실험을 지워 메모리를 회수한다 +kubectl delete ns header-lab +``` + +정리 후 게스트 사용량이 `kc-lab-1 1593Mi(46%)` / `kc-lab-2 872Mi(35%)`로 떨어졌다. + +### 3-2. 배포 + +```bash +# 워크스테이션 — 매니페스트 작성 후 +git add deploy/lab/k8s/keycloak-cluster.yaml +git commit -m "feat: deploy Keycloak multi-node cluster with PostgreSQL" +git push -u origin feature/keycloak-multinode-cluster-jdbc-ping + +# lab host +cd ~/workspace/keycloak-pattern +git fetch origin +git checkout -b feature/keycloak-multinode-cluster-jdbc-ping origin/feature/keycloak-multinode-cluster-jdbc-ping +kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml +``` + +**PostgreSQL을 먼저 기다린다.** Keycloak이 DB 없이 뜨면 기동에 실패한다. + +```bash +kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s +kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s +``` + +이미지를 당겨오고 암묵적 build가 도는 첫 기동은 **수 분** 걸린다. + +### 3-3. 검증 + +```bash +# 파드 배치 — 서로 다른 노드에 있어야 한다 +kubectl -n keycloak-lab get pods -o wide + +# 클러스터 뷰 — Infinispan 로그 +kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000' + +# 디스커버리 테이블 +PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1) +kubectl -n keycloak-lab exec "$PG" -- \ + psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;" + +# 외부 접근과 issuer +curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool + +# 자원 +kubectl -n keycloak-lab top pods +``` + +--- + +## 4. 확인된 사실 + +증거 원자료: [`evidence/keycloak-multinode-cluster/`](evidence/keycloak-multinode-cluster/) + +### 4-1. 클러스터가 형성됐다 + +``` +ISPN000094: Received new cluster view for channel ISPN: + [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501] + ↑ 멤버 수 +ISPN100000: Node keycloak-0-49501 joined the cluster +ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`, + physical addresses are `[10.42.1.18:7800]` +``` + +두 파드가 **동일한 뷰**를 보고 있고, 물리 주소가 **7800**임이 로그에 찍힌다. + +### 4-2. 디스커버리와 통신이 분리되어 있다 + +``` + name | cluster_name | ip | coord +------------------+--------------+-----------------+------- + keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f + keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t +``` + +**테이블 하나에 두 메커니즘이 다 보인다.** + +- `name`·`cluster_name` — **DB로 하는 디스커버리**의 결과 +- `ip` 컬럼의 `:7800` — **실제 통신이 일어날 경로** + +`coord`가 `t`인 `keycloak-1`이 코디네이터다. 이 노드를 죽였을 때 인계가 +일어나는지가 다음 실험 항목이다. + +전체 스키마는 `address / name / cluster_name / ip / coord / last_update / +coordinated_by`이며 기본키는 `address`다. + +### 4-3. 노드당 하나씩 배치됐다 + +``` +keycloak-0 10.42.1.18 kc-lab-2 +keycloak-1 10.42.0.16 kc-lab-1 +postgres 10.42.1.19 kc-lab-2 +``` + +파드 IP 대역이 노드를 알려준다(`10.42.0.x` = kc-lab-1, `10.42.1.x` = kc-lab-2). +**독립된 커널 두 개에 하나씩** 떴으므로 7800 차단 실험의 전제가 성립한다. + +PostgreSQL이 `kc-lab-2`에 있다는 점도 기록해둔다. **`kc-lab-2`를 죽이면 +Keycloak 하나와 데이터베이스가 동시에 사라진다.** + +### 4-4. 2홉 헤더 계약이 실제로 작동한다 + +``` +issuer https://auth.hyeonworks.com/realms/master +authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth +token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token +end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout +jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs +``` + +**전부 `https`이고 외부 호스트명이다.** 첫 실험의 결론이 그대로 값을 했다. + +### 4-5. 자원 + +``` +keycloak-0 594Mi +keycloak-1 593Mi +postgres 67Mi +────────────────────── +kc-lab-1 2248Mi (65%) +kc-lab-2 1447Mi (58%) +``` + +예상(파드당 700Mi)보다 적다. `JAVA_OPTS_KC_HEAP` 제한이 작동했다. +BFF와 Redis를 추가할 여유가 남아 있다. + +--- + +## 5. 겪은 함정 + +### `JGROUPS_PING` 컬럼명은 자료마다 다르다 + +오래된 문서에는 `own_addr`, `ping_data` 같은 이름이 나오지만 **Keycloak 26의 +실제 스키마는 다르다.** + +``` +address / name / cluster_name / ip / coord / last_update / coordinated_by +``` + +쿼리 전에 `\d jgroups_ping`으로 확인한다. + +### Keycloak 컨테이너에 `curl`이 없다 + +메트릭을 파드 안에서 조회하려다 실패했다. + +``` +sh: line 1: curl: command not found +``` + +Keycloak 공식 이미지는 최소 구성이다. 메트릭을 볼 때는 포트포워딩하거나 +임시 파드를 쓴다. + +```bash +kubectl -n keycloak-lab port-forward keycloak-0 9000:9000 & +curl -s localhost:9000/metrics | grep -i cluster + +# 또는 +kubectl -n keycloak-lab run m --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \ + curl -s http://keycloak-0.keycloak-headless:9000/metrics +``` + +### 첫 기동이 느린 것은 정상이다 + +`--optimized` 없이 `start`하면 **암묵적 build**가 실행된다. `startupProbe`를 +넉넉히 주지 않으면 liveness가 먼저 발동해 재시작 루프에 빠진다. + +--- + +## 6. 다음 실험 — 깨뜨려서 무엇이 보이는지 + +정상 상태를 확보했으므로 이제 의도적으로 고장을 만든다. + +| 실험 | 방법 | 확인할 것 | +|---|---|---| +| **7800 차단** | NetworkPolicy로 파드 간 7800만 차단 | **DB엔 등록되는데 클러스터가 안 붙는** 증상. 로그에 무엇이 먼저 보이는가 | +| **노드 상실** | `virsh destroy kc-lab-2` | 코디네이터 인계가 일어나는가. PostgreSQL도 같이 죽는다는 점에 유의 | +| **DB 상실** | postgres 파드 정지 | 이미 형성된 클러스터는 버티는가. 새 로그인은? | + +**7800 차단부터 하는 것이 좋다.** 되돌리기가 가장 쉽고(NetworkPolicy 삭제), +증상이 로그에 선명하게 남는다. + +## 참고 + +| 문서 | 관계 | +|---|---| +| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 실험은 로드맵 1번 | +| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `KC_HOSTNAME`·`KC_PROXY_HEADERS` 값의 근거 | +| [`session-lab-operations.md`](session-lab-operations.md) | 명령·자원 예산 | +| [`session-lab-concepts.md`](session-lab-concepts.md) | StatefulSet·프로브·PVC 등 개념 |