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>
1262 lines
53 KiB
Markdown
1262 lines
53 KiB
Markdown
# 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:52–10: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 은 **직관과 어긋나는 예측**이다. 「클러스터 포트를 막으면 세션이
|
||
깨진다」가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.
|