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>
700 lines
28 KiB
Markdown
700 lines
28 KiB
Markdown
# 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: <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초씩 준다.** 그리고 **두 값의 차가 거의
|
||
고정**되어 있다 — 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
|
||
```
|
||

|
||
|
||
**이 결과가 의미하는 것** — **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 로 기록**해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |
|