Files
keycloak-pattern/docs/guides/experiments/a7a-volatile-cause.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

892 lines
37 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-7a 재현 가이드 — DB 에게 직접 물어서 원인을 확정하고, 같은 설정에서 세 가지 답을 본다
해설 문서: [`docs/experiment-a7a-volatile-cause.md`](../../experiment-a7a-volatile-cause.md) ·
증거 원문: [`docs/evidence/a7a-volatile-cause/`](../../evidence/a7a-volatile-cause/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 로그인이 SQL 을 **0개** 쏘는 것 | PostgreSQL 문장 로그 |
| refresh 가 쏘는 **딱 한 문장**의 이름 | 같은 로그 — `CLIENT_SCOPE_CLIENT` |
| 그 문장이 **첫 refresh 에만** 나오는 것 | 표식 사이 SQL 0건 |
| A-7 이 지목한 `REVOKED_TOKEN`**한 번도 안 나오는 것** | 같은 로그 |
| 같은 설정에서 **400 · 500 · 200 셋이 다 나오는 것** | 캐시 온도 세 상태 |
| 실패한 SQL 을 Keycloak 로그가 **직접 지목하는 것** | `JDBC exception executing SQL [...]` |
## 전제
- [`05-keycloak`](../05-keycloak/) 이 끝나 있다.
- **[A-7](a7-volatile-comparison.md) 을 먼저 한다.** 이 실험은 A-7 이 남긴
가설을 확정하는 것이고, A-7 의 4-4 에서 본 `500` 이 출발점이다.
- [A-3](a3-database-crash.md) 의 문장 로깅을 해 봤으면 3절이 익숙할 것이다.
같은 기법이다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 터미널 **두 개**를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.
## 주의 — 주입이 세 개다. 복구도 세 개다
1. PostgreSQL **문장 로깅**을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
2. Keycloak 을 **volatile** 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
3. PostgreSQL 을 **여러 번 내렸다 올린다** → 마지막에 올라와 있어야 한다
**실험대에서만 한다.** 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어
두었다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 그대로 친다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:1811:24 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
> **시각이 UTC 다.** 증거 파일의 `11:18:49` 는 KST 로 20:18 이다. 문서 상단의
> `20:1820:24 KST` 와 같은 시각이며, **PostgreSQL 컨테이너가 UTC 로 로그를
> 찍기 때문**이다. 로그 시각과 `date` 를 비교할 때 이걸 잊으면 9시간을 헤맨다.
UUID·IP·파드 이름은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
A-7 은 이렇게 끝났다.
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는 `REVOKED_TOKEN`
> 테이블이다 — refresh token 회전에서 **이미 쓴 토큰인지** 확인하려면 그 테이블을
> 봐야 하고, 그 경로는 캐시되지 않는다.
**그럴듯하다. 그리고 틀렸다.**
```
가설을 세우는 것 → 괜찮다
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
```
「refresh 가 어느 테이블 때문에 실패하는가」는 **추측으로 답할 문제가 아니다.**
Keycloak 소스를 읽는 대신 **DB 가 실제로 받은 문장**을 보면 된다.
그리고 확정해 보니 원인만 틀린 게 아니었다. **A-7 의 표 자체가 조건부였다.**
같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. **한 번 재고 표로 적으면
안 되는 종류의 측정**이었던 것이다.
---
# 1. 기준선 — 켜기 전에 지금 상태를 본다
넓은 것부터 좁혀 간다.
```
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
```
## 1-1. 파드
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
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
```
**어디를 봐야 하는가** — 셋 다 `Running`. **`postgres` 가 있어야 한다** —
이 실험은 그것을 내렸다 올렸다 한다.
## 1-2. 문장 로깅이 꺼져 있나
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
none
```
**어디를 봐야 하는가**`none`.
**이 결과가 의미하는 것** — 앞 실험이 켜 둔 채 끝내지 않았다. `all` 이면
**누가 켜 두었는지 모르는 상태**이고, 그대로 진행하면 지금 쌓인 로그가 어느
실험 것인지 구별할 수 없다. 그때는 먼저 끄고, 로그가 한 바퀴 돌 때까지 기다린다.
## 1-3. 지금 args 가 무엇인가
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**형태**
```
["start"]
```
**이 값을 적어 둔다.** 5-3 에서 이대로 되돌린다.
## 1-4. 탐침 파드
Keycloak 컨테이너에는 `curl``wget` 도 없다(`exit 127`). 탐침 파드를 띄운다.
**이 실험은 Keycloak 을 여러 번 재시작하므로 탐침은 반드시 StatefulSet 밖에
있어야 한다.**
**하기**
```bash
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--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 -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 터미널에도 셸
> 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
> **실측** — `19`
>
> **★ 명령줄에 평문 비밀번호를 쓰지 않는다.** 원래 실험의 재현 절차에는 그대로
> 적혀 있는데, **파드 안 `ps` 에도 셸 히스토리에도 남는다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**형태**
```
K0=10.42.1.94 PW길이=19
```
## 1-5. 로그가 지금 무엇으로 차 있나 — **이걸 알아야 걸러 낼 수 있다**
**확인** — 로깅을 켜기 전에 한 번 본다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=20
```
**어디를 봐야 하는가** — 조용하다. 여기까지는 에러만 찍힌다.
**이 결과가 의미하는 것** — 로그가 조용한 것이 기준선이다. 다음 절에서 켜면
**JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링**이 로그를 계속 채운다. 그것이
소음이고, 4절에서 `grep -v JGROUPS_PING` 으로 거른다. **소음을 먼저 봐 두면
거르는 이유를 안다.**
---
# 2. 주입 — 두 개를 순서대로 넣는다
## 2-1. 주입 ① PostgreSQL 문장 로깅
### 개념 — 문장 로깅은 무엇인가
**무엇인가.** `log_statement = 'all'` 을 켜면 서버가 받은 **모든 SQL** 을 로그에
찍는다. 애플리케이션을 고치지 않고 **「이 요청이 DB 를 어떻게 쓰는지」** 를
밖에서 볼 수 있다.
**왜 여기 나오나.** 「refresh 가 어느 테이블 때문에 실패하는가」를 확정하려면
DB 가 실제로 받은 문장을 봐야 한다. Keycloak 안을 들여다볼 필요가 없다.
**없거나 틀리면.** 여기서 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블
이름을 골라 가설로 적게 되고, **그게 틀려도 아무도 모른다.**
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -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 "select pg_reload_conf()"
```
**확인** — 실제로 켜졌나
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**형태**
```
log_statement
---------------
all
```
`none` 이면 `pg_reload_conf()` 가 안 돈 것이다. **`alter system`
`postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.**
**확인** — 로그가 실제로 차기 시작했나
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=10
```
**형태**
```
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
```
**어디를 봐야 하는가****`JGROUPS_PING` 이 계속 나온다.** 1-5 에서 예고한 소음이다.
이게 안 보이면 로깅이 안 켜진 것이다.
## 2-2. 주입 ② volatile 전환
**되돌리기** — 먼저 읽어 둔다
```bash
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**하기**
```bash
sudo 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"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
**빌드 옵션이라 기동 시 재빌드가 일어나 오래 걸린다.** `--timeout=500s` 를 주는
이유다.
**★ 파드 IP 가 바뀌었다.** 탐침 파드를 다시 띄운다.
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --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 -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
---
# 3. 주입이 실제로 걸렸는지 확인한다
## 3-1. args 와 동작을 둘 다 본다
**확인**
```bash
sudo kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
sudo kubectl -n keycloak-lab exec a7a-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"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
volatile 전환 확인
args: ["start","--features-disabled=persistent-user-sessions"]
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
```
**어디를 봐야 하는가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 **행이
안 생기는 것**이 volatile 의 증거다.
> 행 수가 0 이 아니면 옛 행이 남아 있는 것이다. A-7 의 2-1 처럼
> `delete from offline_user_session` 을 먼저 하고 다시 잰다.
## 3-2. 문장 로그가 지금 요청을 잡고 있나
**확인** — 방금 로그인 직후에 친다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
```
**어디를 봐야 하는가**`JGROUPS_PING` 말고 다른 것이 섞여 있는지.
**이 결과가 의미하는 것** — 이 시점에서는 **거의 `JGROUPS_PING` 뿐일 것**이다.
그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다.
**구간을 나눠야 볼 수 있다.** 그게 다음 절이다.
---
# 4. 효과를 관찰한다
## 4-1. 표식으로 구간을 나눈다
로그는 `JGROUPS_PING` 폴링으로 계속 채워진다. 어느 문장이 로그인이고 어느 것이
refresh 인지 가르려면 **경계를 찍어야 한다.**
**개념**`psql` 로 아무 `select` 나 보내면 **그 문장 자체가 로그에 남는다.**
그러면 리터럴 문자열이 로그 안의 이정표가 된다.
**하기** — 표식 하나를 넣어 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
```
**형태**
```
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
```
**어디를 봐야 하는가**`statement: select 'MARK_TEST'` 가 보이는 것.
안 보이면 로깅이 안 켜졌다(2-1 로 돌아간다).
**이 결과가 의미하는 것** — 이제 **표식과 표식 사이만 잘라 볼 수 있다.**
## 4-2. 로그인이 무슨 SQL 을 쏘는가
**하기** — 표식 → 로그인 → 표식. **세 명령을 붙여서 친다**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_START'"
sudo kubectl -n keycloak-lab exec a7a-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"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_END'"
```
**형태**
```
rt 1188 bytes
```
**`1 bytes` 면 파싱이 실패한 것이다.** 그 상태로 4-3 을 하면 빈 토큰을 보내고
엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
**확인** — 표식 사이를 잘라 본다
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_LOGIN_START/,/MARK_LOGIN_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
11:18:49.461 statement: select 'MARK_LOGIN_START'
11:18:49.743 statement: select 'MARK_LOGIN_END'
↑ 사이에 아무것도 없다
```
**어디를 봐야 하는가****두 줄뿐이다.**
**이 결과가 의미하는 것****로그인은 SQL 을 0개 쏜다.** realm·사용자·클라이언트가
전부 Infinispan 캐시에 있고, volatile 이라 세션 쓰기도 없다. DB 없이 완결된다 —
A-7 이 적은 그대로다.
> `awk '/A/,/B/'` 는 **A 가 나온 줄부터 B 가 나온 줄까지** 출력한다. 로그를 구간으로
> 자를 때 이보다 짧게 쓰는 방법은 없다. 파일로 먼저 받는 것은 같은 로그를 여러
> 구간으로 반복해서 잘라 볼 것이기 때문이다.
## 4-3. ★ refresh 는 딱 한 문장을 쏜다 — 그리고 가설이 지목한 것이 아니다
**하기** — 표식 → refresh → 표식
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_START'"
sudo kubectl -n keycloak-lab exec a7a-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)"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_END'"
```
**형태**
```
200
```
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
11:18:52.009 statement: select 'MARK_REFRESH_START'
11:18:52.137 statement: BEGIN
11:18:52.137 execute <unnamed>/C_107:
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
11:18:52.148 execute S_2: COMMIT
11:18:52.253 statement: select 'MARK_REFRESH_END'
```
**어디를 봐야 하는가** — 세 가지다.
- **문장이 하나뿐이다.** `BEGIN` / `COMMIT` 사이에 `select` 한 개
- 테이블 이름이 **`CLIENT_SCOPE_CLIENT`** 다
- `parameters` 줄의 **`$2 = 'f'`**
**확인** — 가설이 지목한 테이블이 정말 없는지 직접 센다
```bash
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
```
**이 결과가 의미하는 것****A-7 의 가설은 틀렸다.** 그럴듯했지만 로그가
아니라고 말한다. 그리고 이제 **로그가 지목하는 문장**이 있다.
### 개념 — `DEFAULT_SCOPE='f'` 가 무슨 뜻인가
**무엇인가.** Keycloak 의 클라이언트는 스코프를 두 종류로 갖는다.
| | 뜻 | `DEFAULT_SCOPE` |
|---|---|---|
| default scope | 항상 붙는다 | `t` |
| **optional scope** | **요청이 `scope=` 로 달라고 해야 붙는다** | **`f`** |
**왜 여기 나오나.** refresh 는 **새 access token 을 만든다.** 그 토큰에 어떤
스코프를 담을지 정하려면 「이 클라이언트가 요청 가능한 optional 스코프가
무엇인가」를 알아야 한다. 그 목록이 `CLIENT_SCOPE_CLIENT` 에 있다. **로그인
때는 이미 결정된 것을 쓰지만, refresh 는 다시 계산한다.**
**없거나 틀리면.** 이 조회가 실패하면 토큰을 만들 수 없어 **500** 이다.
`400 Session not active` 와 달리 **세션 문제가 아니다** — 그래서 A-7 이 세션 계열
테이블(`REVOKED_TOKEN`)을 의심한 것이 자연스러웠지만 틀렸다.
**확인** — 그 UUID 가 어느 클라이언트인지 궁금하면 물어본다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, client_id from client where id='131a9912-b578-4b9c-b16a-97518704077e'"
```
**당신 환경에서는 UUID 가 다르다.** 위 로그의 `$1` 값을 그대로 넣는다.
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
## 4-4. 그 조회는 한 번뿐이다 — 여기서 표가 흔들리기 시작한다
**하기** — refresh 를 연속 3회. 사이사이 표식을 넣는다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R1'"
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -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)" > /tmp/tok
sed -n "s/.*\"refresh_token\":\"\([^\"]*\)\".*/\1/p" /tmp/tok > /tmp/rt
echo "rt $(wc -c < /tmp/rt) bytes"'
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R2'"
```
**★ 매번 `/tmp/rt` 를 다시 채운다.** refresh token 은 회전한다. 옛 것을 계속 쓰면
나오는 오류가 **무효화 때문인지 재사용 때문인지 구별되지 않는다.**
같은 모양으로 `MARK_R3` · `MARK_R_END` 까지 두 번 더 한다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs deploy/postgres --tail=4000 > /tmp/pg.log
awk '/MARK_R1/,/MARK_R_END/' /tmp/pg.log | grep -v JGROUPS_PING
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
연속 refresh 3회, 전부 200. 표식 사이 SQL:
statement: select 'MARK_R1'
statement: select 'MARK_R2'
statement: select 'MARK_R3'
statement: select 'MARK_R_END'
↑ SQL 0건
```
**어디를 봐야 하는가****표식 네 줄만 있고 그 사이에 아무것도 없다.**
**이 결과가 의미하는 것** — **첫 refresh 가 캐시를 채우고, 이후로는 DB 를 보지
않는다.** 그러면 이런 질문이 따라온다.
> **DB 를 언제 내리느냐에 따라 답이 달라지는 것 아닌가?**
그렇다. 그게 다음 절이다.
## 4-5. ★ 같은 설정에서 답이 셋으로 갈린다 — 셋 다 재현한다
| 캐시 상태 | 로그인 | refresh | 실패한 SQL |
|---|---|---|---|
| **완전 냉시동** (재시작 직후) | **400** | 400 | `select ce1_0.ID from CLIENT where CLIENT_ID=? and REALM_ID=?` |
| **CLIENT 만 더움** ← A-7 이 본 것 | 200 | **500** | `select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT …` |
| **완전히 더움** | 200 | **200** | 없음 (SQL 0건) |
**★ 한 번만 재고 넘어가면 반드시 틀린 표를 쓰게 된다.** A-7 이 그렇게 했다.
셋 다 재현해야 한다.
**되돌리기** — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
```
### 캐시를 식히는 방법 — **Keycloak 재시작이 유일하다**
```
Infinispan 캐시 = 프로세스 메모리
└─ 파드가 살아 있는 한 안 식는다
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
```
**이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다.** 이미 더워진 캐시에서 계속
재게 되므로 **A·B 를 재도 C 의 답(200/200)이 나오고**, 「A-7 이 틀렸다」는 엉뚱한
결론에 도달한다.
### 재현 A — 완전 냉시동이면 로그인부터 400
**하기**
```bash
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**★ 재시작과 DB 정지 사이에 아무 요청도 보내지 않는다.** 한 번이라도 로그인하면
캐시가 더워져서 이건 재현 B 가 된다.
파드 IP 가 바뀌었으므로 탐침을 다시 띄운다.
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --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 -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
```
**확인** — 로그인. **본문까지 본다**
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -w "\n%{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"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
```
**어디를 봐야 하는가**`unauthorized_client`. **`invalid_grant` 가 아니다.**
세션 문제가 아니라 **클라이언트를 못 찾은 것**이다.
**확인** — 왜인지는 Keycloak 로그가 직접 말한다
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
**실측** — 같은 파일
```
ERROR [org.keycloak.services] KC-SERVICES0015: Unexpected error when
authenticating client: org.hibernate.exception.GenericJDBCException:
JDBC exception executing SQL [FATAL: terminating connection due to
administrator command]
[select ce1_0.ID from CLIENT ce1_0 where ce1_0.CLIENT_ID=? and ce1_0.REALM_ID=?]
```
**어디를 봐야 하는가** — 대괄호가 **두 쌍**이다. 앞은 **DB 가 준 오류**,
뒤는 **실패한 SQL 원문**. `grep -oE` 로 그 두 쌍만 뽑는 이유가 이것이다.
> 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저
> 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다.
**이 결과가 의미하는 것** — **A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었다.
냉시동에서는 아니다.** 클라이언트 조회조차 캐시에 없기 때문이다.
### 재현 B — A-7 이 본 그 조건
**하기** — DB 를 살리고, 재시작하고, **로그인만 한 번** 하고, DB 를 내린다
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤(재현 A 와 같은 명령), **로그인 한 번만** 한다.
```bash
sudo kubectl -n keycloak-lab exec a7a-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"'
```
**★ 여기서 refresh 를 하면 안 된다.** 하는 순간 재현 C 가 된다.
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**확인** — 이제 refresh
```bash
sudo kubectl -n keycloak-lab exec a7a-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)"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
로그인 200
refresh 500 {"error":"unknown_error"}
```
**확인** — 실패한 SQL
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
**실측** — 같은 파일
```
JDBC exception executing SQL [FATAL: terminating connection due to
administrator command]
[select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
where cscme1_0.CLIENT_ID=? and cscme1_0.DEFAULT_SCOPE=?]
```
**어디를 봐야 하는가****4-3 에서 본 그 문장이다.** 문장 로깅이 「이 문장을
쏜다」를 보여줬고, 여기서는 「이 문장이 실패했다」를 보여준다. **두 개가 만나면
가설이 아니라 확정이다.**
**`500 unknown_error` 인 이유도 이제 안다.** 세션은 멀쩡하다. 토큰을 조립하다가
DB 가 없어서 못 만든 것이고, Keycloak 은 그걸 사용자 오류로 분류할 방법이 없어서
`unknown_error` 를 준다.
### 재현 C — 완전히 더우면 둘 다 200
**하기** — DB 를 살리고, 재시작하고, **refresh 를 3회 미리 돌린 뒤** DB 를 내린다
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤, 로그인 1회 + refresh 3회(4-4 와 같은 형태로
`/tmp/rt` 를 매번 갱신하며).
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=0
sudo kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**확인** — 로그인과 refresh 를 둘 다
```bash
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c \
'curl -s -o /dev/null -w "login %{http_code}\n" -X POST \
"http://$K0:8080/realms/master/protocol/openid-connect/token" \
-d grant_type=password -d client_id=admin-cli -d "password=$PW" -d username=admin
curl -s -o /dev/null -w "refresh %{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)"'
```
**실측** — [`01-cause-determined.txt`](../../evidence/a7a-volatile-cause/01-cause-determined.txt)
```
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
로그인 200
refresh 200 ← A-7 의 표와 정반대다
```
**이 결과가 의미하는 것****같은 설정, 같은 명령, 세 개의 답.** 무엇이 다른지는
`kubectl get` 어디에도 안 나온다. **캐시 온도는 보이지 않는 상태다.**
```
volatile + DB 정지의 결과
= "무엇을 하느냐"가 아니라
"그 경로가 이미 캐시를 채웠느냐"
```
> **A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의
> 함정이다.** 상태가 결과를 바꾸는데 그 상태가 안 보인다.
> **persistent(기본값)에는 해당하지 않는다.** 세션 자체를 DB 에 쓰므로 DB 가
> 없으면 캐시 온도와 무관하게 실패한다. **이 조건부성은 volatile 고유의
> 성질**이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
---
# 5. 복구
**세 개를 순서대로 되돌린다.** 순서가 있다 — DB 가 살아 있어야 나머지가 된다.
## 5-1. PostgreSQL 을 되살린다
**하기**
```bash
sudo kubectl -n keycloak-lab scale deployment/postgres --replicas=1
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
```
## 5-2. ★ 문장 로깅을 끈다 — 잊으면 다음 실험이 전부 오염된다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -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'` 이면 **로그인 하나에 SQL 열 몇 줄씩** 쌓인다.
로그가 폭주하고, 디스크 I/O 가 늘어 **크래시 타이밍 자체가 달라진다.**
**다음 실험의 측정값이 이 설정 때문에 바뀐다.**
## 5-3. args 를 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab patch statefulset keycloak --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["start"]}]'
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
## 5-4. 정말 persistent 로 돌아왔는지 — 동작으로 확인한다
**args 문자열만 보고 끝내지 않는다.**
**하기** — 탐침을 새 IP 로 띄우고 로그인 한 번
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(sudo kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
sudo kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --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 -- sleep 600
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/a7a-probe --timeout=120s
sudo kubectl -n keycloak-lab exec a7a-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"'
```
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**어디를 봐야 하는가****0 이 아니어야 한다.** 로그인 후 행이 생기면 persistent 다.
원래 재현 절차가 마지막에 이 한 줄을 두는 이유가 이것이다.
## 5-5. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| **문장 로깅** | `psql -c "show log_statement"` | **`none`** |
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
| DB | `sudo kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| **동작** | 위 5-4 | 로그인 후 세션 행이 **생긴다** |
| 파드 | `sudo kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `sudo kubectl -n keycloak-lab get pod a7a-probe` | `NotFound` |
| 임시 파일 | `ls /tmp/pg.log` | 지워도 된다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
```bash
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log
```
> **이 실험이 재지 않은 것** — 캐시가 「얼마나 오래」 더운지는 재지 않았다.
> `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로, **한참 뒤에 다시 재면
> 또 다른 답이 나올 수도 있다.** 그것까지 확인하려면 재현 C 뒤에 시간을 두고
> 같은 시험을 반복해야 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement``all` 인지 — 2-1 |
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` — 4-2 |
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
| 로그 시각이 9시간 어긋난다 | **컨테이너 로그가 UTC 다** | `date -u` 와 비교한다 |
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 — 4-4 |
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 — 4-2 |
| 세 재현이 전부 `200/200` | **재시작을 건너뛰어 캐시가 계속 더웠다** | 재현마다 `rollout restart` — 4-5 |
| 재현 A 가 `200` 이 나온다 | 재시작 후 요청을 한 번이라도 보냈다 | 재시작 → **바로** DB 정지 |
| 재현 B 가 `200/200` | 로그인 뒤 refresh 를 미리 했다 | 로그인 **한 번만** 하고 DB 정지 |
| `JDBC exception` grep 이 빈 출력 | `--tail` 이 짧거나 정규식이 안 맞는다 | `grep -i 'JDBC exception'` 으로 먼저 넓게 |
| 재시작 뒤 아무 데도 안 닿는다 | **파드 IP 가 바뀌었다** | 탐침을 지우고 새 IP 로 다시 띄운다 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 탐침 파드를 쓴다 |
| 다음 실험의 postgres 로그가 폭주한다 | **문장 로깅을 끄지 않았다** | `show log_statement``none` — 5-2 |
| 다음 실험의 세션이 안 살아남는다 | **volatile 로 둔 채 끝냈다** | 5-4 의 행 수 확인 |
---
# 왜 이 가이드는 표식을 손으로 넣게 하나
원래 실행은 표식을 셸 함수로 감쌌다.
```bash
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
```
짧고 편하다. 그런데 **출력을 `/dev/null` 로 버린다.** 표식이 실제로 로그에
들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이다. 로깅이 안 켜져
있었다면 **표식 없는 로그를 한참 뒤에 `awk` 로 자르다가** 알게 된다.
이 가이드는 표식을 **한 줄씩 손으로** 넣는다. 느리지만 그 자리에서 보이고,
안 보이면 그 자리에서 안다.
---
# 다음
| 실험 | A-7a 가 남긴 것 |
|---|---|
| [A-7](a7-volatile-comparison.md) volatile 비교 | **그 표에 조건을 붙여야 한다.** 「로그인 200 · refresh 500」은 캐시가 반쯤 더울 때만 참이다 |
| [A-3](a3-database-crash.md) DB 크래시 | 같은 문장 로깅 기법. **RPO 를 재는 데 쓴다** |
| [A-2](a2-database-loss.md) DB 정지 | persistent 에서는 캐시 온도와 무관하게 실패한다 — 대조군 |
| 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |