--- kind: CASE slug: session-sharing-is-the-database-not-replication title: 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다 topic: session-custody-across-nodes topicName: Keycloak 두 노드가 같은 세션을 읽는 경로 project: keycloak-session-store status: 게시 전 lastVerifiedOn: sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#코드보다-먼저-드러난-문제-전제가-무너졌다 - final/document.md#선택의-이유와-지킨-경계-a1 assets: - key: session-sharing-path file: ../../../final/assets/session-sharing-path/session-sharing-path.svg - key: a1-transport-vs-discovery file: ../../../final/assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg evidence: - ../../../final/evidence/raw/a1-jgroups-transport-block__09-cross-node-under-partition.txt - ../../../final/evidence/raw/a1-jgroups-transport-block__10-logout-not-propagated.txt --- # 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 읽기 때문이었다. 노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 잡아 보니 세션 엔트리는 노드 사이를 건너가지 않았다. TCP 7800 을 끊어도 교차 노드 refresh 는 200 이었고 로그아웃 전파만 깨졌다. ## 관계 - **persistent-user-sessions 가 세션의 거처를 정한다** 세션이 데이터베이스에 있다는 이 측정의 전제를 만드는 설정이고, 그 설정을 끄면 같은 실험의 답이 갈린다. - **같은 설정이 캐시 온도만으로 세 가지 답을 냈다** 그 설정을 끈 대조군에서 나온 결과이고, 같은 문장 로깅 기법으로 원인을 확정했다. - **버전과 설정을 결과와 함께 적는다** 이 결론에 버전과 설정 조건이 붙는다는 것을 규칙으로 편 기록이다. ## 문제 앞선 작업의 열린 질문 네 개는 인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다는 전제를 깔고 있었다. 그래서 노드를 둘로 만들고 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는지부터 확인해야 했다. 답은 그렇다였다. 그런데 로그에는 클러스터 뷰가 찍혀 있고 JGROUPS_PING 테이블에도 두 노드가 등록되어 있어서, Infinispan 이 세션을 복제해서 그렇게 된다고 읽기 쉽다. 그렇게 되는 이유를 확인하지 않고 두면 이후 실험의 해석이 전부 그 위에 쌓인다. 클러스터를 끊으면 세션 공유가 깨질 것이라는 예측도 여기서 나왔다. ## 결론 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 읽기 때문이었다. 세션 엔트리가 노드 사이로 복제됨 : x 각 노드가 캐시하는 것 : 자기가 처리한 로그인 두 노드가 함께 읽는 것 : OFFLINE_USER_SESSION JGROUPS_PING 이 하는 일 : 서로를 발견해 클러스터 뷰를 만든다 TCP 7800 을 끊고 다시 재니 예측 둘 가운데 하나가 빗나갔다. 교차 노드 refresh 는 200 이었고, 반대편 노드에서 로그아웃한 뒤 400 이 나와야 할 재갱신도 200 이었다. 세션 조회는 데이터베이스를 거치고 로그아웃 무효화 통지는 7800 을 타므로, 전송만 끊으면 세션 공유는 살아남고 무효화 통지만 막힌다. 이 결과는 persistent-user-sessions 가 기본으로 켜진 Keycloak 26 에서 잰 것이다. 같은 실험을 그 설정 없이 돌리면 교차 노드 refresh 가 400 Session not active 로 갈린다. ## 검증 환경 Keycloak : 26.7.0 · 2노드 persistent-user-sessions : 기본값 그대로 켬 세션 저장소 : PostgreSQL 클러스터 디스커버리 : PostgreSQL 의 JGROUPS_PING 테이블 클러스터 전송 : TCP 7800 차단 방법 : NetworkPolicy 허용 목록에 8080 과 9000 만 남기고 7800 을 뺀다 SQL 관측 : PostgreSQL 문장 로깅 실험대 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노드로 띄우고 JGROUPS_PING 에 두 행이 들어가는지, 클러스터 뷰가 2명인지 확인한다. 2. PostgreSQL 문장 로깅을 켠다. 3. 노드 A 로 로그인하고 그 sid 를 적어 둔다. 4. 노드 B 로 refresh 를 보내고, 그동안 노드 B 가 어떤 SQL 을 쏘는지 로그에서 확인한다. 5. NetworkPolicy 의 허용 포트를 8080 과 9000 만 남겨 7800 을 막는다. 6. 클러스터가 실제로 갈라졌는지 vendor_cluster_size 로 확인한다. ESTABLISHED 연결은 규칙 평가를 건너뛰므로, 값이 2 에서 안 내려가면 파드를 재시작해 연결을 새로 맺게 한다. 7. 분단 상태에서 노드 A 로 로그인하고 노드 B 로 refresh 를 보내 상태 코드를 본다. 8. 노드 B 에서 로그아웃한 뒤 노드 A 로 재갱신을 보내고 상태 코드를 본다. 400 이면 무효화가 전파된 것이고 200 이면 막힌 것이다. ## 본문 ## 로그와 테이블은 클러스터가 섰다고 말한다 Keycloak 을 두 대로 올리면 두 노드는 PostgreSQL 의 `JGROUPS_PING` 테이블에 자기 행을 넣어 서로를 발견하고, 그 행들로 지금 클러스터에 누가 있는지를 나타내는 클러스터 뷰를 만든다. 뷰가 만들어지면 로그에 그대로 찍힌다. ```text label="두 노드가 한 클러스터를 이룬 로그" ISPN000094: Received new cluster view for channel ISPN: [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537] ``` `JGROUPS_PING` 에도 둘 다 등록되어 있다. Infinispan 은 Keycloak 이 세션과 realm 정보를 담아 두는 인메모리 데이터 그리드이고 노드 사이로 복제하는 기능이 있으므로, 이 두 가지만 보면 세션도 그 경로로 복제된다고 읽게 된다. 실험대를 세우면서 적어 둔 예측이 그것이었고, 「클러스터를 끊으면 세션 공유가 깨진다」는 다음 예측도 거기서 나왔다. ## 노드 B 가 무엇을 읽는지 SQL 로 확인했다 노드 A 로 로그인해 세션을 만든 다음 노드 B 로 refresh 를 보내고, 그동안 노드 B 가 PostgreSQL 로 날린 SQL 을 문장 로깅으로 잡았다. 노드 B 는 `OFFLINE_USER_SESSION` 을 직접 읽고 있었다. 세션 엔트리는 노드 사이를 건너가지 않는다. 각 노드는 자기가 처리한 로그인만 자기 쪽 `sessions` 캐시에 남긴다. 그래서 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다. ![keycloak-0 과 keycloak-1 이 각자 캐시를 갖고 PostgreSQL 을 함께 읽는 구성. 두 캐시 사이에는 세션 복제 경로가 없다.](../../../final/assets/session-sharing-path/session-sharing-path.svg) 그림에서 `JGROUPS_PING` 으로 들어가는 화살표는 두 노드의 멤버 등록 둘이고, `PostgreSQL` 로는 한쪽이 세션을 INSERT 하고 다른 쪽이 SELECT 한다. 두 `sessions` 캐시를 잇는 선은 없다. 클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였고, 이 하나가 이후 실험 전체의 해석을 바꿔 놓았다. ## 전송을 끊자 예측 둘 가운데 하나가 빗나갔다 앞 절이 로그인과 refresh 한 번씩으로 경로를 확인한 것이라면, 여기서는 그 경로를 실제로 끊어 본다. Keycloak 노드는 서로를 `JGROUPS_PING` 으로 찾지만 메시지는 TCP 7800 으로 주고받는다. 7800 만 끊으면 둘 다 테이블에 등록된 채로 남아 서로 존재한다고 믿으면서 메시지는 오가지 않는 상태가 된다. NetworkPolicy 는 허용 목록이라 「7800 을 deny 한다」는 규칙을 쓸 수 없다. 그래서 8080 과 9000 만 열고 7800 을 목록에서 빼는 방식으로 막았다. 9000 은 health 와 metrics 가 쓰는 포트라 이것까지 빠뜨리면 kubelet 이 프로브 실패로 파드를 죽이고, 그러면 분단이 아니라 죽은 Keycloak 을 재게 된다. A층 실험은 예측을 먼저 문서에 적어 두고, 주입한 뒤 관측하고, 마지막에 그 예측과 대조하는 순서로 돌렸다. 결과를 보고 나면 무엇을 예상했는지 정직하게 쓸 수 없어서 순서를 그렇게 고정했다. 여기 적어 둔 예측은 둘이었고 하나는 맞고 하나는 틀렸다. | 무엇을 예측했나 | 실제로 무엇이 나왔나 | |---|---| | 세션 공유는 안 깨진다 | 맞다. 교차 노드 refresh 가 `200` | | 로그아웃 전파는 안 깨진다 | 틀렸다. `400` 이어야 할 것이 `200` | 세션은 데이터베이스에 있으니 7800 과 무관한데, 로그아웃 무효화 통지는 7800 을 타기 때문에 끊으면 반대편 노드가 「이 세션은 죽었다」를 알 방법이 없다. 노드 B 에서 로그아웃하자 그 `sid` 의 행은 데이터베이스에서 사라졌는데도 노드 A 로 보낸 재갱신은 `200` 이었고, 그때 노드 A 의 세션 캐시에는 그 세션이 1건 남아 있었다. 캐시에 있으면 데이터베이스를 다시 읽지 않으므로, 로그아웃과 함께 행이 사라지는 것을 보고 무효화가 데이터베이스 삭제로 전파된다고 적어 두었던 앞 실험의 설명을 여기서 정정했다. ![두 노드가 데이터베이스로는 이어져 있고 TCP 7800 으로는 끊긴 구성. 세션 조회는 살아 있고 무효화 통지는 막힌다.](../../../final/assets/a1-transport-vs-discovery/a1-transport-vs-discovery.svg) 발견은 데이터베이스를 쓰고 전송은 7800 을 쓴다. 예측이 하나만 맞은 이유가 이 갈림에 있다. ## 7800 을 목록에서 뺐다고 바로 갈라지지 않았다 규칙을 적용한 뒤에도 클러스터에 지금 몇 명이 있는지를 내보내는 지표 `vendor_cluster_size` 가 25분 동안 2 로 남았다. conntrack 은 커널이 이미 맺어진 연결을 기억해 두는 표인데, ESTABLISHED 로 기록된 연결은 규칙 평가를 건너뛰기 때문에 정책을 바꿔도 기존 연결이 그대로 흘렀다. 실제로 갈라진 것은 파드를 재시작해 연결을 새로 맺게 한 뒤였다. 기록을 다 쓰고 증거와 하나씩 대조할 때 이 실험에서도 한 곳이 걸렸다. 재시작 4초 뒤에 생긴 분단을 처음에는 conntrack 이 풀린 결과라고 적어 두었다. 밖에서만 보면 이 실험은 「아무 일도 없음」으로 끝난다. `curl` 로 외부 진입점을 찍으면 분단 중에도 전부 200 이었는데, 장애가 없어서가 아니라 분단된 노드가 readiness 실패로 스스로 로드밸런서에서 빠졌기 때문이다. 그래서 관측 지점을 외부 `curl` 과 Prometheus 지표와 PostgreSQL 직접 조회 셋으로 늘렸고, 위의 200 과 400 도 그 셋을 함께 보고 판정했다. ## 확인하지 않은 것 Infinispan 복제를 명시적으로 켠 구성에서는 재지 않았다. 이 결론은 `persistent-user-sessions` 가 켜진 26.7.0 기본값에 한정된다. 그 설정을 끄고 같은 차단을 다시 걸었을 때 교차 노드 refresh 가 `400 Session not active` 가 되는 것까지는 확인했다. 다만 그것은 세션을 메모리에 두는 쪽의 결과이지 복제를 켠 구성의 결과가 아니다.