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>
359 lines
14 KiB
Markdown
359 lines
14 KiB
Markdown
# A-5 — 한쪽 방향만 끊기면 어떻게 되는가 (split brain)
|
||
|
||
브랜치 `feature/keycloak-a5-asymmetric-partition` ·
|
||
증거 [`docs/evidence/a5-asymmetric-partition/`](evidence/a5-asymmetric-partition/) ·
|
||
2026-09-04 12:28–12:46 KST
|
||
|
||
선행: [`A-1`](experiment-a1-jgroups-transport-block.md) — 이 실험은 A-1 이
|
||
열어둔 질문에 답한다.
|
||
|
||
> **A-1 의 열린 질문** — *"양쪽이 동시에 NotReady 가 되는 경로가 있다면
|
||
> 전면 장애다. A-5 에서 이어서 본다."*
|
||
|
||
---
|
||
|
||
## 구조
|
||
|
||

|
||
|
||
> 다이어그램 규약은 [`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 이다.
|
||
|
||

|
||
|
||
### 그런데 **한쪽만 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 이면 **세션이 갈라진다** |
|
||
| 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 |
|
||
| 운영 | **완전 분단조차 전면 장애가 아니다** — 코디네이터 쪽이 살아남는다 |
|