# 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:35–14:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 | | **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 | | **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 | > **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는 > 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과 > 수집 시각이 `14:35–14: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=|| └─ 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: , 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: ``` **어디를 봐야 하는가** — **두 줄이 다른 말을 하고 있다.** | 줄 | 뜻 | |---|---| | `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- ``` **왜 여기 나오나.** 티켓 전체가 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 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |