Files
document-haness/docs/keycloak-session-store/tech-log-studio/session-custody-across-nodes/case/case-rolling-restart-keeps-sessions-drops-cache.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 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>
2026-09-17 11:01:55 +09:00

8.6 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 rolling-restart-keeps-sessions-drops-cache 롤링 재시작은 세션을 남기고 캐시만 지웠다 session-custody-across-nodes Keycloak 두 노드가 같은 세션을 읽는 경로 keycloak-session-store 게시 전 2026-09-04 cdac9b8178391311d8eca1ebc6cac15bb62d79af
final/document.md#선택의-이유와-지킨-경계-a8
key file
a8-cache-vs-session ../../../final/assets/a8-cache-vs-session/a8-cache-vs-session.svg
../../../final/evidence/raw/a8-rolling-restart__01-restart-availability.txt
../../../final/evidence/raw/a8-rolling-restart__02-session-survival.txt

롤링 재시작은 세션을 남기고 캐시만 지웠다

Keycloak 2노드를 롤링 재시작했더니 데이터베이스 세션은 151 개 그대로였고 노드의 세션 캐시만 초기화됐다. 재시작 전에 발급한 refresh token 도 200 을 받았다. persistent-user-sessions 를 끄면 같은 재시작이 전원 로그아웃이 된다. 재시작 중 외부 진입점은 5초 해상도에서 끊김이 관측되지 않았다.

관계

  • persistent-user-sessions 가 세션의 거처를 정한다 이 실험이 남긴 차이가 그 설정을 켜는 이유이고, 끈 쪽에서는 같은 재시작이 전원 로그아웃이 된다.
  • 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다 세션이 데이터베이스에 있고 캐시는 각 노드의 사본이라는 구분이 이 측정의 전제다.

문제

파드를 새 설정이나 새 버전으로 바꾸려면 한 번은 재시작해야 한다. 그때 이미 로그인해 둔 사용자가 계속 로그인 상태인지, 아니면 전부 다시 로그인해야 하는지를 알아야 했다.

세션은 데이터베이스에 있고 각 노드는 자기가 처리한 로그인만 캐시에 담는데, 이 둘이 재시작에서 같이 없어지는지 따로 노는지는 재 보지 않았다.

결론

재시작은 캐시만 지웠고 세션은 건드리지 않았다.

DB 세션 수 : 151 에서 151 세션 캐시 keycloak-0 : 0.0 건 세션 캐시 keycloak-1 : 1.0 건 재시작 전 발급한 refresh token : 200 재시작 중 외부 진입점 : 5초 해상도에서 끊김이 관측되지 않았다. 표본 9개

keycloak-1 의 1건은 재시작에서 살아남은 엔트리가 아니라 방금 refresh 를 처리하며 새로 담은 것이다.

persistent-user-sessions 를 켜는 이유가 여기에 있다. 같은 롤링 재시작을 그 설정 없이 돌리면 재시작 뒤 refresh 가 400 Session not active 가 되고 로그인해 둔 사용자가 전부 빠진다.

처음 적을 때는 표본 9개로 무중단을 주장했다. 그 표본 수로는 평시 오류율과 견줄 수 없어서, 5초 해상도에서 끊김이 관측되지 않았다는 데까지로 주장을 낮췄다.

검증 환경

Keycloak : 26.7.0 · 2노드 워크로드 종류 : StatefulSet persistent-user-sessions : 기본값 그대로 켬 세션 저장소 : PostgreSQL 캐시 수 관측 : Prometheus 의 세션 캐시 엔트리 수 세션 수 관측 : PostgreSQL 직접 조회 재시작 방법 : kubectl rollout restart

실험대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis

재현 조건

  1. Keycloak 을 2노드로 띄우고 세션을 미리 만들어 둔다.

  2. 재시작 전 상태를 두 가지로 적어 둔다. PostgreSQL 의 온라인 세션 수와, 방금 로그인해 받은 sid 및 refresh token.

  3. 롤링 재시작을 건다. kubectl rollout restart

  4. 재시작이 도는 동안 외부 진입점을 일정 간격으로 찍어 상태 코드를 시계열로 남긴다. 표본 수를 함께 적는다.

  5. 재시작이 끝나면 2 번에서 받아 둔 refresh token 을 그대로 보내고 상태 코드를 본다.

  6. PostgreSQL 에서 그 sid 의 행이 남아 있는지와 전체 온라인 세션 수를 다시 센다.

  7. 노드별 세션 캐시 엔트리 수를 Prometheus 에서 읽는다.

  8. 파드 나이를 확인해 실제로 교체됐는지 대조한다.

본문

재시작 전에 두 가지를 따로 적어 두었다

Keycloak 에서 로그인 한 건은 두 곳에 흔적을 남긴다. 하나는 PostgreSQL 의 세션 행이고 다른 하나는 그 로그인을 처리한 노드의 Infinispan sessions 캐시 엔트리다. Infinispan 은 Keycloak 이 세션과 realm 정보를 올려 두는 인메모리 데이터 그리드라 프로세스가 내려가면 그 안의 것도 같이 없어진다.

그래서 재시작 전에 두 값을 각각 적었다. PostgreSQL 의 온라인 세션 수는 151 이었고, 방금 로그인해 받은 sid 와 refresh token 을 파드 안에 보관해 두었다. 이 둘을 나눠 세지 않으면 재시작 뒤에 로그인이 유지되는지 아닌지만 알 수 있고 무엇 덕에 유지됐는지는 알 수 없다.

재시작 뒤에 남은 것과 사라진 것

롤링 재시작은 한 번에 한 파드씩 바꾼다. 2노드를 전부 교체한 뒤 같은 두 값을 다시 셌다.

무엇을 봤나 재시작 전 재시작 후
DB 세션 수 151 151
세션 캐시 엔트리 있음 keycloak-0 0.0 건 · keycloak-1 1.0
앞서 발급한 refresh token 200 200
외부 진입점 200 전 구간 200

keycloak-1 의 1.0 건은 재시작에서 살아남은 엔트리가 아니라 방금 refresh 를 처리하며 새로 담은 것이다.

파드가 죽으면 그 노드의 캐시는 함께 없어지는데, 세션 행은 PostgreSQL 에 있어서 재시작과 무관하다. 그래서 재시작 전에 발급한 refresh token 이 새로 뜬 파드에서도 통한다.

재시작으로 Infinispan 캐시가 0 이 되지만 PostgreSQL 의 세션 행은 151 개 그대로여서 refresh 가 계속 통하는 구성.

그림에서 롤링 재시작이 캐시로 보내는 화살표는 초기화이고 세션 행으로 보내는 화살표는 변경 없음이다. refresh token 이 통하는 이유는 그 토큰이 캐시가 아니라 세션 행을 거쳐 확인되기 때문이다.

설정 하나를 끄면 같은 재시작이 전원 로그아웃이 된다

persistent-user-sessions 는 Keycloak 26 에서 기본으로 켜져 있고 세션을 데이터베이스에 쓰게 한다. 24 이전은 그렇지 않아서 세션을 메모리에 두고 Infinispan 으로 복제했다.

그 설정을 끄고 같은 롤링 재시작을 다시 돌리면 재시작 뒤의 refresh 가 400 Session not active 가 된다. 세션이 파드와 함께 없어졌으니 남은 노드도 그 세션을 모르고, 로그인해 둔 사용자가 전부 빠진다. 위 표의 「DB 세션 수 151」은 그 설정을 켜 두었을 때의 값이다. 같은 롤링 재시작인데 한쪽은 견디고 한쪽은 못 견디는 것이 세션을 어디에 두었느냐에서 갈린다.

무중단이라고 적은 근거가 처음에는 모자랐다

재시작 중 외부 진입점을 5초 간격으로 찍었고 받은 상태 코드는 전부 200 이었다. 처음 기록할 때는 그 표본 9개로 중단이 없었다고 적었다.

9개로는 평시 오류율과 견줄 수 없다. 주입 전 평시를 재 두지 않으면 같은 관측이 「영향 없음」으로도 「원래 그랬음」으로도 읽히기 때문에, 이 실험대에서는 대조군 없이 귀속하지 않는다는 규칙을 세우고 어긴 곳을 찾아 고쳤다. 이 주장도 그때 함께 고친 둘 중 하나다.

계획서가 이 실험에 적어 둔 예상은 재시작 중 refresh 가 「일시 실패 후 성공 (파드 전환 시점)」이라는 것이었다. 5초 간격으로 찍은 것은 외부 진입점의 상태 코드라, 파드가 바뀌는 순간에 그런 실패가 있었는지는 이 관측으로 답하지 못한다.

재현 절차를 점검할 때 한 가지가 더 나왔다. 이 실험의 토큰 전달이 /tmp/tok 에 쓰고 /tmp/rt 를 읽는 식으로 적혀 있어서 실제로는 빈 토큰을 보내고 있었다. 절차를 셸 표현식으로 바꾸고 실행해 보기 전까지는 드러나지 않았다.

확인하지 않은 것

표본이 적어 무중단 주장을 처음에 과장했다가 고쳤다. 재시작 중 진행 중이던 요청은 재지 않았다.

5초 간격 폴링은 요청을 보내고 응답을 받는 것까지만 본다. 재시작 순간에 이미 처리 중이던 요청이 어떻게 끝나는지는 이 방식으로 관측되지 않는다.