docs(keycloak-session-store): import the session-storage lab as a new project
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
@@ -0,0 +1,301 @@
|
||||
# B-7a — 고아 세션은 지울 수 있는가
|
||||
|
||||
브랜치 `feature/keycloak-b7a-orphan-session` ·
|
||||
증거 [`docs/evidence/b7a-orphan-session/`](evidence/b7a-orphan-session/) ·
|
||||
2026-09-04 20:29–20:34 KST
|
||||
|
||||
B-7 이 **「★ 지우지 못했다」** 로 남긴 자리를 잰다.
|
||||
결론부터 — **oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.**
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||

|
||||
|
||||
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
| 물음 | 답 |
|
||||
|---|---|
|
||||
| 고아는 정말 사라지는가 | **사라진다.** 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 |
|
||||
| 운영자가 지울 수 있는가 | **지울 수 있다.** `redis-cli del` 로 지워도 산 세션은 `200` |
|
||||
| **어느 것이 고아인지 아는가** | **Redis 값으로는 모른다.** 이름·타입·크기가 같고 값은 암호화됨 |
|
||||
| **그럼 어떻게 고르는가** | **★ TTL 로 생성 시각을 역산한다.** 1초 오차로 맞는다 |
|
||||
| 회전할 때마다 누적하는가 | **누적한다.** 회전 1회 = 그 시점 로그인 사용자 수 |
|
||||
|
||||
---
|
||||
|
||||
## 1. B-7 이 어디서 멈췄나
|
||||
|
||||
```
|
||||
[stored_session.go:97] Error removing session:
|
||||
error decoding ticket to clear session: session ticket cookie failed validation
|
||||
```
|
||||
|
||||
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
|
||||
|
||||
### 개념 — 티켓과 키의 관계
|
||||
|
||||
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 세션 전체가 아니라
|
||||
**티켓(ticket)** 만 담긴다. 티켓은 두 부분이다.
|
||||
|
||||
```
|
||||
티켓 = <세션 ID>.<암호화 키>
|
||||
│ └─ 값을 복호화할 키
|
||||
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
|
||||
```
|
||||
|
||||
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 서명·암호화되어 있다.
|
||||
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
|
||||
값을 못 읽는 게 아니라 **어느 키를 지워야 하는지를 모른다.**
|
||||
|
||||
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 "이 세션은 못 쓴다"까지는
|
||||
알지만 "그 세션이 Redis 어디에 있다"를 모른다. 그래서 `removing session` 을
|
||||
시도하고 실패한다.
|
||||
|
||||
**확인.**
|
||||
```bash
|
||||
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 재현 — 회전이 고아를 만드는 순간
|
||||
|
||||
### 기준선 (11:29:42 UTC)
|
||||
|
||||
```
|
||||
secret = COOKIE_SECRET_A
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||||
type=string ttl=3568초 크기=3510바이트
|
||||
dbsize=1
|
||||
설정: --cookie-expire=1h --session-store-type=redis
|
||||
```
|
||||
|
||||
### 주입 — 1차 회전 A → B (11:29:56 UTC)
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
```
|
||||
|
||||
**회전 직후 Redis 는 그대로 1개다.** 회전 자체는 아무 일도 일으키지 않는다 —
|
||||
**누군가 옛 쿠키를 들고 오는 순간**에 비로소 벌어진다.
|
||||
|
||||
브라우저가 접근한 11:30:27:
|
||||
|
||||
```
|
||||
[stored_session.go:94] Error loading cookied session: … removing session
|
||||
[stored_session.go:97] Error removing session: error decoding ticket to clear session
|
||||
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
|
||||
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com …}
|
||||
```
|
||||
|
||||
```
|
||||
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
|
||||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
|
||||
```
|
||||
|
||||
Keycloak SSO 가 살아 있어 **로그인 화면 없이** 통과했다 — B-7 의 관찰 그대로다.
|
||||
|
||||
---
|
||||
|
||||
## 3. ★ Redis 만 보고는 구분할 수 없다
|
||||
|
||||
| | 새 세션 | 고아 |
|
||||
|---|---|---|
|
||||
| 이름 | `_oauth2_proxy-87faa1c9…` | `_oauth2_proxy-f6a9201f…` |
|
||||
| type | `string` | `string` |
|
||||
| **크기** | **3510바이트** | **3510바이트** |
|
||||
| 값 | `\xcb\xb3h\xfa\x98\xedc\xe4@<…` | `N\xf5\x0e=\xe1N\xfc|\xa2qE\xde…` |
|
||||
|
||||
**바이트 단위로 크기가 같다.** 이름 뒤쪽은 불투명한 32자 hex 이고 사용자도
|
||||
시각도 상태도 담지 않는다. 값은 암호화되어 있어 뜻을 읽을 수 없다.
|
||||
|
||||
> **다른 것은 TTL 하나뿐이다.** 이것이 4절의 열쇠가 된다.
|
||||
|
||||
---
|
||||
|
||||
## 4. TTL 은 정직하고, 갱신되지 않는다
|
||||
|
||||
30초 간격 3회:
|
||||
|
||||
| | 새 세션 | 고아 |
|
||||
|---|---|---|
|
||||
| t+00초 | 3557 | 3479 |
|
||||
| t+30초 | 3526 | 3448 |
|
||||
| t+60초 | 3494 | 3417 |
|
||||
|
||||
1초에 1초씩. **고아는 생성 후 정확히 1시간에 사라진다.**
|
||||
|
||||
요청을 두 번 보낸 뒤에도 산 세션의 TTL 은 `3464` 로 계속 줄었다.
|
||||
기동 로그의 `refresh:disabled` 와 일치한다 — `--cookie-refresh` 가 없다.
|
||||
|
||||
### 개념 — TTL 갱신 여부가 왜 중요한가
|
||||
|
||||
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
|
||||
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
|
||||
|
||||
**왜 여기 나오나.** TTL 이 고정이면 **TTL 은 생성 시각의 정확한 함수**다.
|
||||
|
||||
```
|
||||
생성시각 = 지금 - (cookie-expire - TTL)
|
||||
```
|
||||
|
||||
이 한 줄이 5절의 정리 규칙 전체를 만든다.
|
||||
|
||||
**없거나 틀리면.** `--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.
|
||||
그때는 고아를 골라낼 수단이 사라지고, 회전 후 `FLUSHDB` 로 전부 지워
|
||||
모두 재인증시키는 편이 오히려 정직하다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 운영자는 지울 수 있다
|
||||
|
||||
```bash
|
||||
redis-cli del _oauth2_proxy-f6a9201f… # 반환 1
|
||||
```
|
||||
|
||||
```
|
||||
dbsize 2 → 1
|
||||
남은 키: _oauth2_proxy-87faa1c9…
|
||||
```
|
||||
|
||||
삭제 직후 브라우저 요청:
|
||||
|
||||
```
|
||||
app2.hyeonworks.com GET - "/oauth2/userinfo" … labuser@example.com 200 108
|
||||
```
|
||||
|
||||
**200. 산 세션은 다치지 않는다.**
|
||||
|
||||

|
||||
|
||||
> **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
|
||||
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 누적한다 — 회전할 때마다
|
||||
|
||||
2차 회전 B → A (11:33:27) 후 브라우저 재접근:
|
||||
|
||||
| 키 | TTL | 생성시각(역산) | 판정 |
|
||||
|---|---|---|---|
|
||||
| `_oauth2_proxy-dad9c9fb…` | 3581 | 11:33:54 | 살아있음 |
|
||||
| `_oauth2_proxy-87faa1c9…` | 3373 | 11:30:26 | **★ 고아** |
|
||||
|
||||
**1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다.**
|
||||
회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.
|
||||
|
||||
---
|
||||
|
||||
## 7. ★ 그래서 정리 규칙이 유도된다
|
||||
|
||||
TTL 이 갱신되지 않으므로(4절), 생성 시각을 역산할 수 있다.
|
||||
그 값이 **회전 시각보다 이르면 고아다** — 회전 이후에 만들어진 세션은
|
||||
새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.
|
||||
|
||||
**정확도 검증** — 역산 `11:30:26` 대 로그의 `AuthSuccess 11:30:27`.
|
||||
**1초 오차.** 추정이 아니라 사실상 정확하다.
|
||||
|
||||
```bash
|
||||
NOW=$(date -u +%s)
|
||||
ROT=<회전 시각 epoch> # date -u -d '2026-09-04 11:33:27' +%s
|
||||
EXP=3600 # --cookie-expire 를 초로
|
||||
|
||||
kubectl -n keycloak-lab exec deploy/redis -- \
|
||||
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$K")
|
||||
C=$(( NOW - (EXP - T) ))
|
||||
[ $C -lt $ROT ] && kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
|
||||
done
|
||||
```
|
||||
|
||||
실제 실행: `삭제: _oauth2_proxy-87faa1c9…` · 남은 `dbsize=1`.
|
||||
**산 세션은 남고 고아만 사라졌다.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Q1 미지수 7 에 남기는 보완
|
||||
|
||||
B-7 은 **"고아가 남는다"** 까지 답했다. B-7a 가 덧붙이는 것:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 얼마나 남는가 | **1시간.** TTL 이 갱신되지 않으므로 무한정 쌓이지 않는다 |
|
||||
| 지울 수 있는가 | **있다.** 프록시가 못 할 뿐이다 |
|
||||
| 어떻게 고르는가 | **TTL 역산.** 회전 시각 이전 생성분이 전부 고아다 |
|
||||
| 전제 | **`--cookie-refresh` 를 켜면 이 규칙이 깨진다.** 그때는 `FLUSHDB` 가 정직하다 |
|
||||
|
||||
> **secret 회전의 진짜 비용은 "재로그인"이 아니라 "저장소에 남는 것"이다.**
|
||||
> 그리고 그 비용은 **저장소를 쿠키로 쓰면 0** 이다 — 지울 서버 상태가
|
||||
> 애초에 없기 때문이다. B-7 이 Redis 로 옮긴 대가가 여기서 청구된다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# ── 0. app2 를 oauth2-proxy 로 잠시 빌린다 (Grafana 를 되돌릴 것)
|
||||
kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
|
||||
kubectl -n observability delete ingress grafana
|
||||
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml # Ingress 포함
|
||||
|
||||
# ── 1. 로그인해서 세션을 만든다 (브라우저 필요 — 쿠키가 HttpOnly 다)
|
||||
# https://app2.hyeonworks.com/ → labuser / labpass
|
||||
|
||||
# ── 2. 기준선
|
||||
R() { kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
|
||||
done
|
||||
|
||||
# ── 3. 회전. ★ 회전 시각을 반드시 기록한다 — 7절 규칙이 이걸 쓴다
|
||||
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S))"
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_B"}]'
|
||||
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s
|
||||
|
||||
# ── 4. 브라우저로 다시 접근 → 이때 고아가 생긴다
|
||||
# 로그에서 확인:
|
||||
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
|
||||
|
||||
# ── 5. TTL 이 갱신되지 않는지 확인 (요청을 보낸 뒤에도 줄어야 한다)
|
||||
for i in 1 2 3; do R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
printf "%s ttl=%s\n" "$K" "$(R ttl $K)"; done; sleep 30; done
|
||||
|
||||
# ── 6. 정리 — 회전 시각 이전 생성분이 고아다
|
||||
NOW=$(date -u +%s); EXP=3600
|
||||
R --scan --pattern '_oauth2_proxy-*' | while read K; do
|
||||
T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
|
||||
if [ $C -lt $ROT ]; then echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"; fi
|
||||
done
|
||||
|
||||
# ── 7. 복구
|
||||
kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
kubectl apply -f /tmp/grafana-ingress-backup.yaml
|
||||
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
|
||||
-p '[{"op":"replace",
|
||||
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
|
||||
"value":"COOKIE_SECRET_A"}]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 증거 파일
|
||||
|
||||
| 파일 | 종류 | 무엇을 보여주는가 |
|
||||
|---|---|---|
|
||||
| [`01-orphan-lifecycle.txt`](evidence/b7a-orphan-session/01-orphan-lifecycle.txt) | 터미널 | 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 `200` · 정리 규칙 검증 |
|
||||
| [`b7a-live-session-after-orphan-delete.png`](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) | 스크린샷 | 고아 삭제 직후 살아있는 세션의 응답 |
|
||||
|
||||
파일별 상세는 [`evidence/b7a-orphan-session/README.md`](evidence/b7a-orphan-session/README.md).
|
||||
Reference in New Issue
Block a user