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>
28 KiB
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에서 친다.kubectl은sudo로 쓴다. - 이 실험대에는
jq가 없다. Redis 는 자기 CLI 로 묻는다. - 시각은 전부 UTC 로 다룬다. 이 실험의 결론이 시각 계산이라 여기서 섞이면 전부 틀린다 — 1-4 에서 확인한다.
주의 — 이건 남의 세션을 실제로 지우는 실험이다
redis-cli del 로 세션 키를 지운다. 산 사람의 세션을 잘못 지우면 그 사람은
재로그인해야 한다(SSO 가 살아 있으면 조용히 지나간다). 그 이상의 피해는
측정되지 않았지만, 실험대에서만 한다.
B-7 에서 Grafana 의 Ingress 를 빌렸다면 이 실험이 끝난 뒤에 되돌린다 — 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 가 아니면
이 가이드의 결론은 당신 환경에서 성립하지 않는다.
확인
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
[기준선] 회전 전 — 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
[주입] 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
브라우저가 접근한 순간(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
[측정 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
[측정 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).
하기
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'
[측정 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
[측정 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
실제 실행 결과: `삭제: _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 · 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 cookie secret | 「지우지 못했다」가 정정됐다 — 프록시가 못 하는 것이지 불가능한 것이 아니다 |
| C-1 다중 앱 SSO | 같은 Redis 에 BFF 세션과 프록시 세션이 함께 있다. 지울 때 패턴을 좁혀야 하는 이유 |
| D-3 비밀 관리 | 회전의 진짜 비용은 재로그인이 아니라 저장소에 남는 것이다 |
| 운영 | 회전 시각을 UTC epoch 로 기록해 두면 정리가 한 줄이 된다. 안 적어 두면 못 고른다 |
