Files
keycloak-pattern/docs/experiment-a5-asymmetric-partition.md
T
DongHyeonkaandClaude Opus 5 cdac9b8178 docs: give the twelve experiments that had no architecture diagram one
An audit against the standard the series set — concepts, procedure,
commands, architecture diagram, evidence table, terminal output — found the
three new experiments met it while twelve of the original ones had no
diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2.

Each now has one drawn from what that experiment actually found, not filler:
A-0 shows sharing going through PostgreSQL rather than between the caches;
A-3 the gap between the 200 and the WAL flush, with both failed injections;
A-5 the three silent injection failures; A-6 the two places latency is
multiplied; B-0 the repository keyed by principal with no session id; B-2
the primary key that causes the overwrite; D-2 why the rolling update
stopped the accident halfway.

Also corrected the index's stale claim of 11 experiments without a
screenshot — it is 14, and the reason is recorded: those experiments were
measured from terminals, the database and logs, and the observability stack
does not scrape Redis, the BFF or PostgreSQL, so there is no console to
photograph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:44:36 +09:00

359 lines
14 KiB
Markdown
Raw 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-5 — 한쪽 방향만 끊기면 어떻게 되는가 (split brain)
브랜치 `feature/keycloak-a5-asymmetric-partition` ·
증거 [`docs/evidence/a5-asymmetric-partition/`](evidence/a5-asymmetric-partition/) ·
2026-09-04 12:2812:46 KST
선행: [`A-1`](experiment-a1-jgroups-transport-block.md) — 이 실험은 A-1 이
열어둔 질문에 답한다.
> **A-1 의 열린 질문** — *"양쪽이 동시에 NotReady 가 되는 경로가 있다면
> 전면 장애다. A-5 에서 이어서 본다."*
---
## 구조
![A-5 — 비대칭·양방향 차단과 세 번의 주입 실패](diagrams/a5-asymmetric-partition.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| 비대칭(한 방향) 차단이 클러스터를 가르는가 | **아니다.** 열린 방향으로 재연결한다 |
| 양방향 완전 차단은 가르는가 | **그렇다.** 양쪽 모두 멤버 1개 |
| **그때 양쪽이 다 NotReady 가 되는가** | **아니다. 한쪽만 DOWN 이다** |
| 그래서 서비스는 | **계속된다.** 외부 200 유지 |
**전면 장애 경로는 없었다.** 코디네이터 쪽이 항상 살아남는다.
그리고 주입을 **세 번 실패**했다. 세 번 모두 다른 이유였고, 셋 다
**"아무 일도 없었다"로 보였다.**
---
## 1. 주입을 세 번 실패했다
A-1 에서 NetworkPolicy 가 conntrack 의 `ESTABLISHED` 승인에 막혀 기존 연결을
못 끊는다는 것을 배웠다. 그래서 이번에는 iptables 로 갔는데, **또 다른 벽이
세 개** 있었다.
### 실패 ① — `iptables -I FORWARD 1` 은 최상단에 남지 않는다
```bash
ssh kc-lab-2 'sudo iptables -I FORWARD 1 -p tcp -d 10.42.1.77 --dport 7800 -j DROP'
```
```
num pkts bytes target
1 232 377K KUBE-ROUTER-FORWARD /* kube-router netpol */ ← 다시 1번이 되었다
2 0 0 DROP tcp dpt:57800
3 0 0 DROP tcp dpt:7800 ← 0 패킷
```
**kube-router 가 주기적으로 자기 체인을 최상단에 다시 삽입한다.**
내가 1번에 넣어도 곧 2번, 3번으로 밀려난다.
> **직접 넣은 iptables 규칙은 CNI 가 관리하는 체인과 경쟁한다.**
> 넣는 것으로 끝이 아니라 **패킷 카운터로 확인해야 한다.**
### 실패 ② — 그래도 0 패킷: **연결 방향을 잘못 짚었다**
`raw` 테이블로 옮겼는데도 0 패킷이었다.
```bash
ssh kc-lab-2 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d 10.42.1.77 --dport 7800 -j DROP'
```
실제 연결을 봤더니
```
ESTABLISHED src=10.42.1.77 dst=10.42.0.42 sport=60485 dport=7800
──────────── ────────────────────
keycloak-0 가 클라이언트 keycloak-1 이 서버
```
**A-1 때와 방향이 반대였다.** 파드가 재시작되면서 누가 먼저 연결을 걸었는지가
바뀌었다.
> **JGroups 의 TCP 연결 방향은 고정이 아니다.** 먼저 뜬 쪽, 먼저 JOIN 을 건
> 쪽에 따라 달라진다. **가정하지 말고 `conntrack -L` 로 봐야 한다.**
### 성공 — `raw` 테이블 PREROUTING, 수신측 노드에
```bash
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d 10.42.0.42 --dport 7800 -j DROP
sudo iptables -t raw -I PREROUTING 1 -p tcp -d 10.42.0.42 --dport 57800 -j DROP'
```
```
pkts bytes target
19 1096 DROP tcp dpt:57800
21 3058 DROP tcp dpt:7800 ← 걸린다
```
### 개념 — netfilter 처리 순서
```
패킷 도착
├─▶ raw PREROUTING ← conntrack 보다 먼저. NOTRACK·DROP 용
├─▶ conntrack 조회/생성 ← 여기서 ESTABLISHED 가 결정된다
├─▶ mangle PREROUTING
├─▶ nat PREROUTING
├─▶ filter FORWARD ← NetworkPolicy·kube-router 가 여기 있다
└─▶ 목적지 파드
```
| 어디에 넣는가 | 기존 연결을 끊는가 | CNI 와 경쟁하는가 |
|---|---|---|
| NetworkPolicy (filter) | **못 끊는다** — conntrack 이 먼저 통과시킨다 | 없음 |
| filter FORWARD 직접 | 순서에 따라 | **경쟁한다** (kube-router 가 밀어낸다) |
| **raw PREROUTING** | **끊는다** | **없다** — CNI 가 안 쓰는 테이블 |
**진짜 네트워크 분단을 흉내내려면 `raw` 테이블이 맞다.**
---
## 2. 비대칭 차단 — 클러스터가 갈라지지 않았다
한 방향(`→ keycloak-1:7800`)만 막고 관찰했다.
```
+25초 keycloak-0:1/1 keycloak-1:1/1 | 외부 200
+75초 keycloak-0:1/1 keycloak-1:0/1 | 외부 200 ← 잠깐 흔들림
+100초 keycloak-0:1/1 keycloak-1:1/1 | 외부 200 ← 회복
...
+200초 keycloak-0:1/1 keycloak-1:1/1 | 외부 200
```
**주입 이후 뷰 변화가 하나도 없었다.**
```bash
kubectl -n keycloak-lab logs keycloak-0 --since=20m | grep ISPN000094 | awk '$2 >= "03:33:58"'
# (아무것도 안 나온다)
```
```
뷰 ID 이력: |13] (2) ← 주입 전부터 지금까지 그대로
suspected = 0
```
> **맥락 하나가 빠져 있었다** —
> [`06-view-history-and-cleanup.txt`](evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt)
> 를 보면 뷰 13 은 **주입(03:33:58)보다 9초 앞선 03:33:49 의 `MergeView`** 로
> 만들어졌고, 그 직전에는 `|12] (1)` — 즉 **막 분단됐다가 합쳐진 직후**였다.
> `merge_events = 1.0` 도 그 병합의 것이다.
>
> **"주입 전부터 그대로" 는 맞지만, 그 "전" 이 9초였다.**
> 앞선 실패한 주입 시도들이 만든 흔들림이고, 주입 이후 뷰가 변하지 않았다는
> 결론 자체는 유지된다.
### 왜 안 갈라졌는가 — **연결 방향이 뒤집혔다**
```
차단 전: keycloak-0 → keycloak-1:7800 ← 내가 막은 방향
차단 후: keycloak-1 → keycloak-0:7800 ← 열린 방향으로 다시 붙었다
```
```
ESTABLISHED src=10.42.0.42 dst=10.42.1.77 sport=48473 dport=7800
──────────── ─────────────
keycloak-1 이 클라이언트 keycloak-0 이 서버
```
**JGroups 는 막힌 연결이 죽자 반대 방향으로 새로 연결했고, 그 사이 FD_SOCK2 가
상대를 의심하기 전에 복구가 끝났다.** `suspected = 0` 이 그 증거다.
> **한 방향만 막는 것으로는 JGroups 를 가를 수 없다.**
> 두 노드는 서로에게 연결을 걸 수 있으므로, **한쪽 길이 막히면 다른 길로 간다.**
>
> 운영적으로는 좋은 소식이다 — 단방향 방화벽 오설정은 **자가 치유**된다.
> 반대로 **분단을 재현하려는 실험자에게는 함정**이다.
---
## 3. 양방향 차단 — 갈라졌지만 전면 장애는 아니다
양쪽 노드 모두에 규칙을 넣었다.
```
+75초 keycloak-0:1/1 keycloak-1:1/1 | ready=[10.42.0.42 10.42.1.77] 외부 200
+100초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
...
+225초 keycloak-0:1/1 keycloak-1:0/1 | ready=[10.42.1.77] 외부 200
```
```
=== 뷰 ===
keycloak-0 [keycloak-0-24309|14] (1) [keycloak-0-24309]
keycloak-1 [keycloak-1-45480|14] (1) [keycloak-1-45480]
=== JGROUPS_PING ===
keycloak-0-24309 | 10.42.1.77:7800 | t ← 코디네이터
keycloak-1-45480 | 10.42.0.42:7800 | t ← 코디네이터
```
**`coord = t` 가 둘.** 완전한 split brain 이다.
![cluster_size 가 갈라졌다 합쳐진다](evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png)
### 그런데 **한쪽만 DOWN 이다**
```
--- keycloak-0 ---
{"status":"UP", ... {"name":"Keycloak cluster health check","status":"UP"}
--- keycloak-1 ---
{"status":"DOWN", ... (cluster health check 가 DOWN)
```
| | keycloak-0 | keycloak-1 |
|---|---|---|
| 분단 전 역할 | **코디네이터** (뷰 13 의 발행자) | 일반 멤버 |
| 분단 후 자기 인식 | "멤버가 하나 나갔다" — **정상 사건** | "코디네이터를 잃었다" — **비정상** |
| 헬스체크 | **UP** | **DOWN** |
| Service 엔드포인트 | **남는다** | 빠진다 |
> **A-1 의 열린 질문에 대한 답 — 양쪽이 동시에 NotReady 가 되는 경로는 없었다.**
>
> Keycloak 의 클러스터 헬스체크는 **비대칭**이다. 코디네이터였던 쪽은
> 자기가 정상이라고 보고, 잃은 쪽만 DOWN 이 된다. 그래서 완전 분단조차
> **용량 저하로 끝나고 전면 장애가 되지 않는다.**
>
> A-2(DB 상실)에서 양쪽이 동시에 DOWN 이 된 것과 대조된다. **DB 는 모두가
> 의존하는 하나지만, 클러스터 멤버십은 서로 상대적**이기 때문이다.
---
## 4. 복구
```bash
ssh kc-lab-1 'sudo iptables -t raw -F PREROUTING'
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
```
+25초 keycloak-0:1/1 keycloak-1:0/1
+50초 keycloak-0:1/1 keycloak-1:1/1
→ 복구 완료
MergeView::[keycloak-0-24309|15] (2) [keycloak-0-24309, keycloak-1-45480]
```
**50초, 사람 개입 없음.** `MergeView` 로 뷰 15 가 발행되며 병합됐다.
각 캐시마다 재분배 로그가 남는다.
```
[Context=work] ISPN100007: After merge (or coordinator change) ...
[Context=clientSessions] ISPN100007: After merge ...
[Context=offlineSessions] ISPN100007: After merge ...
[Context=loginFailures] ISPN100007: After merge ...
[Context=actionTokens] ISPN100007: After merge ...
```
**`ISPN100007` 은 병합 후 캐시별 토폴로지 재계산**이다. 캐시가 여럿이므로
로그도 캐시 수만큼 나온다.
---
## 5. 개념
### `MergeView` 와 뷰 ID
```
[keycloak-0-24309|13] (2) ← 정상
[keycloak-0-24309|14] (1) ← 분단. 양쪽이 각자 14 를 발행
MergeView::[...|15] (2) ← 병합. 뷰 ID 는 계속 증가한다
```
**뷰 ID 는 단조 증가**하므로 "언제 몇 번 갈라졌는지"를 로그만으로 셀 수 있다.
### 코디네이터의 비대칭성
JGroups 코디네이터는 **가장 오래된 멤버**다. 분단이 나면
```
코디네이터 쪽: "멤버가 나갔다" → 정상 처리, 계속 코디네이터
나머지 쪽: "코디네이터가 사라졌다" → 새 코디네이터를 자기로 선출
```
**둘 다 자기가 코디네이터라고 믿는 상태**가 split brain 이고,
`JGROUPS_PING.coord` 컬럼에 그대로 드러난다.
### 실험자를 위한 규칙
| 상황 | 확인 방법 |
|---|---|
| 규칙을 넣었는데 안 걸린다 | `iptables -L -n -v`**패킷 카운터** |
| 방향을 모르겠다 | `conntrack -L \| grep <포트>` |
| CNI 가 밀어낸다 | `raw` 테이블을 쓴다 |
| 갈라졌는지 알고 싶다 | `vendor_cluster_size`, `ISPN000094`, `JGROUPS_PING.coord` |
---
---
## 증거 파일
**증거 수집 시각: 2026-09-04 12:29 16:34 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
| [`01-injection.txt`](evidence/a5-asymmetric-partition/01-injection.txt) | 터미널 원문 |
| [`02-injection-verify.txt`](evidence/a5-asymmetric-partition/02-injection-verify.txt) | 터미널 원문 |
| [`03-raw-table-injection.txt`](evidence/a5-asymmetric-partition/03-raw-table-injection.txt) | 터미널 원문 |
| [`04-correct-direction.txt`](evidence/a5-asymmetric-partition/04-correct-direction.txt) | 터미널 원문 |
| [`05-reconnect-observed.txt`](evidence/a5-asymmetric-partition/05-reconnect-observed.txt) | 터미널 원문 |
| [`06-view-history-and-cleanup.txt`](evidence/a5-asymmetric-partition/06-view-history-and-cleanup.txt) | 터미널 원문 |
| [`07-bidirectional-block.txt`](evidence/a5-asymmetric-partition/07-bidirectional-block.txt) | 터미널 원문 |
| [`08-coordinator-and-recovery.txt`](evidence/a5-asymmetric-partition/08-coordinator-and-recovery.txt) | 터미널 원문 |
| [`a5-cluster-size-bidirectional-block.png`](evidence/a5-asymmetric-partition/a5-cluster-size-bidirectional-block.png) | 스크린샷 |
파일별 상세는 [`evidence/a5-asymmetric-partition/README.md`](evidence/a5-asymmetric-partition/README.md).
## 6. 재현 절차 (명령어)
```bash
# 1. 지금 연결이 어느 방향인지 먼저 본다 — 가정하면 실패한다
ssh kc-lab-1 'sudo conntrack -L | grep 7800'
# 2. 수신측 노드의 raw PREROUTING 에 넣는다 (filter 는 CNI 와 경쟁한다)
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') --dport 7800 -j DROP'
ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-1 -o jsonpath='{.status.podIP}') --dport 57800 -j DROP'
# 3. 걸렸는지 카운터로 확인 — 0 이면 해석 금지
ssh kc-lab-1 'sudo iptables -t raw -L PREROUTING -n -v'
# 4. 양방향으로 하려면 반대 노드에도 (한 방향만으로는 자가 치유된다)
ssh kc-lab-2 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d $(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') --dport 7800 -j DROP'
# 5. 분단 확인
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select name, coord from jgroups_ping" # coord=t 가 둘이면 split brain
kubectl -n keycloak-lab get endpoints keycloak -o jsonpath='{.subsets[*].addresses[*].ip}'
# 6. 해제
ssh kc-lab-1 'sudo iptables -t raw -F PREROUTING'
ssh kc-lab-2 'sudo iptables -t raw -F PREROUTING'
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **A-6** 지연 주입 | `tc netem` 도 같은 함정 — **주입이 걸렸는지 먼저 확인** |
| **A-7** volatile 비교 | 여기서는 분단에도 서비스가 계속됐다. volatile 이면 **세션이 갈라진다** |
| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |
| 운영 | **완전 분단조차 전면 장애가 아니다** — 코디네이터 쪽이 살아남는다 |