# 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:29–11: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: , 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초씩 준다.** 그리고 **두 값의 차가 거의 고정**되어 있다 — 3557−3479 = 78, 3526−3448 = 78, 3494−3417 = **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 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |