# 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 에서 이어서 본다."* --- ## 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 ``` ### 왜 안 갈라졌는가 — **연결 방향이 뒤집혔다** ``` 차단 전: 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` | --- ## 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 <수신 파드IP> --dport 7800 -j DROP' ssh kc-lab-1 'sudo iptables -t raw -I PREROUTING 1 -p tcp -d <수신 파드IP> --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 <반대 파드IP> --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 이면 **세션이 갈라진다** | | 운영 | **단방향 방화벽 오설정은 자가 치유된다.** 양방향이어야 사고가 된다 | | 운영 | **완전 분단조차 전면 장애가 아니다** — 코디네이터 쪽이 살아남는다 |