Three injections failed first: kube-router keeps reinserting its chain above a hand-placed FORWARD rule, the JGroups connection direction had reversed since A-1, and only the raw table runs ahead of conntrack. Each failure looked like nothing happening. Blocking one direction never partitioned the cluster because JGroups reconnected the other way before failure detection fired. Blocking both produced a real split brain with two coordinators in JGROUPS_PING, yet only the non-coordinator node reported DOWN, so the Service kept an endpoint and the front door stayed at 200. That answers the question A-1 left open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
A-5 — 한쪽 방향만 끊기면 어떻게 되는가 (split brain)
브랜치 feature/keycloak-a5-asymmetric-partition ·
증거 docs/evidence/a5-asymmetric-partition/ ·
2026-09-04 12:28–12:46 KST
선행: A-1 — 이 실험은 A-1 이
열어둔 질문에 답한다.
A-1 의 열린 질문 — "양쪽이 동시에 NotReady 가 되는 경로가 있다면 전면 장애다. A-5 에서 이어서 본다."
0. 결론부터
| 물음 | 답 |
|---|---|
| 비대칭(한 방향) 차단이 클러스터를 가르는가 | 아니다. 열린 방향으로 재연결한다 |
| 양방향 완전 차단은 가르는가 | 그렇다. 양쪽 모두 멤버 1개 |
| 그때 양쪽이 다 NotReady 가 되는가 | 아니다. 한쪽만 DOWN 이다 |
| 그래서 서비스는 | 계속된다. 외부 200 유지 |
전면 장애 경로는 없었다. 코디네이터 쪽이 항상 살아남는다.
그리고 주입을 세 번 실패했다. 세 번 모두 다른 이유였고, 셋 다 "아무 일도 없었다"로 보였다.
1. 주입을 세 번 실패했다
A-1 에서 NetworkPolicy 가 conntrack 의 ESTABLISHED 승인에 막혀 기존 연결을
못 끊는다는 것을 배웠다. 그래서 이번에는 iptables 로 갔는데, 또 다른 벽이
세 개 있었다.
실패 ① — iptables -I FORWARD 1 은 최상단에 남지 않는다
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 패킷이었다.
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, 수신측 노드에
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
주입 이후 뷰 변화가 하나도 없었다.
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 이다.
그런데 한쪽만 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. 복구
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. 재현 절차 (명령어)
# 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 이면 세션이 갈라진다 |
| 운영 | 단방향 방화벽 오설정은 자가 치유된다. 양방향이어야 사고가 된다 |
| 운영 | 완전 분단조차 전면 장애가 아니다 — 코디네이터 쪽이 살아남는다 |
