Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7-volatile-comparison.md
T
DongHyeonkaandClaude Opus 5 bbd87dc9ab fix(setup): Studio 편집기가 다시 쓰는 공백을 저장소에서 미리 맞춘다
Studio 의 블록 편집기는 본문을 화면에 풀 때 공백을 정규화한다. 이어진 빈 줄
둘을 하나로 줄이고, 닫는 코드펜스 뒤에 빈 줄이 없으면 하나를 넣는다. 저장된
값은 저장소와 바이트가 같지만, 사람이 편집 화면을 열고 저장을 누르는 순간
그 공백이 저장소와 갈린다 — 내용은 그대로인데 SHA 만 달라져서 어느 쪽이
정본인지 알 수 없게 된다.

setup 35편을 훑어 9편 11곳을 찾아 저장소 쪽을 편집기와 같은 모양으로 맞췄다.
빈 줄만 움직였고 내용 줄은 하나도 바꾸지 않았다(추가 2 · 삭제 9).

- 이어진 빈 줄 둘 → 하나: d4 · d4a(3곳) · a7 · b7a · c2 · b0 · tear-down
- 닫는 펜스 뒤 빈 줄 추가: c1 · create-three-guests

코드블록 안의 빈 줄과 frontmatter 는 건드리지 않았다.

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:03:53 +09:00

1050 lines
56 KiB
Markdown

---
id: b8d7db33-afdc-4e49-9eab-ed4edbbe398e
kind: SETUP
slug: reproduce-a7-volatile-comparison
title: persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/b8d7db33-afdc-4e49-9eab-ed4edbbe398e/edit"
pinnedVersions:
- name: Keycloak
version: 26.7.0
- name: curlimages/curl
version: 8.11.1
- name: persistent-user-sessions
version: v1
source:
- final/document.md#a층-재현-절차-열-편을-직접-치는-순서-a-7
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다
`persistent-user-sessions` 를 끈 옛 기본값 위에서 A층 실험 넷을 다시 치는 절차다. A-0 과 A-1 과 A-2 와 A-8 을 명령 한 글자도 바꾸지 않고 그대로 친다. 교차 노드 refresh 는 `200` 인데 DB 세션 행은 `(0 rows)` 가 된다. 전 구간 40~60분.
## 관계
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
이 절차의 마지막 측정값 `① 500 · ② 200` 이 조건부라는 것을 그 기록이 확정한다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
여기서 끄고 켜는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
- **버전과 설정을 결과와 함께 적는다**
같은 명령이 26.7.0 기본값과 옛 기본값에서 정반대 답을 내므로, 결과만 옮겨 적으면 틀린 말이 된다.
- **문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다**
이 절차가 남긴 `500` 의 원인을 확정하는 후속 절차이고, 주입도 복구도 따로 선다.
- **롤링 재시작을 걸고 재시작 전 토큰이 통하는지 본다**
기본값에서 그 시험이 `200` 인 것을 먼저 재 둬야 여기의 `400` 이 뒤집힘으로 읽힌다.
- **7800 을 막고 디스커버리와 트랜스포트를 갈라 끊는다**
기본값에서 세션 공유가 안 깨지던 그 주입을 여기서 다시 건다. 이번에는 깨진다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령을 치는 곳이 둘이다. 대부분은 `[lab host]` 에서 `kubectl` 로 치고, `iptables` 만 노드 자체를 건드리므로 `kc-lab-1``kc-lab-2` 에 각각 들어가 친다. 코드블록마다 `label` 로 어디서 치는지 붙였다.
**`[kc-lab-1]` 라벨이 붙은 블록은 게스트 셸이다.** lab host 에서 `ssh kc-lab-1` 로 들어가서 치고, 끝나면 `exit` 로 나온다. `ssh kc-lab-2 '…'` 한 줄 형태는 lab host 에서 그대로 쳐도 되므로 `[lab host]` 로 두었다.
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl``permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
`kubectl``sudo` 를 붙이지 않는다. 실험 폴더의 README 가 까닭을 적는다 — `sudo` 를 붙이면 root 환경으로 돌아 그 kubeconfig 를 못 본다. root 홈에는 `~/.kube/config` 가 없으므로 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝나고, 그러면 클러스터가 아니라 누구의 설정 파일을 읽느냐가 문제인데 클러스터를 의심하게 된다.
```bash label="[lab host] 두 형태의 차이"
kubectl -n keycloak-lab get pods # 이렇게
sudo kubectl -n keycloak-lab get pods # 이렇게 치면 안 된다
```
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 관측 스택은 `observability` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `a7-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 끄는 기능 | `--features-disabled=persistent-user-sessions` |
| 막는 포트 | 7800 과 57800 을 `raw PREROUTING` 에서 양방향으로 |
| 도구 | `jq` 가 이 실험대에 없다. Prometheus 출력은 `tr` 과 `grep` 으로 자른다 |
터미널은 둘을 연다. 하나는 관찰용, 하나는 대기용이다.
## 이 실험이 가르는 것
A층의 결론 여섯은 전부 하나의 전제 위에 있다.
```text
Keycloak 26 은 persistent-user-sessions 가 기본으로 켜져 있다
├─ A-0 세션은 PostgreSQL 에 있다
├─ A-1 7800 을 끊어도 세션 공유가 안 깨진다
├─ A-2 DB 를 내리면 로그인이 실패한다
└─ A-8 롤링 재시작을 해도 세션이 산다
```
A-1 은 인터넷 자료의 통념과 어긋난 답을 냈고 그 까닭을 「26 이 기본값을 바꿨기 때문」이라고 설명했다. 설명이 맞는지는 옛 기본값으로 되돌려 같은 실험을 다시 해야 판정된다. 자료가 틀린 것이 아니라 버전이 다른 것이라면 옛 설정에서는 통념이 맞아야 한다.
```text
persistent (KC 25+, 26 기본) volatile (KC 24 이전)
로그인 ─▶ PostgreSQL (진실) 로그인 ─▶ Infinispan (진실)
조회 ─▶ 캐시 없으면 DB 조회 ─▶ 클러스터에서 찾는다
공유 ─▶ 같은 DB 를 본다 공유 ─▶ 7800 을 통한 복제
```
이 절차를 끝까지 치면 다섯을 손으로 보게 된다. 로그인했는데 DB 세션 테이블이 0건인 것, 그런데도 교차 노드 refresh 가 `200` 인 것, 롤링 재시작 한 번에 전원이 로그아웃되는 것, 7800 을 끊으면 이번에는 세션 공유가 깨지는 것, 그리고 DB 를 내렸는데 새 로그인이 되는 것.
## 전제와 되돌리기
앞선 구축 단계 `05-keycloak` 과 `06-observability` 가 끝나 있어야 한다. 그리고 A-1 과 A-2 와 A-8 을 먼저 해 두는 편이 좋다. 이 절차는 그 셋의 대조군이고, 먼저 잰 값을 알고 있어야 뒤집힘이 보인다.
이건 클러스터의 동작 모드를 바꾸는 실험이다. 전환하는 순간 기존 세션이 전부 사라지고 되돌릴 때 또 한 번 사라진다. `--features-disabled` 는 빌드 옵션이라 기동할 때 재빌드가 일어나 롤아웃이 평소보다 오래 걸린다. 실험대에서만 한다.
원복을 잊으면 이후 실험이 전부 오염된다. A-0 부터 A-6 까지의 결론은 전부 persistent 기본값 조건이다.
중간에 그만두려면 두 가지를 되돌린다. `args` 쪽은 이 두 줄이다.
```bash label="[lab host] args 를 기본값으로 되돌린다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
`iptables` 쪽은 두 노드에서 각각 지운다. 이 실험대는 `ssh kc-lab-2 '...'` 한 줄로 쳤고, 따라 하는 사람은 먼저 붙은 다음 원격 셸에서 치면 된다. 나눈 형태는 이 실험대에서 치지 않았다(unknown).
```bash label="[kc-lab-1] 이 노드의 규칙을 지운다"
sudo iptables -t raw -F PREROUTING
```
```bash label="[lab host] 반대 노드로 붙는다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] 원격 셸에서 같은 것을 지우고 나온다"
sudo iptables -t raw -F PREROUTING
exit
```
## 주입 전에 같은 명령으로 먼저 본다
전환 후에 볼 것을 전환 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
```text
노드 → 파드 → 지금 args → DB 세션 행 → 대조군 시험 → 이 버전에서 끌 수 있는가
```
### 1. 파드 배치를 보고 두 파드 IP 를 잡는다
**목적** — 두 Keycloak 파드가 서로 다른 노드에 있는지 확인하고, 뒤에서 쓸 IP 를 변수에 담는다.
```bash label="[lab host] ① 노드와 파드를 넓게 본다"
kubectl get nodes
kubectl -n keycloak-lab get pods -o wide
```
**예상 결과** — 모양은 이렇고 값은 환경마다 다르다.
```text
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.94 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.45 kc-lab-1
postgres-7b474b88c8-t6rrf 1/1 Running 0 5d 10.42.0.22 kc-lab-1
```
`READY` 가 둘 다 `1/1`, `RESTARTS` 가 `0`, 그리고 `NODE` 가 서로 다른지를 본다. 파드 번호와 노드 번호는 어긋난다 — `keycloak-0` 이 `kc-lab-2` 에 있다.
```bash label="[lab host] ② IP 를 변수에 담는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```text
10.42.1.94 10.42.0.45
```
**왜 필요한가** — 두 파드가 같은 노드에 있으면 뒤의 노드 간 차단이 아무것도 끊지 않는다. 그리고 이 절차는 롤아웃을 세 번 하므로 IP 를 세 번 다시 잡는다.
**문제가 생기면** — `NODE` 가 같으면 여기서 멈추고 배치부터 고친다.
### 2. 지금 args 를 적어 둔다
**목적** — 복구할 때 되돌릴 문자열을 확보한다.
```bash label="[lab host] 컨테이너 args 를 그대로 찍는다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**예상 결과**
```text
["start"]
```
**왜 필요한가** — 플래그가 하나도 없으므로 26 의 기본값으로 돌고 있고 `persistent-user-sessions` 가 켜져 있다. 복구 단계가 이 문자열로 되돌린다.
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 먼저 그것부터 되돌린다.
### 3. DB 에 세션 행이 있는 것을 센다
**목적** — persistent 에서 로그인이 DB 행을 만든다는 것을 전환 전에 확인한다.
```bash label="[lab host] 온라인 세션과 offline token 을 나눠 센다"
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"
```
**예상 결과** — 모양은 이렇고 숫자는 환경마다 다르다.
```text
offline_flag | count
--------------+-------
0 | 151
```
`offline_flag = '0'` 이 온라인 세션이고 `'1'` 은 offline token 이라 이 실험과 무관하다.
**왜 필요한가** — 전환 후에 같은 질의가 `(0 rows)` 를 내놓는지가 첫 판정이다. 관리 API 호출도 세션을 만들기 때문에 개수에는 소음이 섞인다. 여기서는 0 이 아니라는 것만 본다.
**문제가 생기면** — `(0 rows)` 가 지금 나오면 이미 volatile 이다. 2번으로 돌아간다.
### 4. 상주 탐침 파드를 띄운다
**목적** — 롤링 재시작을 넘어 토큰을 들고 있을 파드를 StatefulSet 밖에 세운다.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
```bash label="[lab host] ① 탐침을 띄우고 Ready 를 기다린다"
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 존재와 길이만 본다.
```bash label="[lab host] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[lab host] ③ 탐침 안에 값이 들어갔는지 본다"
kubectl -n keycloak-lab exec a7-probe -- sh -c 'echo "K0=$K0 K1=$K1 PW길이=${#PW}"'
```
**예상 결과** — 두 IP 가 보이고 `PW길이` 가 0 이 아니다.
**왜 필요한가** — 탐침이 StatefulSet 안에 있으면 롤링 재시작에 같이 죽어서 재시작 전 토큰을 재시작 후에 쓸 수 없다.
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
### 5. 교차 노드 refresh 가 지금은 되는 것을 본다
**목적** — 뒤에서 나올 `400` 과 견줄 값을 먼저 확보한다.
응답을 한 번은 통째로 본다.
```bash label="[lab host] ① 로그인 응답 전문을 본다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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"'
```
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"profile email"}
```
`expires_in` 이 60 이다. access token 은 60초짜리고 그동안은 서버에 안 물어보므로, 이 실험의 탐침은 access token 이 아니라 refresh 다. refresh 는 노드가 세션 저장소를 실제로 뒤져야 답할 수 있다.
```bash label="[lab host] ② 토큰을 파드 안 파일에 담고 길이를 찍는다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
echo "rt $(wc -c < /tmp/rt) bytes"'
```
```text
rt 1188 bytes
```
```bash label="[lab host] ③ 반대 노드에서 그 토큰으로 갱신한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**예상 결과**
```text
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
```
**왜 필요한가** — 이 `200` 을 안 재 두면 뒤의 `400` 이 무엇과 견준 값인지 말할 수 없다. 그리고 `rt` 가 `1 bytes` 면 빈 문자열에 개행만 들어갔다. 파싱이 실패했거나 로그인이 실패한 것인데, 그 상태로 진행하면 빈 토큰을 보내고 그 응답을 「세션이 죽었다」로 읽게 된다.
**문제가 생기면** — `1 bytes` 가 나오면 `cat /tmp/tok` 으로 본문을 본다. refresh token 은 회전하므로 이어서 또 쓰려면 새로 로그인해서 `/tmp/rt` 를 다시 채운다.
### 6. 이 버전에서 정말 끌 수 있는지 확인한다
**목적** — 기능 목록에 이름이 있는지 본다.
```bash label="[lab host] 빌드 기능 목록에서 이름을 찾는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kc.sh build --help-all \
| tr ',' '\n' | grep -i persistent
```
**예상 결과**
```text
persistent-user-sessions[:v1] ← 목록에 있다
```
**★ 2026-09-17 에 쳤더니 두 줄이 나왔다**(observed). 버전 접미사가 붙은 것과 안 붙은 것이 목록에 따로 있다. 하나만 나올 것으로 알고 있으면 두 줄째를 딴 기능으로 읽는다.
```text
persistent-user-sessions[:v1]
persistent-user-sessions
```
`--help-all` 은 출력이 길고 기능 목록이 한 줄에 쉼표로 이어 붙어 나온다. `tr ',' '\n'` 이 그것을 줄로 쪼갠다. 처음 한 번은 `grep` 없이 쳐서 어떤 기능들이 있는지 통째로 본다.
**왜 필요한가** — 목록에 없으면 그 버전에서는 이 절차를 할 수 없다. 기능이 제거돼 기본 동작으로 고정된 것이고, 그 자체가 답이다.
**문제가 생기면** — 아무것도 안 나오면 먼저 `grep` 을 떼고 출력 전체를 본다.
## 주입
주입은 셋이다. 여기서 치는 것은 첫째뿐이고, 7800·57800 양방향 차단과 PostgreSQL 정지는 A-1 과 A-2 를 다시 치는 순서 안에서 넣는다. 그 둘의 명령과 되돌리기는 그 단계에 적었다.
### 7. 세션 테이블을 비운다
**목적** — 전환 후 「DB 0건」이 성립할 수 있게 옛 행을 먼저 없앤다.
```bash label="[lab host] 온라인·오프라인 세션 행을 전부 지운다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_user_session"
```
**예상 결과**
```text
DELETE 151
```
**왜 필요한가** — volatile 은 새로 쓰지 않을 뿐 옛 행을 지우지도 않는다. 이 한 줄을 빼먹으면 전환 뒤에도 테이블에 행이 보이고, 그것을 「전환이 안 됐다」로 읽게 된다. 되돌리는 방법은 없다 — 지운 세션은 돌아오지 않는다. 어차피 전환 자체가 세션을 날리므로 순서만 앞당기는 것이지만, 운영에서 이 명령은 전원 로그아웃이다.
**문제가 생기면** — 삭제 건수가 0 이면 이미 비어 있다. 그대로 다음으로 간다.
### 8. args 를 volatile 로 바꾼다
**목적** — `persistent-user-sessions` 를 끄고 롤아웃이 끝날 때까지 기다린다.
방법은 둘이고 매니페스트를 고치는 쪽을 권한다. 무엇이 바뀌었는지 파일에 남는다.
먼저 매니페스트를 편집기로 연다.
```bash label="[lab host] ① 저장소의 매니페스트를 연다"
vim deploy/lab/k8s/keycloak-cluster.yaml
```
`args` 줄을 이렇게 고친다.
```yaml
# 149번째 줄 근처
args: ["start", "--features-disabled=persistent-user-sessions"]
```
고친 파일을 적용한다.
```bash label="[lab host] ② 적용한다"
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
파일을 안 건드리고 싶으면 patch 를 쓴다.
```bash label="[lab host] 파일 대신 patch 로 바꾸는 형태"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args",
"value":["start","--features-disabled=persistent-user-sessions"]}]'
```
전환 시각을 적고 롤아웃을 기다린다.
```bash label="[lab host] ③ 전환 시각을 남기고 롤아웃을 기다린다"
date '+%H:%M:%S 전환'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**예상 결과**
```text
statefulset.apps/keycloak configured
Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
```
**★ patch 로 바꾸면 `configured` 가 안 나온다**(2026-09-17, observed). `configured` 는 `kubectl apply` 가 내는 말이고, patch 는 `statefulset.apps/keycloak patched` 를 낸다. 아래 「`configured` 가 나와야 한다」는 매니페스트를 고쳐 `apply` 한 경우에만 맞는 판정이다. patch 로 쳤으면 바뀐 것을 args 문자열로 확인한다 — 9번이 그 일을 한다.
```text
statefulset.apps/keycloak patched
```
이 실험대에서 patch 로 쳤을 때 롤아웃은 `18:02:41` 에 시작해 `18:03:47` 에 끝났다. 재빌드까지 66초다.
**왜 필요한가** — `configured` 가 나와야 한다. `unchanged` 면 args 가 안 바뀌었다. 빌드 옵션이라 기동할 때 재빌드가 일어나 평소보다 오래 걸리므로 `--timeout=60s` 로 주면 멀쩡한 롤아웃을 실패로 읽는다. 전환 시각이 없으면 뒤에서 지표가 언제부터 변했는지 볼 때 인과를 못 붙인다.
**문제가 생기면** — 타임아웃이 나면 `--timeout=500s` 로 다시 치고, `logs keycloak-0` 에 빌드 진행이 보이는지 확인한다.
## 주입 검증
결과를 해석하기 전에 주입이 의도한 것만 건드렸는지 본다. 주입 ②와 ③의 검증은 그 주입을 친 단계 안에 있다 — 주입마다 검증이 따로 붙는다.
### 9. args 와 파드가 둘 다 새것인지 본다
**목적** — 선언만 바뀌고 프로세스는 그대로인 상태를 걸러 낸다.
```bash label="[lab host] args 와 파드 나이를 함께 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
kubectl -n keycloak-lab get pods -o wide | grep keycloak
```
**예상 결과**
```text
["start","--features-disabled=persistent-user-sessions"]
```
`AGE` 가 방금이고 `RESTARTS` 가 `0` 인지 함께 본다.
**왜 필요한가** — StatefulSet 의 `spec` 은 바뀌었는데 파드가 옛것이면 persistent 를 재면서 volatile 이라고 적게 된다.
**문제가 생기면** — 파드 나이가 예전 값이면 롤아웃이 안 끝났다. 8번의 `rollout status` 로 돌아간다.
IP 가 바뀌었으므로 다시 잡고, 탐침 파드도 지우고 새 IP 로 다시 띄운다. 탐침의 `K0`·`K1` 은 만들 때 고정된 값이라 롤아웃 뒤에는 낡았고, 낡은 주소로 친 curl 은 아무 데도 안 닿는다. 여기서는 아직 파드 안에 지킬 파일이 없으므로 지우고 다시 만들어도 잃을 것이 없다.
```bash label="[lab host] ① 롤아웃 뒤 IP 를 다시 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```bash label="[lab host] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
### 10. 로그인 5회 뒤 DB 행 수를 센다
**목적** — args 문자열이 아니라 동작이 바뀐 것을 본다.
```bash label="[lab host] ① 한쪽 노드에만 다섯 번 로그인한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'for i in 1 2 3 4 5; do
curl -s -o /dev/null -w "%{http_code} " -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"
done; echo'
```
```text
200 200 200 200 200
```
```bash label="[lab host] ② 같은 질의로 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"
```
**예상 결과**
```text
=== DB 에는 들어갔는가 (persistent 였을 때는 5건이 들어갔다) ===
offline_flag | count
--------------+-------
(0 rows)
```
**왜 필요한가** — 로그인 5회가 성공했는데 DB 에 아무것도 안 남았다. 세션이 메모리에만 있다. 전환 판정은 이 질의로 한다.
**문제가 생기면** — 행이 있으면 7번의 `delete from offline_user_session` 을 건너뛰었다. 지우고 다시 로그인한다.
### 11. 캐시 엔트리 수로는 두 모드를 못 가른다는 것을 확인한다
**목적** — 다음 사람이 이 지표로 판정하지 않도록, 두 모드가 같은 값을 낸다는 것을 눈으로 본다.
```bash label="[lab host] ① 한 줄짜리 JSON 을 통째로 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_statistics_approximate_entries_unique'
```
```json
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-2","pod":"keycloak-0"},"value":[1757046000.1,"5"]},
{"metric":{"__name__":"vendor_statistics_approximate_entries_unique","cache":"sessions","node":"kc-lab-1","pod":"keycloak-1"},"value":[1757046000.1,"0"]}]}}
```
라벨을 보고 나면 읽기 좋게 자른다. 아래 형태는 가이드가 미검증으로 표시한 줄이다(unknown).
```bash label="[lab host] ② 캐시 이름과 파드와 값만 세로로 늘어놓는다"
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]'
```
**예상 결과**
```text
keycloak-0 sessions 캐시 5.0 건
keycloak-1 sessions 캐시 0.0 건
```
**★ 위 두 줄은 이 명령의 출력이 아니다**(2026-09-17, observed). `tr` 과 `grep` 은 줄을 고를 뿐 짝지어 주지 않으므로, 실제로는 캐시 하나에 세 줄씩 나오고 파드 둘의 캐시 열여섯 개가 전부 나온다 — 이 실험대에서 96줄이었다. 위의 요약 두 줄은 원 실행의 스크립트가 만든 모양이다(inferred). 앞의 ① 이 찍는 JSON 도 마찬가지로 `sessions` 만이 아니라 캐시 전부를 담고 있다.
```text
"cache":"sessions"
"pod":"keycloak-1"}
"82"]}
"cache":"clientSessions"
"pod":"keycloak-1"}
"82"]}
```
세 줄이 한 묶음이고 마지막 줄의 숫자가 그 캐시의 엔트리 수다. 세로로 읽으면서 캐시 이름과 파드 이름을 눈으로 짝지어야 한다.
**왜 필요한가** — persistent 였을 때와 똑같은 숫자다. `approximate_entries_unique` 는 그 노드가 소유한 엔트리만 세고 백업본을 들고 있어도 0 으로 보인다. 이 지표만 보고 「전환이 안 됐다」고 판단하면 틀린다. 두 모드를 가르는 것은 10번의 DB 행 수다.
**문제가 생기면** — 빈 결과가 오면 0건이 아니라 그런 지표가 없다. Prometheus 의 스크레이프 대상 목록으로 돌아간다.
## 관찰
앞에서 친 것과 완전히 같은 명령을 순서대로 다시 친다. A-0 · A-8 · A-1 · A-2 차례다.
### 12. A-0 을 다시 돌린다 — 교차 노드는 여전히 200 이다
**목적** — 겉보기 결과가 persistent 때와 같은지 본다.
```bash label="[lab host] 로그인하고 반대 노드에서 갱신한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://$K1:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**예상 결과**
```text
=== 교차 노드 세션은 되는가 ===
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 200
```
**왜 필요한가** — DB 는 0건인데 `200` 이다. 경로가 완전히 달라졌는데 겉보기 답이 같다.
```text
persistent : keycloak-1 이 PostgreSQL 을 읽어서 답했다
volatile : keycloak-1 이 7800 을 통해 keycloak-0 에게 물어서 답했다
```
구별하려면 그 경로를 끊어 봐야 하고, 14번이 그것을 한다.
2026-09-17 에 새 실험대에서 12번과 14번을 이어서 쳤다(observed). 같은 명령 한 벌이 전환 전후로 정반대 답을 냈다.
```text
volatile · 분단 없음 로그인 sid=_szXQIOVaE8_v2-b-frn9npt 교차 노드 refresh 200
DB 세션 행 (0 rows)
캐시 keycloak-0 sessions 1 · keycloak-1 sessions 0
volatile · 7800 차단 로그인 sid=9tpo41BVY32uJdYOyv8maA5j 교차 노드 refresh 400
로그아웃 400 · 발급 노드 재갱신 400 invalid_grant / Invalid refresh token
DB 세션 행 0
```
**persistent 에서는 같은 것을 재면 `200` 이 나왔다.** A-1 에서 7800 을 막고 잰 값이 `200`·`204`·`200` 이었고 여기서는 `400`·`400`·`400` 이다. 그 한 칸이 두 모드의 차이를 전부 말한다 — persistent 는 DB 를 읽고, volatile 은 7800 을 탄다.
**문제가 생기면** — `400` 이 나오면 `/tmp/rt` 를 다시 안 채웠다. 로그인부터 다시 친다.
### 13. A-8 을 다시 돌린다 — 롤링 재시작이 곧 로그아웃이다
**목적** — 재시작 전에 발급한 토큰이 재시작 후에도 통하는지 본다.
재시작 전에 로그인해서 토큰과 `sid` 를 파드 안에 담는다.
```bash label="[lab host] ① 토큰을 담고 access token 의 클레임을 편다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
| cut -d. -f2 | base64 -d 2>/dev/null; echo'
```
access token 의 가운데 토막이 클레임이다. 모양은 이렇고 값은 환경마다 다르다.
```json
{"exp":1757046060,"iat":1757046000,"jti":"...","typ":"Bearer","azp":"admin-cli",
"sid":"aVwYnzKZFFvMqD3bpSeiILuM",...}
```
```text
=== [A-8 재실행] 재시작 전 로그인 ===
sid = aVwYnzKZFFvMqD3bpSeiILuM
```
`sid` 를 적어 둔다. base64 패딩 때문에 끝이 깨져 보일 수 있고 `2>/dev/null` 이 그 불평을 지운다. `sid` 는 앞쪽에 있어서 대개 보인다.
그다음 재시작한다.
```bash label="[lab host] ② 시각을 남기고 롤링 재시작을 건다"
date '+%H:%M:%S 재시작'
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
```text
statefulset.apps/keycloak restarted
partitioned roll out complete: 2 new pods have been updated...
```
파드 IP 를 다시 잡는다. 탐침은 다시 띄우지 않는다 — `/tmp/rt` 가 같이 사라진다. 그래서 새 IP 를 명령줄에 직접 넘긴다. `$K0` 는 `[lab host]` 셸의 변수이고 지금 값은 롤아웃 전 것이므로 먼저 다시 잡는다.
```bash label="[lab host] ③ 롤아웃 뒤 IP 를 다시 잡는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
echo "$K0 $K1"
```
```bash label="[lab host] ④ 재시작 전 토큰으로 갱신을 시도한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -w "\n%{http_code}\n" -X POST \
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
인용이 세 겹이다. 바깥 작은따옴표를 닫고, 셸이 `$K0` 를 펴게 큰따옴표로 감싸고, 다시 작은따옴표를 연다. 파드 안 셸에는 이미 펴진 IP 문자열이 들어간다. 무엇이 들어가는지 `echo` 로 한 번 찍어 보는 확인은 이 실험대에서 치지 않았다(unknown).
**예상 결과**
```text
=== ★ 재시작 전 토큰이 아직 통하는가 (persistent 였을 때는 200) ===
keycloak-0 에서 refresh HTTP 400
--- 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
```
**왜 필요한가** — 본문을 반드시 본다. `400` 만 보면 토큰이 이상한가로 읽히지만 `Session not active` 는 서버가 그 세션을 모른다는 뜻이고, 토큰 자체는 멀쩡하다. 캐시도 함께 보면 `keycloak-1` 에 1건이 있는데, 그것은 방금 실패한 요청이 새로 만든 세션이다. 옛 세션 5건은 어디에도 없다.
**문제가 생기면** — `200` 이 나오면 args 가 아직 기본값이다. 9번으로 돌아간다.
### 14. A-1 을 다시 돌린다 — 이번에는 세션 공유가 깨진다
**목적** — 7800 과 57800 을 양방향으로 버리고 교차 노드가 끊기는지 본다.
두 노드에 각각 규칙을 넣는다. 규칙의 `-d` 는 그 노드에 있는 파드의 IP 다. kubeconfig 가 lab host 에만 있으므로 두 노드 모두 거기서 한 줄 `ssh` 로 친다.
```bash label="[lab host] ① 양쪽 노드에 raw DROP 을 넣는다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP"
ssh kc-lab-1 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'
```
| 노드 | 그 노드에 있는 파드 | 규칙의 `-d` |
|---|---|---|
| `kc-lab-1` | `keycloak-1` | `$K1` |
| `kc-lab-2` | `keycloak-0` | `$K0` |
**네 `ssh` 줄은 중단 절차처럼 나눠 치면 안 된다.** 거기서는 `ssh kc-lab-2` 로 먼저 붙고 원격 셸에서 쳤지만, 여기는 `$K0` 와 `$K1` 이 들어간다. 둘 다 `[lab host]` 셸의 변수라 원격 셸에는 없고, 나눠 치면 빈 문자열이 들어가 `-d` 없는 규칙이 걸린다. 큰따옴표가 그 값을 `[lab host]` 에서 펴서 보내므로 이 네 줄은 한 줄 형태 그대로 친다. 붙어서 치고 싶으면 먼저 `echo "$K0"` 와 `echo "$K1"` 로 값을 읽어 원격 셸에서 IP 를 손으로 넣는다.
**원 가이드는 앞 두 줄을 `kc-lab-1` 안에서 직접 쳤다.** 지우지 않고 남기는 까닭은 아래 실패 출력이 이 실험대에서 실제로 받은 것이기 때문이다. 따라 칠 형태는 위의 ① 이다.
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 이 실험대에서는 안 돈다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 7800 -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K1 --dport 57800 -j DROP
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 7800 -j DROP"
ssh kc-lab-2 "sudo iptables -t raw -I PREROUTING 1 -p tcp -d $K0 --dport 57800 -j DROP"
date '+%H:%M:%S 차단'
```
**★ 이 블록은 `kc-lab-1` 에서 안 돈다**(2026-09-17, observed). 앞의 「읽기 전에」가 적은 그대로다 — 그 기계에는 kubeconfig 가 없어서 첫 두 줄의 `kubectl` 이 막히고, `$K0` 와 `$K1` 이 빈 문자열이 된다. 그 상태로 `iptables` 가 이어지면 `-d` 가 값을 못 받아 규칙이 하나도 안 들어간다. 그리고 `kc-lab-1` 에는 `kc-lab-2` 의 호스트 키가 없어 뒤의 두 줄도 접속 단계에서 끝난다. 여섯 줄이 전부 실패했는데 마지막 `date` 는 그대로 `차단` 을 찍는다.
```text
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied
Bad argument `7800'
Bad argument `57800'
Host key verification failed.
Host key verification failed.
09:04:49 차단
```
찍힌 시각을 주입 시각으로 적으면 A-6 의 `적용완료` 와 같은 함정이다. 그때 두 노드의 `raw PREROUTING` 은 비어 있었다.
NetworkPolicy 대신 `iptables` 를 쓰는 까닭은 A-1 에서 나왔다. NetworkPolicy 는 conntrack 의 ESTABLISHED 를 못 뚫어서 이미 붙어 있는 7800 연결이 계속 산다.
```text
패킷 도착
├─▶ raw PREROUTING ← conntrack 보다 먼저. 여기서 끊는다
├─▶ conntrack: ESTABLISHED 면 통과
└─▶ NetworkPolicy 평가 ← 여기까지 오지 않는다
```
`raw` 테이블은 CNI 가 안 쓰는 테이블이라 규칙이 밀려나지도 않는다. 57800 을 같이 막는 까닭은 장애 감지 채널 FD_SOCK2 가 `bind_port + 50000` 을 쓰기 때문이다. 7800 만 막으면 장애 감지가 살아 있어 분단이 어중간해진다.
주입이 걸렸는지 양쪽 카운터를 둘 다 본다.
```bash label="[lab host] ② 두 노드의 규칙과 카운터를 본다"
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed).
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
sudo iptables -t raw -L PREROUTING -n -v
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n -v'
```
모양은 이렇고 숫자는 환경마다 다르다.
```text
Chain PREROUTING (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
19 1140 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:7800
0 0 DROP tcp -- * * 0.0.0.0/0 10.42.0.46 tcp dpt:57800
```
규칙이 목록에 있는데 `pkts` 가 0 이면 패킷이 그 경로로 안 오는 것이고 분단은 안 만들어졌다. A-5 가 이 함정에 두 번 빠졌다.
분단이 성립할 때까지 25초 간격으로 몇 번 친다.
```bash label="[lab host] ③ 두 노드가 각각 아는 멤버 수를 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' \
| tr ',' '\n' | grep -E '"pod":|^"[0-9]'
```
```text
차단 적용 (A-5 에서 확인한 raw 테이블 방식, 양방향)
분단이 성립할 때까지 대기...
+25초 cluster_size(k0 k1) = [2.0 2.0 ]
+50초 cluster_size(k0 k1) = [1.0 ]
+75초 cluster_size(k0 k1) = [1.0 ]
+100초 cluster_size(k0 k1) = []
+125초 cluster_size(k0 k1) = [1.0 ]
```
`2.0 2.0` 이 `1.0` 으로 떨어지는 데 50초쯤 걸린다.
**★ 이 실험대에서는 `+75초` 에 떨어졌고 시계열에 빈 값이 없었다**(2026-09-17, observed). 손으로 25초마다 친 다섯 번은 `2 2` · `2 2` · `1 1` · `1 1` · `1 1` 이었다. 차단을 푼 뒤 양쪽이 `2` 로 돌아오는 데는 `+60초` 가 걸렸다. 빈 값과 값이 하나뿐인 줄은 측정 실패다 — 원래 실행은 20~25초마다 임시 파드를 띄워 지표를 긁는 스크립트를 썼고 파드 생성이 느려 빈 응답이 섞였다. 손으로 치면 빈 값이 나온 것이 그 즉시 보인다. 빈 값을 「0으로 떨어졌다」로 읽지 않는다.
split brain 은 DB 한 줄로 확인한다.
```bash label="[lab host] ④ 디스커버리 테이블의 코디네이터를 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
```
```text
name | ip | coord
------------------+-----------------+-------
keycloak-0-30843 | 10.42.1.99:7800 | t
keycloak-1-48749 | 10.42.0.46:7800 | t
```
`coord = t` 가 둘이면 분단이고 정상일 때는 하나다.
대조군을 먼저 재고 시험군을 잰다. 대조군은 같은 노드에서 갱신한다.
```bash label="[lab host] ⑤ 대조군 — 로그인한 노드에서 갱신한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
curl -s -o /dev/null -w "same-node %{http_code}\n" -X POST \
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
```bash label="[lab host] ⑥ 시험군 — 새로 로그인해서 반대 노드에서 갱신한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
curl -s -w "\ncross-node %{http_code}\n" -X POST \
"http://'"$K1"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
**예상 결과**
```text
=== ★ 분단 상태에서 교차 노드 세션 (persistent 였을 때는 200) ===
keycloak-0 로그인 → keycloak-0 에서 refresh HTTP 200 ← 대조군
keycloak-0 로그인 → keycloak-1 에서 refresh HTTP 400 ← 시험군
--- 시험군 오류 본문 ---
{"error":"invalid_grant","error_description":"Session not active"}
```
**왜 필요한가** — 대조군을 같이 재야 차단이 모든 것을 망가뜨린 게 아니라 교차 노드만 끊었다고 말할 수 있다. 시험군은 매번 새로 로그인해서 새 토큰으로 한다. refresh token 이 회전하기 때문이다.
```text
persistent : 세션 ── PostgreSQL ──▶ 양쪽이 본다 7800 무관
volatile : 세션 ── 클러스터(7800) ─▶ 상대에게 간다 7800 필수
```
「이 실험이 가르는 것」에서 미뤄 둔 판정이 여기서 난다. 통념은 24 이전에서 맞고, 틀린 것은 자료가 아니라 버전을 확인하지 않고 적용하는 것이다.
**문제가 생기면** — 교차 노드가 계속 `200` 이면 차단이 한쪽만 걸렸다. 두 노드 카운터를 둘 다 본다.
다음으로 넘어가기 전에 차단을 푼다. 명령은 전제와 되돌리기 절의 두 형태와 같다. 양쪽 `vendor_cluster_size` 가 `2` 로 돌아와야 한다 — 분단이 남아 있으면 다음 결과가 DB 때문인지 분단 때문인지 구별되지 않는다.
### 15. A-2 를 다시 돌린다 — 새 로그인은 되는데 refresh 가 안 된다
**목적** — DB 를 내리고 두 경로를 잰다.
내리기 전에 로그인해서 `/tmp/rt` 를 채운다. **12번의 명령을 쓰지 않는다** — 그 블록은 로그인한 다음 곧바로 반대 노드에서 refresh 까지 해서 방금 받은 토큰을 소모한다. refresh token 은 한 번 쓰면 회전하므로 `/tmp/rt` 에는 이미 쓴 값이 남고, DB 를 내린 뒤의 `500` 이 DB 때문인지 재사용 때문인지 구별되지 않는다. 로그인만 하고 끝나는 13번의 ① 을 쓴다.
```bash label="[lab host] ① 로그인만 해서 /tmp/rt 를 채운다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
sed -n "s/.*\"access_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok \
| cut -d. -f2 | base64 -d 2>/dev/null; echo'
```
**인용이 세 겹인 것이 여기서도 중요하다.** 작은따옴표 안에 그냥 `$K0` 를 두면 그것을 펴는 것은 파드 안 셸이고, 그 값은 탐침을 만들 때 `--env` 로 **구워 넣은 것**이다. 12번의 `rollout restart` 로 파드 IP 가 바뀐 뒤라 그 값은 낡았다 — 요청이 없는 주소로 나가고, 그때 나오는 실패가 이 절의 예상 결과인 `500` 과 화면에서 같아 보인다. 위 ④ 와 같은 형태로 `[lab host]` 셸의 지금 값을 넘긴다.
그다음 DB 를 내린다.
```bash label="[lab host] ② 시각을 남기고 DB 를 0 replica 로 내린다"
date '+%H:%M:%S 정지'
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
`delete pod` 이 아니라 `scale --replicas=0` 인 까닭은 Deployment 가 지운 파드를 곧바로 새로 만들기 때문이다. DB 가 없는 구간을 원하는 만큼 유지할 수 있어야 두 경로를 다 잰다.
두 경로를 차례로 친다.
```bash label="[lab host] ③ 캐시를 가진 노드에서 refresh"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"http://'"$K0"':8080/realms/master/protocol/openid-connect/token" \
-d grant_type=refresh_token -d client_id=admin-cli \
-d "refresh_token=$(cat /tmp/rt)"'
```
```bash label="[lab host] ④ 새 로그인"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'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"'
```
**예상 결과** — 이 실험대에서는 이 값이 나왔다. 아래 ①② 는 증거 파일의 번호이고 위 명령의 ③④ 와 차례가 같다.
```text
① 캐시를 가진 노드에서 refresh HTTP 500
② 새 로그인 HTTP 200
```
persistent 에서는 순서가 거꾸로였다. 새 로그인이 `500` 이었는데, 세션을 DB 에 써야 했기 때문이다. 그 쓰기가 없어지니 로그인이 통과한다.
```text
로그인에 필요한 것
├─ realm 설정 → Infinispan `realms` 캐시에 있다
├─ 사용자 자격 → `users` 캐시에 있다
└─ 세션 저장 → volatile 이므로 메모리
→ DB 없이 완결된다
```
**왜 필요한가** — 여기서 잰 두 숫자를 그대로 표에 옮기면 틀린 표가 된다. 같은 설정에서 캐시 온도만으로 답이 셋으로 갈리고, 이 값은 마침 롤아웃 뒤 로그인을 몇 번 했고 refresh 는 안 한 상태에서 쟀다. 캐시 온도는 `kubectl get` 어디에도 안 나오는 상태라 한 번 재고 넘어가면 조건을 모르는 채 결과만 남는다. 셋을 갈라 재는 절차는 A-7a 에 있고, A-7 이 남긴 「refresh 가 500 인 이유는 `REVOKED_TOKEN` 조회일 것」이라는 가설은 거기서 틀린 것으로 확정됐다. 실제 문장은 `CLIENT_SCOPE_CLIENT` 조회다.
**문제가 생기면** — `200 / 200` 이 나오면 캐시가 이미 더워졌다. 틀린 측정이 아니라 다른 상태를 잰 것이므로 A-7a 로 간다. 로그인이 `400 unauthorized_client` 면 완전 냉시동이고, 그것도 A-7a 가 가른다.
마지막으로 DB 를 되살린다.
```bash label="[lab host] ⑤ DB 를 다시 올린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
```text
deployment.apps/postgres scaled
deployment "postgres" successfully rolled out
```
volatile 이 「DB 없이 돌아간다」는 뜻은 아니다. realm 과 사용자와 클라이언트와 취소 토큰은 여전히 DB 에 있고, 세션만 메모리로 옮겼다.
## 복구와 원상복구 확인표
순서가 있다. `iptables` 가 남아 있지 않은지 먼저 보고, PostgreSQL 이 떠 있는지 보고, `args` 를 되돌린다.
```bash label="[lab host] ① 두 노드의 raw 규칙을 확인한다"
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n'
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
```
원 가이드는 앞 줄을 `kc-lab-1` 안에서 직접 치고 뒤 줄만 `ssh` 로 보냈다. 그 기계에는 `kc-lab-2` 의 호스트 키가 없어 뒤 줄이 `Host key verification failed` 로 끝난다(2026-09-17, observed).
```bash label="[kc-lab-1] 원 가이드가 적은 형태 — 뒤 줄이 이 실험대에서는 안 돈다"
sudo iptables -t raw -L PREROUTING -n
ssh kc-lab-2 'sudo iptables -t raw -L PREROUTING -n'
```
```bash label="[lab host] ② DB 파드를 본다"
kubectl -n keycloak-lab get pods -l app=postgres
```
`Running` 이 아니면 `scale deployment/postgres --replicas=1` 을 친다.
```bash label="[lab host] ③ args 를 기본값으로 되돌린다"
kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
매니페스트를 고쳤다면 파일도 같이 되돌린다. 안 그러면 다음에 `apply` 할 때 volatile 로 다시 간다.
```bash label="[lab host] ④ 매니페스트의 변경을 확인하고 되돌린다"
git diff deploy/lab/k8s/keycloak-cluster.yaml
git checkout -- deploy/lab/k8s/keycloak-cluster.yaml
```
```text
=== persistent 모드로 원복 ===
statefulset.apps/keycloak configured
partitioned roll out complete: 2 new pods have been updated...
```
`args` 문자열만 보고 끝내지 않는다. 새 IP 로 탐침을 다시 띄우고 로그인을 한 번 한 다음 DB 행을 센다. 원복도 롤아웃이므로 여기서도 파드 주소가 바뀌었다.
```bash label="[lab host] ⑤ 새 IP 로 탐침을 다시 띄운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1=$(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" --env="K1=$K1" \
--env="PW=$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)" \
--command -- sleep 7200
kubectl -n keycloak-lab wait --for=condition=Ready pod/a7-probe --timeout=120s
```
```bash label="[lab host] ⑥ 로그인을 한 번 한다"
kubectl -n keycloak-lab exec a7-probe -- sh -c \
'curl -s -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"'
```
```bash label="[lab host] ⑦ 로그인 뒤 온라인 세션 행을 센다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
```text
["start"]
로그인
DB 온라인 세션: 1 건 (1 이면 persistent 복귀)
keycloak-0 1/1 Running 0 67s
keycloak-1 1/1 Running 0 89s
postgres-7b474b88c8-t6rrf 1/1 Running 0 2m8s
외부 진입점 HTTP 200
```
앞에서 테이블을 비웠으므로 여기서 세는 값은 방금 만든 세션 하나다. 0 이면 아직 volatile 이다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| args | `kubectl -n keycloak-lab get statefulset keycloak -o jsonpath='{.spec.template.spec.containers[0].args}'` | `["start"]` |
| 매니페스트 | `git diff deploy/lab/k8s/keycloak-cluster.yaml` | 출력 없음 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| 동작 | 로그인 뒤 `select count(*) ...` | 세션 행이 생긴다 |
| iptables | `sudo iptables -t raw -L PREROUTING -n` (두 노드) | 규칙 없음 |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `kubectl -n keycloak-lab get pod a7-probe` | `NotFound` |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
**★ 마지막 줄의 `200` 은 이 실험대에서 안 나온다**(2026-09-17, observed). `auth.hyeonworks.com` 이 lab host 에서 `100.83.212.4` 로 풀리고 그 주소의 443 이 닫혀 있어 `000` 이 나온다. 엣지 주소를 짚으면 `200` 이고, 까닭은 A-6 의 같은 줄에 적었다. 나머지 여덟 줄은 이 실험대에서 그대로 통과했다 — args `["start"]`, `git diff` 출력 없음, 파드 둘 다 `1/1 Running`, postgres `1/1 Running`, 로그인 뒤 세션 행 `1`, 두 노드 `raw PREROUTING` 비어 있음, `vendor_cluster_size` 양쪽 `2`, 탐침 `NotFound`.
```bash label="[lab host] ⑧ 탐침 파드를 지운다"
kubectl -n keycloak-lab delete pod a7-probe --ignore-not-found
```
## 막히면
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `rollout status` 가 타임아웃 | 빌드 옵션이라 재빌드가 일어난다 | `--timeout=500s` 로 다시. `logs keycloak-0` 에 빌드 진행 |
| `apply` 가 `unchanged` | args 를 안 고쳤거나 다른 파일을 고쳤다 | `get statefulset ... -o jsonpath='{...args}'` 로 실제 값 |
| 전환했는데 DB 에 행이 있다 | `delete` 를 건너뛰었다. 옛 행은 안 지워진다 | `delete from offline_user_session` 후 다시 로그인 |
| 캐시가 `5 / 0` 이라 전환이 안 된 것 같다 | 두 모드가 같은 값을 낸다 | 판정은 DB 행 수로 한다 |
| 차단했는데 `cluster_size` 가 계속 2 | 규칙이 안 걸렸거나 `pkts` 가 0 | `iptables -t raw -L PREROUTING -n -v` 의 카운터 |
| `cluster_size` 결과가 비었다 | 측정 실패다. 스크립트가 빈 값을 뱉었다 | 손으로 다시 친다. 빈 값은 판정에서 뺀다 |
| 교차 노드가 계속 `200` | 차단이 한쪽만 걸렸다 = 단방향 | 두 노드 카운터를 둘 다 본다 |
| 재시작 뒤 아무 데도 안 닿는다 | 파드 IP 가 바뀌었다 | `get pod -o jsonpath='{.status.podIP}'` 다시 |
| refresh 가 `400` 인데 이유를 모르겠다 | 본문을 안 봤다 | `-o /dev/null` 을 빼고 `Session not active` 인지 본다 |
| A-2 재실행이 `200 / 200` 이 나온다 | 캐시가 이미 더워졌다. 틀린 게 아니다 | 조건부다 — A-7a |
| 로그인이 `400 unauthorized_client` | 완전 냉시동이다. 클라이언트 조회조차 캐시에 없다 | 이것도 조건부 — A-7a |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| 다음 실험 결과가 이상하다 | 원복을 안 했다 | 확인표를 전부 통과시킨다 |
## 무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 전환 전 `args` 가 `["start"]` 이고 파드 IP 가 `10.42.1.94` 와 `10.42.0.45` 였던 것, `DELETE 151`, 전환 뒤 `args` 가 `["start","--features-disabled=persistent-user-sessions"]` 인 것, 로그인 5회 뒤 `(0 rows)` 와 캐시 `5.0`/`0.0`, 교차 노드 refresh `HTTP 200`, 재시작 전 `sid = aVwYnzKZFFvMqD3bpSeiILuM` 와 재시작 뒤 `HTTP 400` 및 `Session not active`, 재시작 뒤 캐시 `1.0`, 분단 대기 시계열 다섯 줄, 대조군 `200` 과 시험군 `400`, DB 정지 뒤 `① 500` 과 `② 200`, 원복 뒤 `["start"]` 와 `DB 온라인 세션: 1 건` 과 외부 `200`, 비밀번호 길이 `19`, 기능 목록의 `persistent-user-sessions[:v1]` 이다.
가이드가 미검증으로 표시한 `tr ',' '\n' | grep -E` 로 자른 Prometheus 출력은 2026-09-17 에 쳤다(observed). 나오는 모양은 위 11번의 ★ 에 적었다. `ssh kc-lab-2` 로 들어가 원격 셸에서 `iptables` 를 치는 두 단계 형태와, 세 겹 인용에 무엇이 들어가는지 `echo` 로 찍어 보는 확인도 이 실험대에서 치지 않았다.
측정이 샌 곳이 하나 있다. `cluster_size` 시계열의 빈 값과 값이 하나뿐인 줄인데, 임시 파드를 띄워 지표를 긁는 스크립트가 빈 응답을 섞었다. 그 줄들은 판정에서 뺀다.
DB 정지 뒤의 `200 / 500` 은 조건부다. 캐시 온도에 따라 `400 / 400` 이나 `200 / 200` 도 나오고, 셋을 가르는 절차는 A-7a 에 있다. A-7 이 세운 원인 가설도 A-7a 가 틀린 것으로 확정했다.
이 절차가 재지 않은 것은 volatile 상태에서 노드를 추가했을 때 복제 트래픽이 어떻게 늘어나는지다. 파드가 둘뿐이라 그것을 볼 수 없다.
2026-09-17 에 이 절차를 1번부터 복구까지 다시 밟았다(observed). 판정값은 전부 같은 답이 나왔다 — 전환 전 args `["start"]` 와 `DELETE 144`, 전환 뒤 args `["start","--features-disabled=persistent-user-sessions"]`, 로그인 5회 `200 200 200 200 200` 뒤 DB `(0 rows)`, 교차 노드 refresh `200`, 재시작 전 `sid` 를 담고 재시작 뒤 `400` 과 `Session not active`, 분단에서 대조군 `same-node 200` 과 시험군 `cross-node 400`, `coord = t` 둘, DB 정지 뒤 `500` 과 `200`, 원복 뒤 `["start"]` 와 세션 행 `1`. 달랐던 것은 명령이 아니라 화면의 모양이고 위의 ★ 다섯이 그것이다. 원문은 `relive-2026-09-17/a7-06..08` 에 있다.
<!-- body:end -->