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>
This commit is contained in:
DongHyeonka
2026-09-07 18:29:00 +09:00
co-authored by Claude Opus 5
parent 8062cc9a19
commit 6f6ab86345
27 changed files with 22678 additions and 0 deletions
@@ -0,0 +1,766 @@
# B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다
해설 문서: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md) ·
증거 원문: [`docs/evidence/b7-cookie-secret/`](../../evidence/b7-cookie-secret/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| replica 두 개가 상태를 안 나누고도 로그인이 되는 것 | oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica) |
| 큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 | Traefik 직접 vs nginx |
| `--cookie-secret` 이 **단수**라는 것 | `oauth2-proxy --help` |
| 옛 쿠키가 `session ticket cookie failed validation` 로 죽는 것 | 프록시 로그 |
| **로그인 화면 없이 조용히 재로그인**되는 것 | 브라우저 |
| ★ 프록시가 **지우지 못한** 서버 세션이 Redis 에 남는 것 | `redis-cli --scan` |
## 전제
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns`
클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
- Redis 가 `redis.keycloak-lab.svc:6379` 로 떠 있다.
- **브라우저가 필요하다.** 쿠키가 `HttpOnly` 이고 OIDC 흐름을 끝까지 걸어야
세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 이 실험대에는 **`jq``yamllint` 도 없다.**
## 주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다
**두 가지를 건드린다.**
1. **`app2.hyeonworks.com` 은 평소 `observability` 네임스페이스의 Grafana 로
간다.** 인증서가 `auth`·`app1`·`app2` 세 이름만 덮고 있어서 네 번째 이름을
못 만든다. 그래서 **Grafana 의 Ingress 를 잠시 내리고 빌린다.**
**반드시 되돌린다** — [5-3](#5-3--grafana-ingress-를-되돌린다) 이 그 절차다.
백업을 뜨는 것이 [1-1](#1-1-먼저-grafana-ingress-를-백업한다) 의 첫 명령인 이유다.
2. **secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.**
실험대에서만 한다.
전 구간 약 20분이다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 친다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:3514:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
> 수집 시각이 `14:3514:42 KST` 인 것은 **같은 시간대의 같은 순간**이다(KST = UTC+9).
> 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
파드 이름·Redis 키·쿠키 값은 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
Q1 의 미지수 7 은 이렇게 물었다.
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
B-6 에서 Keycloak 은 **두 키를 동시에 들고** 무중단으로 회전했다. `kid`
있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.
| | 예측 |
|---|---|
| B-6 의 모양대로라면 | oauth2-proxy 도 **겹침 구간을 만들 수 있을 것** |
| **실측** | **★ 없다.** `--cookie-secret` 은 단수이고 쿠키에 key 식별자가 없다 |
**그리고 예측하지 않았던 것이 하나 더 나온다** — 사용자는 아무것도 못 느끼는데
**서버 쪽에 지워지지 않는 세션이 남는다.** 그 「지우지 못한다」를 이어서 재는
것이 [B-7a](b7a-orphan-session.md) 다.
핵심은 **상태를 어디에 두었는가**다.
```
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
대신 secret 이 단일 지점
```
**공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.**
---
# 1. 기준선 — 아무것도 바꾸기 전에
넓은 것부터 좁혀 간다.
```
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
```
## 1-1. 먼저 Grafana Ingress 를 백업한다
**이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.**
**확인** — 지금 app2 가 무엇인지 먼저 본다
```bash
curl -sI https://app2.hyeonworks.com/ | head -3
```
**형태** — Grafana 로 가고 있으면 `302``/login` 을 가리킨다.
**하기** — 백업을 뜨고, 파일이 비지 않았는지 확인한다
```bash
sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
**어디를 봐야 하는가** — 줄 수가 **0 이 아니고**, `app2.hyeonworks.com`
**1회 이상** 잡혀야 한다. `0` 이면 백업이 빈 파일이고, 그 상태로 진행하면
복구할 것이 없다.
**하기**
```bash
sudo kubectl -n observability delete ingress grafana
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
```
**되돌리기**`sudo kubectl apply -f ~/grafana-ingress-backup.yaml`
## 1-2. oauth2-proxy 를 배포한다
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
```
**되돌리기**`sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml`
## 1-3. replica 두 개가 서로 다른 노드에 있는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
```
**어디를 봐야 하는가****파드 두 개, 서로 다른 노드.** 그리고 **파드 이름의
끝 다섯 글자**를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을
했는지」를 이 글자로 가른다.
**이 결과가 의미하는 것** — replica 가 둘이라는 것이 Q1 의 질문 자체다.
하나면 「공유」라는 말이 성립하지 않는다.
## 1-4. 진입점이 살아 있는가
**확인**
```bash
curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
```
**어디를 봐야 하는가**
| 경로 | 정상 | 뜻 |
|---|---|---|
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — **프록시가 일하고 있다** |
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — **프록시 자체는 살아 있다** |
**두 값이 갈라지는 것이 중요하다.** `/ping` 도 안 되면 프록시가 안 뜬 것이고,
`/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.
## 1-5. ★ 502 가 나면 — 계층을 가른다
원래 구성에서 **콜백이 계속 502** 였다. 이 절은 그때 무엇을 쳤는지다.
**502 를 안 만났으면 읽고 넘어간다.**
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**502 는 「누가 냈는지」를 안 알려 준다.** 앞단 nginx 인지, 그 뒤 Traefik 인지,
파드인지. **한 겹씩 벗겨서 좁힌다.**
**확인** — nginx 를 건너뛰고 Traefik 에 직접 묻는다
```bash
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
```
**실측**
```
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
```
**어디를 봐야 하는가****Traefik 직접은 정상이다.** 그러면 502 를 내는 것은
그 앞의 nginx 다. 그리고 502 는 **쿠키를 설정하는 응답에서만** 났다.
**이 결과가 의미하는 것** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에
담는다.** 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시
> 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
**해결** — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
```
**형태**
```
"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
```
### ★ 그리고 여기서 조용한 실패를 하나 만난다
nginx 설정을 보려던 시도가 계속 **빈 결과**였다.
**실측**
```
$ sudo -n true
sudo: a password is required
```
**`test-server`(호스트)의 sudo 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는
무암호라 A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
**앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가
조용히 실패한 것이었다.** 호스트에서 무언가가 빈 결과를 주면 **먼저
`sudo -n true` 를 쳐 본다.**
## 1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다
**확인** — 키 이름만 본다
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**형태**
```
"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"
```
**확인** — 길이만 본다. **값은 절대 찍지 않는다**
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
```
**어디를 봐야 하는가****oauth2-proxy 는 정확히 16·24·32 바이트만 받는다.**
매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다.
**미검증** — 원래 실행 기록에 이 명령의 출력은 없다.
**이 결과가 의미하는 것****회전 대상이 미리 두 개 준비되어 있다.**
이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.
## 1-7. 로그인해서 세션을 하나 만든다
**하기** — 브라우저에서
```
https://app2.hyeonworks.com/api/echo → labuser / labpass
```
**어디를 봐야 하는가** — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의
JSON 이 보인다.
**이 결과가 의미하는 것** — upstream 이 받은 헤더가 그대로 찍힌다.
**실측** — [`b7-oauth2proxy-login-success.png`](../../evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
```json
"x-forwarded-email" : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto" : [ "https" ]
```
> **B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
> Forward-Auth 구조의 신원 전달 방식이고, **B-4 의 결론이 그대로 적용된다** —
> edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
## 1-8. 세션이 Redis 에 들어갔는가
**확인** — 먼저 통째로 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
```
**어디를 봐야 하는가****`dbsize` 는 2 인데 세션은 하나다.**
`b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다.
**이 결과가 의미하는 것****`dbsize` 로 세션을 세면 틀린다.**
이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
> `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹한다.
> 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
## 1-9. 쿠키가 「티켓」인지 확인한다
세션 저장소를 Redis 로 옮기면 쿠키에는 **세션 전체가 아니라 티켓**만 담긴다.
**하기** — 브라우저 개발자 도구 → Application/저장소 → Cookies → `_oauth2_proxy`
**실측** — 해설 문서에 남은 값
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**어디를 봐야 하는가**`|` 로 나뉜 **세 토막**과 전체 길이.
```
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
```
**이 결과가 의미하는 것** — 쿠키가 짧아졌고(그래서 502 가 사라졌고),
**Redis 키 이름은 이 티켓에서 계산된다.** 4절의 「지우지 못한다」가 여기서
결정된다.
---
# 2. 주입 — secret 을 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. 먼저 「겹칠 수 있는가」를 묻는다
**바꾸기 전에 이것부터 확인한다.** B-6 의 무중단 회전이 여기서도 되는지가
Q1 의 질문 자체이기 때문이다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**어디를 봐야 하는가****`string`. 복수형이 아니다.**
`--cookie-secrets``--old-cookie-secret` 도 목록에 없다.
**이 결과가 의미하는 것****겹침 구간을 만들 수단이 아예 없다.**
B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid`
있어서였다. **oauth2-proxy 의 쿠키에는 그런 식별자가 없다.**
```
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
```
**이 한 줄이 이 실험의 답이다.** 나머지는 「그래서 실제로 무슨 일이 나는가」다.
## 2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다
아래 patch 는 `env/1` 을 지목한다. **매니페스트의 순서에 달린 값이다.**
그대로 믿지 말고 확인한다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
```
**형태**
```
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
```
**어디를 봐야 하는가**`OAUTH2_PROXY_COOKIE_SECRET`**몇 번째인가**
(0부터 센다). 위 형태에서는 두 번째이므로 `env/1` 이다. 순서가 다르면
patch 의 숫자를 고친다. **틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.**
## 2-3. 바꾼다
**하기**
```bash
date -u '+%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_B"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
```
**시각을 UTC 로 적어 둔다.** 프록시 로그가 UTC 이고, [B-7a](b7a-orphan-session.md)
의 정리 규칙이 **이 시각을 기준으로** 고아를 고른다.
---
# 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
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
현재 secret 키: COOKIE_SECRET_B
```
**어디를 봐야 하는가**`COOKIE_SECRET_B`. **Deployment 의 참조가 바뀐 것이지
Secret 의 내용이 바뀐 것이 아니다.** 두 값 다 그대로 있고 어느 쪽을 읽을지만
바뀌었다 — 그래서 되돌리기가 한 줄이다.
## 3-2. ★ 그런데 Redis 는 그대로다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
Redis 세션은 그대로인가: 2 키
```
**어디를 봐야 하는가** — 세션 수가 **회전 전과 같다.**
**이 결과가 의미하는 것****회전 자체는 아무 일도 일으키지 않는다.**
여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면
**누군가 옛 쿠키를 들고 와야** 한다. 그게 4절이다.
> 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이
> [B-7a](b7a-orphan-session.md) 의 기준선이다.
## 3-3. 파드가 실제로 새로 떴는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**어디를 봐야 하는가****파드 이름이 1-3 과 다르다.** 같으면 patch 가 아무
필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.
---
# 4. 관찰 — 옛 쿠키를 들고 가 본다
## 4-1. 브라우저로 다시 연다
**하기** — 1-7 에서 로그인한 **그 브라우저 그대로**
```
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.
**이 결과가 의미하는 것** — **Keycloak SSO 세션이 살아 있어서 조용히 재인증이
일어났다.** 쿠키는 분명히 무효가 됐는데 **사용자 눈에는 아무 일도 없었다.**
> **여기가 이 실험에서 가장 오해하기 쉬운 자리다.** 「로그인 화면이 안 떴으니
> 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. **쿠키는 죽었고,
> 사용자는 실제로 재인증을 거쳤다.** SSO 가 그 사실을 가려 준 것뿐이다.
> **IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.**
## 4-2. 로그가 무슨 일이 났는지 말한다
**확인** — 먼저 최근 로그를 그대로 본다
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
```
**한 번은 통째로 본다.** 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다 — **replica 가 둘이므로 이게 없으면
누가 무엇을 했는지 못 가린다.**
이제 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
```
**어디를 봐야 하는가****두 줄이 다른 말을 하고 있다.**
| 줄 | 뜻 |
|---|---|
| `stored_session.go:94` | 쿠키를 열 수 없다 → **세션을 지우겠다** |
| `stored_session.go:97` | **그 지우기가 실패했다**`error decoding ticket to clear session` |
**94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.**
이어지는 줄이 사용자 쪽 이야기다.
**실측**
```
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...
```
**어디를 봐야 하는가**`Initiating login``AuthSuccess` 가 **같은 초**에 있다.
**로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다.** 4-1 에서 화면이
깜빡였던 것이 이것이다.
## 4-3. ★ Redis 에 고아가 남는다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
```
**어디를 봐야 하는가****키가 둘이다.** 뒤엣것(`b26111f…`)은 1-8 에서 본
회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.
**이 결과가 의미하는 것****사용자는 하나인데 서버 세션이 둘이다.**
옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. **고아다.**
## 4-4. 왜 못 지우는가 — 티켓과 키의 관계
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 **티켓**만 담긴다(1-9).
티켓은 두 부분이다.
```
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
```
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 봉인되어 있다.
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는
알지만 **「그 세션이 Redis 어디에 있다」를 모른다.** 그래서 `removing session`
을 시도하고 실패한다(4-2 의 97 번 줄).
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** 여기서 이 실험은 멈췄다.
「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를
[B-7a](b7a-orphan-session.md) 가 이어서 잰다. **답은 「지울 수 있다」이고,
「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.**
## 4-5. 덤 — replica 를 넘어도 되는 이유
로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
| grep -E 'Initiating login|AuthSuccess'
```
**실측** — 해설 문서에 남은 형태
```
--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo" ← 흐름을 시작한 replica
--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
```
**어디를 봐야 하는가****시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.**
**이 결과가 의미하는 것**
| | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|---|---|---|
| BFF | **서버 메모리(HttpSession)** | 콜백이 다른 인스턴스로 가면 **실패** (B-0) |
| oauth2-proxy | **쿠키 (secret 으로 봉인)** | **secret 만 같으면 성공** |
**Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다** — 공유할 상태가 없고,
공유할 것은 **k8s Secret 하나뿐**이다. 대신 그 하나가 단일 지점이 된다.
---
# 5. 복구
## 5-1. secret 을 A 로 되돌린다
**하기**
```bash
date -u '+%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
```
**어디를 봐야 하는가****이것도 회전이다.** B 로 만든 세션이 이번에는 고아가
된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.
## 5-2. 고아를 정리한다
**확인** — 지금 몇 개 남았는지 센다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**세 가지 선택지가 있다.**
| | 언제 | |
|---|---|---|
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | [B-7a](b7a-orphan-session.md) 의 규칙 |
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
**하기** — 전부 지울 때. **`b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
done
```
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).
> 1-8 에서 `dbsize` 가 2 였던 이유를 여기서 다시 쓴다.
## 5-3. ★ Grafana Ingress 를 되돌린다
**이것을 빠뜨리면 Grafana 가 안 열린다.**
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
```
**확인** — 실제로 Grafana 로 돌아갔는지 본다
```bash
sudo kubectl -n observability get ingress grafana
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — Ingress 가 `observability` 에 다시 있고, `app2` 응답이
1-1 에서 본 모양으로 돌아왔는가.
> **두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다.** oauth2-proxy 것을
> **먼저 지우고** Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가
> 컨트롤러 판단에 맡겨진다.
**oauth2-proxy 전체를 걷어내려면**
```bash
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
**다만 [B-7a](b7a-orphan-session.md) 와 [C-1](c1-multi-app-sso.md) 이 이 배포를
그대로 쓴다.** 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도
그 실험이 끝난 뒤로 미룬다.
## 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` |
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
| Ingress (빌린 것) | `sudo kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 **없다** (걷어냈다면) |
| Ingress (Grafana) | `sudo kubectl -n observability get ingress grafana` | **있다** |
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 콜백이 `502 Bad Gateway` | **쿠키가 크다.** `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다 |
| 호스트에서 nginx 설정·로그가 **빈 결과** | **`sudo` 가 조용히 실패했다** | `sudo -n true``sudo: a password is required` — 1-5 |
| `--cookie-secrets` 를 찾는데 없다 | **단수다.** 겹침 구간이 애초에 없다 | `--help \| grep cookie-secret` — 2-1 |
| patch 뒤 프록시가 기동에서 죽는다 | **env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다** | `env[*].name` 순서 확인 — 2-2 |
| secret 을 바꿨는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 3-2 |
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | **SSO 가 재인증을 가렸다.** 쿠키는 죽었다 | 로그의 `Initiating login` + `AuthSuccess` — 4-2 |
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` — 4-2 |
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-8 |
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | **호스트에서 파드 IP 는 안 닿는다** ([`02`](../../evidence/b7-cookie-secret/02-cookie-portability.txt)) | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | **브라우저를 쓴다** — 전제 |
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약의 박스 |
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `sudo kubectl -n keycloak-lab get ingress` |
| 실험이 끝났는데 Grafana 가 안 열린다 | **Ingress 복구를 안 했다** | 5-3 |
---
# 다음
| 실험 | B-7 이 남긴 질문 |
|---|---|
| [B-7a](b7a-orphan-session.md) 고아 세션 | **정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가** — 셋 다 답이 나온다 |
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **app1(BFF)과 app2(oauth2-proxy)가 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |