Files
keycloak-pattern/docs/keycloak-multinode-cluster.md
T

378 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 등 개념 |