The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
6.5 KiB
Plaintext
118 lines
6.5 KiB
Plaintext
A-7a — volatile 모드에서 refresh 가 500 인 진짜 이유
|
|
=======================================================
|
|
수집: 2026-09-04 11:18 ~ 11:24 UTC · kc-lab-1 에서 sudo kubectl
|
|
|
|
A-7 이 남긴 가설
|
|
----------------
|
|
> **측정은 확실하지만 원인은 확정하지 못했다.** 유력한 후보는
|
|
> `REVOKED_TOKEN` 테이블이다 — refresh token 회전에서 이미 쓴 토큰인지
|
|
> 확인하려면 그 테이블을 봐야 하고, 그 경로는 캐시되지 않는다.
|
|
|
|
★ 이 가설은 틀렸다.
|
|
|
|
방법 — PostgreSQL 문장 로깅 (A-3 기법)
|
|
---------------------------------------
|
|
ALTER SYSTEM SET log_statement = 'all'; SELECT pg_reload_conf();
|
|
|
|
표식을 넣어 로그인과 refresh 가 각각 어떤 SQL 을 쏘는지 구분했다.
|
|
select 'MARK_LOGIN_START' ... 로그인 ... select 'MARK_LOGIN_END'
|
|
select 'MARK_REFRESH_START' ... refresh ... select 'MARK_REFRESH_END'
|
|
|
|
volatile 전환 확인
|
|
args: ["start","--features-disabled=persistent-user-sessions"]
|
|
로그인 200 · offline_user_session 행수 = 0 ← volatile 맞다
|
|
|
|
[측정 1] 로그인은 SQL 을 0개 쏜다
|
|
----------------------------------
|
|
11:18:49.461 statement: select 'MARK_LOGIN_START'
|
|
11:18:49.743 statement: select 'MARK_LOGIN_END'
|
|
↑ 사이에 아무것도 없다
|
|
|
|
realm·사용자·클라이언트가 전부 Infinispan 캐시에 있어 DB 를 안 본다.
|
|
|
|
[측정 2] ★ refresh 는 CLIENT_SCOPE_CLIENT 를 본다
|
|
--------------------------------------------------
|
|
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'
|
|
|
|
REVOKED_TOKEN 은 **한 번도 나오지 않는다.**
|
|
`DEFAULT_SCOPE='f'` 이므로 **선택적(optional) 클라이언트 스코프** 조회다.
|
|
|
|
[측정 3] 그 조회는 한 번뿐이다 — 캐시에 들어간다
|
|
-------------------------------------------------
|
|
연속 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 를 보지 않는다.
|
|
|
|
════ 그래서 A-7 의 표 자체가 조건부였다 ════
|
|
|
|
같은 설정에서 **캐시 온도만으로 결과가 셋으로 갈린다.** 전부 측정했다.
|
|
|
|
┌──────────────────────┬────────┬─────────┬──────────────────────────────┐
|
|
│ 캐시 상태 │ 로그인 │ refresh │ 실패한 SQL │
|
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
|
│ 완전 냉시동 │ 400 │ 400 │ select ce1_0.ID from CLIENT │
|
|
│ (재시작 직후) │ │ │ where CLIENT_ID=? REALM_ID=?│
|
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
|
│ CLIENT 만 더움 │ 200 │ ★ 500 │ select cscme1_0.SCOPE_ID │
|
|
│ ← A-7 이 본 상태 │ │ │ from CLIENT_SCOPE_CLIENT │
|
|
├──────────────────────┼────────┼─────────┼──────────────────────────────┤
|
|
│ 완전히 더움 │ 200 │ 200 │ 없음 (SQL 0건) │
|
|
└──────────────────────┴────────┴─────────┴──────────────────────────────┘
|
|
|
|
[재현 A] 완전 냉시동 — 로그인부터 400
|
|
keycloak 재시작 → postgres 정지 → 로그인
|
|
로그인 400 {"error":"unauthorized_client",
|
|
"error_description":"Unexpected error when authenticating client"}
|
|
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=?]
|
|
|
|
→ A-7 은 "volatile 이면 DB 없이 로그인된다"고 적었지만,
|
|
**냉시동에서는 그것도 안 된다.**
|
|
|
|
[재현 B] ★ A-7 이 본 그 조건 — 로그인 200, refresh 500
|
|
keycloak 재시작 → (DB 살아있을 때) 로그인 1회 → postgres 정지 → refresh
|
|
로그인 200
|
|
refresh 500 {"error":"unknown_error"}
|
|
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=?]
|
|
|
|
★ 로그가 실패한 SQL 을 직접 지목한다. CLIENT_SCOPE_CLIENT 다.
|
|
|
|
[재현 C] 완전히 더움 — 둘 다 200
|
|
refresh 를 3회 미리 돌려 캐시를 채운 뒤 postgres 정지
|
|
로그인 200
|
|
refresh 200 ← A-7 의 표와 정반대다
|
|
|
|
════ 결론 ════
|
|
|
|
1. **원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회다.**
|
|
`CLIENT_SCOPE_CLIENT` 를 `DEFAULT_SCOPE='f'` 로 조회하는 그 한 문장이다.
|
|
|
|
2. **A-7 의 "volatile → refresh 500" 은 캐시가 차가울 때만 참이다.**
|
|
더워지면 200 이 된다. A-7 은 그 조건을 몰랐고, 그래서 표에 적지 않았다.
|
|
|
|
3. **"volatile 이면 DB 없이 로그인된다" 도 조건부다.**
|
|
냉시동에서는 400 이다. 클라이언트 조회조차 캐시에 없기 때문이다.
|
|
|
|
4. **일반화** — volatile 모드에서 DB 정지 시의 동작은 "무엇을 하느냐"가
|
|
아니라 **"그 경로가 이미 캐시를 채웠느냐"** 로 결정된다. 그래서 같은
|
|
명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는
|
|
**한 번 재보고 표로 적으면 안 된다** — A-7 이 그렇게 했다.
|