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

14 KiB
Raw Blame History

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

2-1. 왜 StatefulSet인가

Deployment를 쓰면 파드 이름이 keycloak-7d9f8b-x4k2p처럼 매번 바뀐다. StatefulSet은 keycloak-0, keycloak-1로 고정된다.

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가 아닌가

args: ["start"]

start-devcache=local을 강제한다. 클러스터가 아예 형성되지 않는다. 저장소의 docker-compose.ymlstart-dev를 쓰는 것은 단일 인스턴스 학습용이며, 이 실험대에서는 쓸 수 없다.

--optimized는 붙이지 않았다. 붙이려면 사전 build가 필요하고, 없으면 첫 기동에 암묵적 build가 실행되어 60~90초가 걸린다. 그래서 아래처럼 startupProbe를 넉넉하게 준다.

2-3. 노드당 하나씩 배치

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels: { app: keycloak }

두 파드가 한 노드에 몰리면 7800 차단 실험이 무의미해진다. 같은 커널 안의 루프백 통신이라 막을 대상이 없기 때문이다.

ScheduleAnyway를 고른 이유는 장애 실험 때문이다. DoNotSchedule이면 노드 하나를 죽였을 때 남은 파드가 배치되지 못하고 Pending에 머문다.

2-4. 헬스체크는 9000 포트다

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를 켜야 엔드포인트가 노출된다.

startupProbefailureThreshold: 60 × periodSeconds: 10 = 최대 10분을 기다린다. 첫 기동의 암묵적 build 때문이다. 이게 없으면 liveness가 먼저 발동해 재시작 루프에 빠진다.

2-5. 환경변수 — 첫 실험에서 확정한 값

- { 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에서 측정으로 확정한 조합이다.

설정 역할
KC_HOSTNAME전체 URL 스킴·호스트를 고정한다. 헤더와 무관하게 iss가 https로 발급된다
KC_HOSTNAME_STRICT=true Host 헤더를 믿지 않는다. 조작으로 흐름을 돌릴 여지를 없앤다
KC_PROXY_HEADERS=xforwarded 클라이언트 IP 등 나머지를 forwarded 헤더에서 가져온다
KC_HTTP_ENABLED=true 앞단이 TLS를 끊었으므로 평문 HTTP를 받는다

이 실험을 먼저 하지 않았다면 지금 isshttp://10.42.x.x로 나왔을 것이고, 원인을 세션 쪽에서 찾느라 헤맸을 것이다.

2-6. 힙 상한

- { 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 — 볼륨이 노드에 고정된다

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. 헤드리스 서비스는 왜 두는가

kind: Service
metadata: { name: keycloak-headless }
spec:
  clusterIP: None

jdbc-ping 디스커버리에는 필요 없다. DB로 서로를 찾기 때문이다. 개별 파드에 안정된 DNS 이름으로 접근해 상태를 조회하기 위해 둔다.


3. 실행한 명령

3-1. 브랜치와 정리

# 워크스테이션
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. 배포

# 워크스테이션 — 매니페스트 작성 후
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 없이 뜨면 기동에 실패한다.

kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s

이미지를 당겨오고 암묵적 build가 도는 첫 기동은 수 분 걸린다.

3-3. 검증

# 파드 배치 — 서로 다른 노드에 있어야 한다
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/

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_nameDB로 하는 디스커버리의 결과
  • ip 컬럼의 :7800실제 통신이 일어날 경로

coordtkeycloak-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 공식 이미지는 최소 구성이다. 메트릭을 볼 때는 포트포워딩하거나 임시 파드를 쓴다.

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 이 실험은 로드맵 1번
two-hop-proxy-header-contract.md KC_HOSTNAME·KC_PROXY_HEADERS 값의 근거
session-lab-operations.md 명령·자원 예산
session-lab-concepts.md StatefulSet·프로브·PVC 등 개념