# 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: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. 파드 **확인** ```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 : 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 /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 에서는 캐시 온도와 무관하게 실패한다 — 대조군 | | 전부 | **한 번 재고 표로 적으면 안 되는 종류가 있다.** 상태가 결과를 바꾸는데 그 상태가 안 보일 때 |