--- id: 8d6b8a7f-e08e-4c9f-9772-a14a9d569260 kind: SETUP slug: reproduce-b7a-orphan-session title: TTL 로 고아 세션을 골라내 지운다 topic: trust-handed-over-at-the-edge topicName: 위조 신원 헤더와 로그아웃 전파 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/8d6b8a7f-e08e-4c9f-9772-a14a9d569260/edit" pinnedVersions: - name: Redis version: 7.4.x source: - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7a sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # TTL 로 고아 세션을 골라내 지운다 기동 로그에서 `refresh:disabled` 를 확인하고 cookie secret 을 두 번 회전시킨 뒤, TTL 을 역산해 고아 세션만 골라 지우는 절차다. 지운 세션은 돌아오지 않으므로 `del` 앞에 같은 루프를 `echo` 로 한 번 돌린다. 약 20분. ## 관계 - **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션** 이 절차가 재는 것을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다. - **산문으로 적힌 측정 장치를 실행 가능하게 고쳤더니 한 건이 깨졌다** 여기 나오는 `R()` 함수와 `while` 루프들이 그 대상이다. 가이드가 전부 미검증으로 표시했고, 루프가 만든 TTL 숫자는 증거 파일에 있지만 그 값을 뽑아낸 형태는 확인되지 않았다. - **두 시계에서 온 값을 빼지 않는다** 이 절차의 결론이 시각 계산이다. 프록시 로그는 UTC 이고 셸의 `date` 는 KST 라, 섞으면 9시간이 틀어진다. - **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다** 같은 배포를 이어 쓰지만 치는 것이 다르다. 그쪽은 Grafana Ingress 를 빌려 oauth2-proxy 를 세우고 회전을 A → B 로 한 번만 쳐서 고아가 생기는 데까지 보며, 여기는 그 배포 위에서 1차와 2차를 직접 치고 TTL 로 고아를 골라 지운다. 전제도 다르다 — 여기는 `refresh:disabled` 확인이 첫 단계이고 그것이 깨진 환경에서는 이 절차를 쓰면 안 된다. 그쪽은 Ingress 까지 돌려주는 것으로 끝나고 여기는 지운 세션이 돌아오지 않는다. ## 본문 ## 읽기 전에 — 어디서 치는가 명령은 전부 `[lab host]` 에서 `kubectl` 로 친다. Redis 에 묻는 것도 `kubectl exec deploy/redis` 를 거치므로 노드에 들어갈 일이 없다. **원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다. 브라우저도 필요하다. 고아는 사람이 옛 쿠키를 들고 와야 생긴다. 셸만으로는 아무리 회전시켜도 키가 늘지 않는다. 시각은 전부 UTC 로 다룬다. 프록시 로그가 UTC 로 찍히고 이 절차의 결론이 시각 계산이라, `date` 에 `-u` 를 안 붙이면 9시간이 틀어진다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` | | 대상 | Deployment `oauth2-proxy` replica 둘 · Deployment `redis` | | 주입 수단 | `patch deployment` 로 `secretKeyRef.key` 를 바꾼다. 1차는 A → B, 2차는 B → A | | 구분 신호 | TTL 하나. 이름도 `type` 도 `strlen` 도 같다 | | 역산에 쓰는 값 | `cookie-expire` 가 `1h0m0s` 이므로 3600 | | 전 구간 | 약 20분. 그중 TTL 을 세 번 재는 데 1분이 그대로 든다 | | 도구 | `jq` 가 이 실험대에 없다. Redis 는 자기 CLI 로 묻는다 | **공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl` 이 `000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다. ## 이 실험이 가르는 것 B-7 은 여기서 멈췄다. ```text [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation ``` 티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다. 그 문장을 그대로 믿으면 「고아는 어쩔 수 없다」가 된다. 못 지우는 주체가 누구인지는 거기서 안 갈랐다. | 어느 기록이 | 무엇을 말하나 | |---|---| | B-7 이 남긴 말 | 「★ 지우지 못했다」 | | 이 절차가 묻는 것 | 그것이 oauth2-proxy 의 한계인가, Redis 의 한계인가 | 답은 oauth2-proxy 쪽이다. 프록시는 티켓을 못 풀어 키를 계산 못 하지만 운영자는 키를 직접 안다 — `--scan` 하면 다 보인다. 그러면 다음 물음이 생긴다. 보이긴 하는데 어느 것이 고아인가. 이 절차가 실제로 재는 것이 그 판별이고 답은 TTL 하나다. ```text (1) 고아의 TTL 은 정말 줄어드는가 — 사라지기는 하는가 (2) 운영자가 지울 수 있는가 — 지우면 산 세션이 다치는가 (3) ★ 어느 키가 고아인지 구분되는가 — 이것이 진짜 질문이다 ``` 절차를 끝까지 밟으면 두 키의 `strlen` 이 바이트 단위로 같은 것, TTL 이 30초에 30초씩 줄고 요청을 보내도 안 늘어나는 것, 역산한 생성시각과 로그의 `AuthSuccess` 가 1초 차인 것, 2차 회전에서 신분이 바뀌는 키가 하나 나오는 것을 자기 화면에서 보게 된다. ## 전제와 되돌리기 - B-7 이 끝나 있다. oauth2-proxy 가 `app2.hyeonworks.com` 에서 돌고 있고 세션 저장소가 Redis 여야 한다. - 브라우저가 있어야 한다. - 기동 로그의 `refresh:disabled` 를 1 절에서 확인한다. **그것이 `disabled` 가 아니면 이 절차의 정리 규칙은 그 환경에서 성립하지 않는다.** **`oauth2-proxy` 는 인증서가 서기 전에는 못 뜬다.** 기동할 때 OIDC 디스커버리를 먼저 하는데 그 주소가 `https://auth.hyeonworks.com/...` 이라, 인증서 단계를 안 끝낸 실험대에서는 `CrashLoopBackOff` 로 끝난다. 2026-09-17 에 그대로 겪었다(observed). ```text [provider.go:55] Performing OIDC Discovery... [main.go:59] ERROR: Failed to initialise OAuth2 Proxy: ... error while discovery OIDC configuration: Get "https://auth.hyeonworks.com/realms/keycloak-patterns/.well-known/openid-configuration": dial tcp 100.83.212.4:443: connect: connection refused ``` `oauth2-proxy-secrets` 가 있고 이미지도 받아지는데 기동 자체가 안 된다 — **이 편의 전제는 배포가 아니라 TLS 다.** **이건 남의 세션을 실제로 지우는 실험이다.** `redis-cli del` 로 세션 키를 지우고, 산 사람의 세션을 잘못 지우면 그 사람은 재로그인해야 한다. SSO 가 살아 있으면 조용히 지나간다. 그 이상의 피해는 측정되지 않았지만 실험대에서만 한다. 되돌리기는 secret 참조를 A 로 되돌리는 한 줄이다. B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 절차가 끝난 뒤에 돌려준다. ```bash label="[lab host] 중간에 그만둘 때 치는 한 줄" 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"}]' ``` ## 주입 전에 같은 명령으로 먼저 본다 ```text 프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계 ``` ### 1. refresh 가 꺼져 있는지 먼저 본다 **무엇을 보는가** — 기동 로그의 쿠키 설정 한 줄. 이 한 단어가 정리 규칙 전체의 전제다. ```bash label="[lab host] ① 기동 로그에서 쿠키 설정을 찾는다" kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings' ``` **어디를 보나** — 실측은 이렇다(observed, `b7-cookie-secret/03-rotation.txt`). ```text [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초 | 역산식에 그대로 들어간다 | | `refresh:disabled` | TTL 이 요청으로 갱신되지 않는다 | 이것이 `enabled` 면 역산이 무너진다 | 기동 로그가 잘려 나갔으면 인자에서 직접 본다. ```bash label="[lab host] ② 인자에서 쿠키 관련 값만 뽑는다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie ``` 모양은 이렇다(모양은 observed). ```text "--cookie-secure=true" "--cookie-expire=1h" ``` **이 값이 뜻하는 것** — `--cookie-refresh` 가 목록에 없으면 `refresh:disabled` 다. TTL 이 고정이면 TTL 은 생성 시각의 정확한 함수가 된다. 여기가 `disabled` 가 아니면 이 절차의 뒷부분을 쓰지 않는다 — 오래 안 쓴 산 세션이 고아로 오판되어 지워진다. ### 2. 세션을 하나 만들고 Redis 를 통째로 본다 **무엇을 보는가** — 키 하나와 그 키의 세 가지 성질. 브라우저에서 `https://app2.hyeonworks.com/api/echo` 를 열고 `labuser` / `labpass` 로 로그인한다. 업스트림의 JSON 이 보이면 세션이 생겼다. ```bash label="[lab host] ① 세션 키와 전체 키 수를 본다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern '_oauth2_proxy-*' kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize ``` **두 명령 자체는 2026-09-17 에 쳐서 돌았다**(observed). 세션이 없는 상태에서는 이렇게 나온다. ```text (--scan --pattern '_oauth2_proxy-*' 는 아무것도 안 낸다) 0 ``` `dbsize` 가 `0` 이고 스캔이 비었다는 것은 **Redis 는 멀쩡한데 엣지 세션이 하나도 없다**는 뜻이다. `oauth2-proxy` 가 안 떠 있으면 늘 이 모양이므로, 이 값을 보고 「Redis 가 안 붙었다」로 읽지 않는다. **어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [기준선] 회전 전 — 11:29:42 UTC secret = COOKIE_SECRET_A _oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568초 크기=3510바이트 dbsize=1 ``` 그 키 하나에 대해 셋을 따로 묻는다. 나중에 루프로 묶더라도 처음에는 `type` · `ttl` · `strlen` 이 각각 무엇을 답하는지 봐 둔다. 키 이름은 위 출력에서 가져온다. ```bash label="[lab host] ② 한 키에 세 가지를 따로 묻는다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli type _oauth2_proxy-f6a9201fd534a047998278452001ccbf kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli ttl _oauth2_proxy-f6a9201fd534a047998278452001ccbf kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli strlen _oauth2_proxy-f6a9201fd534a047998278452001ccbf ``` | 명령 | 답하는 질문 | 이 절차에서 | |---|---|---| | `type` | 무슨 자료형인가 | 전부 `string` — 구분에 못 쓴다 | | `strlen` | 몇 바이트인가 | 전부 `3510` — 구분에 못 쓴다 | | `ttl` | 몇 초 남았나 | 유일하게 다른 값 | **이 값이 뜻하는 것** — `ttl` 이 `-1` 이면 만료가 안 걸린 키이고 이 절차의 대상이 아니다. `-2` 면 키가 없다 — 이름을 잘못 옮겼다. ### 3. 세 번씩 치는 대신 짧은 함수를 하나 둔다 **무엇을 보는가** — 키가 여럿이 됐을 때 같은 것을 한 줄로 보는 형태. 가이드가 이 함수와 아래 루프들을 미검증으로 표시했다(unknown). **이 절은 건너뛸 수 없다.** 7 절부터 끝까지 모든 Redis 조회가 이 `R` 을 부른다. 그리고 셸 함수는 그 셸에만 있다 — 터미널이 끊기거나 다른 창에서 이어 치면 `R: command not found` 가 나오고, 그때는 이 블록의 첫 줄부터 다시 친다. ```bash label="[lab host] 이름 그대로 하는 한 줄짜리 함수 (unknown)" R() { 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 ``` 모양은 이렇다(모양은 observed). ```text _oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510 ``` **2026-09-17 에 이 블록을 그대로 쳤고 오류 없이 돌았다**(observed). 다만 출력이 비었다 — `oauth2-proxy` 가 안 떠서 세션 키가 하나도 없기 때문이고, 같은 순간 `R dbsize` 는 `3` 이었다(BFF 가 쓰는 키다). **그래서 이 절에서 확인된 것은 「명령이 맞다」까지이고 「값이 이렇게 나온다」는 아직 아니다.** 빈 출력을 보고 함수가 틀렸다고 되짚지 않도록 여기 적어 둔다 — `R: command not found` 가 아니라 **아무것도 안 나오는 것**이 이 상태의 정상이다. **이 값이 뜻하는 것** — 이 루프는 키 하나마다 `kubectl exec` 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 `--scan` 결과를 파일로 받아 두고 필요한 것만 묻는다. ### 4. 시계를 맞춘다 **무엇을 보는가** — 셸의 시각과 UTC, 그리고 NTP 동기화 여부. ```bash label="[lab host] 로컬과 UTC 를 나란히 보고 동기화를 확인한다" date; date -u timedatectl show -p NTP -p NTPSynchronized ``` **어디를 보나** — 모양은 이렇다(모양은 observed). ```text NTP=yes NTPSynchronized=yes ``` **이 값이 뜻하는 것** — `NTPSynchronized=yes` 를 보고 나면 앞으로 `date` 는 전부 `-u` 를 붙여 친다. 그래야 로그의 `[2026/09/04 05:42:18]` 과 회전 시각을 같은 축에 놓을 수 있고, 뒤에 나오는 1초 오차도 이 축이 맞아야 나온다. ## 주입 ### 5. 1차 회전 A → B 를 치고 시각을 담는다 **목적** — 옛 쿠키를 무효로 만들고, 그 시각을 정리 규칙의 기준으로 삼는다. **행동** — 시각을 먼저 담고 참조를 바꾼다. ```bash label="[lab host] ① 회전 시각을 UTC 로 담는다" ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)" ``` ```bash label="[lab host] ② secret 참조를 B 로 바꾼다" 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"}]' kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s ``` **예상 결과** — `successfully rolled out` 을 본다. 원래 실행의 1차 회전 시각은 이렇다(observed). ```text 11:29:56 UTC ``` **왜 필요한가** — `ROT` 이 정리 규칙의 절반이다. 안 담아 두면 나중에 어느 키가 회전보다 먼저 생겼는지 못 고른다. `env` 배열의 인덱스가 그 매니페스트와 맞는지는 B-7 에서 확인했고, 안 했으면 지금 `env[*].name` 을 본다. **문제가 생기면** — 롤아웃이 끝나지 않으면 인덱스를 잘못 짚어 클라이언트 비밀을 덮었을 수 있다. 파드 로그를 본다. ## 주입 검증 ### 6. Deployment 의 참조가 바뀌었는가 ```bash label="[lab host] 지금 참조하는 키 이름을 뽑는다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo ``` 모양은 이렇다(모양은 observed). ```text COOKIE_SECRET_B ``` ### 7. Redis 는 그대로인가 주입 전과 똑같은 명령으로 본다. ```bash label="[lab host] 키와 TTL 을 다시 본다" R --scan --pattern '_oauth2_proxy-*' | while read K; do echo "$K ttl=$(R ttl $K)" done R dbsize ``` 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [주입] 1차 회전 A → B — 11:29:56 UTC 회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다) ``` 키 수가 회전 전과 같다. 여기서 「실험 실패」라고 결론 내리면 틀린다. 회전은 방아쇠가 아니라 조건이고, 실제로 벌어지는 것은 누군가 옛 쿠키를 들고 오는 순간이다. A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 모양이다 — 주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다. ### 8. 브라우저로 다시 열고 그 순간의 로그를 본다 **무엇을 보는가** — 옛 쿠키를 들고 왔을 때 프록시가 무엇을 하는지. 로그인했던 그 브라우저 그대로 `https://app2.hyeonworks.com/api/echo` 를 연다. ```bash label="[lab host] 최근 2분의 세션 저장소 로그를 좁힌다" kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session ``` **어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text 브라우저가 접근한 순간(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` 을 적어 둔다. 13 절에서 이 숫자와 역산값을 맞춰 본다. 그리고 Redis 를 다시 본다. ```bash label="[lab host] 키가 늘었는지 본다" R --scan --pattern '_oauth2_proxy-*' | while read K; do echo "$K ttl=$(R ttl $K)" done R dbsize ``` 실측은 이렇다(observed). ```text Redis: _oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션 _oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아 dbsize=2 ``` 키가 둘이고 로그인 화면은 안 봤다. Keycloak SSO 가 살아 있어 조용히 재인증됐고 B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다. ## 관찰 ### 9. Redis 값만 보고는 구분되지 않는 것을 열을 하나씩 지워 보인다 **무엇을 보는가** — 두 키의 네 가지 성질. ```bash label="[lab host] 두 키를 나란히 놓는다" R --scan --pattern '_oauth2_proxy-*' | while read K; do echo "$K type=$(R type $K) len=$(R strlen $K) ttl=$(R ttl $K)" done ``` **어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [측정 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 | 이것뿐이다 | **이 값이 뜻하는 것** — 값을 직접 봐도 소용없다. 암호화되어 있다. 아래 키 이름은 이 실험대에서 나온 값이라 그대로 치면 남의 키를 조회해 `(nil)` 이 돌아온다. 2 절의 `--scan` 출력에서 자기 키 이름을 옮겨 넣는다. ```bash label="[lab host] 값의 앞머리만 이스케이프해서 본다 — 키 이름은 자기 것으로" R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo ``` 세션 값은 암호화된 바이너리라 해시와 이스케이프된 앞머리만 옮긴다(observed). ```text 새 "\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` 로 이스케이프해서 보여 준다. 두 값이 다르다는 것은 알 수 있지만 어느 쪽이 고아인지는 말해 주지 않는다. 뜻을 읽을 수 없기 때문이다. ### 10. TTL 이 신호로 쓸 만한지 잰다 **무엇을 보는가** — 둘이다. TTL 이 실제로 줄어드는가, 요청을 보내면 되살아나는가. 30초 간격으로 세 번이고 여기에 1분이 그대로 든다. ```bash label="[lab host] 30초 간격으로 세 번 재는 루프 (unknown)" 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 ``` **어디를 보나** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [측정 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 이 초 단위 정수라서 생기는 반올림이고, 13 절의 1초 오차와 같은 것이다. 둘째 물음은 브라우저로 요청을 몇 번 보낸 뒤 다시 재서 확인한다(observed). ```text 요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후): 살아있는 세션 ttl=3464 ← 계속 줄어든다 기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다. ``` 쓰고 있어도 TTL 이 안 늘어난다. 1 절에서 본 `refresh:disabled` 가 여기서 값으로 확인됐고, 따라서 고아는 생성 후 1시간에 사라진다. 무한정 쌓이지 않는다. `--cookie-refresh` 를 켜면 요청마다 세션이 갱신되고 TTL 이 연장된다. 끄면 생성 시점부터 고정된 시간이 흐른다. TTL 이 고정이면 이 식이 성립한다. ```text 생성시각 = 지금 - (cookie-expire - TTL) ``` 이 한 줄이 정리 규칙 전체를 만든다. `cookie-expire` 는 1 절에서 `1h0m0s` = 3600 으로 확인했다. `--cookie-refresh` 를 켜는 순간 이 역산이 무너진다 — 활발히 쓰는 세션일수록 TTL 이 크게 남아 방금 만들어진 것처럼 보이고, 오래 안 쓴 산 세션은 TTL 이 작아 고아로 오판되어 지워진다. 그때는 회전 후 `_oauth2_proxy-*` 를 전부 지우고 모두 재인증시키는 편이 오히려 정직하다. **아래 정리 규칙은 `refresh:disabled` 일 때만 유효하다.** ### 11. 고아를 하나 지우고 산 세션이 멀쩡한지 본다 **목적** — 못 지우는 것이 프록시인지 Redis 인지 가른다. **행동** — 되돌리기가 없는 조작이니 지우기 전에 어느 키인지 두 번 확인한다. 지금은 TTL 이 작은 쪽이 고아다. ```bash label="[lab host] ① 고아 하나를 지우고 남은 것을 센다" R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf R dbsize R --scan --pattern '_oauth2_proxy-*' ``` **예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다 redis-cli del _oauth2_proxy-f6a9201f… → 반환 1 dbsize 2 → 1 남은 키: _oauth2_proxy-87faa1c9… ``` 반환값이 `1` 이다. `0` 이면 그 키가 없었던 것이고 이름을 잘못 옮겼다. 산 세션이 멀쩡한지는 브라우저로 다시 열어서 본다. ```bash label="[lab host] ② 삭제 직후 요청이 200 인지 로그로 본다" kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser ``` 실측은 이렇다(observed). ```text 삭제 직후 브라우저 요청 (11:32:49): app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108 ``` 같은 사실을 화면으로 찍은 것이 함께 있다(observed, `b7a-orphan-session__b7a-live-session-after-orphan-delete.png`). **왜 필요한가** — 200 이 나왔으므로 산 세션은 영향이 없다. 「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키를 계산 못 하고, 운영자는 키를 직접 안다. **문제가 생기면** — 반환값이 `0` 이면 키 이름을 `--scan` 출력에서 다시 옮긴다. ### 12. 2차 회전을 쳐서 일회성인지 누적인지 가른다 **목적** — 고아가 사건인지 회전의 고정 비용인지 가른다. **행동** — 2차 회전 시각을 담고 B → A 로 되돌린다. ```bash label="[lab host] 2차 회전 시각을 담고 참조를 A 로 바꾼다" ROT2=$(date -u +%s); echo "2차 회전 $ROT2 ($(date -u -d @$ROT2 +%H:%M:%S) UTC)" 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"}]' kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s ``` 그리고 브라우저로 다시 연 뒤 본다. ```bash label="[lab host] 키와 TTL 을 다시 본다" R --scan --pattern '_oauth2_proxy-*' | while read K; do echo "$K ttl=$(R ttl $K)" done ``` **예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text [측정 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…` 의 신분이 바뀌었다. 8 절에서 새 세션이던 것이 여기서는 고아다. 1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다. 회전 1회에 그 시점 로그인 사용자 수만큼의 고아가 생기므로, 고아는 사건이 아니라 회전의 고정 비용이다. **문제가 생기면** — 키가 안 늘면 브라우저로 접근하지 않은 것이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다. ### 13. 규칙을 쓰기 전에 규칙 자체를 검증한다 **무엇을 보는가** — 역산값과 실측 한 쌍. 위 표의 생성시각(추정)은 역산식으로 나온 값이고, 대조할 실측은 8 절에서 적어 둔 `AuthSuccess` 시각이다. **어디를 보나** — 실측은 이렇다(observed). ```text [측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27. **1초 오차.** 추정이 아니라 사실상 정확하다. ``` **이 값이 뜻하는 것** — 1초는 TTL 이 초 단위 정수라 반올림에서 나올 수 있는 크기다. TTL 역산은 추정이 아니라 측정에 가깝고, 그래서 다음 규칙을 안심하고 쓴다. ```text 생성시각 < 회전시각 → 그 키는 고아다 ``` 회전 이후에 만들어진 세션은 새 secret 으로 만들어졌으므로 반드시 유효하다. 회전 이전 생성분만 고른다. ## 복구와 원상복구 확인표 ### 14. `del` 을 붙이기 전에 같은 루프를 `echo` 로 돌린다 **목적** — 무엇이 지워질지 먼저 읽는다. **행동** — 판정만 하고 지우지 않는 루프를 한 번 돌린다. **기준 시각을 먼저 정한다.** 이 루프가 재는 것은 「지금 걷어내려는 회전보다 앞에 만들어졌나」다. 5 절에서 담은 `ROT` 은 1차 회전 시각이고, 12 절을 쳤으면 지금 유효한 회전은 2차이므로 기준은 `ROT2` 다. **12 절을 친 뒤라면 아래 두 블록의 `"$ROT"` 를 `"$ROT2"` 로 바꿔 친다.** 안 바꾸면 두 회전 사이에 생긴 세션이 「산것」으로 분류되어 그대로 남고, 15 절의 실측(`87faa1c9…` 삭제 · 남은 `dbsize=1`)이 재현되지 않는다 — 그 키의 역산 생성시각 `11:30:26` 은 `ROT`(`11:29:56`)보다 뒤이기 때문이다. ```bash label="[lab host] 지우지 않고 판정만 하는 루프 — 12 절을 쳤으면 $ROT 을 $ROT2 로 (unknown)" 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` 를 루프 밖에서 한 번만 잡는다 — 안에서 잡으면 키마다 기준 시각이 달라진다. **문제가 생기면** — 역산 생성시각이 미래거나 엉뚱하면 `--cookie-expire` 부터 확인한다. 9시간 어긋나면 `date` 에 `-u` 를 안 붙였다. ### 15. 확인한 뒤에 지운다 **목적** — 고아만 지우고 산 세션은 남긴다. **행동** — 같은 루프에 `del` 을 붙인다. 14 절에서 `"$ROT2"` 로 바꿔 쳤으면 여기도 바꾼다. **두 블록은 연달아 친다** — 사이에 누가 로그인하면 리허설에서 못 본 키가 목록에 들어온다. **리허설이 보장하는 범위.** 두 블록은 `NOW` 를 각각 새로 잡고 `C = NOW − (EXP − T)` 로 역산한다. `NOW` 와 `T` 가 같이 흐르므로 `C` 는 대체로 같은 값이 나오지만, 11 절이 적었듯 TTL 이 초 단위 정수라 ±1초가 반올림으로 흔들린다. **생성시각이 기준 회전 시각의 ±1초 안에 놓인 키는 리허설에서 「산것」이었다가 실행에서 「고아」로 뒤집힐 수 있고, 그쪽 방향의 오판이 곧 산 세션 삭제다.** 리허설 출력의 생성시각이 기준 시각에 붙어 있는 키가 보이면 그 키는 이 루프로 지우지 않는다. ```bash label="[lab host] 고아로 판정된 것만 지운다 — 14 절과 같은 기준으로 (unknown)" 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 ``` **예상 결과** — 실측은 이렇다(observed, `01-orphan-lifecycle.txt`). ```text 실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1 산 세션은 남고 고아만 사라졌다. ``` **왜 필요한가** — 지운 뒤 브라우저로 한 번 더 열어 본다. 열리면 산 세션이 안 다쳤다. `dbsize` 는 이 Redis 전체를 센다 — BFF 세션과 B-5 가 남긴 키도 들어 있고, 여기서 `dbsize=1` 이 나온 것은 당시 다른 키가 없었기 때문이라 환경마다 다르다. 세션만 세려면 `--scan --pattern` 을 쓴다. **문제가 생기면** — 전제가 깨졌을 때, 즉 `--cookie-refresh` 가 켜져 있을 때는 위 규칙을 쓰지 않는다. 전부 지우고 모두 재인증시킨다. `FLUSHDB` 를 쓰지 않는다 — 이 Redis 에는 BFF 세션도 들어 있어 패턴으로 좁히는 것이 이 실험대에서는 필수다. ```bash label="[lab host] 전제가 깨졌을 때 — 접두사에 걸린 것만 전부 지운다" R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done ``` ### 16. secret 참조와 Ingress 를 돌려준다 **목적** — 빌린 것을 원래대로 돌린다. **행동** — 참조를 확인하고, Grafana Ingress 를 돌려준다. ```bash label="[lab host] ① 지금 참조가 A 인지 본다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo ``` 2차 회전에서 이미 A 로 돌아왔다면 그대로 둔다. B-7 에서 Grafana Ingress 를 빌렸다면 여기서 돌려준다. C-1 을 이어서 할 생각이면 아직 돌려주지 않고, C-1 이 끝난 뒤에 반드시 복구한다고 가이드가 적는다. **돌려주기 전에 백업 파일이 손에 있는지 본다.** 이 파일은 이 편이 만들지 않는다 — B-7 이 Grafana Ingress 를 걷어내기 **전에** 떠 둔다. 지금은 그 Ingress 가 이미 없으니 파일이 없으면 다시 뜰 수도 없고, 되살리는 경로는 가이드에 없다(unknown). 그러니 없으면 아래 `delete` 를 치지 않는다. ```bash label="[lab host] ② 백업 파일이 쓸 만한지 본다" wc -l ~/grafana-ingress-backup.yaml grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ``` 줄 수가 나오고 둘째 줄이 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 면 여기서 멈춘다. B-4 로 app2 를 빌린 적이 있다면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽도 본다. ```bash label="[lab host] ③ 빌린 Ingress 를 걷고 백업을 올린 뒤 밖에서 본다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml curl -sI --resolve app2.hyeonworks.com:443:192.168.122.10 https://app2.hyeonworks.com/ | head -3 ``` **예상 결과** — `app2` 응답이 B-7 을 시작하기 전 모양으로 돌아온다. 첫 줄의 상태 코드와 이어지는 `location` 헤더를 본다 — 여기서 갈라야 하는 것은 app2 가 아직 oauth2-proxy 로 가는지 Grafana 로 넘어갔는지다. Grafana 자체가 섰는지는 아래 표의 `get ingress grafana` 가 답한다. **왜 필요한가** — 돌려주지 않으면 Grafana 가 안 열린다. **문제가 생기면** — 참조가 B 로 남아 있으면 전제 절의 한 줄을 친다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` | | 파드 | `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 | `kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) | | 셸 변수 | `unset ROT ROT2 NOW EXP` | — | `R` 은 셸 함수라 이 표의 `unset` 으로 안 걷힌다. 걷는 명령이 가이드에 없으므로(unknown), 같은 창으로 다른 실험을 이어 할 거면 창을 새로 연다. 안 그러면 `R` 이 계속 `keycloak-lab` 의 `redis` 를 가리킨다. ## 막히면 원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다. | 증상 | 원인 | 확인 | |---|---|---| | 회전했는데 Redis 가 그대로 | 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 생긴다 | 브라우저로 접근 | | 고아와 산 세션이 구분이 안 간다 | 이름과 타입과 크기가 같다. 값은 암호화 | TTL 만이 신호다 | | `get` 했더니 터미널이 깨진다 | 값이 바이너리다 | `redis-cli --no-raw get` | | `ttl` 이 `-1` | 만료가 안 걸린 키다 | 이 절차의 대상이 아니다 | | `ttl` 이 `-2` 이거나 `del` 이 `0` | 그 키가 없다 | 키 이름을 `--scan` 출력에서 다시 옮긴다 | | 역산 생성시각이 미래거나 엉뚱하다 | `EXP` 가 3600 이 아니다 | `--cookie-expire` 를 확인 | | 역산이 9시간 어긋난다 | `date` 를 로컬로 쳤다 | 전부 `date -u` | | 산 세션이 고아로 잡힌다 | `--cookie-refresh` 가 켜져 있다 | 기동 로그의 `refresh:disabled` 확인 | | 산 세션을 지워 버렸다 | 되돌릴 수 없다 | 재로그인한다. SSO 가 살아 있으면 조용히 지나간다 | | `dbsize` 와 세션 수가 안 맞는다 | `b5:pvc` 와 BFF 세션이 섞인다 | `--scan --pattern '_oauth2_proxy-*'` | | `FLUSHDB` 로 지웠더니 app1 도 끊겼다 | 같은 Redis 에 BFF 세션이 있다 | 패턴으로 좁혀 지운다 | | 루프가 너무 느리다 | 키마다 `kubectl exec` 를 한다 | `--scan` 결과를 먼저 받아 두고 필요한 것만 묻는다 | | 로그 시각이 9시간 어긋난다 | 프록시 로그는 UTC | 표시 규약 | ## 이 실험대에서 아직 못 밟은 단계 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** - **고아 세션 관찰까지 밟았다**(2026-09-17). TLS 를 세우고 B-7 의 전제에 빠져 있던 `oauth2-proxy` 클라이언트를 만든 뒤, B-7 의 회전을 실제로 걸어 고아를 만들었다. 6 절의 `R` 함수와 스캔 루프가 값을 냈다. ```text label="회전 뒤 남은 고아 하나" _oauth2_proxy-5dcd079e0be3eb8a950c6ffa5dc9b8b1 type=string ttl=3551 len=3457 ``` **TTL 이 정직하게 줄고 갱신되지 않는 것도 확인했다**(observed). 30초 간격으로 세 번 재니 `3551 → 3521 → 3490` 이었다. 가리키던 쿠키가 못 쓰게 된 뒤에도 값은 남은 수명만큼 그대로 산다. - **남은 것** — 12 절의 회전 시각 기준 정리와 14·15 절의 판정·삭제 루프. 고아가 하나뿐이라 「여럿 가운데 고르는」 형태는 아직 시험하지 못했다. - **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 11:29–11:34 UTC` 에 돈 한 번의 실행에서 나왔다(observed). 시각은 전부 UTC 로 다룬다 — 이 실험의 결론이 시각 계산이라 KST 와 섞이면 9시간이 틀어진다. - (observed) 기동 로그의 `expiry:1h0m0s` 와 `refresh:disabled`, 회전 전 `_oauth2_proxy-f6a9201fd534a047998278452001ccbf` 의 `type=string` · `ttl=3568초` · `크기=3510바이트` · `dbsize=1`, 1차 회전 시각 `11:29:56 UTC` 와 회전 직후 키가 1개인 것, 브라우저 접근 시각 `11:30:27` 과 그때의 로그 네 줄, 새 세션 `87faa1c9…` `ttl=3588` 과 고아 `f6a9201f…` `ttl=3511`, 두 키의 `strlen` 이 둘 다 3510 인 것, 30초 간격 세 번의 TTL 여섯 값과 요청 후 `ttl=3464`, `del` 반환 `1` 과 `dbsize 2 → 1`, 삭제 직후 `/oauth2/userinfo` 의 `200 108`, 2차 회전 `11:33:27 UTC` 와 그 뒤의 `dad9c9fb… 3581` · `87faa1c9… 3373`, 역산한 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 1초 차인 것. - 비밀은 길이와 존재만 적었다. cookie secret 은 Deployment 가 참조하는 키 이름(`COOKIE_SECRET_A` · `COOKIE_SECRET_B`)으로만 나오고 값은 어디에도 안 적었다. 세션 값은 암호화된 바이너리라 증거 파일이 남긴 md5 두 개와 이스케이프된 앞머리만 옮겼다. Redis 키 이름은 운영자가 `--scan` 으로 보는 식별자라 그대로 적었다. - (unknown) `R()` 함수와 그것을 쓰는 `while` 루프들, 30초 간격 TTL 루프, 역산으로 고아를 고르는 `if` 루프. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다. 루프가 만든 값 자체(TTL 숫자들)는 증거 파일에 있으므로 실측이고, 그 값을 뽑아낸 형태가 미검증이다. - 전제가 깨지면 성립하지 않는 것 — 역산 규칙은 `refresh:disabled` 에서만 유효하다. `--cookie-refresh` 를 켠 뒤에 어떻게 어긋나는지는 재지 않았다(unknown). 전부 지우는 대안을 쓴다는 것까지가 가이드가 적은 내용이다. - 이 절차가 재지 않은 것 — 고아를 1시간 내내 두고 실제로 만료되는 것을 끝까지 지켜보지는 않았다. TTL 이 정직하게 줄고 갱신되지 않는다는 것까지가 측정이고, 「1시간에 사라진다」는 거기서 따라 나온 값이다. 산 세션을 잘못 지웠을 때의 피해도 재로그인 말고는 측정되지 않았다.