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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: cache-temperature-decides-the-outcome
|
||||
title: 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
|
||||
topic: session-custody-across-nodes
|
||||
topicName: Keycloak 두 노드가 같은 세션을 읽는 경로
|
||||
project: keycloak-session-store
|
||||
status: 게시 전
|
||||
lastVerifiedOn: 2026-09-04
|
||||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-a7-a7a
|
||||
assets:
|
||||
- key: cache-temperature-outcomes
|
||||
file: ../../../final/assets/cache-temperature-outcomes/cache-temperature-outcomes.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a7a-volatile-cause__01-cause-determined.txt
|
||||
- ../../../final/evidence/raw/a7-volatile-comparison__05-a2-rerun-db-loss.txt
|
||||
---
|
||||
|
||||
# 같은 설정이 캐시 온도만으로 세 가지 답을 냈다
|
||||
|
||||
캐시 온도만 달라도 같은 명령이 400, 500, 200 세 가지로 갈렸다. refresh 가 쏘는 SQL 은 가설로 둔 REVOKED_TOKEN 이 아니라 CLIENT_SCOPE_CLIENT 조회 한 문장이었고, 그것도 첫 refresh 한 번뿐이라 그 뒤로는 캐시에서 답한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **persistent-user-sessions 가 세션의 거처를 정한다**
|
||||
이 측정은 그 설정을 끈 쪽에서 잰 것이고, 껐을 때 무슨 일이 일어나는지를 숫자로 대는 사례다.
|
||||
- **버전과 설정을 결과와 함께 적는다**
|
||||
한 번 재고 표로 적으면 안 되는 종류가 있다는 것을 이 세 결과가 보였고, 그 규칙을 편 기록이다.
|
||||
|
||||
## 문제
|
||||
|
||||
persistent-user-sessions 를 끄고 A층을 다시 돌렸더니 세 결과가 뒤집혔고, 그 실험이 표 하나를 남겼다. 표에는 데이터베이스를 세운 동안 로그인은 200 이고 refresh 는 500 이라고 적혀 있었다.
|
||||
|
||||
500 의 원인은 확정하지 못했고 REVOKED_TOKEN 테이블일 것이라는 가설만 붙어 있었다. 세션을 메모리에 두면 데이터베이스를 안 볼 텐데 왜 refresh 만 실패하는지가 설명되지 않았기 때문이다.
|
||||
|
||||
가설이 맞는지 재지 않은 채로 두면 「volatile 이면 이렇다」는 표가 조건 없이 유통된다.
|
||||
|
||||
## 결론
|
||||
|
||||
원인은 REVOKED_TOKEN 이 아니라 선택적 클라이언트 스코프 조회였다.
|
||||
|
||||
로그인이 쏘는 SQL : 0건
|
||||
refresh 가 쏘는 SQL : CLIENT_SCOPE_CLIENT 한 문장
|
||||
그 조회의 조건 : DEFAULT_SCOPE = 'f'
|
||||
REVOKED_TOKEN 이 나온 횟수 : 0
|
||||
그 조회가 일어나는 때 : 첫 refresh 한 번. 이후로는 캐시에서 답한다
|
||||
|
||||
캐시가 그 조회를 삼키는 순간 결과가 바뀌므로, 같은 설정에서 캐시 온도만으로 답이 셋으로 갈린다. 완전 냉시동이면 로그인부터 400 이고, CLIENT 만 더우면 로그인 200 에 refresh 500 이며, 완전히 더우면 둘 다 200 이다.
|
||||
|
||||
그래서 원래 표가 적은 「volatile 이면 데이터베이스 없이 로그인된다」도 조건부였다. 냉시동에서는 클라이언트 조회조차 캐시에 없어 로그인이 400 이 된다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak : 26.7.0 · 2노드
|
||||
기능 플래그 : --features-disabled=persistent-user-sessions
|
||||
세션 위치 : 메모리. offline_user_session 행 수 0 으로 확인
|
||||
데이터베이스 : PostgreSQL
|
||||
SQL 관측 : PostgreSQL 문장 로깅
|
||||
수집 기록 : 2026-09-04 11:18–11:24 UTC. PostgreSQL 컨테이너가 UTC 로 로그를 찍는다
|
||||
구간 표시 : 로그인과 refresh 앞뒤에 표식 SELECT 를 넣어 어느 SQL 이 어느 요청 것인지 가른다
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Keycloak 을 --features-disabled=persistent-user-sessions 로 띄운다.
|
||||
|
||||
2. 로그인한 뒤 offline_user_session 행 수가 0 인지 확인한다. 0 이어야 세션이 메모리에 있는 상태다.
|
||||
|
||||
3. PostgreSQL 문장 로깅을 켠다.
|
||||
|
||||
4. 로그인과 refresh 를 각각 표식 SELECT 로 감싸 그 사이에 나오는 SQL 을 가른다.
|
||||
|
||||
5. 냉시동을 만든다. Keycloak 을 재시작하고 PostgreSQL 을 내린 다음 로그인한다.
|
||||
|
||||
6. 중간 상태를 만든다. Keycloak 을 재시작하고 데이터베이스가 살아 있을 때 로그인을 한 번 한 다음 PostgreSQL 을 내리고 refresh 한다.
|
||||
|
||||
7. 완전히 더운 상태를 만든다. refresh 를 세 번 미리 돌려 캐시를 채운 다음 PostgreSQL 을 내리고 로그인과 refresh 를 각각 보낸다.
|
||||
|
||||
8. 세 경우의 상태 코드와, 실패한 경우 로그가 지목한 SQL 문장을 함께 적는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 가설은 REVOKED_TOKEN 이었다
|
||||
|
||||
`--features-disabled=persistent-user-sessions` 는 세션을 데이터베이스에 쓰지 않고 메모리에 두게 하는 설정이다. 이 상태로 A층을 다시 돌리자 세 실험의 결과가 뒤집혔고, 그중 하나가 데이터베이스를 세운 동안 로그인은 `200` 인데 refresh 는 `500` 이 되는 것이었다.
|
||||
|
||||
세션이 메모리에 있으면 refresh 도 데이터베이스를 볼 이유가 없다. 그 실험은 「측정은 확실하지만 원인은 확정하지 못했다」고 적고 유력한 후보로 `REVOKED_TOKEN` 테이블을 남겼다. refresh token 회전은 이미 쓴 토큰이 다시 왔는지 확인해야 하고, 그 확인은 발급 이력을 담은 테이블을 읽어야 하니 그 경로가 캐시를 못 쓸 것이라는 추측이었다.
|
||||
|
||||
## 문장 로깅으로 실제 SQL 을 잡았다
|
||||
|
||||
PostgreSQL 의 `log_statement` 를 `all` 로 올려 오가는 문장을 전부 남기고, 로그인과 refresh 앞뒤에 표식 SELECT 를 하나씩 넣어 어느 문장이 어느 요청 것인지 갈랐다.
|
||||
|
||||
로그인은 SQL 을 0개 쏜다. realm 과 사용자, 클라이언트가 전부 Infinispan 캐시에 있어서 데이터베이스를 보지 않는다. refresh 는 딱 한 문장을 쏜다. 그 문장이 이것이다.
|
||||
|
||||
```text label="refresh 가 쏜 단 한 문장"
|
||||
select cscme1_0.SCOPE_ID from CLIENT_SCOPE_CLIENT cscme1_0
|
||||
where cscme1_0.CLIENT_ID=$1 and cscme1_0.DEFAULT_SCOPE=$2
|
||||
parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'f'
|
||||
```
|
||||
|
||||
`REVOKED_TOKEN` 은 한 번도 나오지 않았다. `DEFAULT_SCOPE='f'` 이므로 이것은 선택적 클라이언트 스코프 조회다. 클라이언트 스코프는 그 클라이언트에게 발급할 토큰에 어떤 권한 범위를 담을지를 묶어 둔 설정이고, 기본 스코프는 항상 들어가지만 선택적 스코프는 요청이 달라고 해야 들어간다. refresh 는 새 access token 에 무엇을 담을지 다시 계산하므로 그 목록을 읽어야 하고, 목록이 이 테이블에 있다.
|
||||
|
||||
## 그 조회는 첫 refresh 한 번뿐이다
|
||||
|
||||
refresh 를 연속 세 번 돌리고 표식 사이의 SQL 을 다시 셌더니 0건이었다. 첫 refresh 가 캐시를 채우고 그다음부터는 데이터베이스를 보지 않는다.
|
||||
|
||||
그러면 데이터베이스를 세웠을 때 무엇이 실패하는지는 그 순간 캐시가 무엇을 이미 갖고 있느냐로 정해진다. 셋을 각각 만들어 재 봤다.
|
||||
|
||||
| 캐시 상태 | 로그인 · refresh 가 받는 것 | 어느 SQL 이 실패했나 |
|
||||
|---|---|---|
|
||||
| 완전 냉시동 | `400` · `400` | `select ce1_0.ID from CLIENT ...` |
|
||||
| CLIENT 만 더움 | `200` · `500` | `CLIENT_SCOPE_CLIENT ...` |
|
||||
| 완전히 더움 | `200` · `200` | 없음 (SQL 0건) |
|
||||
|
||||

|
||||
|
||||
세 결과를 만드는 것은 조회 두 개다. 요청이 먼저 클라이언트를 확인하고, 그다음 스코프를 다시 계산한다. 캐시가 둘 다 못 삼킨 상태면 앞의 조회에서 400 이 나고, 앞은 삼켰는데 뒤는 못 삼킨 상태면 500 이 나며, 둘 다 삼킨 뒤에는 데이터베이스에 닿는 조회 자체가 없다.
|
||||
|
||||
## 원래 표가 본 것은 그 사이의 한 상태였다
|
||||
|
||||
앞의 실험은 Keycloak 을 재시작하고 로그인을 한 번 한 다음 데이터베이스를 내렸다. 로그인이 CLIENT 캐시를 채웠고 refresh 는 한 번도 돌지 않아 스코프 캐시는 비어 있었으므로, 표에 적힌 「로그인 200, refresh 500」은 그 중간 상태에서 나온 값이었다.
|
||||
|
||||
같은 표의 다른 줄도 마찬가지다. 「volatile 이면 데이터베이스 없이 로그인된다」는 냉시동에서 성립하지 않는다. 재시작 직후에는 클라이언트 조회조차 캐시에 없어서 로그인이 `400` 으로 떨어지고, 로그는 `select ce1_0.ID from CLIENT` 가 실패했다고 지목한다.
|
||||
|
||||
세션을 메모리에 두는 구성에서 데이터베이스가 멈췄을 때의 동작은 무엇을 하느냐가 아니라 그 경로가 이미 캐시를 채웠느냐로 결정된다. 그래서 같은 명령이 재시작 직후와 얼마 쓴 뒤에 다른 답을 낸다. 이런 종류의 결과는 한 번 재보고 표로 적으면 안 되는데, 앞 실험이 그렇게 했다.
|
||||
|
||||
## 확인하지 않은 것
|
||||
|
||||
캐시가 식는 시간을 재지 않았다. 냉시동과 중간, 완전히 더운 세 상태를 만들어 확인했을 뿐 그 사이의 전이는 관측하지 않았다.
|
||||
|
||||
세 상태는 재시작과 요청 횟수로 만든 것이라, 운영에서 얼마나 오래 쓰지 않으면 어느 상태로 돌아가는지는 이 측정으로 답할 수 없다.
|
||||
<!-- body:end -->
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
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. 파드 나이를 확인해 실제로 교체됐는지 대조한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 재시작 전에 두 가지를 따로 적어 두었다
|
||||
|
||||
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 이 새로 뜬 파드에서도 통한다.
|
||||
|
||||

|
||||
|
||||
그림에서 롤링 재시작이 캐시로 보내는 화살표는 초기화이고 세션 행으로 보내는 화살표는 변경 없음이다. 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초 간격 폴링은 요청을 보내고 응답을 받는 것까지만 본다. 재시작 순간에 이미 처리 중이던 요청이 어떻게 끝나는지는 이 방식으로 관측되지 않는다.
|
||||
<!-- body:end -->
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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 이면 막힌 것이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
## 로그와 테이블은 클러스터가 섰다고 말한다
|
||||
|
||||
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` 캐시에 남긴다. 그래서 두 노드가 같은 답을 내놓는 이유는 복제가 아니라 같은 데이터베이스를 보기 때문이다.
|
||||
|
||||

|
||||
|
||||
그림에서 `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` 가 되는 것까지는 확인했다. 다만 그것은 세션을 메모리에 두는 쪽의 결과이지 복제를 켠 구성의 결과가 아니다.
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user