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

37 KiB
Raw Blame History

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 에서 친다. kubectlsudo 로 쓴다.
  • 터미널 두 개를 열어 두면 편하다. 하나는 표식·요청용, 하나는 로그 관찰용.

주의 — 주입이 세 개다. 복구도 세 개다

  1. PostgreSQL 문장 로깅을 켠다 → 끄지 않으면 다음 실험의 로그가 폭주한다
  2. Keycloak 을 volatile 로 바꾼다 → 되돌리지 않으면 A층 결론이 오염된다
  3. PostgreSQL 을 여러 번 내렸다 올린다 → 마지막에 올라와 있어야 한다

실험대에서만 한다. 전 구간 약 40분이고, 되돌리는 방법은 매 단계에 적어 두었다. 중간에 그만두려면 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. 파드

확인

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 컨테이너에는 curlwget 도 없다(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 systempostgresql.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'"

실측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. 문장 로그가 지금 요청을 잡고 있나

확인 — 방금 로그인 직후에 친다

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

실측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 → 표식

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

실측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'

확인 — 가설이 지목한 테이블이 정말 없는지 직접 센다

awk '/MARK_REFRESH_START/,/MARK_REFRESH_END/' /tmp/pg.log | grep -ci revoked_token

실측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 가 어느 클라이언트인지 궁금하면 물어본다

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

실측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 이 그렇게 했다. 셋 다 재현해야 한다.

되돌리기 — 세 재현 모두 공통이다. 어느 단계에서 멈추든 이것부터

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"'

실측01-cause-determined.txt

    로그인 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)"'

실측01-cause-determined.txt

    로그인   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)"'

실측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 을 되살린다

하기

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_statementall 인지 — 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 -- curlexit 127 Keycloak 이미지에 curl 도 wget 도 없다 탐침 파드를 쓴다
다음 실험의 postgres 로그가 폭주한다 문장 로깅을 끄지 않았다 show log_statementnone — 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 에서는 캐시 온도와 무관하게 실패한다 — 대조군
전부 한 번 재고 표로 적으면 안 되는 종류가 있다. 상태가 결과를 바꾸는데 그 상태가 안 보일 때