기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
kind, slug, title, topic, topicName, project, status, lastVerifiedOn, sourceRevision, source, assets, evidence
| kind | slug | title | topic | topicName | project | status | lastVerifiedOn | sourceRevision | source | assets | evidence | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | orphan-sessions-and-the-ttl-that-finds-them | 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션 | trust-handed-over-at-the-edge | 위조 신원 헤더와 로그아웃 전파 | keycloak-session-store | 게시 전 | cdac9b8178391311d8eca1ebc6cac15bb62d79af |
|
|
|
쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션
secret 을 바꾼 뒤 남은 고아 세션은 TTL 로 생성 시각을 역산해 골라 지웠고, 산 세션만 남았다. 프록시는 티켓을 못 풀어 Redis 키를 지우지 못했고, 값으로는 어느 것이 고아인지 구별할 수 없었다. 고아는 생성 후 정확히 1시간이면 사라졌다.
관계
- nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다 같은 엣지 프록시를 쓰고, 그쪽은 세션이 로그인 시점의 스냅샷이라 클레임 변경이 늦게 반영되는 것을 쟀다.
- 믿기 전에 그 헤더를 먼저 지운다 엣지가 만든 인증 결과를 앱이 어디까지 믿을지 정하는 기준이고, 여기서는 그 결과를 담은 세션을 지우는 쪽을 다룬다.
문제
oauth2-proxy 의 cookie secret 은 값을 하나만 받는다. 옛 secret 도 당분간 받아 주는 구간을 만들 수 없으므로 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다.
Redis 세션 저장소를 켜면 쿠키에는 티켓만 담기고 세션 본문은 Redis 에 있다. 그러면 secret 을 바꿨을 때 무엇이 막히는지가 달라진다. 프록시가 티켓을 못 풀고, 티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다.
프록시 로그에 남은 줄 : Error removing session: error decoding ticket to clear session
B-7 은 여기서 멈췄다. 지우지 못한 키가 언제까지 살아 있는지, 운영자가 지울 수 있는지, 어느 것이 고아인지 구별할 수 있는지는 재지 않았다.
결론
고아 세션이 사라지는가 : o — 생성 후 정확히 1시간 TTL 이 요청으로 갱신되는가 : x — 기동 로그의 refresh:disabled 와 같다 운영자가 지울 수 있는가 : o — redis-cli del 뒤에도 산 세션은 200 Redis 값으로 고아를 구별할 수 있는가 : x 이름 · 타입 · 크기 : 같다 (3510바이트) 값 : 암호화되어 있다 고아를 고르는 방법 : TTL 로 생성 시각을 역산한다 역산한 시각과 로그의 AuthSuccess 차이 : 1초 그 기준으로 골라 지운 결과 : 산 세션만 남았다
지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니다.
검증 환경
인증 프록시 : oauth2-proxy 두 replica 세션 저장소 : Redis 쿠키 secret : --cookie-secret 하나. 옛 값과 새 값이 함께 유효한 구간 없음 호스트 이름 : 인증서 SAN(Subject Alternative Name) 에 auth · app1 · app2 셋뿐이라 Grafana 의 app2 를 빌렸다 실험대 : 베어메탈 test-server 한 대 위에 VM 두 대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af 실행일 : 이 측정 기록에 적혀 있지 않다
재현 조건
-
oauth2-proxy 를 두 replica 로 띄우고 Redis 세션 저장소를 켠다.
-
로그인한 뒤 Redis 에 세션 키가 생겼는지 확인한다. kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
-
--cookie-secret 을 새 값으로 바꾸고 배포한다.
-
브라우저로 다시 접근한다. 옛 쿠키가 검증에 실패하고 새 세션이 생긴다.
-
Redis 키를 다시 조회해 각 키의 TTL 을 읽는다.
-
일정 간격으로 TTL 을 다시 읽어 요청이 TTL 을 늘리는지 본다.
-
생성시각 = 지금 − (cookie-expire − TTL) 로 각 키의 생성 시각을 구하고 secret 을 바꾼 시각과 견준다.
-
회전 시각보다 이른 키를 지우고, 산 세션이 그대로 응답을 받는지 확인한다.
본문
서버에 상태가 없으니 공유할 것도 없다
B층 실험의 주제는 BFF(Backend For Frontend) 가 서버에 들고 있던 세션과 토큰을 Redis 와 PostgreSQL 로 빼는 것이었다. oauth2-proxy 는 정반대다. 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 을 읽을 뿐이므로, 인스턴스 사이에 맞출 상태가 없어 콜백이 다른 replica 로 가도 문제가 없다.
대신 --cookie-secret 이 값을 하나만 받는다. 「옛 secret 도 당분간 받아 준다」를 표현할 방법이 없으니 겹침 구간을 만들 수 없고, secret 을 교체하는 순간 발급돼 있던 쿠키가 한꺼번에 무효가 된다.
쿠키가 한꺼번에 무효가 되는 것은 사용자 쪽에서 잘 보이지 않는다. Keycloak 의 SSO 세션이 살아 있으면 애플리케이션 세션이 죽어도 로그인 화면 없이 조용히 다시 인증되기 때문이다. 세션이 두 겹이라 앱 쪽 한 겹만 끊긴다.
티켓을 못 풀면 어느 키를 지울지 모른다
Redis 세션 저장소를 켜면 쿠키에 담기는 것이 세션 전체에서 티켓으로 바뀐다.
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
티켓 전체가 --cookie-secret 으로 암호화되어 있다. secret 을 바꾸면 프록시는 티켓을 열지 못하고, 세션 ID 를 읽지 못하니 Redis 키 이름도 만들지 못한다. 그래서 로그에 이 줄이 남는다.
Error removing session: error decoding ticket to clear session
그림에서 Redis 로 들어가는 화살표는 쿠키의 티켓에서만 나온다. 키 이름을 만드는 경로가 그 하나뿐이라, 티켓이 안 열리면 Redis 쪽에서 그 세션을 가리킬 방법이 없어진다. 이렇게 프록시가 지우지 못한 채 Redis 에 남은 세션이 고아 세션이다.
Redis 값으로는 고아를 구별할 수 없었다
B-7 이 남긴 말은 「지우지 못했다」였다. 그 문장을 그대로 믿으면 고아는 어쩔 수 없는 것이 되는데, 못 지우는 주체가 프록시인지 Redis 인지는 거기서 갈리지 않았다. 그 갈래를 포함해 B-7a 가 셋을 이어서 쟀다.
| 물음 | 잰 결과 |
|---|---|
| 고아는 정말 사라지는가 | 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 |
| 운영자가 지울 수 있는가 | 있다. redis-cli del 후에도 산 세션은 200 |
| 어느 것이 고아인지 아는가 | Redis 값으로는 모른다. 이름·타입·크기(3510바이트)가 같고 값은 암호화 |
| 그럼 어떻게 고르는가 | TTL 로 생성 시각을 역산한다 |
첫 줄이 먼저 정해져야 나머지가 의미를 갖는다. 고아가 영영 쌓이는 것이라면 정리 규칙을 만드는 것과 별개로 저장소가 계속 커지기 때문이다. TTL 은 요청을 보내도 늘지 않았고, 이 구성의 기동 로그에 찍힌 refresh:disabled 와 맞는다.
고아가 생기는 시점도 secret 을 바꾸는 순간이 아니다. 회전 직후 Redis 의 키 수는 그대로였고, 브라우저가 옛 쿠키를 들고 다시 와서 프록시가 그것을 못 푼 다음에야 새 세션이 하나 더 생기면서 앞의 것이 고아가 됐다. 사람이 접근하지 않으면 고아도 안 생긴다.
회전을 한 번 더 걸었더니 1차 회전에서 살아남았던 세션이 이번에는 고아가 됐다. 회전 한 번이 그 시점에 로그인해 있던 사용자 수만큼 고아를 만드는 셈이고, 그 고아들도 생성 후 1시간이면 사라진다.
셋째 줄이 실제로 막혔던 곳이다. 키 이름은 접두사가 같고 뒤는 불투명한 값이며, 타입도 크기도 같고 값은 암호화되어 있다. 값의 md5 를 떠 봐도 둘이 다르다는 것만 알 뿐, 어느 쪽이 산 세션인지는 그 차이에서 나오지 않는다. 두 키를 나란히 놓고 보면 다른 것은 TTL 하나뿐이었다.
kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
TTL 이 갱신되지 않으니 생성 시각을 되돌릴 수 있다
TTL 이 요청으로 갱신되지 않는다는 것이 구별의 근거가 됐다. 30초 간격으로 세 번 읽었더니 산 세션은 3557 · 3526 · 3494 였고 고아는 3479 · 3448 · 3417 이었다. 1초에 1초씩 줄기만 한다.
그래서 이 방법은 기동 로그의 refresh:disabled 한 단어에 통째로 매달려 있다. 재기 전에 그것부터 읽었다. 갱신이 없으면 남은 TTL 은 만료 설정에서 흘러간 시간을 뺀 값이므로, 거꾸로 계산하면 그 세션이 언제 만들어졌는지 나온다.
생성시각 = 지금 − (cookie-expire − TTL)
이 값이 secret 을 바꾼 시각보다 이르면 그 키는 고아다. 회전 뒤에 생긴 세션은 새 secret 으로 만들어졌으므로 유효하다.
시각은 전부 UTC(협정 세계시)로 다뤘다. 이 방법의 결론이 시각 계산이라 한국 시간과 한 번만 섞여도 9시간이 통째로 틀어진다.
역산이 맞는지는 로그와 견줘 확인했다. 계산한 11:30:26 과 로그의 AuthSuccess 11:30:27 이 1초 차였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다. 지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키 이름을 만들지 못하지만 운영자는 키 목록을 직접 본다.
이 방법이 성립하지 않는 구성
이 정리는 남의 세션을 실제로 지우는 일이다. 기준을 잘못 잡아 산 세션을 지우면 그 사람은 다시 인증해야 하는데, Keycloak 의 SSO 세션이 살아 있으면 로그인 화면 없이 조용히 지나가므로 잘못 지웠다는 것이 밖에서 보이지 않는다.
--cookie-refresh 를 켜면 이 역산이 무너진다. TTL 이 요청마다 갱신되면 남은 TTL 이 생성 시각의 함수가 아니게 되고, 그러면 회전 시각과 견줄 값이 없다. 그 구성에서 고아를 어떻게 고를지는 재지 않았다. 회전 뒤 FLUSHDB 로 전부 지우고 모두 재인증시키는 편이 정직하다.
고아가 사라지기까지 잰 1시간도 이 구성에서 나온 값이다. 쿠키 만료를 다르게 잡은 구성에서는 다시 재지 않았다.