--- kind: CASE slug: rolling-restart-keeps-sessions-drops-cache title: 롤링 재시작은 세션을 남기고 캐시만 지웠다 topic: session-custody-across-nodes topicName: Keycloak 두 노드가 같은 세션을 읽는 경로 project: keycloak-session-store status: 게시 전 lastVerifiedOn: 2026-09-04 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택의-이유와-지킨-경계-a8 assets: - key: a8-cache-vs-session file: ../../../final/assets/a8-cache-vs-session/a8-cache-vs-session.svg evidence: - ../../../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 가 계속 통하는 구성.](../../../final/assets/a8-cache-vs-session/a8-cache-vs-session.svg) 그림에서 롤링 재시작이 캐시로 보내는 화살표는 초기화이고 세션 행으로 보내는 화살표는 변경 없음이다. 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초 간격 폴링은 요청을 보내고 응답을 받는 것까지만 본다. 재시작 순간에 이미 처리 중이던 요청이 어떻게 끝나는지는 이 방식으로 관측되지 않는다.