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>
37 KiB
A-7a 재현 가이드 — DB 에게 직접 물어서 원인을 확정하고, 같은 설정에서 세 가지 답을 본다
해설 문서: docs/experiment-a7a-volatile-cause.md ·
증거 원문: docs/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이 끝나 있다.- A-7 을 먼저 한다. 이 실험은 A-7 이 남긴
가설을 확정하는 것이고, A-7 의 4-4 에서 본
500이 출발점이다. - A-3 의 문장 로깅을 해 봤으면 3절이 익숙할 것이다. 같은 기법이다.
- 명령은
kc-lab-1에서 친다.kubectl은sudo로 쓴다. - 터미널 두 개를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.
주의 — 주입이 세 개다. 복구도 세 개다
- PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
- Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
- PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다
실험대에서만 한다. 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어 두었다. 중간에 그만두려면 5. 복구 를 위에서부터 그대로 친다.
표시 규약
| 표시 | 뜻 |
|---|---|
| 실측 | 2026-09-04 11:18–11:24 UTC 수집 기록의 출력 원문. 증거 파일에 그대로 있다 |
| 형태 | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| 미검증 | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했다 |
시각이 UTC 다. 증거 파일의
11:18:49는 KST 로 20:18 이다. 문서 상단의20:18–20:24 KST와 같은 시각이며, PostgreSQL 컨테이너가 UTC 로 로그를 찍기 때문이다. 로그 시각과date를 비교할 때 이걸 잊으면 9시간을 헤맨다.
UUID·IP·파드 이름은 당신 환경에서 다르다. 이 문서는 자리표시자(<...>)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
0. 왜 이 실험을 하는가
A-7 은 이렇게 끝났다.
측정은 확실하지만 원인은 확정하지 못했다. 유력한 후보는
REVOKED_TOKEN테이블이다 — refresh token 회전에서 이미 쓴 토큰인지 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
그럴듯하다. 그리고 틀렸다.
가설을 세우는 것 → 괜찮다
가설을 표에 적는 것 → 다음 사람이 사실로 읽는다
확정하는 방법이 있는데 안 하는 것 → 이 실험이 고치는 것
「refresh 가 어느 테이블 때문에 실패하는가」는 추측으로 답할 문제가 아니다. Keycloak 소스를 읽는 대신 DB 가 실제로 받은 문장을 보면 된다.
그리고 확정해 보니 원인만 틀린 게 아니었다. A-7 의 표 자체가 조건부였다. 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. 한 번 재고 표로 적으면 안 되는 종류의 측정이었던 것이다.
1. 기준선 — 켜기 전에 지금 상태를 본다
넓은 것부터 좁혀 간다.
파드 → 문장 로깅이 꺼져 있나 → args → 탐침 파드 → 로그가 지금 무엇으로 차 있나
1-1. 파드
확인
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. 문장 로깅이 꺼져 있나
확인
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 가 무엇인가
확인
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 밖에
있어야 한다.
하기
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
되돌리기
sudo kubectl -n keycloak-lab delete pod a7a-probe --ignore-not-found
비밀번호를 화면에 찍지 않는다. 명령 치환으로 넘기므로 터미널에도 셸 히스토리에도 값이 남지 않는다. 길이만 보고 싶으면:
sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c실측 —
19★ 명령줄에 평문 비밀번호를 쓰지 않는다. 원래 실험의 재현 절차에는 그대로 적혀 있는데, 파드 안
ps에도 셸 히스토리에도 남는다.
확인
sudo kubectl -n keycloak-lab exec a7a-probe -- sh -c 'echo "K0=$K0 PW길이=${#PW}"'
형태
K0=10.42.1.94 PW길이=19
1-5. 로그가 지금 무엇으로 차 있나 — 이걸 알아야 걸러 낼 수 있다
확인 — 로깅을 켜기 전에 한 번 본다
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 이 겪은 일이 벌어진다 — 그럴듯한 테이블 이름을 골라 가설로 적게 되고, 그게 틀려도 아무도 모른다.
되돌리기 — 먼저 읽어 둔다
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 "alter system set log_statement='all'" -c "select pg_reload_conf()"
확인 — 실제로 켜졌나
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 를 해야 적용된다.
확인 — 로그가 실제로 차기 시작했나
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 전환
되돌리기 — 먼저 읽어 둔다
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
하기
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 가 바뀌었다. 탐침 파드를 다시 띄운다.
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 와 동작을 둘 다 본다
확인
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'"
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. 문장 로그가 지금 요청을 잡고 있나
확인 — 방금 로그인 직후에 친다
sudo kubectl -n keycloak-lab logs deploy/postgres --since=60s | tail -20
어디를 봐야 하는가 — JGROUPS_PING 말고 다른 것이 섞여 있는지.
이 결과가 의미하는 것 — 이 시점에서는 거의 JGROUPS_PING 뿐일 것이다.
그게 이 실험의 첫 발견인데, 지금은 「내 요청이 어디 있는지 모르겠다」로만 보인다.
구간을 나눠야 볼 수 있다. 그게 다음 절이다.
4. 효과를 관찰한다
4-1. 표식으로 구간을 나눈다
로그는 JGROUPS_PING 폴링으로 계속 채워진다. 어느 문장이 로그인이고 어느 것이
refresh 인지 가르려면 경계를 찍어야 한다.
개념 — psql 로 아무 select 나 보내면 그 문장 자체가 로그에 남는다.
그러면 리터럴 문자열이 로그 안의 이정표가 된다.
하기 — 표식 하나를 넣어 본다
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-tAc "select 'MARK_TEST'"
확인
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 을 쏘는가
하기 — 표식 → 로그인 → 표식. 세 명령을 붙여서 친다
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 으로 본문을 본다.
확인 — 표식 사이를 잘라 본다
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
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 → 표식
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
확인
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
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'
확인 — 가설이 지목한 테이블이 정말 없는지 직접 센다
awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token
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 가 어느 클라이언트인지 궁금하면 물어본다
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회. 사이사이 표식을 넣는다
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 까지 두 번 더 한다.
확인
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
연속 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 이 그렇게 했다. 셋 다 재현해야 한다.
되돌리기 — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터
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
하기
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 가 바뀌었으므로 탐침을 다시 띄운다.
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
확인 — 로그인. 본문까지 본다
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"'
로그인 400 {"error":"unauthorized_client",
"error_description":"Unexpected error when authenticating client"}
어디를 봐야 하는가 — unauthorized_client. invalid_grant 가 아니다.
세션 문제가 아니라 클라이언트를 못 찾은 것이다.
확인 — 왜인지는 Keycloak 로그가 직접 말한다
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 를 내린다
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 와 같은 명령), 로그인 한 번만 한다.
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 가 된다.
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
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)"'
로그인 200
refresh 500 {"error":"unknown_error"}
확인 — 실패한 SQL
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 를 내린다
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 를 매번 갱신하며).
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 를 둘 다
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)"'
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 을 되살린다
하기
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. ★ 문장 로깅을 끈다 — 잊으면 다음 실험이 전부 오염된다
하기
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 은 수백 건의 로그인을 최대한 빨리
돈다. log_statement='all' 이면 로그인 하나에 SQL 열 몇 줄씩 쌓인다.
로그가 폭주하고, 디스크 I/O 가 늘어 크래시 타이밍 자체가 달라진다.
즉 다음 실험의 측정값이 이 설정 때문에 바뀐다.
5-3. args 를 되돌린다
하기
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 로 띄우고 로그인 한 번
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"'
확인
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 |
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 의 행 수 확인 |
왜 이 가이드는 표식을 손으로 넣게 하나
원래 실행은 표식을 셸 함수로 감쌌다.
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 volatile 비교 | 그 표에 조건을 붙여야 한다. 「로그인 200 · refresh 500」은 캐시가 반쯤 더울 때만 참이다 |
| A-3 DB 크래시 | 같은 문장 로깅 기법. RPO 를 재는 데 쓴다 |
| A-2 DB 정지 | persistent 에서는 캐시 온도와 무관하게 실패한다 — 대조군 |
| 전부 | 한 번 재고 표로 적으면 안 되는 종류가 있다. 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |