Files
keycloak-pattern/docs/experiment-a1-jgroups-transport-block.md
DongHyeonkaandClaude Opus 5 2a98ef1090 docs: A-1 — sessions survive a JGroups partition but logout invalidation does not
Cutting TCP 7800 leaves cross-node refresh working (200), confirming sessions travel through PostgreSQL rather than the cluster transport. Logout is the opposite: the database row is deleted but the other node answers from its stale local cache, so the A-0 conclusion that invalidation rides the database is corrected here.

Two things the plan did not anticipate: a NetworkPolicy cannot sever an established connection because conntrack accepts it before policy evaluation, and Keycloak reports the partition through its readiness probe so the split node removes itself from the Service.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:52:49 +09:00

507 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# A-1 — 노드 간 통신(TCP 7800)을 끊으면 무엇이 깨지는가
브랜치 `feature/keycloak-a1-jgroups-transport-block` ·
증거 [`docs/evidence/a1-jgroups-transport-block/`](evidence/a1-jgroups-transport-block/) ·
2026-09-04 11:3811:52 KST · Keycloak 26.7.0 / Infinispan 16.0.12
맥락은 [`session-lab-prerequisites.md`](session-lab-prerequisites.md),
기준선은 [`experiment-00-session-replication.md`](experiment-00-session-replication.md).
---
## 0. 결론부터
| 예측 | 결과 |
|---|---|
| 세션 공유는 **안 깨진다** | **맞다.** 교차 노드 refresh 가 `200` |
| 로그아웃 전파는 **안 깨진다** | **틀렸다.** `400` 이어야 할 것이 `200` |
| — | **NetworkPolicy 만으로는 분단이 일어나지 않는다** (예상 못 함) |
| — | **분단된 노드가 스스로 로드밸런서에서 빠진다** (예상 못 함) |
**예측 하나가 빗나갔고, 예상하지 못한 것이 둘 나왔다.** 그중 하나는
실험 방법 자체를 무효화할 뻔했다.
---
## 1. 왜 이 실험인가
A-0 에서 **세션은 Infinispan 복제가 아니라 PostgreSQL 로 공유된다**는 것을
측정했다. 그렇다면 통념과 정면으로 어긋난다.
| | |
|---|---|
| **통념** (Keycloak 24 이전 자료) | 세션은 7800 으로 복제된다 → **막으면 세션 공유가 깨진다** |
| **A-0 측정** | 세션은 DB 로 공유된다 → **막아도 안 깨진다** |
둘 중 하나는 틀렸고, 이 실험이 판정한다.
---
## 2. 기준선
```
=== [기준선 1] 클러스터 뷰 ===
keycloak-0: [keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
keycloak-1: [keycloak-1-48749|5] (2) [keycloak-1-48749, keycloak-0-30843]
=== [기준선 2] JGROUPS_PING ===
keycloak-0-30843 | 10.42.1.43:7800 | f
keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터는 하나
=== [기준선 4] JGroups 지표 (양쪽 동일) ===
fd_sock2_get_num_suspected_members 0.0
merge3_get_num_merge_events 0.0
nakack2_get_xmit_table_missing 0.0
```
**대조군** — 차단 전에 같은 절차를 그대로 한 번 돌린다.
```
=== [대조군] keycloak-0 로그인 → keycloak-1 에서 refresh ===
sid tAWs2gCPr6SOcD4jDR9-_CzB
keycloak-1 에서 refresh: 200
```
A-0 에서 배운 규칙이다 — **시험군만 재는 측정은 측정이 아니다.**
---
## 3. 주입 — NetworkPolicy 로 7800 만 막는다
```bash
kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
```
```yaml
spec:
podSelector: { matchLabels: { app: keycloak } }
policyTypes: [Ingress]
ingress:
- ports:
- { port: 8080, protocol: TCP } # HTTP — 열어둔다
- { port: 9000, protocol: TCP } # health+metrics — 열어둔다
# 7800 은 일부러 없다
```
### 개념 — NetworkPolicy 는 방화벽이 아니라 **허용 목록**이다
**"7800 을 거부"라고 쓸 수 없다.** 파드가 `policyTypes: [Ingress]` 를 가진
정책에 선택되는 순간 **모든 인바운드가 거부**되고, 규칙에 적힌 것만 통과한다.
그래서 7800 은 **빠뜨림으로써** 막힌다.
이 구조가 두 허용 규칙을 **결정적으로 만든다.** 잘못 쓰면 분단된 클러스터가
아니라 **죽은 Keycloak 을 측정하게 된다.**
| 포트 | 빼면 |
|---|---|
| 8080 | Traefik·상대 노드의 REST 호출이 전부 끊긴다 |
| **9000** | **readiness 프로브가 실패해 kubelet 이 파드를 죽인다** — 엉뚱한 이유로 클러스터가 깨진다 |
적용 직후 확인했다.
```
파드 상태: keycloak-0 ready=true restarts=0
keycloak-1 ready=true restarts=0
9000 도달: 10.42.1.43:9000 health=200 / 10.42.0.35:9000 health=200
8080 도달: 10.42.1.43:8080 root=200 / 10.42.0.35:8080 root=200
```
**주입이 의도한 것만 건드렸음을 먼저 확인한 뒤에 결과를 해석한다.**
---
## 4. 문제 ① — **NetworkPolicy 만으로는 분단이 안 된다**
가장 중요한 발견이며, 하마터면 **실험 전체를 무효로 만들 뻔했다.**
차단 후 지표가 꿈쩍도 하지 않았다. 신규 연결은 분명히 막히는데.
```
=== 7800 신규 연결 ===
10.42.1.43:7800 curl exit=7 (연결 실패)
10.42.1.43:9000 curl exit=28 (연결됨, telnet 이라 대기 → 타임아웃)
```
그런데 파드 내부 소켓을 보니
```
=== /proc/net/tcp6 · 7800 = 0x1E78 ===
keycloak-0: ...2B012A0A:1E78 ...23002A0A:9C57 01 ← 01 = ESTABLISHED
keycloak-1: ...23002A0A:9C57 ...2B012A0A:1E78 01
(10.42.0.35:40023 → 10.42.1.43:7800)
```
**기존 연결이 멀쩡히 살아 있다.**
### 왜 그런가 — conntrack
```
패킷 도착
├─▶ [ conntrack: ESTABLISHED/RELATED 이면 ACCEPT ] ← 여기서 통과해버린다
└─▶ [ NetworkPolicy 규칙 평가 ] ← 여기까지 오지 않는다
```
리눅스 방화벽은 성능을 위해 **이미 성립한 연결을 먼저 통과**시킨다.
NetworkPolicy 는 그 뒤에 있으므로 **신규 연결(SYN)만** 걸러낸다.
```
=== conntrack 확인 ===
tcp 6 86398 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=40023 dport=7800 ... [ASSURED]
tcp 6 79982 ESTABLISHED src=10.42.0.35 dst=10.42.1.43 sport=50477 dport=57800 ... [ASSURED]
tcp 6 33 SYN_SENT src=10.42.1.58 dst=10.42.0.35 sport=34824 dport=7800 [UNREPLIED]
─────────────────────────────────────────────────────────────
신규 연결은 응답을 못 받는다 = 정책이 동작하고는 있다
```
> **운영적 함의 — NetworkPolicy 는 이미 붙어 있는 것을 떼어내지 못한다.**
> 보안 사고 대응으로 "지금 당장 이 통신을 끊어라"에 NetworkPolicy 를 적용하면,
> **새 연결만 막히고 진행 중인 연결은 계속된다.** 끊으려면 conntrack 을 지우거나
> 파드를 재시작해야 한다.
### 덤 — **57800 포트도 있다**
`sport=50477 dport=57800` — FD_SOCK2 는 **`bind_port + 50000`** 을 쓴다.
7800 만 막고 57800 을 열어두면 장애 감지 채널이 남는다.
이 실험의 허용 목록 방식은 **둘 다 자동으로 막았다** — 8080·9000 외 전부 거부이므로.
### 조치
```bash
# 정확한 튜플로 지정해야 지워진다. --dport 만으로는 0건이었다
sudo conntrack -D -p tcp -s 10.42.0.35 -d 10.42.1.43 --sport 40023 --dport 7800
sudo conntrack -D -p tcp -s 10.42.1.43 -d 10.42.0.35 --sport 7800 --dport 40023 # 역방향
```
**양쪽 노드에서, 양쪽 방향으로** 지워야 한다. 서버 쪽 노드에는 튜플이 뒤집혀
기록되어 있다.
그리고 **즉시 끊기지 않는다.**
```
11:41 conntrack 삭제
11:44 cluster_size 2 → 1 ← 약 3분 뒤
```
TCP 는 상대가 사라졌음을 **재전송 타임아웃**으로 알아낸다. 소켓은 한동안
`ESTABLISHED` 로 남아 있다.
---
## 5. 문제 ② — 계측 도구가 잘못됐다
임시 curl 파드로 20초마다 지표를 긁었더니 이런 결과가 나왔다.
```
+20초 suspected(k0 k1) = []
+60초 suspected(k0 k1) = [0.0 0.0 0.0 0.0 ]
+140초 suspected(k0 k1) = [0.0 ]
```
**빈 값, 개수가 맞지 않는 값이 섞인다.** `kubectl run --rm` 은 매번 파드를
만들고 지우므로 느리고 경합이 있다.
게다가 첫 시도의 판정 조건이
```sh
[ "$R" != "0.0 0.0 " ] && echo "→ 변화 감지" && break
```
여서 **빈 문자열을 "변화"로 읽고 즉시 빠져나왔다.** A-0 에서 똑같은 실수를
했는데 또 했다.
> **임시 파드는 계측 도구가 아니다.** 15초마다 이미 긁고 있는 Prometheus 가
> 그러라고 있는 것이다.
```bash
kubectl -n observability port-forward svc/prometheus 19090:9090 &
curl -s "http://localhost:19090/api/v1/query_range?query=vendor_cluster_size&start=$START&end=$END&step=60"
```
그리고 이 과정에서 **`vendor_cluster_size`** 를 발견했다 — 멤버 수를 직접
알려주는 지표다. 처음부터 이걸 봤어야 했다.
```bash
curl -s "http://localhost:19090/api/v1/label/__name__/values" | grep -E "cluster|member|view"
```
---
## 6. 진짜 분단이 일어난 순간
```
=== vendor_cluster_size ===
keycloak-1: 11:43:57=2 11:44:27=1 ... 11:51:28=2
keycloak-0: 11:43:57=2 (파드 교체) 11:45:27=1 ... 11:51:28=2
```
![cluster_size 추이](evidence/a1-jgroups-transport-block/a1-cluster-size-partition-recovery.png)
정책이 걸린 채 `keycloak-0` 이 재시작되자, 로그가 정확히 말해준다.
```
GMS: JOIN(keycloak-0-26403) sent to keycloak-1-48749 timed out ← 10회
GMS: too many JOIN attempts (10): becoming singleton ← 포기
ISPN000094: new cluster view [keycloak-0-26403|0] (1) [keycloak-0-26403]
```
`keycloak-1` 쪽도 혼자가 되었다.
```
ISPN000094: [keycloak-1-48749|6] (1) [keycloak-1-48749]
```
### **DB 에는 둘 다 있는데 클러스터는 안 붙는다** — 예측한 그 상태
```
=== JGROUPS_PING ===
name | ip | coord
------------------+-----------------+-------
keycloak-0-26403 | 10.42.1.67:7800 | t ← 코디네이터
keycloak-1-48749 | 10.42.0.35:7800 | t ← 코디네이터
```
**`coord = t` 가 둘.** 교과서적인 split brain 이며, **데이터베이스 한 줄로
확인된다.** 디스커버리(DB)는 살아 있고 트랜스포트(7800)만 죽은 상태다.
**단일 노드에서는 만들 수 없는 고장**이며, 이 실험대를 2 VM 으로 만든 이유다.
---
## 7. 본 시험 — 분단 상태에서 세션은 어떻게 되는가
```
[1] keycloak-0 로그인 sid=nShl5TaBrZnKStDqaspjgmJB
[2] keycloak-1 에서 refresh HTTP 200 ← 예측대로
[3] keycloak-1 에서 로그아웃 HTTP 204
[4] keycloak-0 에서 재갱신 시도 HTTP 200 ← 400 이어야 했다
```
### [2] 세션 공유 — **예측이 맞았다**
클러스터가 갈라졌는데도 **한쪽에서 만든 세션을 반대쪽이 갱신했다.**
A-0 의 모델이 맞고, **통념이 틀렸다.** 세션은 7800 으로 다니지 않는다.
### [4] 로그아웃 전파 — **예측이 틀렸다**
A-0 에서는 같은 절차가 `400 invalid_grant / Session not active` 였다.
분단 상태에서는 `200` 이다. **로그아웃한 세션이 반대편에서 살아 있다.**
기제를 확정했다.
```
=== 그 sid 가 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
-----------------+--------------+----------------------
(0 rows) ← DB 행은 삭제되었다
=== 노드별 세션 캐시 엔트리 ===
keycloak-1 kc-lab-1 = 0
keycloak-0 kc-lab-2 = 1 ← 캐시에는 남아 있다
```
```
keycloak-1 로그아웃
├──▶ PostgreSQL 행 삭제 ✔ 되었다
└──▶ keycloak-0 에게 "캐시에서 지워라" ✗ 7800 이 막혀 못 갔다
keycloak-0 은 자기 캐시로 200 을 준다 ◀────────────┘
```
### **A-0 의 결론을 정정한다**
A-0 에서 나는 이렇게 썼다.
> 로그아웃과 함께 DB 행이 사라졌다 → 무효화가 DB 삭제로 전파된다
**그 인과는 틀렸다.** DB 행 삭제는 일어나지만, **반대편 노드는 DB 를 다시
읽지 않는다.** 자기 캐시에 있으면 그걸로 답한다.
> **룩어사이드 캐시는 읽을 때 DB 와 대조하지 않는다.**
> 캐시 무효화는 **클러스터 메시지(7800)를 타고** 간다.
A-0 에서 400 이 나온 것은 DB 덕분이 아니라 **그때는 7800 이 살아 있어서**였다.
두 실험을 붙여야 비로소 정확한 그림이 나온다.
| | 세션 **조회** | 세션 **무효화** |
|---|---|---|
| 경로 | PostgreSQL | **클러스터 메시지 (7800)** |
| 7800 차단 시 | 정상 | **전파되지 않음** |
---
## 8. 그런데 안전장치가 있었다 — 예상 못 한 발견
`keycloak-0``Ready=false` 였다. 이유를 물었더니
```json
{ "status": "DOWN",
"checks": [
{ "name": "Keycloak cluster health check", "status": "DOWN",
"data": { "Failing since": "2026-09-04 02:45:14,251" } },
{ "name": "Keycloak database connections async health check", "status": "UP" }
] }
```
**Keycloak 은 클러스터 분단을 readiness 로 신고한다.** 그리고 쿠버네티스가
그 신고를 받아 처리했다.
```
=== Service 엔드포인트 ===
ready 주소: [10.42.0.35] ← keycloak-1 만 트래픽을 받는다
notReady : [10.42.1.67] ← keycloak-0 은 제외되었다
=== 외부 진입점 ===
https://auth.hyeonworks.com/realms/master HTTP 200
토큰 발급 HTTP 200
```
**분단된 노드가 스스로 로드밸런서에서 빠졌고, 서비스는 계속되었다.**
### 그래서 7절의 로그아웃 우회는 어떻게 봐야 하나
| | |
|---|---|
| 내가 한 것 | Service 를 우회해 **파드 IP 로 직접** 호출 |
| 실제 사용자 | nginx → Traefik → **Service** → Ready 인 파드만 |
**정문으로 들어오면 낡은 캐시에 닿지 않는다.** readiness 게이트가 막는다.
> 다만 이건 **비대칭이라서 살았다.** `keycloak-1` 은 원래 뷰에서 멤버가 하나
> 줄어든 정상적인 사건이라 Ready 를 유지했고, `keycloak-0` 은 합류 자체를
> 못 해 DOWN 이 되었다. **양쪽이 동시에 DOWN 이 되는 경로가 있다면 전면 장애다.**
> A-5(비대칭 파티션)에서 이어서 본다.
---
## 9. 복구
```bash
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
```
```
+30초 keycloak-0=1 keycloak-1=1
+60초 keycloak-0=1 keycloak-1=1
+90초 keycloak-0=2 keycloak-1=2 ← 재형성
```
**90초 만에 자동으로 다시 붙었다. 사람 손이 필요 없었다.**
```
=== MERGE3 가 합쳤는가 ===
merge_events keycloak-0 = 1
merge_events keycloak-1 = 1
```
**MERGE3 가 한 일이다.** split brain 을 감지해 뷰를 병합하는 프로토콜이며,
지표가 `0 → 1` 로 올라간 것이 그 증거다.
```
=== JGROUPS_PING ===
keycloak-0-26403 | 10.42.1.67:7800 | t
keycloak-1-48749 | 10.42.0.35:7800 | f ← 코디네이터가 하나로 돌아왔다
```
**코디네이터가 keycloak-1 에서 keycloak-0 으로 넘어갔다.** 코디네이터는
특권이 아니라 역할이며, 병합 시 재선출된다.
---
## 10. 개념 정리
### conntrack — 연결 추적
리눅스 커널이 **진행 중인 연결을 기억**하는 표. 패킷마다 규칙을 다시 평가하지
않기 위해 존재한다.
| 상태 | 뜻 |
|---|---|
| `NEW` | 첫 패킷(SYN) |
| **`ESTABLISHED`** | **양방향 통신이 성립함 — 규칙 평가를 건너뛴다** |
| `[ASSURED]` | 충분히 오래된 연결. 표가 꽉 차도 안 지워진다 |
| `SYN_SENT [UNREPLIED]` | 보냈는데 답이 없음 = **차단되고 있다** |
```bash
sudo conntrack -L | grep 7800
sudo conntrack -D -p tcp -s <src> -d <dst> --sport <sp> --dport <dp>
```
### FD_SOCK2 와 포트 규약
| 프로토콜 | 포트 | 하는 일 |
|---|---|---|
| TCP (트랜스포트) | **7800** | 클러스터 메시지 |
| **FD_SOCK2** | **57800** = 7800 + 50000 | 소켓으로 상대 생존 감시 |
**방화벽 규칙을 손으로 쓸 때 57800 을 빠뜨리기 쉽다.**
### MERGE3
split brain 이 생긴 뒤 **갈라진 뷰를 다시 합치는** JGroups 프로토콜.
주기적으로 다른 코디네이터의 존재를 확인하고, 발견하면 병합을 개시한다.
```promql
vendor_jgroups_merge3_get_num_merge_events
```
### readiness 프로브와 Service 엔드포인트
```
readiness 실패 → 파드가 Service 의 notReadyAddresses 로 이동
→ kube-proxy 가 그 파드로 라우팅하지 않음
→ 살아 있지만 트래픽은 안 받음
```
**liveness 와 다르다.** liveness 실패는 **재시작**, readiness 실패는
**격리**다. 클러스터 분단처럼 "재시작해도 안 나아지는" 문제에는 readiness 가
맞는 신호다.
---
## 11. 재현 절차 (명령어)
```bash
# 0. 기준선
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, ip, coord from jgroups_ping order by name"
kubectl -n observability port-forward svc/prometheus 19090:9090 &
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
# 1. 차단
kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
# 2. 주입이 의도한 것만 건드렸는지 확인 (8080/9000 은 살아 있어야 한다)
kubectl -n keycloak-lab get pods -o wide | grep keycloak # restarts=0 확인
# 3. 기존 연결이 남아 있음을 확인 — 이걸 안 하면 실험이 무효다
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
# 4. conntrack 삭제 (양쪽 노드, 양쪽 방향). 반영까지 약 3분
ssh kc-lab-1 'sudo conntrack -D -p tcp -s <k1ip> -d <k0ip> --sport <sp> --dport 7800'
ssh kc-lab-2 'sudo conntrack -D -p tcp -s <k0ip> -d <k1ip> --sport 7800 --dport <sp>'
# 5. 분단 확인
curl -s "http://localhost:19090/api/v1/query?query=vendor_cluster_size"
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, coord from jgroups_ping" # coord=t 가 둘이면 split brain
# 6. 복구
kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
curl -s "http://localhost:19090/api/v1/query?query=vendor_jgroups_merge3_get_num_merge_events"
```
---
## 12. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **A-5** 비대칭 파티션 | **양쪽이 동시에 NotReady 가 되는 경로가 있는가.** 여기서는 비대칭이라 살았다 |
| **A-2** DB 정지 | 캐시가 DB 와 대조하지 않는다는 사실 → **캐시에 있는 세션은 DB 없이도 읽힐 수 있다** |
| **A-7** volatile 비교 | 같은 주입에서 세션 공유가 **깨져야** 한다. 이 실험이 그 대조군 |
| 전체 | **주입이 실제로 걸렸는지 먼저 확인한다.** NetworkPolicy 는 기존 연결을 못 끊는다 |