Files
keycloak-pattern/docs/experiment-00-session-replication.md
T
DongHyeonkaandClaude Opus 5 e5ebaeb623 docs: prove sessions are shared by PostgreSQL, not Infinispan replication
Experiment 0 with three probes: cross-node refresh/logout, cache counter deltas around a single login, and cache entry ownership. Each node caches only what it handled; cache totals sum exactly to the database count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:58:10 +09:00

20 KiB

실험 0 — 한 노드에서 만든 세션이 다른 노드에서 쓰이는가

로드맵 A-0. 이후 모든 장애 실험의 기준선이다.


0. 결론부터

물음
한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가 그렇다
로그아웃이 반대 방향으로 전파되는가 그렇다
그 공유는 Infinispan 복제 덕분인가 아니다
그럼 무엇이 공유하는가 PostgreSQL

클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다. 로그에는 (2) [keycloak-0, keycloak-1]이 찍히고 JGROUPS_PING에도 둘 다 등록되어 있지만, 세션 엔트리는 노드 사이를 건너가지 않는다.

각 노드는 자기가 처리한 로그인만 캐시한다. 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다.


1. 왜 이 실험이 첫 번째인가

앞선 작업에서 Keycloak 2노드 클러스터를 세우고 ISPN000094로 멤버 2개를 확인했다. 거기서 멈추면 "클러스터가 떴다"까지만 아는 것이고, 그 위에서 장애를 주입해봐야 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.

기준선이 없으면 이런 잘못된 추론을 하게 된다.

7800을 막았더니 세션이 깨졌다 → 역시 세션은 7800으로 복제되는구나

실제로는 7800으로 세션이 오가지 않는다는 것을 먼저 알아야, 7800을 막았을 때 깨지는 것이 무엇인지 정확히 말할 수 있다.


2. 실험 설계에서 배운 것 세 가지

측정값보다 어떻게 측정할지에서 더 많이 틀렸다. 세 번 고쳤다.

2-1. 대조군 없는 측정은 해석할 수 없다

첫 판본은 이렇게 보고했다.

=== [4] keycloak-0 이 발급한 토큰을 keycloak-1 이 받는가 ===
  http_code=403

403을 "복제 실패"로 읽을 뻔했다. 발급 노드에도 같은 요청을 보내보니

--- userinfo, scope 없음 ---
  k0(발급노드) 403
  k1(반대편)   403
--- 403 본문 ---
WWW-Authenticate: Bearer realm="master", error="insufficient_scope",
                  error_description="Missing openid scope"

양쪽 다 403이었다. 원인은 복제가 아니라 요청에 openid scope가 없다는 것이었다. 오히려 두 노드가 똑같이 답했다는 사실 자체가 일치의 증거였다.

원칙 — 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지 아무 의미가 없다. 시험군만 재는 측정은 측정이 아니다.

2-2. 개수가 아니라 식별자로 추적한다

client-session-statsactive=2를 돌려줬다. 그런데 스크립트 자체가 로그인을 두 번 하고(시험용 + 관리 API 호출용) 있었다. 개수는 실험 도구가 만든 잡음에 그대로 오염된다.

바꾼 방식: 토큰의 sid를 뽑아, 각 노드의 세션 목록에 그 sid가 있는지를 본다. 개수가 몇이든 상관없다.

  keycloak-0 (발급 노드)       세션 2개 중 대상 sid → 보임 ✔
  keycloak-1 (반대편)         세션 2개 중 대상 sid → 보임 ✔

2-3. 세션 저장소를 실제로 건드리는 탐침을 골라야 한다

탐침 하는 일 적합한가
userinfo 서명 검증 + scope 확인 아니다. 세션을 몰라도 통과할 수 있다
refresh_token 그랜트 세션을 찾고, 살아있는지 보고, 갱신 시각을 쓴다 그렇다

refresh는 읽고 쓴다. 그래서 "저 노드가 이 세션을 정말로 아는가"에 답한다.

여기에 더해 refresh token은 회전(rotation) 된다 — 한 번 쓰면 옛 것이 무효가 된다. 따라서 반대편 노드에 먼저 써야 한다. 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이 사라진다. 대조군과 시험군의 순서가 강제된다.


3. 실험 0 — 교차 노드 세션 사용

실행

kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak    # 캐시를 비운다
./deploy/lab/scripts/experiment-session-replication.sh

nginx나 Traefik을 거치지 않고 파드 IP로 직접 말을 건다. 로드밸런서를 거치면 어느 노드가 처리했는지가 감춰지는데, 그게 바로 이 실험의 질문이다.

결과 — 01-cross-node-session.txt

### 사전 확인: 클러스터가 2 멤버로 형성되었는가
ISPN000094: Received new cluster view for channel ISPN:
  [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749, keycloak-0-30843]

       name       |       ip        | coord
------------------+-----------------+-------
 keycloak-1-48749 | 10.42.0.35:7800 | t
 keycloak-0-30843 | 10.42.1.43:7800 | f

=== 대상 ===
  keycloak-0  10.42.1.43     kc-lab-2
  keycloak-1  10.42.0.35     kc-lab-1

=== [1] keycloak-0 에서 로그인 ===
  sid          jiv3rVZi1VeaO07oVJkL_MYW
  iss          https://auth.hyeonworks.com/realms/master
  access 수명  60초
  refresh 수명 1800초   typ=Refresh

=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
  keycloak-0 (발급 노드)       세션 2개 중 대상 sid → 보임 ✔
  keycloak-1 (반대편)         세션 2개 중 대상 sid → 보임 ✔

=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
  HTTP 200   ← 기대대로
  새 토큰의 sid → 동일 ✔

=== [6] keycloak-1 을 통해 로그아웃 ===
  http_code=204

=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
  HTTP 400   ← 기대대로
  error             invalid_grant
  error_description Session not active

=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
  행 없음 — 로그아웃으로 삭제되었다

네 가지가 모두 기대대로다.

확인된 것
조회 같은 sid가 양쪽에서 보인다
쓰기 keycloak-0의 refresh token을 keycloak-1이 받아 갱신했고, sid가 유지된다
역방향 무효화 keycloak-1의 로그아웃이 keycloak-0의 갱신을 막았다
영속 로그아웃과 함께 DB 행이 사라졌다

sid는 JWT 안에만 있는 값이 아니다. PostgreSQL의 OFFLINE_USER_SESSION.user_session_id 컬럼에 문자 그대로 들어 있다.


4. 실험 0b — 복제인가, 같은 DB를 본 것인가

실험 0은 "두 노드가 같은 답을 한다"까지만 증명한다. 그것으로는 Infinispan이 복제했다고 말할 수 없다. persistent-user-sessions(Keycloak 26 기본값)에서는 세션이 PostgreSQL에 기록되므로, 캐시를 아예 꺼도 두 노드는 같은 답을 한다.

가르는 방법: 로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다.

결과 — 02-cache-delta.txt

=== 로그인은 keycloak-0 에만 보냈다 ===
  로그인 응답: http_code=200

=== keycloak-0 (로그인을 받은 노드) ===
  계수기                                        캐시                     전        후      증가
  approximate_entries_unique                 sessions               1        2      +1  ←
  stores                                     sessions               2        3      +1  ←
  misses                                     sessions               3        4      +1  ←
  rpc.replication_count                      sessions               1        1      +0

=== keycloak-1 (아무 요청도 받지 않은 노드) ===
  approximate_entries_unique                 sessions               0        0      +0
  stores                                     sessions               1        1      +0
  hits                                       sessions               4        4      +0
  rpc.replication_count                      sessions               7        7      +0

keycloak-1의 계수기가 하나도 움직이지 않았다. 엔트리도 0, 저장도 0.

그리고 keycloak-1의 sessions 캐시 엔트리는 처음부터 끝까지 0이다. keycloak-0이 세션을 9개 들고 있는 동안에도 0이었다.


5. 실험 0c — 엔트리는 어느 노드에 있는가

0b의 결과에는 두 가지 설명이 가능하다.

(a) 분산 캐시 + owners=1 일관 해싱으로 흩어지는데 이번 건이 우연히 keycloak-0에 떨어졌다
(b) 로컬 캐시 각 노드는 자기가 처리한 것만 캐시한다

반대편 노드에 로그인을 몰아주면 갈린다. (a)라면 어느 쪽에 요청하든 엔트리는 양쪽에 흩어진다. (b)라면 요청을 받은 노드에서만 는다.

결과 — 03-cache-ownership.txt

단계                             k0 entries   k1 entries
시작                                    2.0          0.0
keycloak-1 에 로그인 5회             2.0          5.0      ← k0 그대로, k1 만 +5
keycloak-0 에 로그인 5회             7.0          5.0      ← k0 만 +5, k1 그대로

=== 대조: PostgreSQL 에는 몇 건인가 ===
  online 세션 12                                            ← 7 + 5 = 12, 정확히 일치

(b)다. 그리고 7 + 5 = 12로 DB 총계와 정확히 맞는다 — 모든 세션이 DB에 있고, 각각은 자기를 만든 노드 한 곳에만 캐시되어 있다.

그래프로 본 같은 사실

세션 캐시 엔트리 수

vendor_statistics_approximate_entries_unique{cache="sessions"} — Grafana Explore.

파란 선(keycloak-1)이 0에 붙어 있는 동안 초록 선(keycloak-0)만 14까지 올라간다. 파란 선은 09:50, 즉 keycloak-1에 직접 로그인을 보낸 순간에만 5로 뛴다. 중간의 절벽은 캐시를 비우려고 파드를 재시작한 지점이다.

캐시 설정은 파일에서 읽을 수 없다. 파드의 /opt/keycloak/conf/cache-ispn.xml<cache-container name="keycloak"><transport/></cache-container> 뿐이고, Keycloak 26은 캐시를 코드에서 만든다. 그래서 위 결론은 설정을 읽어서가 아니라 동작을 측정해서 얻었다.


6. 그래서 무엇이 세션을 공유하는가

    로그인 (keycloak-0)
         │
         ├──▶ PostgreSQL OFFLINE_USER_SESSION        ← 진실의 원천. 양쪽이 본다
         │
         └──▶ keycloak-0 로컬 캐시                   ← 자기 것만. 건너가지 않는다

    keycloak-1 이 그 세션을 물으면
         │
         └──▶ 자기 캐시에 없음 → PostgreSQL 에서 읽는다
계층 역할 노드 간 공유
PostgreSQL 진실의 원천 여기서 일어난다
Infinispan sessions 자기 노드가 처리한 세션의 룩어사이드 캐시 일어나지 않는다
Infinispan 클러스터 무효화 메시지, work 캐시 등 형성은 되어 있다

이건 Keycloak 26의 의도된 설계다. persistent-user-sessions가 기본이 되면서 DB가 진실의 원천이 됐고, 세션 캐시는 복제할 이유가 없어졌다. 복제를 하면 네트워크와 메모리를 쓰면서 DB와 캐시 두 벌을 정합하게 유지해야 한다.


7. 개념

7-1. persistent-user-sessions

Keycloak 25에서 도입되고 26에서 기본값이 된 기능. 사용자 세션을 Infinispan에만 두지 않고 데이터베이스에 기록한다.

켜져 있을 때 (기본) 꺼져 있을 때 (volatile)
진실의 원천 PostgreSQL Infinispan
전체 재시작 후 세션이 남는다 전부 사라진다
노드 간 공유 DB가 한다 복제가 해야 한다
로그인당 비용 DB 쓰기 네트워크 복제

이 실험의 결론은 전부 "켜져 있을 때"의 이야기다. 끄면 다른 그림이 나오고, 그 비교가 로드맵 A-2다.

kubectl -n keycloak-lab exec keycloak-0 -- \
  /opt/keycloak/bin/kc.sh show-config 2>/dev/null | grep -i feature

7-2. 온라인 세션이 OFFLINE_ 테이블에 들어간다

USER_SESSION 테이블은 존재하지 않는다. 처음에 이걸 찾다가 없어서 당황했다.

 public | auth_session                  | table | keycloak
 public | jgroups_ping                  | table | keycloak
 public | offline_client_session        | table | keycloak
 public | offline_user_session          | table | keycloak
 public | revoked_token                 | table | keycloak
 public | root_auth_session             | table | keycloak

persistent-user-sessions기존 오프라인 세션 테이블을 재사용하고 offline_flag 컬럼으로 구분한다.

offline_flag 의미
'0' 온라인 세션 (일반 로그인)
'1' 오프라인 세션 (offline_access)

기본키가 (user_session_id, offline_flag) 복합키인 이유다 — 같은 세션 id가 온라인/오프라인 두 행으로 존재할 수 있다.

select offline_flag, count(*) from offline_user_session group by offline_flag;
select user_session_id, offline_flag, created_on, last_session_refresh
  from offline_user_session where user_session_id = '<sid>';

이름이 내용을 배신하는 스키마다. 운영에서 "온라인 세션이 DB 어디 있냐"를 찾을 때 이걸 모르면 한참 헤맨다.

7-3. sid — 토큰과 DB를 잇는 열쇠

JWT access_token 의 sid   jiv3rVZi1VeaO07oVJkL_MYW
                          ↕ 같은 값
DB user_session_id        jiv3rVZi1VeaO07oVJkL_MYW
                          ↕ 같은 값
Admin API 세션 목록의 id  jiv3rVZi1VeaO07oVJkL_MYW

세 곳에서 같은 문자열이다. 장애를 추적할 때 이 값 하나로 토큰·DB·관리 API를 꿰뚫을 수 있다. 백채널 로그아웃의 sid 클레임도 이것이다.

7-4. openid scope가 없으면 OIDC 토큰이 아니다

admin-cliscope 없이 direct grant를 하면 나오는 클레임은 이렇다.

--- access_token ---
  클레임: azp, exp, iat, iss, jti, scope, sid, typ
  typ = Bearer | sub = None | sid = OxikTqdHCJ7ESm2GPKI1oa6c

sub이 없다. OIDC가 아니라 순수 OAuth2 액세스 토큰이기 때문이다. sub은 OIDC가 요구하는 클레임이고, openid scope가 있어야 붙는다.

같은 이유로 userinfo가 403 insufficient_scope를 준다 — userinfo는 OIDC 엔드포인트다. 두 현상은 하나의 원인이다.

7-5. 룩어사이드(lookaside) 캐시

  읽기:  캐시 확인 → 없으면 DB → 캐시에 채움
  쓰기:  DB 에 쓰고 → 캐시에도 씀

캐시가 DB 앞에 서 있되 DB를 대체하지 않는 구조. 캐시를 통째로 날려도 정확성은 유지되고 느려지기만 한다. Keycloak 26의 세션 캐시가 이 모양이다.

이 성질이 노드 상실 실험(A-3)의 결과를 미리 결정한다 — 노드가 죽으면 그 노드의 캐시는 사라지지만 세션은 DB에 있으므로 살아남아야 한다.


8. 다음 실험에 대한 예측

기준선이 생겼으므로 틀릴 수 있는 예측을 세울 수 있다. 예측이 빗나가면 그것이야말로 배울 거리다.

실험 예측 근거
A-1 TCP 7800 차단 세션 공유는 안 깨진다. 대신 무효화 전파와 work 캐시가 깨진다 세션은 7800으로 오가지 않는다
A-2 DB 손실 즉시 전면 장애. 캐시에 있는 세션도 못 쓴다 DB가 진실의 원천
A-3 노드 상실 (kc-lab-2) 세션은 살아남는다. 죽은 노드의 캐시만 사라진다 룩어사이드
A-4 volatile 비교 7800 차단이 A-1과 정반대로 치명적이 된다 그때는 캐시가 진실의 원천

특히 A-1은 직관과 어긋나는 예측이다. "클러스터 포트를 막으면 세션이 깨진다"가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.


9. 겪은 함정

9-1. kubectl 스트림에서 출력이 통째로 사라졌다

kubectl run --rm -i ... | grep 로 받으면 중간 조각이 유실됐다. keycloak-1의 스냅샷과 그 다음 마커가 함께 없어져, 전값이 0으로 잡히면서 가짜 델타가 만들어졌다.

###BEFORE_K1        ← 여기 있어야 할 지표 20줄과
http_code=200          다음 마커 ###LOGIN 이 통째로 사라졌다
###AFTER_K0

이때 리포트는 keycloak-1이 +9, +7 증가한 것처럼 보였다. 없는 복제가 있는 것처럼 보이는, 가장 나쁜 종류의 오류다.

고친 방법
파드 안에서 파일로 모으고 마지막에 cat 한 번 스트리밍 중 유실을 없앤다
스냅샷이 비면 경고를 출력한다 조용히 0으로 계산되는 것을 막는다
for n in ('BEFORE_K0','BEFORE_K1','AFTER_K0','AFTER_K1'):
    if not blocks.get(n):
        print(f'  !! {n} 스냅샷이 비었다 — 델타를 신뢰할 수 없다')

계측 코드는 자기가 실패했는지 스스로 말해야 한다.

9-2. DB에서 직접 지우면 캐시는 남는다

정리하려고 delete from offline_user_session을 실행했더니, 캐시 엔트리는 그대로 남아 캐시 합계(19)와 DB 총계(15)가 어긋났다.

운영에서 세션 테이블을 직접 손대면 캐시와 DB가 갈라진다. 세션을 지울 때는 관리 API(logout-all)를 쓰거나, DB를 건드렸다면 파드를 재시작해야 한다.

이 실험의 최종 수치는 파드 재시작 후 다시 잰 것이다.

9-3. Keycloak 이미지에는 curl이 없다

kubectl exec keycloak-0 -- curl 은 실패한다. 임시 curlimages/curl 파드를 띄워 파드 네트워크 안에서 호출했다. 파드 IP는 클러스터 밖에서 닿지 않으므로 이 방법이 사실상 유일하다.

9-4. 중첩 셸의 변수 치환

ssh host '... $VAR ...' 안에 다시 sh -c "..." 를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨진다. 첫 시도에서 파드 IP가 빈 문자열이 되어 아무 출력도 나오지 않았다.

스크립트 파일로 만들어 scp 로 옮기는 쪽이 옳다. 재현도 되고 저장소에 남는다. deploy/lab/scripts/ 아래 세 스크립트가 그 결과다.


10. 재현

# 1. 깨끗한 상태로 되돌린다 (DB 비우고 캐시 비우기)
ssh test-server '
  kubectl -n keycloak-lab exec deploy/postgres -- \
    psql -U keycloak -d keycloak -c "delete from offline_user_session"
  kubectl -n keycloak-lab rollout restart statefulset/keycloak
  kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s'

# 2. 세 실험을 순서대로
ssh test-server '/tmp/experiment-session-replication.sh'      # 교차 노드 사용
ssh test-server '/tmp/experiment-cache-replication-delta.sh'  # 복제인가 DB인가
ssh test-server '/tmp/experiment-cache-ownership.sh'          # 엔트리 위치

# 3. 그래프
#    https://app2.hyeonworks.com/explore
#    vendor_statistics_approximate_entries_unique{cache="sessions"}
#    Legend: {{pod}} on {{node}}

확인용 명령 모음

# 클러스터 멤버
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab exec deploy/postgres -- \
  psql -U keycloak -d keycloak -c "select name, ip, coord from jgroups_ping"

# DB 세션
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
  -c "select offline_flag, count(*) from offline_user_session group by offline_flag"

# 노드별 캐시 엔트리 (파드 안에서)
curl -s http://<pod-ip>:9000/metrics \
  | grep 'approximate_entries_unique{cache="sessions"'