Files
keycloak-pattern/docs/guides/experiments/a0-session-replication.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

1262 lines
53 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.
# A-0 재현 가이드 — 세션을 공유하는 것이 Infinispan 인지 PostgreSQL 인지 직접 가른다
해설 문서: [`docs/experiment-00-session-replication.md`](../../experiment-00-session-replication.md) ·
증거 원문: [`docs/evidence/session-replication/`](../../evidence/session-replication/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 한 노드에서 만든 세션을 반대편이 갱신하는 것 | 상주 curl 파드 |
| 반대편에서 로그아웃하면 원래 노드가 `400` 을 주는 것 | 같은 파드 |
| 로그인을 받은 노드의 캐시만 늘고 **반대편은 `+0`** 인 것 | Prometheus |
| 캐시 합계와 DB 총계가 정확히 맞는 것 (`7 + 5 = 12`) | Prometheus · PostgreSQL |
| 반대편 노드가 실제로 날린 `SELECT` · `UPDATE` 문장 | PostgreSQL 문장 로그 |
| 그 트랜잭션 안의 `SET LOCAL synchronous_commit TO OFF` | 같은 로그 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
- 네임스페이스는 전부 `keycloak-lab` 이다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 탐침 파드 셸용(붙잡고 있어야 한다),
하나는 관찰용.
- `jq` 는 이 실험대 어디에도 없다. 이 가이드는 `jq` 를 쓰지 않는다.
## 주의 — 이건 상태를 부수는 실험이다
세션 테이블을 비우고, StatefulSet 을 재시작하고, PostgreSQL 의 문장 로깅을
켠다. **실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에
적어 두었다. 중간에 그만두려면 [5. 복구](#5-복구) 의 두 명령이면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 09:5210:14 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
IP·파드 이름·sid 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를 쓰지
않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 실행 기록의
실제 값이다.
---
# 0. 왜 이 실험을 먼저 하는가
앞 단계에서 Keycloak 2노드 클러스터를 세우고 로그에서 `ISPN000094` 멤버 2개를
확인했다. **거기서 멈추면 「클러스터가 떴다」까지만 아는 것**이다. 그 위에
장애를 주입해 봐야 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.
기준선이 없으면 이런 추론을 하게 된다.
> 7800 을 막았더니 세션이 깨졌다 → 역시 세션은 7800 으로 복제되는구나
**「클러스터가 형성됐다」와 「세션이 복제된다」는 다른 얘기다.** 이 실험은 그 둘을
가른다. 갈라야 할 것은 이것이다.
```
두 노드가 같은 답을 한다
├── (a) Infinispan 이 세션을 복제했다 ← 통념
└── (b) 두 노드가 같은 PostgreSQL 을 본다 ← 확인할 것
```
**(a) 와 (b) 는 겉보기 결과가 같다.** 「반대편에서도 된다」만 보면 구별이 안 된다.
그래서 이 가이드는 네 개의 시험을 순서대로 한다.
| 시험 | 무엇을 가르나 |
|---|---|
| **0** 교차 노드 사용 | 반대편이 그 세션을 쓸 수 있는가 (여기까지는 (a)·(b) 구별 안 됨) |
| **0b** 캐시 계수기 델타 | 로그인 하나에 반대편 캐시가 **움직이는가** |
| **0c** 엔트리 소유 | 엔트리가 **어느 노드에** 생기는가 |
| **0d** SQL 포획 | 반대편이 **정말 DB 를 읽는가** — 추론을 관측으로 바꾼다 |
---
# 1. 기준선 — 세션을 만들기 전에
넓은 것부터 좁혀 간다.
```
노드 → 파드 → 클러스터 뷰(로그) → 디스커버리(DB) → DB 세션 수 → 노드별 캐시 → 탐침 고르기
```
## 1-1. 노드와 파드
**확인**
```bash
sudo kubectl get nodes
```
**형태**
```
NAME STATUS ROLES AGE VERSION
kc-lab-1 Ready control-plane,master 12d v1.33.x+k3s1
kc-lab-2 Ready <none> 12d v1.33.x+k3s1
```
둘 다 `Ready` 여야 한다. 여기서부터 어긋나면 이 실험의 결과는 전부 무의미하다.
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**어디를 봐야 하는가**
- `READY` 가 둘 다 `1/1`, `RESTARTS``0`
- **`NODE` 가 서로 다르다** — 같은 노드에 있으면 이 실험은 성립하지 않는다
- `postgres` 가 어느 노드에 있는지도 적어 둔다. A-2·A-3 에서 이게 중요해진다
**이 결과가 의미하는 것** — 원래 실행에서는 `keycloak-0``kc-lab-2`,
`keycloak-1``kc-lab-1` 이었다. **파드 번호와 노드 번호가 어긋난다.**
이걸 헷갈리면 뒤에서 엉뚱한 노드를 뒤지게 된다.
IP 는 변수로 잡아 둔다. 파드가 재시작되면 **바뀌므로** 그때 다시 잡는다.
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt)
```
=== 대상 ===
keycloak-0 10.42.1.43 kc-lab-2
keycloak-1 10.42.0.35 kc-lab-1
```
## 1-2. 클러스터 뷰 — 로그가 말하는 것
**확인**
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
sudo kubectl -n keycloak-lab logs keycloak-1 | grep ISPN000094 | tail -1
```
**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt)
```
2026-09-04 00:52:09,294 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
```
**어디를 봐야 하는가**
```
[keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
└── 코디네이터 ──┘ │ │ └────── 멤버 목록 ──────┘
│ └─ 멤버 수
└─ 뷰 ID (바뀔 때마다 1 증가)
```
**이 결과가 의미하는 것** — 멤버가 2 다. **그리고 이 줄이 이 실험에서 증명하는
것은 여기까지다.** 「멤버가 둘」은 「세션이 오간다」가 아니다. 이 문서 전체가
그 간극을 재는 일이다.
## 1-3. 디스커버리 — DB 가 말하는 것
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
**실측**
```
name | ip | coord
------------------+-----------------+-------
keycloak-1-48749 | 10.42.0.35:7800 | t
keycloak-0-30843 | 10.42.1.43:7800 | f
(2 rows)
```
**어디를 봐야 하는가****`coord` 열에 `t` 가 정확히 하나.**
**이 결과가 의미하는 것** — 두 노드가 서로를 찾을 수 있고 코디네이터가 하나로
합의되어 있다. 이 테이블은 **「지금 등록되어 있다」**만 말한다.
## 1-4. 세션이 사는 테이블을 먼저 본다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c "\dt"
```
**실측** — 해설 문서 8-2 절
```
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
```
**어디를 봐야 하는가****`USER_SESSION` 테이블이 없다.**
### 개념 — 온라인 세션이 `OFFLINE_` 테이블에 들어간다
`persistent-user-sessions`(Keycloak 26 기본값)는 새 테이블을 만들지 않고
**기존 오프라인 세션 테이블을 재사용**한다. `offline_flag` 컬럼으로 구분한다.
| `offline_flag` | 의미 |
|---|---|
| **`'0'`** | **온라인 세션** (일반 로그인) |
| `'1'` | 오프라인 세션 (`offline_access`) |
기본키가 `(user_session_id, offline_flag)` 복합키인 이유다 — 같은 세션 id 가
온라인/오프라인 두 행으로 존재할 수 있다.
**이름이 내용을 배신하는 스키마다.** 운영에서 「온라인 세션이 DB 어디 있냐」를
찾을 때 이걸 모르면 한참 헤맨다. 이 가이드의 모든 질의는 `offline_flag='0'` 이다.
**확인** — 지금 몇 건인가
```bash
sudo 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"
```
**형태**
```
offline_flag | count
--------------+-------
0 | 2
```
**이 값을 적어 둔다.** 뒤에서 캐시 합계와 맞춰 볼 대조군이다.
## 1-5. 노드별 캐시 엔트리 — 이 실험의 주 계기(計器)
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). **밖에서 Prometheus 에
묻는 것이 가장 짧다.** 15초마다 이미 긁고 있다.
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
```
한 줄짜리 JSON 이 통째로 나온다. **처음 한 번은 그대로 본다.** 어떤 라벨이
붙어 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
**형태**
```json
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757037600.123,"0"]},
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","cache_manager":"keycloak","job":"keycloak","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757037600.123,"2"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. **미검증**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**어디를 봐야 하는가**`cache``sessions` 인 두 줄과 그 값.
`clientSessions` `work` 등 다른 캐시도 같이 나오므로 `cache` 라벨을 반드시 본다.
> 중괄호를 URL 에 그대로 넣으면 `wget` 이 싫어할 수 있다. 그래서 이 가이드는
> 쿼리에 라벨 필터를 걸지 않고 **받은 뒤에 거른다.** 통하는 환경이라면
> `query=vendor_statistics_approximate_entries_unique{cache="sessions"}` 가 짧다.
**이 결과가 의미하는 것** — 각 노드가 자기 `sessions` 캐시에 몇 개를 들고 있는지
보고한다. **이 값 하나로 0b·0c 를 전부 판정한다.**
## 1-6. 탐침을 고르는 법 — 왜 `refresh` 인가
**이 절을 건너뛰면 뒤의 숫자를 잘못 읽는다.** 원래 실행이 실제로 잘못 읽었다.
첫 판본은 `userinfo` 로 재고 이렇게 보고했다.
**실측** — 해설 문서 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 가 없다는
것이었고, 오히려 **두 노드가 똑같이 답했다는 사실 자체가 일치의 증거**였다.
> **원칙** — 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지 아무
> 의미가 없다. **시험군만 재는 측정은 측정이 아니다.**
탐침도 바꿔야 했다.
| 탐침 | 하는 일 | 적합한가 |
|---|---|---|
| `userinfo` | 서명 검증 + scope 확인 | **아니다.** 세션을 몰라도 통과할 수 있다 |
| **`refresh_token` 그랜트** | 세션을 찾고, 살아 있는지 보고, 갱신 시각을 쓴다 | **그렇다** |
**refresh 는 읽고 쓴다.** 그래서 「저 노드가 이 세션을 정말로 아는가」에 답한다.
그리고 **refresh token 은 회전한다** — 한 번 쓰면 옛 것이 무효가 된다. 따라서
**반대편 노드에 먼저 써야** 한다. 발급 노드에 먼저 쓰면 시험군에 쓸 토큰이
사라진다. **대조군과 시험군의 순서가 강제된다.**
## 1-7. 개수가 아니라 sid 로 추적한다
원래 실행은 관리 API 의 `active=2` 를 보고 판정하려다 실패했다. 스크립트 자체가
로그인을 두 번 했기 때문이다(시험용 + 관리 API 호출용).
> **개수는 실험 도구가 만든 잡음에 그대로 오염된다.** 토큰에서 `sid` 를 뽑아,
> 각 노드의 답에 **그 sid 가 있는지**를 본다. 개수가 몇이든 상관없다.
`sid` 는 세 곳에서 같은 문자열이다.
```
JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW
```
**장애를 추적할 때 이 값 하나로 토큰·DB·관리 API 를 꿰뚫을 수 있다.**
---
# 2. 주입 ① — 출발점을 깨끗하게 만든다
여기부터 상태가 바뀐다. **세션이 전부 지워지고 두 파드가 재시작된다.**
**되돌리기** — 되돌릴 수 없다. 지운 세션은 돌아오지 않는다. 실험대에서만 한다.
## 2-1. 왜 비우고 시작하나
0b·0c 는 **증가분(델타)** 으로 판정한다. 시작값이 지저분해도 델타는 맞지만,
**「7 + 5 = 12」 같은 합계 대조는 시작값이 깨끗해야 읽힌다.** 그리고 남아 있는
캐시 엔트리는 다음 함정을 부른다.
> **DB 에서 직접 지우면 캐시는 남는다.** 원래 실행에서 정리하려고
> `delete from offline_user_session` 만 했더니 **캐시 엔트리는 그대로 남아**
> 캐시 합계(19)와 DB 총계(15)가 어긋났다. 해설 문서 10-2 절이 그 기록이다.
>
> 운영에서도 같다. 세션을 지울 때는 관리 API(`logout-all`)를 쓰거나,
> **DB 를 건드렸다면 파드를 재시작**해야 한다.
**그래서 두 가지를 같이 한다.** 하나만 하면 안 된다.
## 2-2. 적용
**하기** — DB 를 비운다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
**형태**
```
DELETE 12
```
**하기** — 캐시를 비운다. StatefulSet 을 굴려 재시작한다
```bash
date '+%H:%M:%S 재시작'
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**어디를 봐야 하는가**`rollout status`**끝날 때까지 블록한다.** 돌아오면
두 파드가 새로 떠 있다.
**시각을 적어 둔다.** 나중에 Grafana 로 시계열을 볼 때 이 시각이 「캐시가 0 으로
떨어진 절벽」이다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것을 정확히 했는지 먼저 본다.**
## 3-1. 파드가 새로 떴고 IP 가 바뀌었다
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide | grep keycloak
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(sudo kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
**어디를 봐야 하는가**`AGE` 가 방금이고 `RESTARTS``0`(새 파드다),
그리고 **IP 가 아까 적어 둔 값과 다르다.**
**★ IP 를 다시 잡지 않으면 뒤의 모든 curl 이 아무 데도 안 닿는다.**
그걸 「복제 실패」로 읽는 것이 이 실험에서 가장 하기 쉬운 실수다.
## 3-2. DB 가 비었나
**확인**
```bash
sudo 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"
```
**형태**
```
offline_flag | count
--------------+-------
(0 rows)
```
한 행도 없어야 한다. 남아 있으면 `delete` 가 실패했거나 그 사이 누가 로그인했다.
## 3-3. 캐시가 비었나 — **양쪽 다** 본다
**확인**
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**어디를 봐야 하는가**`cache":"sessions"`**두 줄이 다 `0`.**
**이 결과가 의미하는 것** — 이제 나오는 모든 증가분은 **내가 만든 것**이다.
한쪽만 확인하고 넘어가면, 원래 남아 있던 값을 나중에 「복제가 왔다」로 읽는다.
> Prometheus 는 15초마다 긁는다. 재시작 직후에 물으면 **옛 값이 남아 있을 수
> 있다.** 30초쯤 기다렸다가 다시 친다. A-2 의 기준선 기록에도
> 「스크레이프 지연」 때문에 값이 한 박자 늦은 자국이 남아 있다.
---
# 4. 관찰 — 네 개의 시험
## 4-0. 상주 탐침 파드를 띄운다
### 왜 파드를 띄우나
- Keycloak 이미지에 `curl` 이 없다 → 파드 안에서는 못 친다
- 토큰을 단계 사이로 넘겨야 한다 → **한 셸 안에서** 다 해야 한다
- Service 로 가면 **어느 노드가 처리했는지 알 수 없다** → 파드 IP 로 직접 친다.
이 실험의 질문 자체가 「어느 노드인가」다
**하기**
```bash
sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
**되돌리기** — 셸에서 `exit` 하면 `--rm` 이 파드를 지운다.
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다(히스토리에는 `$(...)` 문자열만 남는다).
> 존재와 길이만 확인하고 싶으면 밖에서:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
여기부터 프롬프트가 `/ $` 로 바뀐다. **파드 안 셸**이다.
**확인** — 환경변수가 제대로 들어왔나
```sh
echo "K0=$K0 K1=$K1 PW길이=${#PW}"
```
**형태**
```
K0=10.42.1.43 K1=10.42.0.35 PW길이=19
```
`PW길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 나가서 다시 띄운다.
## 4-1. 시험 0 — 교차 노드 세션 사용
### [1] `keycloak-0` 에서 로그인한다. 이 노드가 세션의 출생지다
**하기**
```sh
TOK=/realms/master/protocol/openid-connect/token
curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"
```
**형태** — 한 줄 JSON 이 나온다. 한 번은 통째로 본다
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"}
```
**어디를 봐야 하는가**`expires_in``refresh_expires_in`.
**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt)
```
=== [1] keycloak-0 에서 로그인 ===
sid jiv3rVZi1VeaO07oVJkL_MYW
sub None
iss https://auth.hyeonworks.com/realms/master
access 수명 60초
refresh 수명 1800초 typ=Refresh
refresh jti 7669cc49-4778-851f-3c49-65f76964ae8e
```
**이 결과가 의미하는 것** — **access token 은 60초짜리고 그동안은 서버에 안
물어본다.** 그래서 탐침이 access token 이면 안 된다. `iss` 가 파드 IP 가 아니라
`https://auth.hyeonworks.com/...` 인 것은 `KC_HOSTNAME` 이 그렇게 잡혀 있기
때문이고, 정상이다.
> **`sub` 이 없다.** `admin-cli` 에 `scope` 없이 direct grant 를 하면 나오는
> 클레임은 `azp, exp, iat, iss, jti, scope, sid, typ` 뿐이다(**실측**,
> 해설 문서 8-4). OIDC 가 아니라 순수 OAuth2 액세스 토큰이라 `sub` 이 안 붙는다.
> 1-6 의 `userinfo` 403 과 **같은 원인**이다.
### [2] 토큰과 sid 를 변수에 담는다
**하기**
```sh
R=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
AT=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 access=${#AT}자"
```
**형태**
```
refresh=1187자 access=2043자
```
길이가 `0자` 로 나오면 로그인이 실패한 것이다. `echo "$R"` 로 에러 본문을 본다.
**하기** — JWT 의 가운데 토막이 클레임이다. 여기서 sid 를 읽는다 **미검증**
```sh
echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**형태**
```json
{"exp":1757037660,"iat":1757037600,"jti":"...","iss":"https://auth.hyeonworks.com/realms/master",
"typ":"Bearer","azp":"admin-cli","sid":"jiv3rVZi1VeaO07oVJkL_MYW","scope":"profile email"}
```
```sh
SID=$(echo "$AT" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "SID=$SID"
```
**`SID` 를 적어 둔다.** 밖에서 DB 를 뒤질 때 이 값이 필요하다.
> base64 패딩 때문에 끝이 깨져 보일 수 있다(`2>/dev/null` 이 그 불평을 지운다).
> `sid` 는 앞쪽에 있어서 대개 보인다.
### [3] 같은 sid 가 두 노드 모두에서 보이는가
관리 API 의 세션 목록을 **두 노드에 똑같이** 묻는다. 먼저 `admin-cli` 의 내부
id 가 필요하다.
**확인** — 응답을 한 번 그대로 본다
```sh
curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli"
```
**형태** — 객체 하나짜리 배열이 나온다. `"id"` 가 맨 앞에 있다
```json
[{"id":"131a9912-b578-4b9c-b16a-97518704077e","clientId":"admin-cli","name":"${client_admin-cli}", ...}]
```
무엇을 자르는지 눈으로 본 다음 잘라낸다. **미검증**
```sh
CID=$(curl -s -H "Authorization: Bearer $AT" \
"http://$K0:8080/admin/realms/master/clients?clientId=admin-cli" \
| tr ',' '\n' | grep -m1 '"id"' | cut -d'"' -f4)
echo "CID=$CID"
```
> `sed -n 's/.*"id":"\([^"]*\)".*/\1/p'` 로 뽑으면 **뒤쪽의 다른 `id` 를 잡을 수
> 있다.** `.*` 가 탐욕적이라 줄에서 **마지막** `"id":"` 를 고른다.
> `tr ',' '\n' | grep -m1` 은 **첫 번째** 것을 고르므로 안전하다.
**하기** — 같은 질문을 두 노드에 던지고 sid 가 있는지만 본다 **미검증**
```sh
for H in "$K0" "$K1"; do
echo -n "$H : "
curl -s -H "Authorization: Bearer $AT" \
"http://$H:8080/admin/realms/master/clients/$CID/user-sessions?max=100" \
| grep -c "$SID"
done
```
**실측** — [`01-cross-node-session.txt`](../../evidence/session-replication/01-cross-node-session.txt)
```
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
```
**어디를 봐야 하는가** — 두 줄 다 `1`(또는 그 이상). **개수가 아니라 sid 의
유무다.** 세션이 2개인 것은 실험 도구가 만든 잡음이고, 판정에 안 쓴다.
**이 결과가 의미하는 것** — 두 노드가 같은 세션을 안다. **여기까지는 (a) 와 (b)
를 구별하지 못한다.** 다음 절부터가 진짜다.
### [5] 시험군 — `keycloak-0` 이 발급한 refresh token 을 `keycloak-1` 에 쓴다
**refresh 는 회전하므로 반대편에 먼저 쓴다.** 순서가 강제된다(1-6).
**하기**
```sh
R=$(curl -s -w '\n%{http_code}' -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT")
echo "$R" | tail -1
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
```
**실측**
```
=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
HTTP 200 ← 기대대로
새 토큰의 sid → 동일 ✔
```
**어디를 봐야 하는가**`200`, 그리고 **새 토큰의 sid 가 같은 값**인 것.
sid 가 바뀌었다면 세션을 이어받은 게 아니라 새로 만든 것이다.
> **매번 `RT` 를 다시 담는다.** 옛 것을 계속 쓰면 나중에 나오는 400 이
> 무효화 때문인지 재사용 때문인지 알 수 없게 된다.
### [6][7] 무효화가 반대 방향으로도 전파되는가
**하기**
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/logout" \
-d client_id=admin-cli -d "refresh_token=$RT"
curl -s -w '\n%{http_code}\n' -X POST "http://$K0:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
```
**실측**
```
=== [6] keycloak-1 을 통해 로그아웃 ===
http_code=204
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
HTTP 400 ← 기대대로
error invalid_grant
error_description Session not active
```
**이 결과가 의미하는 것**`keycloak-1` 에서 로그아웃하면 `keycloak-0` 에서도
갱신이 막힌다. **무효화가 전파된다.**
> **이 `400` 을 기억해 둔다.** [A-1](a1-jgroups-transport-block.md) 에서 7800 을
> 막으면 **바로 이 자리가 `200` 으로 바뀐다.** 그게 A-1 의 결론이다.
### [8] DB 를 직접 본다
`exit` 으로 파드에서 나온다. 밖에서:
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select user_session_id, offline_flag, created_on, last_session_refresh
from offline_user_session where user_session_id='jiv3rVZi1VeaO07oVJkL_MYW'"
```
`jiv3rVZi1VeaO07oVJkL_MYW` 자리에 위에서 적어 둔 **당신의 `SID`** 를 넣는다.
**실측**
```
=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
대상 sid: jiv3rVZi1VeaO07oVJkL_MYW
행 없음 — 로그아웃으로 삭제되었다
전체 세션 수: 1
```
**이 결과가 의미하는 것****`sid` 는 JWT 안에만 있는 값이 아니다.**
`OFFLINE_USER_SESSION.user_session_id` 컬럼에 **문자 그대로** 들어 있다.
로그아웃과 함께 행이 사라졌다.
**여기까지 네 가지가 모두 기대대로다.**
| | 확인된 것 |
|---|---|
| 조회 | 같은 sid 가 양쪽에서 보인다 |
| **쓰기** | `keycloak-0` 의 refresh token 을 `keycloak-1` 이 받아 갱신했고 **sid 가 유지된다** |
| **역방향 무효화** | `keycloak-1` 의 로그아웃이 `keycloak-0` 의 갱신을 막았다 |
| 영속 | 로그아웃과 함께 DB 행이 사라졌다 |
**그런데 이것으로 「Infinispan 이 복제했다」고 말할 수 없다.** 두 노드가 같은
PostgreSQL 을 보면 캐시를 아예 꺼도 같은 결과가 나온다.
## 4-2. 시험 0b — 복제인가, 같은 DB 를 본 것인가
**가르는 방법: 로그인 한 번을 사이에 두고 양쪽 노드의 캐시 계수기를 잰다.**
| | (a) 복제라면 | (b) 같은 DB 라면 |
|---|---|---|
| 로그인을 받은 노드 | 는다 | 는다 |
| **반대편 노드** | **같이 는다** | **안 움직인다** |
**하기** — 전값을 잰다
```bash
sudo kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique' \
| tr ',' '\n' | grep -E '"cache":|"pod":|^"[0-9]'
```
**하기**`keycloak-0` 에만 로그인 한 번. 탐침 파드를 다시 띄워서 친다
```bash
sudo kubectl -n keycloak-lab run kc-probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 --env="K0=$K0" \
--env="PW=$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sh
```
```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"
exit
```
**형태**
```
200
```
**30초 기다린다.** Prometheus 가 다음 스크레이프를 할 때까지다. 그리고 후값을
같은 명령으로 잰다.
**실측** — [`02-cache-delta.txt`](../../evidence/session-replication/02-cache-delta.txt)
```
=== keycloak-0 (로그인을 받은 노드) ===
계수기 캐시 전 후 증가
rpc.replication_count clientSessions 1 1 +0
rpc.replication_count sessions 1 1 +0
approximate_entries_unique clientSessions 1 2 +1 ←
approximate_entries_unique sessions 1 2 +1 ←
hits clientSessions 2 2 +0
hits sessions 2 2 +0
misses clientSessions 2 3 +1 ←
misses sessions 3 4 +1 ←
stores clientSessions 2 3 +1 ←
stores sessions 2 3 +1 ←
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
계수기 캐시 전 후 증가
rpc.replication_count clientSessions 7 7 +0
rpc.replication_count sessions 7 7 +0
approximate_entries_unique clientSessions 0 0 +0
approximate_entries_unique sessions 0 0 +0
hits clientSessions 4 4 +0
hits sessions 4 4 +0
misses clientSessions 0 0 +0
misses sessions 0 0 +0
stores clientSessions 1 1 +0
stores sessions 1 1 +0
```
**어디를 봐야 하는가****`keycloak-1` 열이 전부 `+0`.** 엔트리도 0, 저장도 0.
그리고 `keycloak-1``sessions` 엔트리는 **처음부터 끝까지 0** 이다.
**이 결과가 의미하는 것****(b) 다.** 복제였다면 `keycloak-1`
`approximate_entries_unique``stores` 가 최소한 하나는 움직였어야 한다.
> `rpc.replication_count` 가 `1`·`7` 로 0 이 아닌 것에 속으면 안 된다.
> **이 계수기는 세션 캐시만의 것이 아니다.** 클러스터가 다른 용무로 주고받은
> 것까지 센다. 판정은 **증가분이 0** 이라는 사실로 한다.
> 다른 계수기 이름을 같이 보고 싶으면 쿼리 이름만 바꿔서 같은 명령을 친다 —
> `vendor_statistics_stores` · `vendor_statistics_hits` ·
> `vendor_statistics_misses` · `vendor_rpc_manager_replication_count`.
## 4-3. 시험 0c — 엔트리는 어느 노드에 있는가
0b 의 결과에는 두 가지 설명이 남아 있다.
| | |
|---|---|
| (a) **분산 캐시 + owners=1** | 일관 해싱으로 흩어지는데 이번 건이 우연히 `keycloak-0` 에 떨어졌다 |
| (b) **로컬 캐시** | 각 노드는 자기가 처리한 것만 캐시한다 |
**반대편 노드에 로그인을 몰아주면 갈린다.** (a) 라면 어느 쪽에 요청하든
엔트리는 양쪽에 흩어진다. (b) 라면 **요청을 받은 노드에서만** 는다.
**하기** — 탐침 파드 안에서, `keycloak-1` 에 5회
```sh
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{http_code} ' -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW"
done; echo
```
**형태**
```
200 200 200 200 200
```
30초 기다렸다가 엔트리를 잰다. 그 다음 `$K1``$K0` 로 바꿔 5회 더 하고
다시 잰다.
**실측** — [`03-cache-ownership.txt`](../../evidence/session-replication/03-cache-ownership.txt)
```
keycloak-0 = 10.42.1.43 (kc-lab-2)
keycloak-1 = 10.42.0.35 (kc-lab-1)
단계 k0 entries k1 entries
시작 2.0 0.0
keycloak-1 에 로그인 5회 2.0 5.0
keycloak-0 에 로그인 5회 7.0 5.0
=== 대조: PostgreSQL 에는 몇 건인가 ===
online 세션 12
```
**어디를 봐야 하는가** — 대각선이다. **한 번에 한 쪽만 는다.**
**확인** — DB 총계와 맞춰 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select count(*) from offline_user_session where offline_flag='0'"
```
**이 결과가 의미하는 것****(b) 다.** 그리고 **7 + 5 = 12** 로 DB 총계와 정확히
맞는다. 모든 세션이 DB 에 있고, 각각은 **자기를 만든 노드 한 곳에만** 캐시되어
있다. 어느 엔트리도 두 번 세어지지 않았다 — 복제가 없다는 뜻이다.
> **그래프로 같은 사실을 본다.** Grafana Explore 에서
> `vendor_statistics_approximate_entries_unique{cache="sessions"}`,
> Legend `{{pod}} on {{node}}`. 원래 실행의 그림이
> [`session-cache-entries-per-pod.png`](../../evidence/session-replication/session-cache-entries-per-pod.png)
> 이고, **파란 선(keycloak-1)이 0 에 붙어 있는 동안 초록 선(keycloak-0)만 14 까지
> 오른다.** 중간의 절벽이 2-2 의 재시작이다.
> **캐시 설정은 파일에서 읽을 수 없다.** 파드의 `/opt/keycloak/conf/cache-ispn.xml`
> 은 `<cache-container name="keycloak"><transport/></cache-container>` 뿐이고,
> Keycloak 26 은 캐시를 **코드에서** 만든다. 그래서 위 결론은 설정을 읽어서가
> 아니라 **동작을 측정해서** 얻은 것이다.
## 4-4. 시험 0d — 주입 ②: SQL 을 직접 잡는다
0b·0c 까지는 **추론**이다. 「`keycloak-1` 메모리에 없는데 쓸 수 있으니 DB 에서
읽었을 것이다」 — 그럴듯하지만 **SQL 을 본 적은 없다.**
**PostgreSQL 문장 로깅을 몇 초만 켠다.** 여기부터 두 번째 주입이다.
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
-c "select pg_reload_conf()"
```
### 켠다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" \
-c "alter system set log_line_prefix='%m [%p] %h '" \
-c "select pg_reload_conf()"
```
**`%h` 가 핵심이다.** 클라이언트 IP 를 로그 줄 앞에 남긴다. 이게 없으면
**어느 파드가 보낸 질의인지 구별할 수 없다.**
**확인** — 실제로 켜졌나
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
**실측** — [`04-read-path-sql.txt`](../../evidence/session-replication/04-read-path-sql.txt)
```
log_statement = all
log_line_prefix = %m [%p] %h
```
`log_statement` 가 아직 `none` 이면 `pg_reload_conf()` 가 안 돈 것이다.
`alter system``postgresql.auto.conf` 에 쓸 뿐이고, **reload 를 해야 적용된다.**
### 요청을 딱 한 번 보낸다
**하기** — 탐침 파드 안에서. `keycloak-0` 에서 만든 세션을 `keycloak-1` 에 갱신
```sh
TOK=/realms/master/protocol/openid-connect/token
R=$(curl -s -X POST "http://$K0:8080$TOK" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d "password=$PW")
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "SID=$SID"
curl -s -o /dev/null -w '%{http_code}\n' -X POST "http://$K1:8080$TOK" \
-d grant_type=refresh_token -d client_id=admin-cli -d "refresh_token=$RT"
```
**실측**
```
=== 요청 ===
SID=jSt9GEPVQLJsO-1CeJjVgltg
K1_ENTRIES_BEFORE=5.0
REFRESH_ON_K1=200
K1_ENTRIES_AFTER=5.0
```
**한 번만 보낸다.** 여러 번 보내면 로그에서 어느 트랜잭션이 어느 요청인지
구별하기 어려워진다.
### 로그를 뒤진다
**확인** — 먼저 `keycloak-1` 이 보낸 것만 본다. `%h` 가 남긴 IP 로 거른다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep "$K1" | grep 'LOG: execute'
```
**실측** — [`04-read-path-sql.txt`](../../evidence/session-replication/04-read-path-sql.txt)
```
select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,...,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2))
select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked
select pcse1_0.CLIENT_ID,...,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (...) in (($1,$2,$3,$4,$5))
select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where ... for no key update of pcse1_0 skip locked
update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and ... and VERSION=$8
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
SET LOCAL synchronous_commit TO OFF
COMMIT
```
**이 결과가 의미하는 것****추론이 관측이 되었다.** `keycloak-1` 은 세션을
DB 에서 읽고, DB 에 쓴다.
**확인** — 그 sid 를 언급한 줄을 누가 보냈나. 파라미터는 `DETAIL` 줄에 있다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep 'jSt9GEPVQLJsO-1CeJjVgltg' | cut -c1-120
```
**실측**
```
2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
...
2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0'
2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-...', ...
2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0'
```
> **증거 파일에는 IP 자리에 `[keycloak-0]` `[keycloak-1]` 이 적혀 있다.**
> 원래 실행 스크립트가 `sed` 로 IP 를 파드 이름으로 바꿔 놓은 것이다. **당신
> 화면에는 `10.42.0.35` 같은 IP 가 그대로 나온다.** 1-1 에서 적어 둔 값과 맞춰
> 읽는다.
**어디를 봐야 하는가****pid 가 다르다.** `81407``keycloak-0` 의 연결,
`81376``keycloak-1` 의 연결이다. pid 가 트랜잭션의 경계다.
**확인** — 파드별 질의 건수 **미검증**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K0"
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s \
| grep 'jSt9GEPVQLJsO-1CeJjVgltg' | grep -c "$K1"
```
**실측**
```
=== 요약: 파드별 질의 건수 ===
6 [keycloak-1]
6 [keycloak-0]
```
**sid 하나에 대해 `keycloak-0` 이 6건(로그인), `keycloak-1` 이 6건(갱신)을 날렸다.**
### 곧바로 끈다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "alter system reset log_line_prefix" \
-c "select pg_reload_conf()"
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**실측**
```
log_statement = none
```
**★ 켜 둔 채로 다음 실험에 들어가면 안 된다.** 로그인 루프를 도는
[A-3](a3-database-crash.md) 에서 `log_statement='all'` 을 켜 두면 로그가 폭주한다.
### 덤: 캐시는 읽어도 채워지지 않는다
위 실측의 세 줄을 다시 본다.
```
K1_ENTRIES_BEFORE=5.0
REFRESH_ON_K1=200
K1_ENTRIES_AFTER=5.0 ← 갱신을 처리하고도 그대로
```
**`keycloak-1` 은 남의 세션을 DB 에서 읽어 처리하고도 캐시에 담지 않았다.**
0c 에서 세운 모델을 더 좁혀야 한다.
> 캐시에 담기는 것은 **그 노드가 로그인시켜 만든 세션**뿐이다.
> 남의 세션은 **매번 DB 에서 읽는다.**
로드밸런서가 세션을 만든 노드가 아닌 쪽으로 요청을 보내면 **매번 DB 를 친다.**
세션 어피니티(sticky session)가 정확성이 아니라 **성능** 문제인 이유가 이것이다.
### 덤 2: jdbc-ping 하트비트가 그대로 보인다
같은 로그에 이것도 있다.
**실측**
```
01:12:37.551 pid=81369 | BEGIN
01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1
01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...)
01:12:37.553 pid=81369 | COMMIT
```
**디스커버리는 별도 연결(pid 가 다르다)에서 주기적으로 자기 행을 지우고 다시
넣는다.** 세션 트래픽과 완전히 분리된 경로다 — **「디스커버리와 트랜스포트는
다른 경로」가 로그에서 눈으로 확인된다.** [A-1](a1-jgroups-transport-block.md) 이
이 둘을 갈라 끊는 실험이다.
## 4-5. 그 13밀리초짜리 트랜잭션에 들어 있던 세 가지
**원래 질문들의 답이 절반쯤 여기 있다.** 위에서 잡은 SQL 을 다시 읽는다.
### (1) 낙관적 락 — `VERSION` 컬럼
```sql
update OFFLINE_USER_SESSION
set LAST_SESSION_REFRESH=$1, VERSION=$2
where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
─────────────
읽을 때의 버전과 같을 때만 쓴다
```
읽은 뒤 다른 노드가 먼저 고쳤다면 `VERSION` 이 달라져 **`UPDATE` 가 0행을
갱신하고 실패한다.** 잠금을 오래 잡지 않고 충돌을 사후에 검출하는 방식이다.
**리프레시 토큰 동시 갱신 경쟁(B-3)이 여기서 갈린다.**
### (2) `FOR NO KEY UPDATE ... SKIP LOCKED`
```sql
select VERSION from OFFLINE_USER_SESSION
where USER_SESSION_ID=$1 and OFFLINE_FLAG=$2
for no key update of puse1_0 skip locked
────────────── ────────────
키가 아닌 컬럼만 잠근다 잠긴 행은 건너뛴다 (기다리지 않는다)
```
| 절 | 뜻 |
|---|---|
| `FOR NO KEY UPDATE` | 행을 잠그되 **외래키 참조는 막지 않는다.** `FOR UPDATE` 보다 약해 경합이 준다 |
| **`SKIP LOCKED`** | 이미 잠긴 행을 **기다리지 않고 건너뛴다** |
같은 세션에 동시 요청이 몰려도 **줄을 서지 않는다.** 대기 대신 낙관적 락
실패로 처리한다 — 처리량을 위해 **지연 대신 재시도**를 고른 설계다.
### (3) `SET LOCAL synchronous_commit TO OFF` — 내구성을 일부 포기한다
**같은 트랜잭션 안에서**, `COMMIT` 직전에 나온다. pid 로 경계를 확인했다.
| | |
|---|---|
| 기본값 `on` | `COMMIT`**WAL 이 디스크에 내려간 뒤** 돌아온다 |
| **`off`** | **WAL 플러시를 기다리지 않고** 즉시 돌아온다 |
**확인** — 전역 설정은 다르다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show synchronous_commit"
```
**형태**
```
synchronous_commit
--------------------
on
```
**전역은 `on` 이고, Keycloak 이 세션 트랜잭션에만 `SET LOCAL` 로 끈다.**
`SET LOCAL` 은 그 트랜잭션이 끝나면 되돌아간다.
**결과: PostgreSQL 이 갑자기 죽으면 직전 수백 밀리초의 세션 쓰기가 사라질 수
있다.** 커밋했다고 응답해 놓고 없어진다. **버그가 아니라 설계된 트레이드오프**다.
> **[A-3](a3-database-crash.md) 이 이 값을 실측한다.** 여기서 본 한 줄이 거기서
> 「200 을 받았는데 DB 에 없는 sid 4건」으로 나타난다.
---
# 5. 복구
이 실험은 세션을 만들 뿐 클러스터를 부수지 않는다. 되돌릴 것은 두 가지다.
## 5-1. 문장 로깅을 껐는지 확인한다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement" -c "show log_line_prefix"
```
`none` 이 아니면 4-4 의 reset 세 줄을 다시 친다.
## 5-2. 실험이 만든 세션을 정리한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**★ 재시작을 빼면 안 된다.** DB 만 지우면 캐시가 남아 다음 실험의 기준선이
어긋난다(2-1 의 함정).
## 5-3. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 뷰 | `sudo kubectl -n keycloak-lab logs keycloak-0 \| grep ISPN000094 \| tail -1` | 멤버 `(2)` |
| 디스커버리 | `psql -c "select name, ip, coord from jgroups_ping order by name"` | `coord = t`**하나** |
| DB 세션 | `psql -c "select count(*) from offline_user_session"` | `0` |
| 캐시 | `vendor_statistics_approximate_entries_unique` | `sessions` 두 줄 다 `0` |
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod kc-probe` | `NotFound` (없어야 정상) |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
탐침 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
```bash
sudo kubectl -n keycloak-lab delete pod kc-probe --ignore-not-found
```
---
# 6. 그래서 무엇이 세션을 공유하는가
```
로그인 (keycloak-0)
├──▶ PostgreSQL OFFLINE_USER_SESSION ← 진실의 원천. 양쪽이 본다
└──▶ keycloak-0 로컬 캐시 ← 자기 것만. 건너가지 않는다
keycloak-1 이 그 세션을 물으면
└──▶ 자기 캐시에 없음 → PostgreSQL 에서 읽는다 (캐시에 담지도 않는다)
```
| 계층 | 역할 | 노드 간 공유 |
|---|---|---|
| **PostgreSQL** | 진실의 원천 | **여기서 일어난다** |
| **Infinispan `sessions`** | 자기 노드가 처리한 세션의 룩어사이드 캐시 | **일어나지 않는다** |
| **Infinispan 클러스터** | 무효화 메시지, `work` 캐시 등 | 형성은 되어 있다 |
이건 **Keycloak 26 의 의도된 설계**다. `persistent-user-sessions` 가 기본이
되면서 DB 가 진실의 원천이 됐고, 세션 캐시는 **복제할 이유가 없어졌다.**
### 개념 — 룩어사이드(lookaside) 캐시
```
읽기: 캐시 확인 → 없으면 DB → (Keycloak 은 남의 세션이면 담지도 않는다)
쓰기: DB 에 쓴다
```
캐시가 **DB 앞에 서 있되 DB 를 대체하지 않는** 구조다. 캐시를 통째로 날려도
정확성은 유지되고 느려지기만 한다.
**이 성질이 다음 실험들의 결과를 미리 결정한다.**
| | 이 성질이 예측하는 것 |
|---|---|
| **읽을 때 DB 와 대조하지 않는다** | 무효화가 안 가면 캐시가 **낡은 답**을 준다 → A-1 |
| **쓰기는 대신하지 못한다** | DB 가 죽으면 캐시가 있어도 refresh 가 실패한다 → A-2 |
| **DB 가 진실의 원천이다** | 노드가 죽어도 세션은 살아남는다 → A-4 |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 띄우거나 Prometheus 에 묻는다 |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | `get pod -o jsonpath='{.status.podIP}'` 를 다시 — 3-1 |
| 로그인이 `401`/`400` | 비밀번호가 안 넘어갔다 | 파드 안에서 `echo ${#PW}``0` 이면 `--env` 가 빈 값 |
| 반대편 응답만 보고 「복제 실패」로 읽었다 | **대조군이 없다** | 발급 노드에 같은 요청을 나란히 — 1-6 |
| `userinfo` 가 양쪽 다 `403` | **`openid` scope 가 없다.** 복제와 무관 | 본문의 `insufficient_scope` — 1-6 |
| 세션 개수가 계속 어긋난다 | **관리 API 호출도 세션을 만든다** | 개수 말고 **sid** 로 본다 — 1-7 |
| 캐시 합계와 DB 총계가 안 맞는다 | **DB 만 지우고 파드를 재시작 안 했다** | `rollout restart statefulset/keycloak` — 2-1 |
| 로그인했는데 지표가 안 움직인다 | Prometheus 스크레이프는 15초 간격 | 30초 기다렸다 다시 — 3-3 |
| `rpc.replication_count` 가 0 이 아니라 당황 | 세션 캐시만의 계수기가 아니다 | 절대값이 아니라 **증가분**으로 본다 — 4-2 |
| `CID` 가 엉뚱한 값이다 | `sed``.*` 가 탐욕적이라 **마지막** `"id"` 를 잡는다 | `tr ',' '\n' \| grep -m1 '"id"'` — 4-1 |
| 문장 로깅을 켰는데 SQL 이 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement``all` 인지 — 4-4 |
| 로그에 어느 파드인지 안 나온다 | `log_line_prefix``%h` 가 없다 | `show log_line_prefix` — 4-4 |
| 다음 실험에서 postgres 로그가 폭주한다 | **문장 로깅을 껐는지 확인 안 했다** | `show log_statement``none` — 5-1 |
---
# 왜 이 가이드에는 스크립트가 거의 없나
원래 실행은 네 개의 bash 스크립트로 했고, 그 과정에서 **관측 자체가 두 번
틀렸다.**
**하나는 스트림 유실이다.** `kubectl run --rm -i ... | grep` 로 받았더니 중간
조각이 통째로 사라져, `keycloak-1` 의 스냅샷과 다음 마커가 함께 없어졌다.
전값이 0 으로 잡히면서 **가짜 델타**가 만들어졌다.
**실측** — 해설 문서 10-1 절
```
###BEFORE_K1 ← 여기 있어야 할 지표 20줄과
http_code=200 다음 마커 ###LOGIN 이 통째로 사라졌다
###AFTER_K0
```
이때 리포트는 `keycloak-1``+9`, `+7` 증가한 것처럼 보였다. **없는 복제가
있는 것처럼 보이는, 가장 나쁜 종류의 오류다.**
**다른 하나는 중첩 인용이다.** `ssh host '... $VAR ...'` 안에 다시 `sh -c "..."`
를 넣으면 인용이 세 겹이 되어 치환이 조용히 깨진다. 첫 시도에서 파드 IP 가 빈
문자열이 되어 아무 출력도 나오지 않았다.
| 고친 방법 | |
|---|---|
| 파드 안에서 파일로 모으고 마지막에 `cat` 한 번 | 스트리밍 중 유실을 없앤다 |
| 스냅샷이 비면 **경고를 출력**한다 | 조용히 0 으로 계산되는 것을 막는다 |
| 스크립트 파일로 만들어 옮긴다 | 인용이 한 겹으로 준다 |
> **계측 코드는 자기가 실패했는지 스스로 말해야 한다.**
그래서 이 가이드는 관찰을 **한 번에 하나씩 손으로** 친다. 값이 이상하면 그
자리에서 보이고, 다시 칠 수 있고, 무엇을 봤는지 남는다.
---
# 다음
이제 **틀릴 수 있는 예측**을 세울 수 있다. 예측이 빗나가면 그것이야말로 배울
거리다.
| 실험 | A-0 이 세운 예측 | 근거 |
|---|---|---|
| [A-1](a1-jgroups-transport-block.md) 7800 차단 | **세션 공유는 안 깨진다.** 대신 무효화 전파가 깨진다 | 세션은 7800 으로 오가지 않는다 — 4-2·4-3 |
| [A-2](a2-database-loss.md) DB 정지 | **즉시 전면 장애.** 캐시에 있는 세션도 못 쓴다 | DB 가 진실의 원천이고 refresh 는 쓰기다 — 4-4 |
| [A-3](a3-database-crash.md) DB 강제 종료 | 직전 수백 ms 의 쓰기가 **사라진다** | `SET LOCAL synchronous_commit TO OFF` — 4-5 |
| B-3 동시 갱신 경쟁 | 한쪽이 `VERSION` 검사에서 지고 재시도한다 | 낙관적 락 — 4-5 |
| A-4 노드 상실 | **세션은 살아남는다.** 죽은 노드의 캐시만 사라진다 | 룩어사이드 — 6절 |
특히 A-1 은 **직관과 어긋나는 예측**이다. 「클러스터 포트를 막으면 세션이
깨진다」가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.