기록 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 | session-sharing-is-the-database-not-replication | 클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다 | session-custody-across-nodes | Keycloak 두 노드가 같은 세션을 읽는 경로 | keycloak-session-store | 게시 전 | cdac9b8178391311d8eca1ebc6cac15bb62d79af |
|
|
|
클러스터는 형성됐는데 세션을 나르는 것은 데이터베이스였다
두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 읽기 때문이었다. 노드 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
재현 조건
-
Keycloak 을 2노드로 띄우고 JGROUPS_PING 에 두 행이 들어가는지, 클러스터 뷰가 2명인지 확인한다.
-
PostgreSQL 문장 로깅을 켠다.
-
노드 A 로 로그인하고 그 sid 를 적어 둔다.
-
노드 B 로 refresh 를 보내고, 그동안 노드 B 가 어떤 SQL 을 쏘는지 로그에서 확인한다.
-
NetworkPolicy 의 허용 포트를 8080 과 9000 만 남겨 7800 을 막는다.
-
클러스터가 실제로 갈라졌는지 vendor_cluster_size 로 확인한다. ESTABLISHED 연결은 규칙 평가를 건너뛰므로, 값이 2 에서 안 내려가면 파드를 재시작해 연결을 새로 맺게 한다.
-
분단 상태에서 노드 A 로 로그인하고 노드 B 로 refresh 를 보내 상태 코드를 본다.
-
노드 B 에서 로그아웃한 뒤 노드 A 로 재갱신을 보내고 상태 코드를 본다. 400 이면 무효화가 전파된 것이고 200 이면 막힌 것이다.
본문
로그와 테이블은 클러스터가 섰다고 말한다
Keycloak 을 두 대로 올리면 두 노드는 PostgreSQL 의 JGROUPS_PING 테이블에 자기 행을 넣어 서로를 발견하고, 그 행들로 지금 클러스터에 누가 있는지를 나타내는 클러스터 뷰를 만든다. 뷰가 만들어지면 로그에 그대로 찍힌다.
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 캐시에 남긴다. 그래서 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다.
그림에서 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건 남아 있었다. 캐시에 있으면 데이터베이스를 다시 읽지 않으므로, 로그아웃과 함께 행이 사라지는 것을 보고 무효화가 데이터베이스 삭제로 전파된다고 적어 두었던 앞 실험의 설명을 여기서 정정했다.
발견은 데이터베이스를 쓰고 전송은 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 가 되는 것까지는 확인했다. 다만 그것은 세션을 메모리에 두는 쪽의 결과이지 복제를 켠 구성의 결과가 아니다.