기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과 keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지 밟았고 밟은 범위를 편마다 적었다. 명령이 못 도는 것을 고쳤다. - kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다. 라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다 - `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표 - busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다 - `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을 못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다 - 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v` - `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어 그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다 - B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다 - D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는 `kc-lab-edge` 의 `certbot.service` 다 - `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로 - `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다 결과가 조건부인 것을 갈랐다. - readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다 - 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다 - A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다 - A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다 증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은 길이만 적었고 화면에 찍힌 토큰은 가렸다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
38 KiB
id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
| id | kind | slug | title | topic | topicName | project | status | studio | pinnedVersions | source | sourceRevision | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 8d6b8a7f-e08e-4c9f-9772-a14a9d569260 | SETUP | reproduce-b7a-orphan-session | TTL 로 고아 세션을 골라내 지운다 | trust-handed-over-at-the-edge | 위조 신원 헤더와 로그아웃 전파 | keycloak-session-store | 게시 전 | https://hyeonworks.com/studio/documents/8d6b8a7f-e08e-4c9f-9772-a14a9d569260/edit |
|
|
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 로 묻는다 |
이 실험이 가르는 것
B-7 은 여기서 멈췄다.
[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 하나다.
(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).
[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 를 빌렸다면 이 절차가 끝난 뒤에 돌려준다.
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"}]'
주입 전에 같은 명령으로 먼저 본다
프록시 설정(refresh 여부) → 세션 하나 만들기 → Redis 원문 → 시계
1. refresh 가 꺼져 있는지 먼저 본다
무엇을 보는가 — 기동 로그의 쿠키 설정 한 줄. 이 한 단어가 정리 규칙 전체의 전제다.
kubectl -n keycloak-lab logs -l app=oauth2-proxy | grep 'Cookie settings'
어디를 보나 — 실측은 이렇다(observed, 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초 | 역산식에 그대로 들어간다 |
refresh:disabled |
TTL 이 요청으로 갱신되지 않는다 | 이것이 enabled 면 역산이 무너진다 |
기동 로그가 잘려 나갔으면 인자에서 직접 본다.
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i cookie
모양은 이렇다(모양은 observed).
"--cookie-secure=true"
"--cookie-expire=1h"
이 값이 뜻하는 것 — --cookie-refresh 가 목록에 없으면 refresh:disabled 다. TTL 이 고정이면 TTL 은 생성 시각의 정확한 함수가 된다. 여기가 disabled 가 아니면 이 절차의 뒷부분을 쓰지 않는다 — 오래 안 쓴 산 세션이 고아로 오판되어 지워진다.
2. 세션을 하나 만들고 Redis 를 통째로 본다
무엇을 보는가 — 키 하나와 그 키의 세 가지 성질.
브라우저에서 https://app2.hyeonworks.com/api/echo 를 열고 labuser / labpass 로 로그인한다. 업스트림의 JSON 이 보이면 세션이 생겼다.
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). 세션이 없는 상태에서는 이렇게 나온다.
(--scan --pattern '_oauth2_proxy-*' 는 아무것도 안 낸다)
0
dbsize 가 0 이고 스캔이 비었다는 것은 Redis 는 멀쩡한데 엣지 세션이 하나도 없다는 뜻이다. oauth2-proxy 가 안 떠 있으면 늘 이 모양이므로, 이 값을 보고 「Redis 가 안 붙었다」로 읽지 않는다.
어디를 보나 — 실측은 이렇다(observed, 01-orphan-lifecycle.txt).
[기준선] 회전 전 — 11:29:42 UTC
secret = COOKIE_SECRET_A
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
type=string ttl=3568초 크기=3510바이트
dbsize=1
그 키 하나에 대해 셋을 따로 묻는다. 나중에 루프로 묶더라도 처음에는 type · ttl · strlen 이 각각 무엇을 답하는지 봐 둔다. 키 이름은 위 출력에서 가져온다.
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 가 나오고, 그때는 이 블록의 첫 줄부터 다시 친다.
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).
_oauth2_proxy-f6a9201fd534a047998278452001ccbf type=string ttl=3568 len=3510
이 값이 뜻하는 것 — 이 루프는 키 하나마다 kubectl exec 를 세 번 한다. 느리다. 키가 수백 개면 그대로 쓰지 말고 --scan 결과를 파일로 받아 두고 필요한 것만 묻는다.
4. 시계를 맞춘다
무엇을 보는가 — 셸의 시각과 UTC, 그리고 NTP 동기화 여부.
date; date -u
timedatectl show -p NTP -p NTPSynchronized
어디를 보나 — 모양은 이렇다(모양은 observed).
NTP=yes
NTPSynchronized=yes
이 값이 뜻하는 것 — NTPSynchronized=yes 를 보고 나면 앞으로 date 는 전부 -u 를 붙여 친다. 그래야 로그의 [2026/09/04 05:42:18] 과 회전 시각을 같은 축에 놓을 수 있고, 뒤에 나오는 1초 오차도 이 축이 맞아야 나온다.
주입
5. 1차 회전 A → B 를 치고 시각을 담는다
목적 — 옛 쿠키를 무효로 만들고, 그 시각을 정리 규칙의 기준으로 삼는다.
행동 — 시각을 먼저 담고 참조를 바꾼다.
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%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_B"}]'
kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
예상 결과 — successfully rolled out 을 본다. 원래 실행의 1차 회전 시각은 이렇다(observed).
11:29:56 UTC
왜 필요한가 — ROT 이 정리 규칙의 절반이다. 안 담아 두면 나중에 어느 키가 회전보다 먼저 생겼는지 못 고른다. env 배열의 인덱스가 그 매니페스트와 맞는지는 B-7 에서 확인했고, 안 했으면 지금 env[*].name 을 본다.
문제가 생기면 — 롤아웃이 끝나지 않으면 인덱스를 잘못 짚어 클라이언트 비밀을 덮었을 수 있다. 파드 로그를 본다.
주입 검증
6. Deployment 의 참조가 바뀌었는가
kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
모양은 이렇다(모양은 observed).
COOKIE_SECRET_B
7. Redis 는 그대로인가
주입 전과 똑같은 명령으로 본다.
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
실측은 이렇다(observed, 01-orphan-lifecycle.txt).
[주입] 1차 회전 A → B — 11:29:56 UTC
회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)
키 수가 회전 전과 같다. 여기서 「실험 실패」라고 결론 내리면 틀린다. 회전은 방아쇠가 아니라 조건이고, 실제로 벌어지는 것은 누군가 옛 쿠키를 들고 오는 순간이다. A-1 에서 NetworkPolicy 를 걸었는데 클러스터가 안 깨졌던 것과 같은 모양이다 — 주입이 걸렸다는 것과 효과가 나타났다는 것은 다른 사건이다.
8. 브라우저로 다시 열고 그 순간의 로그를 본다
무엇을 보는가 — 옛 쿠키를 들고 왔을 때 프록시가 무엇을 하는지.
로그인했던 그 브라우저 그대로 https://app2.hyeonworks.com/api/echo 를 연다.
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session
어디를 보나 — 실측은 이렇다(observed, 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 을 적어 둔다. 13 절에서 이 숫자와 역산값을 맞춰 본다. 그리고 Redis 를 다시 본다.
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
R dbsize
실측은 이렇다(observed).
Redis:
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593 ttl=3588 ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf ttl=3511 ← ★ 고아
dbsize=2
키가 둘이고 로그인 화면은 안 봤다. Keycloak SSO 가 살아 있어 조용히 재인증됐고 B-7 의 관찰 그대로다. 사용자는 하나인데 서버 세션은 둘이 됐다.
관찰
9. 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
어디를 보나 — 실측은 이렇다(observed, 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 | 이것뿐이다 |
이 값이 뜻하는 것 — 값을 직접 봐도 소용없다. 암호화되어 있다.
아래 키 이름은 이 실험대에서 나온 값이라 그대로 치면 남의 키를 조회해 (nil) 이 돌아온다. 2 절의 --scan 출력에서 자기 키 이름을 옮겨 넣는다.
R --no-raw get _oauth2_proxy-f6a9201fd534a047998278452001ccbf | head -c 120; echo
세션 값은 암호화된 바이너리라 해시와 이스케이프된 앞머리만 옮긴다(observed).
새 "\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분이 그대로 든다.
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).
[측정 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).
요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
살아있는 세션 ttl=3464 ← 계속 줄어든다
기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.
쓰고 있어도 TTL 이 안 늘어난다. 1 절에서 본 refresh:disabled 가 여기서 값으로 확인됐고, 따라서 고아는 생성 후 1시간에 사라진다. 무한정 쌓이지 않는다.
--cookie-refresh 를 켜면 요청마다 세션이 갱신되고 TTL 이 연장된다. 끄면 생성 시점부터 고정된 시간이 흐른다. TTL 이 고정이면 이 식이 성립한다.
생성시각 = 지금 - (cookie-expire - TTL)
이 한 줄이 정리 규칙 전체를 만든다. cookie-expire 는 1 절에서 1h0m0s = 3600 으로 확인했다. --cookie-refresh 를 켜는 순간 이 역산이 무너진다 — 활발히 쓰는 세션일수록 TTL 이 크게 남아 방금 만들어진 것처럼 보이고, 오래 안 쓴 산 세션은 TTL 이 작아 고아로 오판되어 지워진다. 그때는 회전 후 _oauth2_proxy-* 를 전부 지우고 모두 재인증시키는 편이 오히려 정직하다. 아래 정리 규칙은 refresh:disabled 일 때만 유효하다.
11. 고아를 하나 지우고 산 세션이 멀쩡한지 본다
목적 — 못 지우는 것이 프록시인지 Redis 인지 가른다.
행동 — 되돌리기가 없는 조작이니 지우기 전에 어느 키인지 두 번 확인한다. 지금은 TTL 이 작은 쪽이 고아다.
R del _oauth2_proxy-f6a9201fd534a047998278452001ccbf
R dbsize
R --scan --pattern '_oauth2_proxy-*'
예상 결과 — 실측은 이렇다(observed, 01-orphan-lifecycle.txt).
[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
redis-cli del _oauth2_proxy-f6a9201f… → 반환 1
dbsize 2 → 1
남은 키: _oauth2_proxy-87faa1c9…
반환값이 1 이다. 0 이면 그 키가 없었던 것이고 이름을 잘못 옮겼다. 산 세션이 멀쩡한지는 브라우저로 다시 열어서 본다.
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=1m | grep labuser
실측은 이렇다(observed).
삭제 직후 브라우저 요청 (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 로 되돌린다.
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
그리고 브라우저로 다시 연 뒤 본다.
R --scan --pattern '_oauth2_proxy-*' | while read K; do
echo "$K ttl=$(R ttl $K)"
done
예상 결과 — 실측은 이렇다(observed, 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… 의 신분이 바뀌었다. 8 절에서 새 세션이던 것이 여기서는 고아다. 1차 회전을 살아남았던 세션이 2차 회전에서 고아가 됐다. 회전 1회에 그 시점 로그인 사용자 수만큼의 고아가 생기므로, 고아는 사건이 아니라 회전의 고정 비용이다.
문제가 생기면 — 키가 안 늘면 브라우저로 접근하지 않은 것이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다.
13. 규칙을 쓰기 전에 규칙 자체를 검증한다
무엇을 보는가 — 역산값과 실측 한 쌍. 위 표의 생성시각(추정)은 역산식으로 나온 값이고, 대조할 실측은 8 절에서 적어 둔 AuthSuccess 시각이다.
어디를 보나 — 실측은 이렇다(observed).
[측정 5] 검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
**1초 오차.** 추정이 아니라 사실상 정확하다.
이 값이 뜻하는 것 — 1초는 TTL 이 초 단위 정수라 반올림에서 나올 수 있는 크기다. TTL 역산은 추정이 아니라 측정에 가깝고, 그래서 다음 규칙을 안심하고 쓴다.
생성시각 < 회전시각 → 그 키는 고아다
회전 이후에 만들어진 세션은 새 secret 으로 만들어졌으므로 반드시 유효하다. 회전 이전 생성분만 고른다.
복구와 원상복구 확인표
14. del 을 붙이기 전에 같은 루프를 echo 로 돌린다
목적 — 무엇이 지워질지 먼저 읽는다.
행동 — 판정만 하고 지우지 않는 루프를 한 번 돌린다.
기준 시각을 먼저 정한다. 이 루프가 재는 것은 「지금 걷어내려는 회전보다 앞에 만들어졌나」다. 5 절에서 담은 ROT 은 1차 회전 시각이고, 12 절을 쳤으면 지금 유효한 회전은 2차이므로 기준은 ROT2 다. 12 절을 친 뒤라면 아래 두 블록의 "$ROT" 를 "$ROT2" 로 바꿔 친다. 안 바꾸면 두 회전 사이에 생긴 세션이 「산것」으로 분류되어 그대로 남고, 15 절의 실측(87faa1c9… 삭제 · 남은 dbsize=1)이 재현되지 않는다 — 그 키의 역산 생성시각 11:30:26 은 ROT(11:29:56)보다 뒤이기 때문이다.
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초 안에 놓인 키는 리허설에서 「산것」이었다가 실행에서 「고아」로 뒤집힐 수 있고, 그쪽 방향의 오판이 곧 산 세션 삭제다. 리허설 출력의 생성시각이 기준 시각에 붙어 있는 키가 보이면 그 키는 이 루프로 지우지 않는다.
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).
실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
산 세션은 남고 고아만 사라졌다.
왜 필요한가 — 지운 뒤 브라우저로 한 번 더 열어 본다. 열리면 산 세션이 안 다쳤다. dbsize 는 이 Redis 전체를 센다 — BFF 세션과 B-5 가 남긴 키도 들어 있고, 여기서 dbsize=1 이 나온 것은 당시 다른 키가 없었기 때문이라 환경마다 다르다. 세션만 세려면 --scan --pattern 을 쓴다.
문제가 생기면 — 전제가 깨졌을 때, 즉 --cookie-refresh 가 켜져 있을 때는 위 규칙을 쓰지 않는다. 전부 지우고 모두 재인증시킨다. FLUSHDB 를 쓰지 않는다 — 이 Redis 에는 BFF 세션도 들어 있어 패턴으로 좁히는 것이 이 실험대에서는 필수다.
R --scan --pattern '_oauth2_proxy-*' | while read K; do R del "$K"; done
16. secret 참조와 Ingress 를 돌려준다
목적 — 빌린 것을 원래대로 돌린다.
행동 — 참조를 확인하고, Grafana Ingress 를 돌려준다.
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 를 치지 않는다.
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 에 떠 두므로 그쪽도 본다.
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI 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 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. 못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.
- 남은 것 — 고아 세션 관찰 전부. 같은 이유로
oauth2-proxy가 안 뜬다. 지금까지 밟은 것 — Redis 쪽 명령(dbsize·--scan)이 도는 것까지. - 막는 것 —
https://auth.hyeonworks.com이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirtguest_input구멍 — A-4 에서 확인한ExecStartPost누락)이 둘 다 있어야 한다. - 그때까지 이 편의 실측 가운데
(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시간에 사라진다」는 거기서 따라 나온 값이다. 산 세션을 잘못 지웠을 때의 피해도 재로그인 말고는 측정되지 않았다.