Files
keycloak-pattern/docs/guides/experiments/b7a-orphan-session.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 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>
2026-09-07 18:29:00 +09:00

28 KiB
Raw Blame History

B-7a 재현 가이드 — 고아 세션을 TTL 로 골라내 지운다

해설 문서: docs/experiment-b7a-orphan-session.md · 증거 원문: docs/evidence/b7a-orphan-session/

이 가이드가 끝나면

당신 터미널에서 이것들을 직접 본다.

보게 되는 것 어디서
회전만으로는 Redis 가 안 변하는 회전 직후 --scan
옛 쿠키를 들고 온 그 순간 고아가 생기는 것 프록시 로그 + Redis
새 세션과 고아가 이름·타입·크기까지 같은 것 type · strlen
TTL 이 요청을 보내도 갱신되지 않는 30초 간격 3회
TTL 로 역산한 생성 시각이 로그와 1초 차이인 것 AuthSuccess 시각과 대조
고아만 지워도 산 세션은 200 인 것 브라우저

전제

  • B-7 이 끝나 있다. oauth2-proxy 가 app2.hyeonworks.com 에서 돌고 있고 세션 저장소가 Redis 여야 한다. 이 실험은 B-7 이 「지우지 못했다」로 멈춘 자리에서 시작한다.
  • 브라우저가 필요하다. 고아는 사람이 옛 쿠키를 들고 와야 생긴다.
  • 명령은 kc-lab-1 에서 친다. kubectlsudo 로 쓴다.
  • 이 실험대에는 jq 가 없다. Redis 는 자기 CLI 로 묻는다.
  • 시각은 전부 UTC 로 다룬다. 이 실험의 결론이 시각 계산이라 여기서 섞이면 전부 틀린다 — 1-4 에서 확인한다.

주의 — 이건 남의 세션을 실제로 지우는 실험이다

redis-cli del 로 세션 키를 지운다. 산 사람의 세션을 잘못 지우면 그 사람은 재로그인해야 한다(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는 측정되지 않았지만, 실험대에서만 한다.

B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다5-3 이 그 절차다.

전 구간 약 20분이고, 그중 TTL 을 세 번 재는 데 1분이 그대로 든다.

표시 규약

표시
실측 2026-09-04 11:2911: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 가 아니면 이 가이드의 결론은 당신 환경에서 성립하지 않는다.

확인

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'

실측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 에서 그걸로 고아를 고른다.

기동 로그가 잘려 나갔으면 인자에서 직접 본다.

확인

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 이 각각 무엇을 답하는지 봐 두어야 한다.

확인 — 무엇이 있나

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

[기준선] 회전 전 — 11:29:42 UTC
  secret = COOKIE_SECRET_A
  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트
  dbsize=1

확인 — 그 키 하나에 대해 셋을 묻는다. 키 이름은 위 출력에서 가져온다

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 키가 없다 — 이름을 잘못 옮긴 것이다.

키가 여럿이 되면 손으로 세 번씩 치기 번거로우니 짧은 함수를 하나 둔다. 한 줄짜리고, 하는 일이 이름 그대로다.

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시간이 틀어진다.

확인

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. 시각을 반드시 기록한다

여기부터 상태가 바뀐다. 되돌리는 명령을 먼저 읽어 둔다.

되돌리기

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절 규칙의 절반이다. 안 적어 두면 나중에 고아를 못 고른다.

하기

ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S) UTC)"

실측 — 원래 실행의 1차 회전 시각

11:29:56 UTC

2-2. 바꾼다

하기

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 에서 확인했다. 안 했으면 지금 한다.


3. 주입이 걸렸는지 확인한다

3-1. 어느 키를 참조하는가

확인

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 과 똑같은 명령

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K ttl=$(R ttl $K)"
done
R dbsize

실측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

확인 — 그 순간의 로그

sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_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

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 값만 보고는 구분할 수 없다

확인 — 두 키를 나란히 놓는다

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

[측정 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 ✓ 이것뿐이다

값을 직접 봐도 소용없다. 암호화되어 있다.

확인 — 바이너리를 이스케이프해 보여 준다

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분이 그대로 든다

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

[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
  30초 간격 3회:
     t+00초  새=3557  고아=3479
     t+30초  새=3526  고아=3448
     t+60초  새=3494  고아=3417

어디를 봐야 하는가30초에 30초씩 준다. 그리고 두 값의 차가 거의 고정되어 있다 — 35573479 = 78, 35263448 = 78, 34943417 = 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).

하기

R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'

실측01-orphan-lifecycle.txt

[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
  redis-cli del _oauth2_proxy-f6a9201f…   → 반환 1
  dbsize  2 → 1
  남은 키: _oauth2_proxy-87faa1c9…

어디를 봐야 하는가반환값 1. 0 이면 그 키가 없었던 것이다 (이름을 잘못 옮겼다).

확인 — 산 세션이 멀쩡한지. 브라우저로 다시 연다

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. 누적한다 — 회전할 때마다

한 번 더 회전해 본다. 고아가 일회성인지 누적인지가 갈린다.

하기

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

그리고 브라우저로 다시 연다.

확인

R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K ttl=$(R ttl $K)"
done

실측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)에서 담아 둔 값이다

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 를 루프 밖에서 한 번만 잡는 것이 중요하다. 안에서 잡으면 키마다 기준 시각이 달라진다.

하기 — 확인한 뒤에 지운다

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

  실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
  산 세션은 남고 고아만 사라졌다.

어디를 봐야 하는가 — 지운 뒤 브라우저로 한 번 더 열어 본다. 열리면 산 세션이 안 다친 것이다(4-4).

dbsize 는 이 Redis 전체를 센다. BFF 세션과 B-5 가 남긴 키도 들어 있다. 여기서 dbsize=1 이 나온 것은 당시 다른 키가 없었기 때문이고, 당신 환경에서는 다를 수 있다. 세션만 세려면 --scan --pattern 을 쓴다.

5-2. 전제가 깨졌을 때 — 정직한 대안

--cookie-refresh 가 켜져 있으면 5-1 을 쓰면 안 된다(4-3). 그때는 전부 지우고 모두 재인증시킨다.

R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done

FLUSHDB 를 쓰지 않는다. 이 Redis 에는 BFF 세션도 들어 있다. 패턴으로 좁히는 것이 이 실험대에서는 필수다.

5-3. 원래 자리로 돌려놓는다

하기 — secret 을 A 로

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 를 빌렸다면 여기서 돌려준다

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 을 이어서 할 생각이면 아직 돌려주지 않는다. 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 · del0 그 키가 없다 키 이름을 --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 cookie secret 「지우지 못했다」가 정정됐다 — 프록시가 못 하는 것이지 불가능한 것이 아니다
C-1 다중 앱 SSO 같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다. 지울 때 패턴을 좁혀야 하는 이유
D-3 비밀 관리 회전의 진짜 비용은 재로그인이 아니라 저장소에 남는 것이다
운영 회전 시각을 UTC epoch 로 기록해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다