docs(keycloak-session-store): remake all 28 diagrams through the techviz pipeline

The originating repository's SVGs were drawn by hand and every one of them
put a title, a subtitle and an explanation band inside the canvas. This
repository forbids both, so they could not be carried over — the whole set
was rebuilt through the skill's pipeline instead.

Each diagram went through prepare, references, prompt, a VizSpec 1.1 citing
document line ranges, lint, and render. All 28 pass lint and produce the
same eight formats the existing keycloak project has. Sentences moved out of
the canvas into <desc> and the paragraph beside each figure; the drawings
carry names only.

Two lint rules did real work rather than formatting work:

  edge-through-node                  caught arrows crossing an unrelated
                                     node and implying an adjacency that
                                     does not exist — four diagrams had to
                                     be restructured, not just relaid out
  evidence-outside-prepared-context  caught a diagram citing another
                                     section; its anchor moved from B-0 to
                                     B-1 so all three sections it draws on
                                     are inside the prepared context

lab-topology also had to change profile: its context offers a different
candidate set, and query-fanout with shard roles is what the section
actually shows — one entry point spreading to two Keycloak nodes.

The document now carries all 28 inline, one per claim that needed one, and
the section recording what was still missing is updated: the diagram gap is
closed, Studio records remain.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-05 11:03:10 +09:00
co-authored by Claude Opus 5
parent b2963105a8
commit 75bed382c8
286 changed files with 65385 additions and 710 deletions
+144 -12
View File
@@ -78,6 +78,11 @@ Keycloak 26 은 `persistent-user-sessions` 가 기본값이다. 세션을 DB 에
**「Keycloak 은 이렇다」고 쓸 수 있는 문장이 거의 없다.** 버전과 설정을
같이 적지 않으면 절반은 틀린 말이 된다.
![같은 주입, 정반대 결과](assets/version-conditional-results/version-conditional-results.svg)
설정 하나가 세션의 거처를 바꾸고, 그 거처가 장애 결과를 결정한다.
---
## 문제를 어렵게 만든 제약
@@ -93,6 +98,11 @@ Keycloak 26 은 `persistent-user-sessions` 가 기본값이다. 세션을 DB 에
| kc-lab-2 | k3s agent · keycloak-0 · PostgreSQL · Redis |
| 호스트 nginx | Let's Encrypt TLS 종단 → traefik 으로 프록시 |
![실험대의 구성](assets/lab-topology/lab-topology.svg)
저장소가 kc-lab-2 한 곳에 몰려 있다. A-4 의 두 결과가 이 배치에서 갈린다.
이름 셋(`auth` · `app1` · `app2`)이 한 인증서의 SAN 에 들어 있다. 와일드카드가
아니다. 이 제약이 나중에 실제 비용을 청구한다 — oauth2-proxy 실험(B-7)을 할 때
네 번째 이름이 없어 **Grafana 의 `app2` 를 빌려야 했다.**
@@ -133,6 +143,11 @@ kc-lab-1/2 는 무암호 sudo 라 `conntrack`·`tc`·`iptables` 를 자유롭게
> 주입 뒤에는 「대상이 실제로 그 상태인가」를 따로 확인한다.
> `cluster_size`, 워커 PID, conntrack 표, 패킷 카운터 — 결과가 아니라 상태를 본다.
![주입이 걸렸는지 따로 확인한다](assets/injection-verification/injection-verification.svg)
아홉 번의 실패가 전부 같은 자리에서 생겼다. 주입과 관측 사이가 비어 있었다.
---
## 검토한 선택지와 막힌 지점
@@ -158,6 +173,11 @@ kc-lab-1/2 는 무암호 sudo 라 `conntrack`·`tc`·`iptables` 를 자유롭게
동안에도 `up` 은 1 이었다.** 프로세스가 살아 있고 `/metrics` 가 응답하면
`up` 은 1 이다. **「살아 있지만 쓸모없는」 상태를 못 본다.**
![관측을 어디에 둘 것인가](assets/observation-points/observation-points.svg)
세 지점이 서로 다른 층을 본다. 하나만 두면 그 층의 사각이 그대로 사각으로 남는다.
### 스크립트를 쓰지 않는다
절차를 스크립트로 감싸면 「무엇을 했는지」가 스크립트 안으로 숨는다.
@@ -195,7 +215,12 @@ kc-lab-1/2 는 무암호 sudo 라 `conntrack`·`tc`·`iptables` 를 자유롭게
탄다.** 끊으면 반대편 노드가 「이 세션은 죽었다」를 모른다.
NetworkPolicy 는 허용목록이라 「deny 7800」을 쓸 수 없다. 8080·9000 만 열고
7800 을 **누락시켜** 막는다. 이 두 포트가 하중을 진다 — 9000(health·metrics)을
7800 을 **누락시켜** 막는다.
![발견과 전송은 다른 경로다](assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg)
발견은 DB 를, 전송은 7800 을 쓴다. 예측이 하나만 맞은 이유가 이 분기에 있다.
이 두 포트가 하중을 진다 — 9000(health·metrics)을
빠뜨리면 kubelet 이 파드를 죽여서 **분단이 아니라 죽은 Keycloak 을 재게 된다.**
#### A-2 · A-3 — DB 가 멈출 때와 죽을 때
@@ -220,6 +245,11 @@ COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그
**의도된 설계이고, 그 대가를 숫자로 확인한 것이다.**
![200 과 디스크 사이의 빈 구간](assets/a3-commit-to-disk-gap/a3-commit-to-disk-gap.svg)
성공 응답과 영속화가 다른 사건이다. RPO 가 0 이 아닌 이유가 그 사이에 있다.
#### A-4 · 노드 상실 — 둘 다 전면 장애지만 이유가 다르다
| | 4a 워커 상실 | 4b 컨트롤 플레인 상실 |
@@ -243,6 +273,11 @@ COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그
> 장애 시간의 대부분은 복구가 아니라 **「누가 죽은 것을 알아채는 데」** 걸린 시간이었다.
![노드를 잃는 두 가지](assets/a4-two-node-losses/a4-two-node-losses.svg)
저장소 상실과 진입 경로 상실. 복구 시간이 같아도 대비가 다르다.
#### A-5 · 비대칭 분단 — 전면 장애 경로가 없다
한 방향만 막으면 **열린 방향으로 재연결한다.** 가르지 못한다.
@@ -252,6 +287,11 @@ COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그
이 실험에서 주입을 세 번 실패했다(위 표의 #4·#5·#6). **세 번 모두 다른
이유였고, 셋 다 「아무 일도 없었다」로 보였다.**
![비대칭 차단은 가르지 못한다](assets/a5-partition-asymmetry/a5-partition-asymmetry.svg)
한 방향을 막는 것과 둘을 막는 것의 차이.
#### A-6 · 지연 주입 — 200밀리초가 22초가 된다
| 측정 | 값 |
@@ -269,6 +309,11 @@ COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그
그리고 파드가 죽는다. readiness 가 타임아웃으로 실패해 느린 노드가
로드밸런서에서 빠진다. **느림이 장애로 승격된다.**
![지연이 곱해지는 두 단계](assets/a6-latency-multiplication/a6-latency-multiplication.svg)
왕복 누적과 풀 경합을 하나로 보면 28배가 어디서 왔는지 설명되지 않는다.
#### A-8 · 롤링 재시작 — 세션은 살아남고 캐시만 사라진다
| 확인 | 결과 |
@@ -280,6 +325,11 @@ COMMIT 이 WAL 디스크 기록을 기다리지 않고 즉시 반환한다. 그
**이것이 `persistent-user-sessions` 를 켜는 진짜 이유다.**
![재시작이 지우는 것과 남기는 것](assets/a8-cache-vs-session/a8-cache-vs-session.svg)
캐시와 세션을 분리하지 않으면 재시작 후 로그인이 유지되는 이유를 설명할 수 없다.
#### A-7 · A-7a — 전부 뒤집는 설정 하나, 그리고 그 표에도 조건이 있었다
A-7 은 `--features-disabled=persistent-user-sessions` 로 A층을 다시 돌려
@@ -316,6 +366,11 @@ select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
> **「그 경로가 이미 캐시를 채웠느냐」** 로 결정된다.
> 이런 종류는 **한 번 재고 표로 적으면 안 된다.**
![캐시 온도가 결과를 가른다](assets/cache-temperature-outcomes/cache-temperature-outcomes.svg)
세 결과를 만드는 조회 두 개. 캐시가 그 조회를 삼키면 결과가 바뀐다.
---
## 선택이 코드와 흐름에 반영되는 방식
@@ -388,6 +443,11 @@ Redis 세션 : 0 키 ← 정리됨
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
```
![덮어쓰기를 만드는 기본키](assets/b2-primary-key-overwrite/b2-primary-key-overwrite.svg)
저장소가 아니라 스키마가 원인이다.
#### B-3 · Refresh Token Rotation 경쟁 (Q2)
`revokeRefreshToken=true` · `refreshTokenMaxReuse=0` 에서 같은 refresh token
@@ -398,6 +458,11 @@ PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
client session 을 지우기 때문이다. 「하나는 성공하고 나머지가 실패한다」가
아니라 **전부 못 쓰게 된다.**
![회전 경쟁에서 이긴 요청도 진다](assets/b3-rotation-contention/b3-rotation-contention.svg)
실패가 진 요청에만 오지 않는다. 재시도 설계가 여기서 갈린다.
#### B-4 · Edge 인가의 범위 (Q4)
nginx → oauth2-proxy → 앱의 2홉 구조에서 헤더를 위조해 봤다.
@@ -412,6 +477,11 @@ nginx → oauth2-proxy → 앱의 2홉 구조에서 헤더를 위조해 봤다.
> **세션은 로그인 시점의 스냅샷이다.** `--cookie-refresh` 가 없으면
> 쿠키 만료나 재인증까지 옛 값이 간다. 요청 횟수와 무관하다.
![지우지 않으면 통과한다](assets/b4-header-trust-boundary/b4-header-trust-boundary.svg)
위조 경로와 정상 경로가 같은 헤더 이름을 쓴다. 지우는 단계가 없으면 구별할 수 없다.
#### B-5 · B-6 — 저장소 상실과 키 회전
B-5 에서 `redis-cli config set appendonly yes` 를 켜도 아무것도 달라지지
@@ -421,6 +491,11 @@ B-5 에서 `redis-cli config set appendonly yes` 를 켜도 아무것도 달라
B-6 에서 realm 키를 회전하고 JWKS 캐시의 유예 구간을 기대했는데 **없었다.**
`NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다.
![볼륨 없는 영속화와 유예 없는 회전](assets/b5-b6-storage-and-keys/b5-b6-storage-and-keys.svg)
설정과 매체를 분리해 보아야 한다. 설정만 보면 둘 다 되어 있는 것으로 읽힌다.
#### B-7 · B-7a — 쿠키에 담는 세션, 그리고 그 대가
oauth2-proxy 는 BFF 와 정반대다. **서버 상태가 없다.** 세션 전체가 쿠키에
@@ -461,6 +536,11 @@ TTL 이 요청으로 갱신되지 않으므로(`refresh:disabled`) **TTL 은 생
전제도 같이 적는다 — **`--cookie-refresh` 를 켜면 이 역산이 무너진다.**
그때는 `FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 정직하다.
![쿠키에 담으면 공유할 것이 없다](assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg)
쿠키 저장과 Redis 저장. 옮기는 순간 지울 수 없는 상태가 생긴다.
### C층 — SSO 와 로그아웃 전파
C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인했고, 로그아웃이 다른
@@ -476,6 +556,11 @@ C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인했고, 로그
**아무도 구현하지 않았다.** 그리고 「설정이 빠졌다」와 「기능이 없다」는 다르게
고쳐야 한다. 여기는 둘 다였고, 확인 순서를 바꿨다면 한쪽만 고치고 끝냈을 것이다.
![백채널 로그아웃은 양쪽이 있어야 한다](assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg)
IdP 쪽 결손과 앱 쪽 결손이 한 경로 위에 있다. 하나만 고치면 여전히 안 된다.
### D층 — 운영
#### D-1 · D-2 — 백업과 업그레이드
@@ -501,11 +586,21 @@ select count(*) from databasechangelog
업그레이드 전후 이 수가 같으면 롤백된다. 늘었으면 안 된다. 26.7.3 → 26.7.0
을 스키마 변경 없이 되돌리는 것은 **실제로 성공했다**(전환 순간 `000` 1회).
![방향에 따라 갈리는 업그레이드](assets/d2-upgrade-direction/d2-upgrade-direction.svg)
체크섬 검증은 막고, 롤링 업데이트는 피해를 줄인다.
#### D-3 · 비밀
`kubectl get secret -o yaml` 의 base64 는 암호화가 아니다. etcd 에 평문으로
있다. 파드 안에서 `env | grep -i secret` 이면 그대로 나온다.
![base64 는 암호화가 아니다](assets/d3-secret-exposure/d3-secret-exposure.svg)
인코딩과 암호화는 다르다. 두 경로 모두 끝이 평문이다.
#### D-4 · D-4a — 인증서, 그리고 이 실험대 최대의 발견
계획서의 물음은 「nginx reload 중 진행 중이던 요청은 어떻게 되는가」였다.
@@ -587,6 +682,11 @@ reload 자체는 무중단이었다 — 새 연결 **8856건 전부 200**, p95 2
그리고 전송 12초째에 reload 를 맞은 42초짜리 요청이 **845361바이트를 온전히**
받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임졌다.
![훅 하나가 만드는 차이](assets/d4a-hook-effect/d4a-hook-effect.svg)
훅의 유무가 만드는 차이. 판정은 로그 문구가 아니라 워커 PID 로 한다.
---
## 결정이 지켜지는지 확인하는 방법
@@ -615,6 +715,11 @@ D-4 에서 갱신 중 비200 이 한 번 나왔다고 하자. **평시 오류율
**대조군이 오보를 막았다.**
![대조군 없이는 귀속할 수 없다](assets/measurement-control/measurement-control.svg)
대조군이 관측과 귀속 사이에 있다. 그 자리가 비면 같은 관측이 두 가지로 읽힌다.
#### 두 시계에서 온 값을 빼면 안 된다
D-4a 에서 1~2초를 재려다 걸렸다. `test-server` 는 NTP 가 꺼져 있고
@@ -673,6 +778,11 @@ CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초
부하 생성기가 깨졌다 — 일회성 파드의 출력 스트림이 유실됐다. 상주 탐침 +
파드 안 파일 수집으로 고쳐 20/20 을 확인했다.
![명령으로 적는 것과 도는 것](assets/reproducibility-gap/reproducibility-gap.svg)
산문에서 명령으로, 명령에서 실행 확인으로. 두 번째 단계에서 한 건이 깨졌다.
---
## 얻은 것, 잃은 것, 적용하지 않을 때
@@ -686,6 +796,11 @@ CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초
| Q3 | Session 과 AuthorizedClient 를 어디에 | **둘은 조회 키가 다르므로 각각 결정해야 한다.** 세션을 Redis 로 옮겨도 토큰은 따라오지 않는다 |
| Q4 | Edge 인가의 범위 | **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않는다.** 먼저 지워야 한다. 그리고 **IdP 의 클레임 변경은 재인증 전까지 반영되지 않는다** |
![열린 질문 네 개가 닿은 곳](assets/open-questions-answered/open-questions-answered.svg)
네 질문이 공통 원인으로 모인다. 저장소 선택으로 풀리지 않는 것들이 한자리에 있다.
### 이 기록이 적용되지 않는 조건
- **Keycloak 26 미만.** `persistent-user-sessions` 가 기본이 아니면 A층 결론
@@ -695,6 +810,11 @@ CT 로그가 자체 시계로 서명)가 보정한 훅 시각의 정확히 1초
- **`--cookie-refresh` 를 켠 oauth2-proxy.** B-7a 의 TTL 역산 정리 규칙이 무너진다
- **NTP 가 동기된 환경.** 이 실험대는 106초 왜곡이 있었고 그것을 보정한 수치다
![이 기록이 적용되지 않는 조건](assets/not-applicable-conditions/not-applicable-conditions.svg)
적용 조건을 목록이 아니라 무엇을 무효로 만드는가로 이었다.
### 재보지 않은 것
| 항목 | 왜 |
@@ -730,6 +850,11 @@ A-0 의 인과 설명이 잘못된 채로 남았을 것이고, A-7 의 가설이
세 번째가 가장 자주 어겨졌고, 가장 비쌌다.
![틀린 예측이 남긴 것](assets/wrong-predictions/wrong-predictions.svg)
세 규칙을 순서로 놓았다. 각 단계가 빠졌을 때 어떻게 틀리는지가 실제 이력이다.
---
## 자료
@@ -741,7 +866,7 @@ A-0 의 인과 설명이 잘못된 채로 남았을 것이고, A-7 의 가설이
| 증거 원문 | [`evidence/raw/`](evidence/raw/) — 125건. 정본이다 |
| 실행 메타 | [`evidence/meta/`](evidence/meta/) — 125건 |
| 브라우저 캡처 | [`evidence/browser/`](evidence/browser/) — 22건 |
| 그림 | [`assets/`](assets/) — techviz 로 만든 3건. 정본은 [`.techviz/`](.techviz/) 의 VizSpec |
| 그림 | [`assets/`](assets/) — techviz 로 만든 28건. 정본은 [`.techviz/`](.techviz/) 의 VizSpec |
| 실험 목록 | [`../source/docs/experiment-index.md`](../source/docs/experiment-index.md) |
| 로드맵 | [`../source/docs/experiment-plan.md`](../source/docs/experiment-plan.md) — 실험별 예측·판정 규칙 |
| 개념 | [`../source/docs/session-lab-concepts.md`](../source/docs/session-lab-concepts.md) · [`../source/docs/session-lab-prerequisites.md`](../source/docs/session-lab-prerequisites.md) |
@@ -753,17 +878,24 @@ A-0 의 인과 설명이 잘못된 채로 남았을 것이고, A-7 의 가설이
## 이 기록에 아직 없는 것
**그림 3건만 techviz 로 만들었다.** 원본 저장소에는 손으로 그린 SVG 28개
있고 [`../source/docs/diagrams/`](../source/docs/diagrams/) 에 그대로 있다.
이 저장소의 규약은 손으로 SVG 를 그리지 않고 techviz 파이프라인
(context → profile → VizSpec 1.1 → lint → render)을 거치게 하며,
**발행 SVG 안에 제목·부제·설명 밴드를 넣지 못하게** 한다. 손그림 28개는
전부 캔버스 안에 제목과 설명 문단을 담고 있어 그 계약을 어긴다.
**그림은 28건 모두 techviz 로 다시 만들었다.** 원본 저장소의 손그림 28개
[`../source/docs/diagrams/`](../source/docs/diagrams/) 에 그대로 있다.
그래서 원본은 `source/` 에 두고, `final/assets/` 에는 규약을 통과한 것만
넣었다. 나머지는 같은 파이프라인으로 다시 만들어야 한다 — 각 그림마다
문서 줄 범위를 인용하는 VizSpec 을 쓰고 lint(레이아웃 검사 포함)를
통과시켜야 하므로, 형식 변환이 아니라 다시 그리는 일이다.
형식 변환이 아니라 다시 그린 것이다. 이 저장소는 발행 SVG 안에 제목·부제·
설명 밴드를 넣지 못하게 하는데 손그림은 전부 캔버스 안에 제목과 설명 문단을
담고 있었다. 그래서 그림 안에는 이름만 남기고 문장은 `<desc>` 와 옆 문단으로
옮겼으며, 각 그림마다 문서의 줄 범위를 인용하는 VizSpec 을 쓰고 lint 를
통과시켰다.
lint 가 잡아낸 것 중 사람이 놓치기 쉬운 것 둘을 적어 둔다.
| 검사 | 무엇을 막았나 |
|---|---|
| `edge-through-node` | 화살표가 무관한 노드를 관통해 잘못된 인접을 암시하는 것 |
| `evidence-outside-prepared-context` | 그림이 다른 절의 내용을 근거로 대는 것 |
두 번째 때문에 그림 하나는 앵커를 옮겨야 했다. B-0 절에 앵커를 두고 B-2 의
내용을 인용하려다 막혔고, B-1 로 옮겨 세 절이 문맥에 들어오게 했다.
**Studio 기록은 아직 쓰지 않았다.** 이 문서까지가 SSOT 이고,
`tech-log-studio/` 아래 글감 추출과 기록 작성은 다음 단계다.