Files
keycloak-pattern/docs/guides/experiments/b7a-orphan-session.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

700 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# B-7a 재현 가이드 — 고아 세션을 TTL 로 골라내 지운다
해설 문서: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md) ·
증거 원문: [`docs/evidence/b7a-orphan-session/`](../../evidence/b7a-orphan-session/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 회전만으로는 Redis 가 **안 변하는** 것 | 회전 직후 `--scan` |
| 옛 쿠키를 들고 온 **그 순간** 고아가 생기는 것 | 프록시 로그 + Redis |
| 새 세션과 고아가 이름·타입·**크기까지** 같은 것 | `type` · `strlen` |
| TTL 이 요청을 보내도 **갱신되지 않는** 것 | 30초 간격 3회 |
| TTL 로 역산한 생성 시각이 로그와 **1초** 차이인 것 | `AuthSuccess` 시각과 대조 |
| 고아만 지워도 산 세션은 `200` 인 것 | 브라우저 |
## 전제
- [`B-7`](b7-cookie-secret-rotation.md) 이 끝나 있다. oauth2-proxy 가
`app2.hyeonworks.com` 에서 돌고 있고 **세션 저장소가 Redis** 여야 한다.
이 실험은 B-7 이 「지우지 못했다」로 멈춘 자리에서 시작한다.
- **브라우저가 필요하다.** 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 이 실험대에는 **`jq` 가 없다.** Redis 는 자기 CLI 로 묻는다.
- **시각은 전부 UTC 로 다룬다.** 이 실험의 결론이 시각 계산이라 여기서
섞이면 전부 틀린다 — [1-4](#1-4-시계를-맞춰-둔다) 에서 확인한다.
## 주의 — 이건 남의 세션을 실제로 지우는 실험이다
`redis-cli del` 로 세션 키를 지운다. **산 사람의 세션을 잘못 지우면 그 사람은
재로그인해야 한다**(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는
측정되지 않았지만, **실험대에서만 한다.**
**B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다**
[5-3](#5-3-원래-자리로-돌려놓는다) 이 그 절차다.
전 구간 약 20분이고, 그중 **TTL 을 세 번 재는 데 1분**이 그대로 든다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 11:2911:34 **UTC** 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
Redis 키 이름과 TTL 은 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위 수집
기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-7 은 여기서 멈췄다.
```
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
```
**티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**
그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. **그런데 못 지우는
주체가 누구인지를 안 갈랐다.**
| | |
|---|---|
| B-7 이 남긴 말 | **「★ 지우지 못했다」** |
| B-7a 가 묻는 것 | 그건 **oauth2-proxy 의 한계인가, Redis 의 한계인가** |
**답은 oauth2-proxy 의 한계다.** 프록시는 티켓을 못 풀어 키를 계산 못 하지만,
**운영자는 키를 직접 안다.** `--scan` 하면 다 보인다.
그러면 다음 물음이 생긴다 — **보이긴 하는데 어느 것이 고아인가.**
이 실험이 실제로 재는 것은 그 판별이고, 답은 **TTL 하나**다.
세 물음을 차례로 잰다.
```
(1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가
(2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가
(3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다
```
---
# 1. 기준선 — 회전하기 전에
넓은 것부터 좁혀 간다.
```
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
```
## 1-1. ★ `refresh:disabled` 를 먼저 확인한다
**이 한 단어가 5절 규칙 전체의 전제다.** 여기가 `disabled` 가 아니면
이 가이드의 결론은 당신 환경에서 성립하지 않는다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
```
**실측** — [`b7-cookie-secret/03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
```
**어디를 봐야 하는가** — 두 값이다.
| 값 | 이 실험에서 | |
|---|---|---|
| `expiry:1h0m0s` | **3600초** | 5절의 역산식에 그대로 들어간다 |
| **`refresh:disabled`** | **TTL 이 요청으로 갱신되지 않는다** | 이게 `enabled` 면 역산이 무너진다 |
**이 결과가 의미하는 것** — TTL 이 고정이면 **TTL 은 생성 시각의 정확한
함수**다. 4-3 에서 그 식을 세우고 5-1 에서 그걸로 고아를 고른다.
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
```
**형태**
```
"--cookie-secure=true"
"--cookie-expire=1h"
```
**어디를 봐야 하는가****`--cookie-refresh` 가 목록에 없어야 한다.**
없으면 `refresh:disabled` 다.
## 1-2. 세션을 하나 만든다
**하기** — 브라우저에서
```
https://app2.hyeonworks.com/api/echo → labuser / labpass
```
**어디를 봐야 하는가** — upstream 의 JSON 이 보이면 세션이 생긴 것이다.
## 1-3. Redis 를 있는 그대로 본다
**한 번은 통째로, 필드를 하나씩 본다.** 나중에 루프로 묶더라도 처음에는
`type`·`ttl`·`strlen` 이 각각 무엇을 답하는지 봐 두어야 한다.
**확인** — 무엇이 있나
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[기준선] 회전 전 — 11:29:42 UTC
secret = COOKIE_SECRET_A
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
dbsize=1
```
**확인** — 그 키 하나에 대해 셋을 묻는다. **키 이름은 위 출력에서 가져온다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf
```
**어디를 봐야 하는가**
| 명령 | 답하는 질문 | 이 실험에서 |
|---|---|---|
| `type` | 무슨 자료형인가 | 전부 `string`**구분에 못 쓴다** |
| `strlen` | 몇 바이트인가 | 전부 `3510`**구분에 못 쓴다** |
| **`ttl`** | 몇 초 남았나 | **유일하게 다른 값** |
**`ttl``-1` 이면** 만료가 안 걸린 키다(이 실험의 대상이 아니다).
**`-2` 면** 키가 없다 — 이름을 잘못 옮긴 것이다.
키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다.
**한 줄짜리고, 하는 일이 이름 그대로다.**
```bash
R() { sudo 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
```
**형태**
```
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
```
> 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. **느리다.** 키가 수백 개면
> 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다.
## 1-4. 시계를 맞춰 둔다
**이 실험은 시각 계산이 결론이다.** 프록시 로그는 **UTC** 이고, 당신 셸의
`date` 는 KST 일 것이다. 섞이면 9시간이 틀어진다.
**확인**
```bash
date; date -u
timedatectl show -p NTP -p NTPSynchronized
```
**형태**
```
NTP=yes
NTPSynchronized=yes
```
**어디를 봐야 하는가**`NTPSynchronized=yes`. 그리고 **앞으로 `date`
전부 `-u` 를 붙여 친다.**
**이 결과가 의미하는 것** — 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을
같은 축에 놓을 수 있게 된다. 4-6 의 「1초 오차」는 이 축이 맞아야 나온다.
---
# 2. 주입 — 1차 회전 A → B. 시각을 반드시 기록한다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo 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"}]'
```
## 2-1. 회전 시각을 변수에 담는다
**★ 이 값이 5절 규칙의 절반이다.** 안 적어 두면 나중에 고아를 못 고른다.
**하기**
```bash
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"
```
**실측** — 원래 실행의 1차 회전 시각
```
11:29:56 UTC
```
## 2-2. 바꾼다
**하기**
```bash
sudo 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"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**어디를 봐야 하는가**`successfully rolled out`. 그리고 **env 배열의 인덱스가
당신 매니페스트와 맞는지**는 B-7 의 [2-2](b7-cookie-secret-rotation.md#2-2-env-배열의-어느-칸을-바꾸는지-먼저-확인한다) 에서
확인했다. 안 했으면 지금 한다.
---
# 3. 주입이 걸렸는지 확인한다
## 3-1. 어느 키를 참조하는가
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
**형태**
```
COOKIE_SECRET_B
```
## 3-2. ★ 그런데 Redis 는 그대로다
**확인** — 1-3 과 **똑같은 명령**
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[주입] 1차 회전 A → B — 11:29:56 UTC
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
```
**어디를 봐야 하는가****키 수가 회전 전과 같다.**
**이 결과가 의미하는 것** — 여기서 「실험 실패」라고 결론 내리면 틀린다.
**회전은 방아쇠가 아니라 조건이다.** 실제로 벌어지는 것은
**누군가 옛 쿠키를 들고 오는 순간**이다.
> A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 자리다.
> **주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.**
## 3-3. 브라우저로 다시 연다 — 여기서 고아가 생긴다
**하기** — 1-2 에서 로그인한 **그 브라우저 그대로**
```
https://app2.hyeonworks.com/api/echo
```
**확인** — 그 순간의 로그
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
브라우저가 접근한 순간(11:30:27) 로그:
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
```
**어디를 봐야 하는가****`AuthSuccess` 의 시각을 적어 둔다.**
`11:30:27`. **4-6 에서 이 숫자와 역산값을 맞춰 본다.**
**확인** — Redis
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
```
**실측**
```
Redis:
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
dbsize=2
```
**어디를 봐야 하는가****키가 둘.** 그리고 로그인 화면을 안 봤다는 사실.
**이 결과가 의미하는 것** — Keycloak SSO 가 살아 있어 **조용히 재인증**됐다.
B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
---
# 4. 관찰 — 어느 것이 고아인가
## 4-1. ★ Redis 값만 보고는 구분할 수 없다
**확인** — 두 키를 나란히 놓는다
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)"
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 1] ★ Redis 만 보고는 구분할 수 없다
키 type strlen ttl
_oauth2_proxy-87faa1c9…(새) string 3510 3558
_oauth2_proxy-f6a9201f…(고아) string 3510 3480
```
**어디를 봐야 하는가** — 열을 하나씩 지운다.
| 신호 | 새 세션 | 고아 | 쓸 수 있나 |
|---|---|---|---|
| 이름 접두사 | `_oauth2_proxy-` | 같다 | ✗ |
| 이름 뒷부분 | 불투명한 32자 hex | 같은 성질 | ✗ — 사용자·시각·상태 어느 것도 안 담긴다 |
| `type` | `string` | `string` | ✗ |
| **`strlen`** | **3510** | **3510** | ✗ — **바이트 단위로 같다** |
| `ttl` | 3558 | 3480 | **✓ 이것뿐이다** |
값을 직접 봐도 소용없다. **암호화되어 있다.**
**확인** — 바이너리를 이스케이프해 보여 준다
```bash
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
```
**실측**
```
새 "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
md5 f9ad43cc6bbb2db4 / 9b31f7c4138e6472 (다르지만 뜻을 읽을 수 없다)
```
> **`--no-raw` 를 안 붙이면 터미널이 깨진다.** 세션 값은 바이너리다.
> 붙이면 `\xNN` 로 이스케이프해서 보여 준다.
**이 결과가 의미하는 것** — 두 값이 다르다는 것은 알 수 있지만 **어느 쪽이
고아인지는 말해 주지 않는다.** 뜻을 읽을 수 없기 때문이다.
**다른 것은 TTL 하나뿐이다.**
## 4-2. TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
**TTL 을 신호로 쓰려면 그것이 믿을 만한지부터 재야 한다.**
두 가지를 확인한다 — ① 실제로 줄어드는가 ② 요청을 보내면 되살아나는가.
**확인** — 30초 간격으로 세 번. 여기에 1분이 그대로 든다
```bash
for i in 1 2 3; do
date -u '+%H:%M:%S'
R --scan --pattern '_oauth2_proxy-*' | while read K; do
printf " %s ttl=%s\n" "$K" "$(R ttl $K)"
done
sleep 30
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
30초 간격 3회:
t+00초 새=3557 고아=3479
t+30초 새=3526 고아=3448
t+60초 새=3494 고아=3417
```
**어디를 봐야 하는가****30초에 30초씩 준다.** 그리고 **두 값의 차가 거의
고정**되어 있다 — 35573479 = 78, 35263448 = 78, 34943417 = **77**.
**차이가 (1초 안에서) 고정이라는 것이 「둘 다 생성 시각에만 달렸다」는 뜻이다.**
그 1초의 흔들림은 TTL 이 초 단위 정수라서 생기는 반올림이고, **4-6 에서 나오는
「1초 오차」와 같은 것**이다.
이제 ②를 확인한다. **브라우저로 요청을 몇 번 보낸 뒤** 다시 잰다.
**실측**
```
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
살아있는 세션 ttl=3464 ← 계속 줄어든다
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
```
**이 결과가 의미하는 것****쓰고 있어도 TTL 이 안 늘어난다.**
1-1 에서 본 `refresh:disabled` 가 여기서 값으로 확인됐다.
따라서 **고아는 생성 후 정확히 1시간에 사라진다.** 무한정 쌓이지 않는다.
## 4-3. 개념 — TTL 갱신 여부가 왜 결정적인가
**무엇인가.** `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이
연장된다. 끄면 **생성 시점부터 고정된 시간이 흐른다.**
**왜 여기 나오나.** TTL 이 고정이면 이 식이 성립한다.
```
생성시각 = 지금 - (cookie-expire - TTL)
```
**이 한 줄이 5절의 정리 규칙 전체를 만든다.** `cookie-expire` 는 1-1 에서
`1h0m0s` = 3600 으로 확인했다.
**없거나 틀리면.** **`--cookie-refresh` 를 켜는 순간 이 역산이 무너진다.**
활발히 쓰는 세션일수록 TTL 이 크게 남아 「방금 만들어진 것」처럼 보이고,
오래 안 쓴 산 세션은 TTL 이 작아 **고아로 오판되어 지워진다.**
> **그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이
> 오히려 정직하다.** 골라내는 척하면서 산 세션을 죽이는 것보다 낫다.
> **이 가이드의 5절은 `refresh:disabled` 일 때만 유효하다.**
## 4-4. 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
**되돌리기가 없는 조작이다. 지우기 전에 어느 키인지 두 번 확인한다.**
지금은 TTL 이 작은 쪽이 고아다(4-1).
**하기**
```bash
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
dbsize 2 → 1
남은 키: _oauth2_proxy-87faa1c9…
```
**어디를 봐야 하는가****반환값 `1`.** `0` 이면 그 키가 없었던 것이다
(이름을 잘못 옮겼다).
**확인** — 산 세션이 멀쩡한지. **브라우저로 다시 연다**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
```
**실측**
```
삭제 직후 브라우저 요청 (11:32:49):
app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108
```
![고아 삭제 후 살아있는 세션](../../evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png)
**이 결과가 의미하는 것****200. 산 세션은 영향이 없다.**
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
> 프록시는 티켓을 못 풀어 키를 계산 못 한다. **운영자는 키를 직접 안다.**
> 0절의 물음 (2)에 대한 답이 이것이다.
## 4-5. 누적한다 — 회전할 때마다
**한 번 더 회전해 본다.** 고아가 일회성인지 누적인지가 갈린다.
**하기**
```bash
ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)"
sudo 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"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
그리고 **브라우저로 다시 연다.**
**확인**
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
[측정 4] ★ 누적한다 — 회전할 때마다
2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:
키 TTL 생성시각(추정) 판정
_oauth2_proxy-dad9c9fb… 3581 11:33:54 살아있음
_oauth2_proxy-87faa1c9… 3373 11:30:26 ★ 고아
dbsize=2
```
**어디를 봐야 하는가****`87faa1c9…` 의 신분이 바뀌었다.**
3-3 에서 「새 세션」이던 것이 여기서는 고아다.
**이 결과가 의미하는 것** — **1차 회전을 살아남았던 세션이 2차 회전에서
고아가 됐다.** 회전 1회 = **그 시점 로그인 사용자 수**만큼의 고아.
고아는 사건이 아니라 **회전의 고정 비용**이다.
## 4-6. ★ 역산이 실제로 맞는지 검증한다
**규칙을 쓰기 전에 규칙 자체를 검증한다.** 위 표의 「생성시각(추정)」은
4-3 의 식으로 나온 값이고, 우리에겐 대조할 실측이 하나 있다 —
**3-3 의 `AuthSuccess` 로그 시각.**
**실측**
```
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
**1초 오차.** 추정이 아니라 사실상 정확하다.
```
**어디를 봐야 하는가****1초.** TTL 이 초 단위 정수라 반올림에서 나올 수
있는 크기다.
**이 결과가 의미하는 것****TTL 역산은 추정이 아니라 측정에 가깝다.**
그래서 다음 규칙을 안심하고 쓸 수 있다.
```
생성시각 < 회전시각 → 그 키는 고아다
```
**왜 성립하는가** — 회전 **이후에** 만들어진 세션은 **새 secret 으로**
만들어졌으므로 반드시 유효하다. 그러니 회전 이전 생성분만 고르면 된다.
---
# 5. 정리와 복구
## 5-1. ★ 먼저 눈으로 보고, 그 다음에 지운다
**`del` 을 바로 붙이지 않는다.** 같은 루프를 `echo` 로 한 번 돌려
**무엇이 지워질지 읽는다.**
**확인** — 지우지 않는 판. `ROT` 은 2-1(또는 4-5의 `ROT2`)에서 담아 둔 값이다
```bash
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))"
else
echo "산것 $K (생성 $(date -u -d @$C +%H:%M:%S))"
fi
done
```
**어디를 봐야 하는가****「산것」이 정확히 지금 로그인해 있는 사람 수만큼
있는가.** 아니면 `ROT` 이 틀렸거나 `EXP` 가 3600 이 아니다.
**`NOW` 를 루프 밖에서 한 번만 잡는 것이 중요하다.** 안에서 잡으면 키마다
기준 시각이 달라진다.
**하기** — 확인한 뒤에 지운다
```bash
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
R dbsize
```
**실측** — [`01-orphan-lifecycle.txt`](../../evidence/b7a-orphan-session/01-orphan-lifecycle.txt)
```
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
산 세션은 남고 고아만 사라졌다.
```
**어디를 봐야 하는가** — 지운 뒤 브라우저로 한 번 더 열어 본다.
**열리면 산 세션이 안 다친 것이다**(4-4).
> **`dbsize` 는 이 Redis 전체를 센다.** BFF 세션과 B-5 가 남긴 키도 들어 있다.
> 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이고,
> **당신 환경에서는 다를 수 있다.** 세션만 세려면 `--scan --pattern` 을 쓴다.
## 5-2. 전제가 깨졌을 때 — 정직한 대안
`--cookie-refresh` 가 켜져 있으면 5-1 을 **쓰면 안 된다**(4-3).
그때는 전부 지우고 모두 재인증시킨다.
```bash
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
```
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 에는 BFF 세션도 들어 있다.
> 패턴으로 좁히는 것이 이 실험대에서는 필수다.
## 5-3. 원래 자리로 돌려놓는다
**하기** — secret 을 A 로
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
`COOKIE_SECRET_B` 로 나오면 A 로 되돌린다(2절의 되돌리기).
4-5 에서 이미 A 로 돌아왔다면 그대로 둔다.
**하기** — B-7 에서 Grafana Ingress 를 빌렸다면 **여기서 돌려준다**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
> **[C-1](c1-multi-app-sso.md) 을 이어서 할 생각이면 아직 돌려주지 않는다.**
> C-1 이 app2 를 그대로 쓴다. 그 대신 **C-1 이 끝난 뒤에 반드시 복구한다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
| 세션 | `R --scan --pattern '_oauth2_proxy-*'` | 지금 로그인한 사람 수만큼만 |
| 다른 키 | `R --scan --pattern '*'` | `b5:pvc`·BFF 세션이 **살아 있다** (안 지웠어야 한다) |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
| 셸 변수 | `unset ROT ROT2 NOW EXP` | — |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 회전했는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 — 3-2·3-3 |
| 고아와 산 세션이 구분이 안 간다 | **이름·타입·크기가 같다.** 값은 암호화 | **TTL 만이 신호다** — 4-1 |
| `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` — 4-1 |
| `ttl``-1` | 만료가 안 걸린 키다 | 이 실험의 대상이 아니다 |
| `ttl``-2` · `del``0` | **그 키가 없다** | 키 이름을 `--scan` 출력에서 다시 옮긴다 |
| 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 — 1-1 |
| 역산이 9시간 어긋난다 | **`date` 를 로컬로 쳤다** | 전부 `date -u` — 1-4 |
| **산 세션이 고아로 잡힌다** | **`--cookie-refresh` 가 켜져 있다** | 기동 로그의 `refresh:disabled` 확인. 켜져 있으면 5-2 |
| 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인하면 된다. SSO 가 살아 있으면 조용히 지나간다 |
| `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc`·BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-3 |
| `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | **같은 Redis 에 BFF 세션이 있다** | 패턴으로 좁혀 지운다 — 5-2 |
| 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 |
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약 |
---
# 다음
| 실험 | B-7a 가 남긴 것 |
|---|---|
| [B-7](b7-cookie-secret-rotation.md) cookie secret | **「지우지 못했다」가 정정됐다** — 프록시가 못 하는 것이지 불가능한 것이 아니다 |
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다.** 지울 때 패턴을 좁혀야 하는 이유 |
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | 회전의 진짜 비용은 **재로그인이 아니라 저장소에 남는 것**이다 |
| 운영 | 회전 시각을 **UTC epoch 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |