Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/setup/setup-reproduce-a7a-volatile-cause.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

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

856 lines
44 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.
---
id: 21dce25a-a165-47dc-bb40-2ed9f6f9efea
kind: SETUP
slug: reproduce-a7a-volatile-cause
title: 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
topic: session-custody-across-nodes
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/21dce25a-a165-47dc-bb40-2ed9f6f9efea/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-7a
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 문장 로깅으로 그 500 을 낸 SQL 을 확정하고 캐시 온도 셋을 재현한다
PostgreSQL 문장 로깅을 켜고 volatile 상태의 로그인과 refresh 가 각각 SQL 을 몇 개 쏘는지 화면에서 직접 보는 절차다. 이어서 재현 셋을 `rollout restart` 로 갈라 치면 같은 설정에서 `400``500``200` 이 차례로 나온다. 약 40분.
## 관계
- **같은 설정이 캐시 온도만으로 세 가지 답을 냈다**
이 절차가 재현 A·B·C 로 갈라 잰 것을 그 기록이 결론으로 적는다.
- **persistent-user-sessions 가 세션의 거처를 정한다**
여기서 끄는 그 기능이 무엇을 바꾸는지는 그 기록이 설명한다.
- **persistent-user-sessions 를 끄고 A층 결론 넷을 다시 잰다**
이 절차가 확정하는 `500` 이 거기서 나왔다. 그쪽은 주입이 args 와 `iptables` 와 DB 정지이고 계기가 교차 노드 응답 코드이며, 여기는 주입이 문장 로깅과 args 와 DB 정지이고 계기가 표식과 문장 로그다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
주입이 셋이라 검증도 셋이고, 표식이 로그에 들어갔는지를 확인하지 않으면 뒤의 구간 자르기가 통째로 헛돈다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[kc-lab-1]` 에서 `kubectl` 로 친다. 이 절차에는 노드 자체를 건드리는 명령이 없어서 `kc-lab-2` 로 들어갈 일이 없다. `kubectl``sudo` 를 붙이지 않는다 — root 홈에는 `~/.kube/config` 가 없어서 `localhost:8080` 으로 붙으려다 `connection refused` 로 끝난다.
이 편의 시각은 UTC 다. 증거의 `11:18:49` 는 KST 로 `20:18` 이고 같은 순간이다. PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이고, 로그 시각과 `date` 를 견줄 때 이걸 잊으면 9시간을 헤맨다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` |
| 대상 | StatefulSet `keycloak` 파드 둘 · Deployment `postgres` 하나 |
| 탐침 파드 | `a7a-probe``curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
| 켜는 것 | `log_statement = 'all'` · 반드시 `pg_reload_conf()` 까지 |
| 끄는 기능 | `--features-disabled=persistent-user-sessions` |
| 표식 | `MARK_TEST` · `MARK_LOGIN_START` · `MARK_LOGIN_END` · `MARK_REFRESH_START` · `MARK_REFRESH_END` · `MARK_R1`~`MARK_R_END` |
| 소음 | `JGROUPS_PING` 폴링이 5초마다 로그를 채운다 |
터미널은 둘을 연다. 하나는 표식과 요청용, 하나는 로그 관찰용이다.
## 이 실험이 가르는 것
A-7 은 이렇게 끝났다.
> 측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는 `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
그럴듯하고, 틀렸다.
```text
가설을 세우는 것 → 괜찮다
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
```
「refresh 가 어느 테이블 때문에 실패하는가」는 Keycloak 소스를 읽지 않고도 답할 수 있다. DB 가 실제로 받은 문장을 보면 된다. 확정해 보니 원인만 틀린 게 아니었다 — 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다.
이 절차를 끝까지 치면 여섯을 손으로 보게 된다. 로그인이 SQL 을 0개 쏘는 것, refresh 가 쏘는 딱 한 문장의 이름이 `CLIENT_SCOPE_CLIENT` 인 것, 그 문장이 첫 refresh 에만 나오는 것, `REVOKED_TOKEN` 이 한 번도 안 나오는 것, 같은 설정에서 `400``500``200` 이 전부 나오는 것, 그리고 실패한 SQL 을 Keycloak 로그가 직접 지목하는 것.
## 전제와 되돌리기
앞선 구축 단계 `05-keycloak` 이 끝나 있어야 한다. A-7 을 먼저 한다 — 이 절차는 A-7 이 남긴 가설을 확정하는 것이고, 거기서 본 `500` 에서 출발한다. A-3 에서 문장 로깅을 해 봤으면 같은 기법이다.
주입이 셋이고 복구도 셋이다.
- PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
- Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
- PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다
실험대에서만 한다. 중간에 그만두려면 복구 절을 위에서부터 그대로 친다.
표식을 넣는 방식에서 이 절차가 원 실행과 갈라진다. 원 실행은 표식을 셸 함수로 감쌌다.
```bash label="[kc-lab-1] 이 실험대는 이렇게 했다 (observed)"
m() { kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -tAc "select 'MARK_$1'" >/dev/null; }
```
짧고 편한데 출력을 `/dev/null` 로 버린다. 표식이 실제로 로그에 들어갔는지 확인하지 않고 다음 명령으로 넘어간다는 뜻이고, 로깅이 안 켜져 있었다면 표식 없는 로그를 한참 뒤에 `awk` 로 자르다가 알게 된다.
따라 하는 사람은 표식을 한 줄씩 손으로 넣는다. 느리지만 그 즉시 보이고, 안 보이면 그 즉시 안다. 아래 절차가 전부 그 형태다.
```bash label="[kc-lab-1] 따라 하는 사람은 이 형태로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
## 주입 전에 같은 명령으로 먼저 본다
```text
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
```
### 1. 파드 셋이 전부 떠 있는지 본다
**목적** — 이 절차가 내렸다 올릴 `postgres` 가 지금 있는지 확인한다.
```bash label="[kc-lab-1] 네임스페이스의 파드를 넓게 본다"
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
```
**왜 필요한가** — 셋 다 `Running` 이어야 하고 `postgres` 가 특히 그렇다. 이 절차는 그것을 세 번 내렸다 올린다.
**문제가 생기면** — `postgres` 가 없으면 `scale deployment/postgres --replicas=1` 부터 친다.
### 2. 문장 로깅이 지금 꺼져 있는지 본다
**목적** — 지금 쌓이는 로그가 이 실험 것인지 앞 실험 것인지 가른다.
```bash label="[kc-lab-1] 현재 설정값을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
**예상 결과**
```text
log_statement
---------------
none
```
**왜 필요한가** — `all` 이면 앞 실험이 켜 둔 채 끝낸 것이고, 지금 쌓인 로그가 어느 실험 것인지 구별할 수 없다.
**문제가 생기면** — `all` 이 나오면 먼저 끄고 로그가 한 바퀴 돌 때까지 기다린 뒤에 시작한다.
### 3. 지금 args 를 적어 둔다
**목적** — 복구에서 되돌릴 문자열을 확보한다.
```bash label="[kc-lab-1] 컨테이너 args 를 그대로 찍는다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
```
**예상 결과**
```text
["start"]
```
**왜 필요한가** — 복구 단계가 이 값 그대로 되돌린다.
**문제가 생기면** — 이미 `--features-disabled=persistent-user-sessions` 가 붙어 있으면 앞 실험이 원복하지 않고 끝냈다. 그것부터 되돌린다.
### 4. 탐침 파드를 StatefulSet 밖에 띄운다
**목적** — Keycloak 을 여러 번 재시작해도 죽지 않는 요청 장치를 세운다.
Keycloak 컨테이너에는 `curl` 도 `wget` 도 없어서 `kubectl exec keycloak-0 -- curl` 은 `exit 127` 로 끝난다.
```bash label="[kc-lab-1] ① 탐침을 띄우고 Ready 를 기다린다"
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="K0=$K0" \
--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/a7a-probe --timeout=120s
```
비밀번호는 명령 치환으로 넘어가므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 본다.
```bash label="[kc-lab-1] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
```text
19
```
```bash label="[kc-lab-1] ③ 탐침 안에 값이 들어갔는지 본다"
kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
```
**예상 결과** — IP 가 보이고 `PW길이` 가 0 이 아니다.
**왜 필요한가** — 명령줄에 평문 비밀번호를 쓰면 파드 안 `ps` 에도 셸 히스토리에도 남는다. 원래 실험의 재현 절차에 그 형태가 그대로 적혀 있었다.
**문제가 생기면** — `PW길이=0` 이면 `--env` 가 빈 값을 받았다. 파드를 지우고 다시 띄운다.
### 5. 로그가 지금 무엇으로 차 있는지 본다
**목적** — 켜기 전의 로그를 한 번 봐 두고, 켠 뒤의 소음과 견준다.
```bash label="[kc-lab-1] 마지막 20줄을 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=20
```
**예상 결과** — 조용하다. 여기까지는 에러만 찍힌다.
**왜 필요한가** — 다음 절에서 로깅을 켜면 JGroups 가 5초마다 하는 `JGROUPS_PING` 폴링이 로그를 계속 채운다. 그 소음을 먼저 봐 두면 나중에 `grep -v JGROUPS_PING` 으로 거르는 까닭을 안다.
**문제가 생기면** — 지금 SQL 이 줄줄이 나오면 로깅이 이미 켜져 있다. 2번으로 돌아간다.
## 주입
주입 셋을 차례로 넣는다. 셋 다 되돌리는 명령을 먼저 읽어 둔다.
### 6. PostgreSQL 문장 로깅을 켠다
**목적** — 서버가 받은 모든 SQL 을 로그에 찍게 한다.
되돌리는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] ① 되돌리는 명령 — 먼저 읽어 둔다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
```
```bash label="[kc-lab-1] ② 문장 로깅을 켜고 설정을 다시 읽힌다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system set log_statement='all'" -c "select pg_reload_conf()"
```
**예상 결과** — `ALTER SYSTEM` 과 `pg_reload_conf` 가 차례로 돌고, 곧 로그가 차기 시작한다.
**왜 필요한가** — 애플리케이션을 고치지 않고 「이 요청이 DB 를 어떻게 쓰는지」를 밖에서 볼 수 있다. 이것 없이 하면 정확히 A-7 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
**문제가 생기면** — 로그가 안 차면 `pg_reload_conf()` 가 안 돌았다. `alter system` 은 `postgresql.auto.conf` 에 쓸 뿐이고 reload 를 해야 적용된다.
### 7. Keycloak 을 volatile 로 바꾼다
**목적** — 세션을 메모리로 옮겨 A-7 이 본 조건을 만든다.
되돌리는 명령을 먼저 읽어 둔다.
```bash label="[kc-lab-1] ① 되돌리는 명령 — 먼저 읽어 둔다"
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
```
```bash label="[kc-lab-1] ② persistent-user-sessions 를 끈다"
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"]}]'
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
파드 IP 가 바뀌었으므로 탐침 파드를 다시 띄운다.
```bash label="[kc-lab-1] ③ 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--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/a7a-probe --timeout=120s
```
**예상 결과** — 롤아웃이 `partitioned roll out complete` 로 끝나고 탐침이 Ready 가 된다.
**왜 필요한가** — 빌드 옵션이라 기동할 때 재빌드가 일어나 오래 걸린다. 그래서 `--timeout=500s` 를 준다.
**문제가 생기면** — 타임아웃이 나면 같은 명령을 다시 치고 `logs keycloak-0` 에 빌드 진행이 보이는지 본다.
### 8. PostgreSQL 을 내린다
**목적** — DB 가 없는 구간을 만들어 캐시가 무엇을 대신하는지 본다.
```bash label="[kc-lab-1] DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
**예상 결과** — `postgres` 파드가 목록에서 사라진다.
**왜 필요한가** — 세 재현마다 한 번씩, 모두 세 번 내린다. 각 재현에서 내리는 시점이 다르고 그 시점이 곧 캐시 온도를 정한다.
**문제가 생기면** — 파드가 안 사라지면 `--timeout` 을 늘려서 다시 기다린다. `delete pod` 은 쓰지 않는다 — Deployment 가 곧바로 새로 만든다.
## 주입 검증
주입이 셋이라 검증도 셋이다. 로깅이 켜졌는지, 표식이 로그에 들어가는지, volatile 전환이 동작으로도 바뀌었는지를 따로 본다.
### 9. 로깅이 실제로 켜졌고 로그가 차기 시작했는지 본다
**목적** — 설정값과 실제 출력을 둘 다 확인한다.
```bash label="[kc-lab-1] ① 설정값을 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
```text
log_statement
---------------
all
```
```bash label="[kc-lab-1] ② 로그가 차는지 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=10
```
**예상 결과**
```text
2026-09-04 11:17:40.112 UTC [214] LOG: execute <unnamed>: select ... from JGROUPS_PING ...
```
**왜 필요한가** — `JGROUPS_PING` 이 계속 나오는 것이 앞에서 예고한 소음이고, 이게 안 보이면 로깅이 안 켜졌다.
**문제가 생기면** — `none` 이 나오면 `pg_reload_conf()` 를 다시 친다.
### 10. 표식이 로그에 들어가는지 본다
**목적** — 구간을 자를 수 있는 상태인지 확인한다.
```bash label="[kc-lab-1] ① 표식을 하나 넣는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
```
```bash label="[kc-lab-1] ② 그 표식이 로그에 있는지 본다"
kubectl -n keycloak-lab logs deploy/postgres --tail=5 | grep MARK_TEST
```
**예상 결과**
```text
2026-09-04 11:18:40.102 UTC [301] LOG: statement: select 'MARK_TEST'
```
**왜 필요한가** — `statement: select 'MARK_TEST'` 가 보이면 이제 표식과 표식 사이만 잘라 볼 수 있다.
**문제가 생기면** — 안 보이면 9번의 로깅 확인으로 돌아간다.
### 11. volatile 전환을 args 와 동작으로 둘 다 본다
**목적** — 선언과 동작이 같이 바뀌었는지 확인한다.
```bash label="[kc-lab-1] args · 로그인 응답 코드 · DB 행 수를 이어서 본다"
kubectl -n keycloak-lab get statefulset keycloak \
-o jsonpath='{.spec.template.spec.containers[0].args}' ; echo
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select count(*) from offline_user_session where offline_flag='0'"
```
**예상 결과**
```text
volatile 전환 확인
args: ["start","--features-disabled=persistent-user-sessions"]
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
```
**왜 필요한가** — 세 가지가 다 맞아야 한다. 로그인이 `200` 인데 행이 안 생기는 것으로 판정한다.
**문제가 생기면** — 행 수가 0 이 아니면 옛 행이 남아 있다. A-7 처럼 `delete from offline_user_session` 을 먼저 하고 다시 잰다.
문장 로그가 지금 요청을 잡고 있는지도 본다.
```bash label="[kc-lab-1] 최근 60초의 로그 끝을 본다"
kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
```
이 시점에서는 거의 `JGROUPS_PING` 뿐일 텐데, 그게 이 실험의 첫 발견이다. 지금은 「내 요청이 어디 있는지 모르겠다」로만 보이고, 구간을 나눠야 보인다.
## 관찰
표식 → 요청 → 표식 순으로 치고 `awk` 로 그 사이를 자른다.
### 12. 로그인이 무슨 SQL 을 쏘는지 본다
**목적** — 로그인 한 번이 DB 에 무엇을 보내는지 센다.
```bash label="[kc-lab-1] ① 표식 · 로그인 · 표식을 차례로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_START'"
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_LOGIN_END'"
```
```text
rt 1188 bytes
```
```bash label="[kc-lab-1] ② 로그를 파일로 받아 구간을 자른다"
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
```
**예상 결과**
```text
11:18:49.461 statement: select 'MARK_LOGIN_START'
11:18:49.743 statement: select 'MARK_LOGIN_END'
↑ 사이에 아무것도 없다
```
**왜 필요한가** — 두 줄뿐이고 로그인은 SQL 을 0개 쏜다. realm 과 사용자와 클라이언트가 전부 Infinispan 캐시에 있고 volatile 이라 세션 쓰기도 없다. `awk '/A/,/B/'` 는 A 가 나온 줄부터 B 가 나온 줄까지 출력한다. 로그를 파일로 먼저 받는 까닭은 같은 로그를 여러 구간으로 반복해서 잘라 볼 것이기 때문이다.
**문제가 생기면** — `rt 1 bytes` 면 파싱이 실패했고, 그 상태로 다음을 하면 빈 토큰을 보내고 엉뚱한 오류를 보게 된다. `cat /tmp/tok` 으로 본문을 본다.
### 13. refresh 가 쏘는 한 문장의 이름을 읽는다
**목적** — A-7 의 가설이 지목한 테이블이 실제로 나오는지 본다.
```bash label="[kc-lab-1] ① 표식 · refresh · 표식을 차례로 친다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_START'"
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)"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_REFRESH_END'"
```
```bash label="[kc-lab-1] ② 그 구간을 자른다"
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
```
**예상 결과**
```text
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 label="[kc-lab-1] ③ 그 구간에서 REVOKED_TOKEN 을 센다"
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
```
```text
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
```
**왜 필요한가** — `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 이 세션 계열 테이블을 의심한 것이 자연스러웠지만 빗나갔다.
그 UUID 가 어느 클라이언트인지 궁금하면 물어본다. UUID 는 렐름을 만들 때 정해지므로 실험대마다 다르다. ②가 자른 구간의 `parameters` 줄에 있는 `$1` 값을 그대로 옮겨 넣는다.
```bash label="[kc-lab-1] ④ 그 UUID 가 어느 클라이언트인지 묻는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select id, client_id from client where id='{{CLIENT_UUID}}'"
```
이 실험대의 값은 `131a9912-b578-4b9c-b16a-97518704077e` 였다(observed).
`admin-cli` 가 나오면 방금 친 요청의 클라이언트가 맞다.
**문제가 생기면** — 구간에 표식이 두 번 나오면 로그를 여러 번 받아 구간이 겹쳤다. `--tail` 을 줄이거나 새 표식 이름을 쓴다.
### 14. 그 조회가 한 번뿐인 것을 본다
**목적** — 첫 refresh 가 캐시를 채우고 이후로는 DB 를 보지 않는다는 것을 확인한다.
```bash label="[kc-lab-1] ① 표식을 사이사이에 넣으며 refresh 를 돈다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R1'"
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"'
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_R2'"
```
같은 모양으로 `MARK_R3` 과 `MARK_R_END` 까지 두 번 더 한다. 매번 `/tmp/rt` 를 다시 채운다 — refresh token 은 회전하고, 옛것을 계속 쓰면 나오는 오류가 무효화 때문인지 재사용 때문인지 구별되지 않는다.
```bash label="[kc-lab-1] ② 표식 넷 사이를 통째로 자른다"
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
```
**예상 결과**
```text
연속 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 를 언제 내리느냐에 따라 답이 달라진다.
**문제가 생기면** — `400 Session not active` 가 나오면 옛 refresh token 을 재사용했다. 매번 `/tmp/rt` 를 갱신한다.
### 15. 재현 A — 완전 냉시동이면 로그인부터 400 이다
**목적** — 캐시가 전부 빈 상태에서 DB 를 내렸을 때의 답을 잰다.
캐시는 Keycloak 을 재시작해야만 식는다.
```text
Infinispan 캐시 = 프로세스 메모리
└─ 파드가 살아 있는 한 안 식는다
└─ 그래서 세 재현 사이마다 rollout restart 를 한다
```
이 재시작을 건너뛰면 세 상태가 하나로 뭉개진다. 이미 더워진 캐시에서 계속 재게 되므로 A 와 B 를 재도 C 의 답이 나오고, 「A-7 이 틀렸다」는 엉뚱한 결론에 이른다.
```bash label="[kc-lab-1] ① 재시작하고 곧바로 DB 를 내린다"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
재시작과 DB 정지 사이에 아무 요청도 보내지 않는다. 한 번이라도 로그인하면 캐시가 더워져서 이건 재현 B 가 된다. 파드 IP 가 바뀌었으므로 탐침을 다시 띄운 다음 로그인을 본문까지 본다.
탐침의 `K0` 는 만들 때 고정된 값이라 재시작 뒤에는 낡았다. 지우고 새 IP 로 다시 만든다. 이 블록을 건너뛰면 뒤의 curl 이 없는 주소로 가고, 그 침묵을 「DB 가 없어서 실패」로 읽게 된다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--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/a7a-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 로그인을 본문과 함께 본다"
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"'
```
**예상 결과**
```text
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
```
`unauthorized_client` 이고 `invalid_grant` 가 아니다. 세션 문제가 아니라 클라이언트를 못 찾았다. 왜인지는 Keycloak 로그가 직접 말한다.
```bash label="[kc-lab-1] ④ 실패한 SQL 을 Keycloak 로그에서 뽑는다"
kubectl -n keycloak-lab logs keycloak-0 --tail=150 \
| grep -oE 'JDBC exception executing SQL \[[^]]*\] \[[^]]*\]'
```
```text
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` 가 그 두 쌍만 뽑는다. A-7 은 「volatile 이면 DB 없이 로그인된다」고 적었는데 냉시동에서는 클라이언트 조회조차 캐시에 없어서 로그인부터 실패한다.
**문제가 생기면** — 아무것도 안 나오면 `--tail` 을 늘리거나 `grep -i 'JDBC exception'` 으로 먼저 넓게 본다. 정규식이 안 맞는 것과 로그에 없는 것은 다르다. 로그인이 `200` 이 나오면 재시작 후 요청을 한 번이라도 보낸 것이므로 이 재현을 처음부터 다시 한다.
### 16. 재현 B — 로그인만 한 번 하면 refresh 가 500 이다
**목적** — A-7 이 본 그 조건을 그대로 만든다.
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
탐침을 새 IP 로 다시 띄운 뒤 로그인 한 번만 한다. 15번과 같은 이유로 여기서도 탐침을 다시 만든다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--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/a7a-probe --timeout=120s
```
```bash label="[kc-lab-1] ③ 로그인 한 번으로 캐시를 절반만 데운다"
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 가 된다. 8번의 DB 정지를 친 다음에 refresh 한다.
```bash label="[kc-lab-1] ④ DB 가 없는 상태에서 refresh 한다"
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)"'
```
**예상 결과**
```text
로그인 200
refresh 500 {"error":"unknown_error"}
```
실패한 SQL 을 15번과 같은 `grep -oE` 로 뽑으면 이렇게 나온다.
```text
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=?]
```
**왜 필요한가** — 13번에서 문장 로깅이 「이 문장을 쏜다」를 보여 줬고 여기서는 「이 문장이 실패했다」가 나온다. 둘이 만나면 가설이 아니라 확정이 된다. `500 unknown_error` 인 까닭도 이제 안다 — 세션은 멀쩡하고, 토큰을 조립하다가 DB 가 없어서 못 만든 것을 Keycloak 이 사용자 오류로 분류할 방법이 없어서 `unknown_error` 를 준다.
**문제가 생기면** — `200 / 200` 이 나오면 로그인 뒤 refresh 를 미리 했다. 로그인 한 번만 하고 DB 를 내린다.
### 17. 재현 C — 미리 세 번 갱신해 두면 둘 다 200 이다
**목적** — 캐시가 완전히 더운 상태의 답을 잰다.
DB 를 살리고, 재시작하고, 탐침을 새로 만들고, 로그인하고, refresh 를 3회 미리 돌린 뒤 DB 를 내린다. 앞 절들의 명령을 그대로 다시 친다.
```bash label="[kc-lab-1] ① DB 를 살리고 다시 재시작한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=500s
```
파드가 새로 떴으므로 `K0` 가 낡았다. 탐침도 그 값을 `--env` 로 박아 뒀으니 같이 다시 만든다.
```bash label="[kc-lab-1] ② 탐침을 지우고 새 IP 로 다시 띄운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}')
kubectl -n keycloak-lab run a7a-probe --image=curlimages/curl:8.11.1 \
--restart=Never --env="K0=$K0" \
--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/a7a-probe --timeout=120s
```
새 탐침에는 `/tmp/rt` 가 없다. 16번의 ③ 과 같은 명령으로 다시 만든다.
```bash label="[kc-lab-1] ③ 로그인해서 /tmp/rt 를 새로 만든다"
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"'
```
**여기서 재현 C 가 끊긴다.** 다음에 와야 할 것은 DB 를 내리기 전에 refresh 를 세 번 돌려 캐시를 마저 채우는 단계인데, **그 세 번을 치는 명령이 원본 가이드에 없다**(unknown). 가이드는 「refresh 3회를 미리 돌린 뒤」라고 쓰고 그 세 번의 명령도, 회전하는 refresh token 을 `/tmp/rt` 에 매번 다시 쓰는 형태도 남기지 않았다. 14번의 ① 이 표식 사이에서 refresh 를 한 번 돌리며 `/tmp/rt` 를 갱신하는 형태를 갖고 있지만, 그것을 세 번 돌리는 것이 가이드가 말한 그 3회와 같은지는 확인되지 않았다. **이 단계를 채우지 못하면 아래 ④⑤ 를 쳐도 재현 B 와 같은 상태이고 `500` 이 나온다.**
그 세 번을 돌렸다고 보고, 8번과 같은 명령으로 DB 를 내린다.
```bash label="[kc-lab-1] ④ DB 를 0 replica 로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=90s
```
```bash label="[kc-lab-1] ⑤ 로그인과 refresh 를 이어서 친다"
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)"'
```
**예상 결과**
```text
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
로그인 200
refresh 200 ← A-7 의 표와 정반대다
```
**왜 필요한가** — 같은 설정, 같은 명령, 세 개의 답이 나왔다.
| 캐시 상태 | 로그인 | 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건) |
무엇이 다른지는 `kubectl get` 어디에도 안 나온다. 캐시 온도는 보이지 않는 상태이고, A-1 에서 conntrack 이 「주입했는데 안 걸렸다」를 만든 것과 같은 계열의 함정이다.
```text
volatile + DB 정지의 결과
= "무엇을 하느냐"가 아니라
"그 경로가 이미 캐시를 채웠느냐"
```
persistent 기본값에는 이 조건부성이 없다. 세션 자체를 DB 에 쓰므로 DB 가 없으면 캐시 온도와 무관하게 실패한다. 이것은 volatile 고유의 성질이고, 옛 방식이 「DB 의존이 적다」고 말할 때 놓치는 부분이다.
**문제가 생기면** — 세 재현이 전부 `200/200` 이면 재시작을 건너뛰어 캐시가 계속 더웠다. 재현마다 `rollout restart` 를 넣는다.
## 복구와 원상복구 확인표
셋을 순서대로 되돌린다. DB 가 살아 있어야 나머지가 된다.
```bash label="[kc-lab-1] ① DB 를 올리고 Ready 까지 기다린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab wait --for=condition=Ready pod -l app=postgres --timeout=180s
```
문장 로깅을 끈다. 잊으면 다음 실험이 전부 오염된다.
```bash label="[kc-lab-1] ② 문장 로깅을 끄고 값을 다시 읽는다"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "alter system reset log_statement" -c "select pg_reload_conf()"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "show log_statement"
```
```text
log_statement
---------------
none
```
왜 급한가 — A-3 은 수백 건의 로그인을 최대한 빨리 돈다. `log_statement='all'` 이면 로그인 하나에 SQL 열 몇 줄씩 쌓이고, 로그가 폭주하고 디스크 입출력이 늘어 크래시 타이밍 자체가 달라진다. 다음 실험의 측정값이 이 설정 때문에 바뀐다.
```bash label="[kc-lab-1] ③ 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
```
`args` 문자열만 보고 끝내지 않는다. 탐침을 새 IP 로 띄우고 로그인을 한 번 한 다음 행을 센다.
```bash label="[kc-lab-1] ④ 로그인 뒤 온라인 세션 행을 센다"
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 로 돌아온 것이고, 원래 재현 절차도 마지막에 이 한 줄을 둔다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 문장 로깅 | `psql -c "show log_statement"` | `none` |
| args | `get statefulset keycloak -o jsonpath='{...containers[0].args}'` | `["start"]` |
| DB | `kubectl -n keycloak-lab get pods -l app=postgres` | `1/1 Running` |
| 동작 | 로그인 뒤 `select count(*) ...` | 세션 행이 생긴다 |
| 파드 | `kubectl -n keycloak-lab get pods -o wide` | `keycloak` 둘 다 `1/1 Running` |
| 클러스터 | `vendor_cluster_size` | 양쪽 `2` |
| 탐침 파드 | `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 label="[kc-lab-1] ⑤ 탐침과 임시 파일을 치운다"
kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
rm -f /tmp/pg.log
```
## 막히면
아래는 전부 이 실험대가 실제로 겪은 증상이고 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 표식이 로그에 안 보인다 | `pg_reload_conf()` 를 안 했다 | `show log_statement` 가 `all` 인지 |
| 표식 사이가 `JGROUPS_PING` 으로 가득하다 | 정상이다. 5초마다 폴링한다 | `grep -v JGROUPS_PING` |
| 표식이 두 번 나온다 | 로그를 여러 번 받아 구간이 겹쳤다 | `--tail` 을 줄이거나 새 표식 이름을 쓴다 |
| 로그 시각이 9시간 어긋난다 | 컨테이너 로그가 UTC 다 | `date -u` 와 비교한다 |
| refresh 가 `400 Session not active` | 옛 refresh token 을 재사용했다 | 매번 `/tmp/rt` 를 갱신 |
| `rt 1 bytes` | 파싱 실패. 빈 토큰을 보내게 된다 | `cat /tmp/tok` 으로 본문 확인 |
| 세 재현이 전부 `200/200` | 재시작을 건너뛰어 캐시가 계속 더웠다 | 재현마다 `rollout restart` |
| 재현 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` |
| 다음 실험의 세션이 안 살아남는다 | volatile 로 둔 채 끝냈다 | 로그인 뒤 행 수 확인 |
## 무엇이 관측이고 무엇이 아닌가
이 실험대가 실제로 본 것(observed)은 volatile 전환 확인의 세 줄(`args` · 로그인 `200` · 행수 `0`), 로그인 구간의 표식 두 줄 `11:18:49.461` 과 `11:18:49.743` 및 그 사이 SQL 0건, refresh 구간의 다섯 줄과 `CLIENT_SCOPE_CLIENT` 문장 전문과 파라미터 `$1 = '131a9912-b578-4b9c-b16a-97518704077e'` 및 `$2 = 'f'`, `REVOKED_TOKEN` 0건, 연속 refresh 3회의 표식 네 줄과 SQL 0건, 재현 A 의 `400 unauthorized_client` 와 `select ce1_0.ID from CLIENT ...` 실패 SQL, 재현 B 의 `200` 과 `500 unknown_error` 및 `CLIENT_SCOPE_CLIENT` 실패 SQL, 재현 C 의 `200` 과 `200`, 비밀번호 길이 `19`, 표식 시험의 `statement: select 'MARK_TEST'` 다.
이 편에는 가이드가 미검증으로 표시한 명령이 하나도 없다(unknown 이 0건이다). 표식을 감싼 셸 함수 `m()` 은 원 실행이 실제로 썼고(observed), 그것을 한 줄씩 손으로 푸는 형태가 가이드의 권고다.
시각 표기는 UTC 다. 증거 파일과 위 인용이 전부 UTC 이고 KST 로는 `20:1820:24` 이며, PostgreSQL 컨테이너가 UTC 로 찍기 때문이다.
A-7 에서 틀린 것으로 확정된 것이 둘이다. 원인 테이블을 `REVOKED_TOKEN` 으로 본 가설, 그리고 「volatile 이면 DB 없이 로그인된다」는 서술이다. 냉시동에서는 로그인부터 실패한다.
이 절차가 재지 않은 것은 캐시가 얼마나 오래 더운지다. `CLIENT_SCOPE_CLIENT` 결과의 캐시 만료 시간을 모르므로 한참 뒤에 다시 재면 또 다른 답이 나올 수도 있다. 그것까지 확인하려면 재현 C 뒤에 시간을 두고 같은 시험을 반복해야 한다.
<!-- body:end -->