Files
keycloak-pattern/docs/experiment-b7a-orphan-session.md
T
DongHyeonkaandClaude Opus 5 a2f749292a docs: add architecture diagrams for the three follow-up experiments
Each of A-7a, B-7a and D-4a had the concepts, procedure, commands and
terminal evidence but no diagram, which the rest of the series carries.

  a7a-cache-temperature.svg  the three cache states and the SQL each issues
  b7a-orphan-lifecycle.svg   ticket structure, accumulation across two
                             rotations, and why TTL is the only signal
  d4a-hook-timeline.svg      the 38m25s gap against the 1-2s one, on one axis

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:37:00 +09:00

11 KiB
Raw Blame History

B-7a — 고아 세션은 지울 수 있는가

브랜치 feature/keycloak-b7a-orphan-session · 증거 docs/evidence/b7a-orphan-session/ · 2026-09-04 20:2920:34 KST

B-7 이 「★ 지우지 못했다」 로 남긴 자리를 잰다. 결론부터 — oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.


구조

B-7a — 고아 세션의 발생·누적·정리

다이어그램 규약은 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 을 시도하고 실패한다.

확인.

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)

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

바이트 단위로 크기가 같다. 이름 뒤쪽은 불투명한 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. 운영자는 지울 수 있다

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초 오차. 추정이 아니라 사실상 정확하다.

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. 재현 절차 (명령어)

# ── 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 터미널 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 200 · 정리 규칙 검증
b7a-live-session-after-orphan-delete.png 스크린샷 고아 삭제 직후 살아있는 세션의 응답

파일별 상세는 evidence/b7a-orphan-session/README.md.