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>
14 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 에서 이어서 본다."
구조
다이어그램 규약은
diagrams/_style.md. 실험대 전체 구조는diagrams/lab-topology.svg.
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
맥락 하나가 빠져 있었다 —
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. 복구
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 |
터미널 원문 |
02-injection-verify.txt |
터미널 원문 |
03-raw-table-injection.txt |
터미널 원문 |
04-correct-direction.txt |
터미널 원문 |
05-reconnect-observed.txt |
터미널 원문 |
06-view-history-and-cleanup.txt |
터미널 원문 |
07-bidirectional-block.txt |
터미널 원문 |
08-coordinator-and-recovery.txt |
터미널 원문 |
a5-cluster-size-bidirectional-block.png |
스크린샷 |
파일별 상세는 evidence/a5-asymmetric-partition/README.md.
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 $(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 이면 세션이 갈라진다 |
| 운영 | 단방향 방화벽 오설정은 자가 치유된다. 양방향이어야 사고가 된다 |
| 운영 | 완전 분단조차 전면 장애가 아니다 — 코디네이터 쪽이 살아남는다 |
