# Split-Brain 프로덕션 완벽 해결 가이드: 분산 시스템에서 두 개의 리더가 동시에 존재할 때 데이터 충돌 방지하기
Table of Contents
한 클러스터에 두 명의 리더: Split-Brain의 악몽
NVIDIA AIStore의 충격적인 고백 (2025년 2월):
NVIDIA의 분산 스토리지 시스템 AIStore 팀이 블로그를 통해 충격적인 고백을 했습니다: “Split-brain is Inevitable” (Split-Brain은 피할 수 없다). 그들의 v3.26 릴리스에서 발견된 Split-Brain 버그의 근본 원인은 놀랍게도 단순한 net.ipv4.tcp_mem 설정 오류였습니다. 이 작은 설정 하나가 전체 클러스터를 두 개의 독립된 리더를 가진 분리된 그룹으로 분할시켰습니다.
Split-Brain이 초래하는 재앙:
정상 상태:
Cluster → 1 Leader → 모든 노드 동기화 → 일관된 데이터
Split-Brain 상태:
Cluster → Network Partition → Group A (Leader 1) ← 독립적 쓰기
→ Group B (Leader 2) ← 독립적 쓰기
→ 재연결 시 데이터 충돌! (Merge 불가능)
실제 피해 사례:
- 주문 중복: 같은 주문이 두 파티션에서 독립적으로 처리 → 이중 결제
- 재고 불일치: 파티션 A는 재고 10개 차감, 파티션 B는 재고 15개 차감 → 초과 판매
- 데이터 손실: 재연결 시 한쪽 파티션의 데이터 폐기 → 영구 손실
- 보안 침해: 파티션 A는 사용자 차단, 파티션 B는 여전히 접근 허용 → 무단 접근
왜 Split-Brain이 발생하는가?
분산 시스템에서 네트워크는 언제나 불안정합니다. 다음과 같은 상황에서 Split-Brain이 발생합니다:
- 네트워크 파티션: 데이터센터 간 연결 끊김, 스위치 장애
- 긴 GC Pause: Java 애플리케이션의 Full GC로 노드가 30초간 응답 불가
- 설정 오류: NVIDIA 사례처럼 TCP 메모리 설정 오류
- Clock Skew: 노드 간 시간 불일치로 타임아웃 판단 오류
- 하드웨어 장애: NIC 장애, 디스크 I/O 정체
2025년 주요 분산 시스템의 Split-Brain 위험:
- Elasticsearch: 클러스터 분할 시 두 개의 Master 노드 선출
- Redis Cluster: 네트워크 파티션으로 두 Master가 같은 슬롯 처리
- Kafka: Controller 분리로 두 개의 Leader Broker 존재
- Etcd/Consul: Raft 클러스터 분할 시 독립된 리더 선출
- PostgreSQL Patroni: Failover 과정에서 Old Primary와 New Primary 동시 존재
핵심 해결 전략 (2025년 표준):
- Quorum 기반 의사결정: 과반수 합의 없이는 쓰기 불가
- Raft/Paxos Consensus: 알고리즘적으로 단일 리더 보장
- STONITH Fencing: “Shoot The Other Node In The Head” - 의심되는 노드 강제 종료
- Generation/Epoch Number: 리더의 세대 번호로 구 리더 거부
- Witness Node: 홀수 노드로 Quorum 구성
이 글에서는 Split-Brain의 근본 원인, 실제 디버깅 사례, 2025년 최신 방지 전략을 다룹니다.
Split-Brain 기본 개념
Split-Brain이란?
Definition: 분산 시스템이 네트워크 장애로 인해 두 개 이상의 독립적인 그룹으로 분할되고, 각 그룹이 자신이 유일한 정상 클러스터라고 판단하여 독립적으로 쓰기를 수행하는 현상
메커니즘:
Step 1: 정상 클러스터
┌─────────────────────────────────┐
│ Leader: Node 1 │
│ Followers: Node 2, Node 3 │
│ 모든 쓰기가 Node 1을 통해 처리 │
└─────────────────────────────────┘
Step 2: 네트워크 파티션 발생
┌──────────────┐ ️ Network ┌──────────────┐
│ Partition A │ Partition │ Partition B │
│ Node 1 │ ←──────────→ │ Node 2 │
│ │ 연결 끊김! │ Node 3 │
└──────────────┘ └──────────────┘
Step 3: Split-Brain 발생
┌──────────────┐ ┌──────────────┐
│ Partition A │ │ Partition B │
│ Leader: Node1│ │ Leader: Node2│ ← 새 리더 선출!
│ "나만 살아있음"│ │ "Node 1 죽음" │
│ 쓰기 계속 처리│ │ 쓰기 계속 처리│
└──────────────┘ └──────────────┘
↓ ↓
데이터 A 데이터 B
(독립적) (독립적)
Step 4: 재연결 시 충돌
┌─────────────────────────────────┐
│ 데이터 A와 데이터 B가 충돌! │
│ 어느 것이 정답? │
│ Merge 불가능한 경우 많음 │
└─────────────────────────────────┘
Split-Brain vs 일반 장애
일반 노드 장애:
Before:
Node 1 (Leader), Node 2, Node 3
After (Node 3 죽음):
Node 1 (Leader), Node 2 ← 여전히 과반수 (2/3)
정상 작동 계속
Split-Brain:
Before:
Node 1, Node 2, Node 3
After (Network Partition):
Group A: Node 1 ← 1/3 (과반수 아님, 하지만 자신만 본다)
Group B: Node 2, Node 3 ← 2/3 (과반수)
문제: Group A도 자신이 유일하다고 착각하면?
→ 두 그룹 모두 독립적으로 쓰기!
왜 위험한가?
데이터 충돌 시나리오:
// Split-Brain 상황에서의 데이터 충돌
// Partition A에서:
// User가 Product 1 주문 (재고 10 → 9)
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = 1")
// stock = 9
// Partition B에서 (동시에):
// 다른 User가 Product 1 주문 (재고 10 → 9)
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = 1")
// stock = 9
// 재연결 후:
// 실제로는 2개 팔렸는데 stock = 9!
// 정답은 stock = 8이어야 함
// 1개 초과 판매 위험!
은행 시스템에서의 치명적 시나리오:
Initial Balance: $1,000
Partition A: 출금 $500 → Balance = $500
Partition B: 출금 $800 → Balance = $200
재연결 후:
- Last Write Wins 전략 사용 시: Balance = $200 (Partition A의 출금 무시!)
- 실제 정답: Balance = -$300 (초과 출금 발생!)
- 어느 쪽이든 틀림!
NVIDIA AIStore 실제 사례 (2025년)
사건 개요
배경:
- 제품: AIStore (NVIDIA의 대용량 분산 스토리지)
- 시기: 2025년 2월 블로그 공개
- 원인:
net.ipv4.tcp_mem커널 파라미터 설정 오류
증상:
# 클러스터 상태 확인
ais cluster show
# 출력:
# Cluster Map:
# Group A: Node1 (Primary), Node2, Node3 ← "나는 Primary"
# Group B: Node4 (Primary), Node5, Node6 ← "나도 Primary!"
# 두 개의 Primary가 동시에 존재!
근본 원인 분석
TCP 메모리 고갈:
# 잘못된 설정
net.ipv4.tcp_mem = "184320 245760 368640" # 너무 낮음!
# 이 설정으로 인해:
# 1. 높은 네트워크 트래픽 시 TCP 버퍼 고갈
# 2. Keepalive 패킷 전송 실패
# 3. 노드 간 연결이 끊어진 것으로 판단
# 4. 각 그룹이 독립적으로 새 Primary 선출
타임라인:
T+0s: 정상 클러스터 (1 Primary)
T+10s: 높은 네트워크 트래픽 발생
T+15s: TCP 메모리 고갈, Keepalive 실패
T+20s: Node1-Node4 간 연결 타임아웃
T+25s: Group A와 Group B 분리
T+30s: Group B가 새 Primary 선출 (Node4)
T+35s: Split-Brain 상태! (Node1 + Node4 모두 Primary)
AIStore의 해결책
v3.26 릴리스: Ex Post Facto Reunification
// AIStore의 재통합 전략
type ClusterMap struct {
Generation int // 세대 번호
Primary NodeID // Primary 노드
Nodes []NodeID // 클러스터 멤버
}
func reunifyCluster(partitionA, partitionB ClusterMap) {
// 1. Generation 비교
if partitionA.Generation > partitionB.Generation {
// Partition A가 최신 → B를 폐기
discardPartition(partitionB)
return partitionA
}
// 2. Generation 같으면 Node ID 비교 (결정론적)
if partitionA.Primary < partitionB.Primary {
discardPartition(partitionB)
return partitionA
}
discardPartition(partitionA)
return partitionB
}
문제점:
️ 재통합 시 한쪽 파티션의 데이터 손실!
- Partition B가 폐기되면
- Partition B에서 발생한 모든 쓰기가 사라짐
- 일부 데이터 손실은 불가피
Quorum 기반 Split-Brain 방지
Quorum이란?
Definition: 과반수(Majority) 합의를 요구하여, 네트워크 파티션 발생 시 과반수를 차지한 그룹만 작동하도록 하는 메커니즘
기본 원리:
클러스터: 5개 노드
Quorum: 3개 (5 / 2 + 1)
정상 상태:
Node 1, Node 2, Node 3, Node 4, Node 5
쓰기 요청 → 최소 3개 노드가 응답해야 성공
Network Partition:
Group A: Node 1, Node 2 ← 2개 (Quorum 미달!)
Group B: Node 3, Node 4, Node 5 ← 3개 (Quorum 달성!)
결과:
Group A: 쓰기 거부 (Quorum 없음)
Group B: 쓰기 계속 (Quorum 있음)
Split-Brain 방지! (한쪽만 작동)
Elasticsearch의 Quorum 설정
elasticsearch.yml:
# Split-Brain 방지 설정
# Cluster 구성
cluster.name: production-cluster
node.name: node-1
# Discovery 설정
discovery.seed_hosts:
- node-1.example.com
- node-2.example.com
- node-3.example.com
- node-4.example.com
- node-5.example.com
# Quorum 설정 (핵심!)
cluster.initial_master_nodes:
- node-1
- node-2
- node-3
- node-4
- node-5
# Master 선출을 위한 최소 노드 수
discovery.zen.minimum_master_nodes: 3 # (5 / 2) + 1
# 이 설정으로:
# - 3개 미만의 Master-eligible 노드는 Master 선출 불가
# - Network Partition 시 과반수 그룹만 작동
동작 방식:
5 노드 클러스터, minimum_master_nodes = 3
Scenario 1: 2-3 분할
Group A (2 nodes): Master 선출 불가 → Read-Only 모드
Group B (3 nodes): Master 선출 가능 → 정상 작동
Scenario 2: 1-4 분할
Group A (1 node): Master 선출 불가 → Read-Only
Group B (4 nodes): Master 선출 가능 → 정상 작동
Scenario 3: 3-2 분할
Group A (3 nodes): Master 선출 가능 → 정상 작동
Group B (2 nodes): Master 선출 불가 → Read-Only
모든 경우에 최대 1개 그룹만 쓰기 가능!
Redis Cluster의 Quorum
redis.txt:
# Redis Cluster Quorum 설정
# Cluster 활성화
cluster-enabled yes
cluster-txtig-file nodes.txt
cluster-node-timeout 5000
# Replica가 Master로 승격되기 위한 최소 Master 수
cluster-replica-validity-factor 10
# Cluster 구성 (3 Master + 3 Replica)
# Master 1: Node A
# Master 2: Node B
# Master 3: Node C
# Replica 1: Node D (Master A의 Replica)
# Replica 2: Node E (Master B의 Replica)
# Replica 3: Node F (Master C의 Replica)
Failover with Quorum:
# Redis Cluster의 Quorum 기반 Failover
# 상황: Master A 죽음
# Replica D가 승격을 시도:
# 1. 다른 Master들(B, C)에게 투표 요청
# 2. 과반수(2개) 동의 필요 (총 Master 3개)
# 3. B와 C가 동의 → Replica D가 새 Master로 승격
# Network Partition 발생:
# Group 1: Master A, Replica D
# Group 2: Master B, Master C, Replica E, Replica F
# Master A 죽음:
# Group 1: Replica D만 남음
# - 투표 요청하지만 다른 Master 없음 (Quorum 미달)
# - 승격 불가!
# Group 2: Master B, C 생존
# - 정상 작동 계속
# - 과반수 Master 존재
# Split-Brain 방지!
Raft Consensus Algorithm
Raft 기본 원리
Raft의 핵심 보장:
- 단일 리더: 한 Term에 최대 1개의 Leader만 존재
- 과반수 합의: 모든 결정은 과반수 투표 필요
- Term (임기): 리더의 세대 번호, 높은 Term이 우선
노드 상태:
Follower: 평소 상태, Leader로부터 명령 수신
Candidate: Leader 선출 시도 중
Leader: 클러스터의 리더, 모든 쓰기 처리
Leader 선출 과정:
Step 1: Follower → Candidate
- Election Timeout (150-300ms) 내에 Heartbeat 없으면
- Term 증가, 자신에게 투표, 다른 노드에 투표 요청
Step 2: 투표 수집
- 과반수 투표 받으면 Leader로 승격
- 과반수 못 받으면 다시 Follower
Step 3: Leader 작동
- 주기적으로 Heartbeat 전송 (Append Entries RPC)
- 모든 쓰기 처리, 과반수 복제 확인 후 커밋
Raft가 Split-Brain을 방지하는 방법
Scenario: Network Partition (3-2 분할)
5 노드 클러스터: Node 1 (Leader), Node 2, 3, 4, 5
Network Partition:
Group A: Node 1, Node 2 ← 2개 (과반수 아님)
Group B: Node 3, Node 4, Node 5 ← 3개 (과반수!)
Group A (Node 1 - 기존 Leader):
- Heartbeat를 Node 3, 4, 5에 전송 시도
- 응답 없음 (Network Partition)
- 과반수 응답 없으면 Leader 자격 상실!
- Follower로 강등 (Step-down)
- 쓰기 거부!
Group B (Node 3, 4, 5):
- Election Timeout 도래
- Node 3이 Candidate가 됨, Term 증가 (Term 2)
- Node 4, 5에게 투표 요청
- 2표 획득 (과반수 3/5 달성!)
- Node 3이 새 Leader 선출 (Term 2)
- 쓰기 계속 처리
재연결 시:
- Node 1 (Term 1, Old Leader)
- Node 3 (Term 2, New Leader)
- Node 1이 Node 3의 Heartbeat 수신
- Term 2 > Term 1 → Node 1이 Node 3를 Leader로 인정
- 자동 재통합!
코드 예시 (etcd - Raft 구현):
// etcd의 Raft Leader Election
type Raft struct {
term int // 현재 Term
state NodeState // Follower, Candidate, Leader
votedFor NodeID // 투표한 후보
leader NodeID // 현재 리더
votes int // 받은 표 수
}
func (r *Raft) startElection() {
// 1. Term 증가
r.term++
r.state = Candidate
r.votedFor = r.id
r.votes = 1 // 자신에게 투표
// 2. 모든 노드에게 투표 요청
for _, peer := range r.peers {
go func(p Node) {
resp := p.RequestVote(r.term, r.id)
if resp.VoteGranted {
r.votes++
// 3. 과반수 획득?
if r.votes > len(r.peers)/2 {
r.becomeLeader()
}
}
}(peer)
}
}
func (r *Raft) becomeLeader() {
r.state = Leader
r.leader = r.id
// 4. Heartbeat 전송 시작
go r.sendHeartbeats()
}
func (r *Raft) sendHeartbeats() {
for r.state == Leader {
successCount := 0
for _, peer := range r.peers {
if peer.AppendEntries(r.term, r.id) {
successCount++
}
}
// 과반수 응답 없으면 Leader 자격 상실!
if successCount <= len(r.peers)/2 {
r.stepDown() // Follower로 강등
return
}
time.Sleep(50 * time.Millisecond) // Heartbeat interval
}
}
etcd 클러스터 설정 예시
docker-compose.yml:
version: '3'
services:
etcd-1:
image: quay.io/coreos/etcd:v3.5.10
command:
- /usr/local/bin/etcd
- --name=etcd-1
- --initial-advertise-peer-urls=http://etcd-1:2380
- --listen-peer-urls=http://0.0.0.0:2380
- --advertise-client-urls=http://etcd-1:2379
- --listen-client-urls=http://0.0.0.0:2379
- --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster
etcd-2:
image: quay.io/coreos/etcd:v3.5.10
command:
- /usr/local/bin/etcd
- --name=etcd-2
- --initial-advertise-peer-urls=http://etcd-2:2380
- --listen-peer-urls=http://0.0.0.0:2380
- --advertise-client-urls=http://etcd-2:2379
- --listen-client-urls=http://0.0.0.0:2379
- --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster
etcd-3:
image: quay.io/coreos/etcd:v3.5.10
command:
- /usr/local/bin/etcd
- --name=etcd-3
- --initial-advertise-peer-urls=http://etcd-3:2380
- --listen-peer-urls=http://0.0.0.0:2380
- --advertise-client-urls=http://etcd-3:2379
- --listen-client-urls=http://0.0.0.0:2379
- --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
- --initial-cluster-state=new
- --initial-cluster-token=etcd-cluster
클러스터 상태 확인:
# Leader 확인
etcdctl --endpoints=http://etcd-1:2379 endpoint status --cluster -w table
# 출력:
# +-------------------+------------------+---------+---------+-----------+
# | ENDPOINT | ID | VERSION | LEADER | RAFT TERM |
# +-------------------+------------------+---------+---------+-----------+
# | http://etcd-1:2379| 8e9e05c52164694d | 3.5.10 | false | 2 |
# | http://etcd-2:2379| 91bc3c398fb3c146 | 3.5.10 | false | 2 |
# | http://etcd-3:2379| fd422379fda50e48 | 3.5.10 | true | 2 | ← Leader
# +-------------------+------------------+---------+---------+-----------+
STONITH Fencing (강제 격리)
STONITH란?
“Shoot The Other Node In The Head”
Definition: Split-Brain 의심 시 물리적으로 노드를 강제 종료하여 데이터 충돌을 방지하는 메커니즘
동작 원리:
정상 클러스터:
Node 1 (Primary), Node 2 (Standby)
네트워크 장애:
Node 2가 Node 1의 Heartbeat를 받지 못함
Node 2의 판단:
"Node 1이 죽었나? 내가 Primary가 되어야 하나?"
️ 위험 상황:
- 실제로는 Node 1이 살아있을 수 있음 (네트워크만 끊김)
- Node 2가 Primary로 승격하면 Split-Brain!
STONITH 작동:
Node 2: "Node 1을 확실히 죽이자!"
→ IPMI/iLO를 통해 Node 1의 전원 강제 OFF
→ Node 1 완전히 종료 확인
→ Node 2가 안전하게 Primary로 승격
IPMI를 이용한 Fencing
IPMI (Intelligent Platform Management Interface):
# IPMI로 원격 노드 전원 제어
# Node 상태 확인
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power status
# 출력:
# Chassis Power is on
# Node 강제 종료 (STONITH!)
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power off
# 출력:
# Chassis Power Control: Down/Off
# Node 재시작
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power on
Pacemaker + STONITH 설정
Pacemaker Cluster 구성:
# Pacemaker 설치 (Ubuntu)
apt-get install pacemaker pcs fence-agents
# Cluster 생성
pcs cluster auth node1 node2 -u hacluster -p password
pcs cluster setup --name ha-cluster node1 node2
pcs cluster start --all
# STONITH 활성화 (매우 중요!)
pcs property set stonith-enabled=true
# IPMI Fence Agent 설정
pcs stonith create fence-node1 fence_ipmilan \
pcmk_host_list="node1" \
ipaddr="node1-ipmi.example.com" \
login="admin" \
passwd="password" \
lanplus=1
pcs stonith create fence-node2 fence_ipmilan \
pcmk_host_list="node2" \
ipaddr="node2-ipmi.example.com" \
login="admin" \
passwd="password" \
lanplus=1
# Fence 테스트
pcs stonith fence node2
# 출력:
# Node: node2 fenced
# (node2가 강제 종료됨!)
Split-Brain 방지 시나리오:
Cluster: node1 (Primary), node2 (Standby)
T+0s: 네트워크 파티션 발생
T+5s: node2가 node1의 Heartbeat 수신 실패
T+10s: node2가 Fence 결정
"node1을 죽이고 내가 Primary가 되자"
T+15s: node2 → IPMI 명령 → node1 강제 종료
T+20s: node1 전원 OFF 확인
T+25s: node2가 Primary로 승격
T+30s: 단일 Primary 보장! (node1은 죽음)
재연결 시:
T+60s: 관리자가 node1 수동 재시작
T+65s: node1이 Standby로 재참여
T+70s: 정상 클러스터 복구
PostgreSQL Patroni + STONITH
Patroni 설정 (patroni.yml):
# Patroni (PostgreSQL HA) + Watchdog
scope: postgres-cluster
name: node1
restapi:
listen: 0.0.0.0:8008
connect_address: node1:8008
etcd:
hosts: etcd1:2379,etcd2:2379,etcd3:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
watchdog:
mode: required # Watchdog 강제!
device: /dev/watchdog
safety_margin: 5
postgresql:
listen: 0.0.0.0:5432
connect_address: node1:5432
data_dir: /var/lib/postgresql/14/main
authentication:
replication:
username: replicator
password: rep_password
superuser:
username: postgres
password: postgres_password
Watchdog 동작:
# Linux Watchdog 설정
# Watchdog 장치 확인
ls -l /dev/watchdog
# crw------- 1 root root 10, 130 Nov 6 10:00 /dev/watchdog
# Watchdog 모듈 로드
modprobe softdog
# Patroni가 Watchdog에 주기적으로 "kick" (I'm alive!)
# 만약 Patroni가 죽거나 응답 없으면:
# → Watchdog이 5초 후 자동으로 시스템 리부트!
# → Split-Brain 방지 (죽은 Primary가 계속 쓰기 못 함)
Generation/Epoch Number 전략
Generation Number란?
Definition: 리더의 세대 번호를 사용하여, 재연결 시 낮은 세대의 리더를 거부하는 메커니즘
기본 원리:
초기 상태:
Leader: Node 1 (Generation 1)
Network Partition:
Group A: Node 1 (Generation 1 - Old Leader)
Group B: Node 2, 3 (새 Leader 선출 → Generation 2)
재연결 시:
Node 1 (Generation 1): "나는 Leader다!"
Node 2 (Generation 2): "너의 Generation이 낮다. 거부!"
Node 1이 자동으로 Follower로 강등
Node 2가 유일한 Leader
Kafka의 Epoch Number
Kafka Controller Epoch:
# Kafka Cluster 구성
# Broker 1 (Controller, Epoch 10)
# Broker 2, Broker 3
# ZooKeeper에 저장된 Controller Epoch
zkCli.sh
get /controller_epoch
# 출력:
# {"version":1,"brokerid":1,"timestamp":"1699305600000","epoch":10}
# Network Partition 발생
# Group A: Broker 1 (Epoch 10)
# Group B: Broker 2, 3
# Group B가 새 Controller 선출 (Broker 2)
# Epoch 증가: 10 → 11
set /controller_epoch {"version":1,"brokerid":2,"timestamp":"1699305700000","epoch":11}
# 재연결 시:
# Broker 1 (Epoch 10): "나는 Controller"
# Broker 2 (Epoch 11): "Epoch 10 < 11, 거부!"
# Broker 1 강제 Follower로 전환
Kafka Producer 코드:
// Kafka Producer가 Epoch를 통해 Leader 검증
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092,broker3:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all"); // 모든 Replica 확인
props.put("enable.idempotence", "true"); // Exactly-Once
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
ProducerRecord<String, String> record = new ProducerRecord<>("my-topic", "key", "value");
try {
// Producer가 Leader에게 전송
// Leader는 응답에 Epoch 포함
RecordMetadata metadata = producer.send(record).get();
// 만약 Old Leader (낮은 Epoch)에게 전송 시도하면:
// NotLeaderForPartitionException 발생!
// Producer가 자동으로 새 Leader 발견 및 재전송
} catch (NotLeaderForPartitionException e) {
// Old Leader 감지 → Metadata 갱신
producer.send(record).get(); // 재시도
}
MongoDB Replica Set의 Term
MongoDB Replica Set:
// MongoDB의 Term (Raft의 Term과 유사)
// Replica Set 상태 확인
rs.status()
// 출력:
{
"set": "rs0",
"members": [
{
"_id": 0,
"name": "mongo1:27017",
"stateStr": "PRIMARY",
"electionTime": Timestamp(1699305600, 1),
"electionDate": ISODate("2025-11-06T10:00:00Z"),
"term": NumberLong("5") // ← Term 5
},
{
"_id": 1,
"name": "mongo2:27017",
"stateStr": "SECONDARY",
"syncSourceHost": "mongo1:27017",
"term": NumberLong("5")
}
]
}
// Network Partition:
// Group A: mongo1 (Term 5, Primary)
// Group B: mongo2, mongo3 (새 Primary 선출 → Term 6)
// 재연결 시:
// mongo1 (Term 5) → mongo2로부터 Heartbeat 수신
// Heartbeat에 Term 6 포함
// mongo1: "Term 6 > Term 5, 나는 더 이상 Primary 아님"
// mongo1이 자동으로 Secondary로 강등
// Split-Brain 방지!
실전 디버깅 사례
사례 1: Elasticsearch Split-Brain (3-2 파티션)
증상:
# 클러스터 상태 확인
curl -X GET "http://es-node1:9200/_cluster/health?pretty"
# 출력:
{
"cluster_name" : "production",
"status" : "red", # RED 상태!
"number_of_nodes" : 2, # 5개 중 2개만 보임
"active_primary_shards" : 150,
"active_shards" : 300,
"unassigned_shards" : 150 # 절반이 Unassigned!
}
# 다른 파티션 확인
curl -X GET "http://es-node3:9200/_cluster/health?pretty"
# 출력:
{
"cluster_name" : "production",
"status" : "yellow",
"number_of_nodes" : 3, # 5개 중 3개만 보임
"active_primary_shards" : 300,
"active_shards" : 600
}
# Split-Brain 발생!
# Group A (node1, node2): 2개 노드, Master = node1
# Group B (node3, node4, node5): 3개 노드, Master = node3
원인:
# 잘못된 설정 (elasticsearch.yml)
# Group A와 Group B 모두:
discovery.zen.minimum_master_nodes: 2 # ← 너무 낮음!
# 5개 노드 클러스터에서 minimum_master_nodes = 2이면:
# Group A (2개): Master 선출 가능 (2 >= 2)
# Group B (3개): Master 선출 가능 (3 >= 2)
# 두 그룹 모두 독립적으로 Master 선출!
** 수정:**
# 올바른 설정
# 5개 노드 클러스터
discovery.zen.minimum_master_nodes: 3 # (5 / 2) + 1
# 이제:
# Group A (2개): Master 선출 불가 (2 < 3)
# Group B (3개): Master 선출 가능 (3 >= 3)
# 한쪽만 작동!
복구:
# 1. 모든 노드 종료
systemctl stop elasticsearch
# 2. 설정 수정
vim /etc/elasticsearch/elasticsearch.yml
# discovery.zen.minimum_master_nodes: 3
# 3. 모든 노드 재시작
systemctl start elasticsearch
# 4. 클러스터 상태 확인
curl -X GET "http://localhost:9200/_cluster/health?pretty"
# 출력:
{
"cluster_name" : "production",
"status" : "green", # GREEN!
"number_of_nodes" : 5, # 5개 노드 모두 복구
"active_primary_shards" : 300,
"active_shards" : 600,
"unassigned_shards" : 0
}
사례 2: Redis Cluster Split-Brain
증상:
# Redis Cluster 상태 확인
redis-cli --cluster check redis-node1:6379
# 출력:
>>> Performing Cluster Check (using node redis-node1:6379)
M: abc123... redis-node1:6379
slots:0-5460 (5461 slots) master
1 additional replica(s)
S: def456... redis-node2:6379
slots: (0 slots) slave
replicates abc123...
# 다른 Master가 안 보임!
# 다른 파티션 확인
redis-cli -h redis-node3 --cluster check redis-node3:6379
# 출력:
>>> Performing Cluster Check (using node redis-node3:6379)
M: ghi789... redis-node3:6379
slots:0-5460 (5461 slots) master # 같은 슬롯!
1 additional replica(s)
# Split-Brain!
# node1과 node3이 모두 slot 0-5460을 처리 중!
데이터 충돌:
# Partition A (node1):
redis-cli -h redis-node1 SET user:1000:name "Alice"
# OK
# Partition B (node3):
redis-cli -h redis-node3 SET user:1000:name "Bob"
# OK
# 재연결 후:
redis-cli -h redis-node1 GET user:1000:name
# "Alice"
redis-cli -h redis-node3 GET user:1000:name
# "Bob"
# 데이터 충돌! 어느 것이 정답?
** 해결: Cluster Reunification**
# 1. 클러스터 상태 진단
redis-cli --cluster fix redis-node1:6379
# 출력:
>>> Fixing open slot 0-5460
# Slot 0-5460 assigned to multiple nodes!
# Node abc123... (redis-node1:6379)
# Node ghi789... (redis-node3:6379)
# 2. Manual Fix
redis-cli -h redis-node1 CLUSTER SETSLOT 0 NODE abc123...
redis-cli -h redis-node1 CLUSTER SETSLOT 1 NODE abc123...
#... (모든 충돌 슬롯 수동 할당)
# 3. Cluster 재구성
redis-cli --cluster reshard redis-node1:6379
# 4. 확인
redis-cli --cluster check redis-node1:6379
# 출력:
>>> Performing Cluster Check (using node redis-node1:6379)
# [OK] All nodes agree about slots txtiguration.
# [OK] All 16384 slots covered.
# Split-Brain 해결!
방지 전략:
# redis.txt
# Cluster Quorum 설정
cluster-node-timeout 5000 # 5초
# Replica가 Master로 승격되기 위한 조건
cluster-replica-validity-factor 10
# Minimum Master 수 설정 (Replica 승격 시)
cluster-require-full-coverage yes
# 동작:
# - Replica가 승격하려면 다른 Master와 통신 가능해야 함
# - 네트워크 파티션 시 과반수 없는 그룹은 승격 불가
프로덕션 체크리스트
Split-Brain 방지 설정
# Elasticsearch
discovery.zen.minimum_master_nodes: (N / 2) + 1 # N = 총 Master-eligible 노드 수
cluster.initial_master_nodes:
- node-1
- node-2
- node-3
- node-4
- node-5
# etcd (Raft)
--initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
--initial-cluster-state=new
--heartbeat-interval=100 # 100ms
--election-timeout=1000 # 1000ms
# Redis Cluster
cluster-enabled yes
cluster-node-timeout 5000
cluster-require-full-coverage yes
# PostgreSQL Patroni
watchdog:
mode: required
device: /dev/watchdog
safety_margin: 5
dcs:
ttl: 30
loop_wait: 10
maximum_lag_on_failover: 1048576
# Kafka
# ZooKeeper 기반 (기존)
zookeeper.connect=zk1:2181,zk2:2181,zk3:2181
controller.socket.timeout.ms=30000
# KRaft 기반 (신규)
process.roles=broker,controller
controller.quorum.voters=1@kafka1:9093,2@kafka2:9093,3@kafka3:9093
모니터링 설정
Prometheus Alerts:
groups:
- name: split_brain_detection
interval: 30s
rules:
# Elasticsearch Multiple Masters
- alert: ElasticsearchMultipleMasters
expr: count(elasticsearch_cluster_health_status{role="master"}) > 1
for: 1m
labels:
severity: critical
annotations:
summary: "Multiple Elasticsearch masters detected - Split-Brain!"
# etcd Multiple Leaders
- alert: EtcdMultipleLeaders
expr: count(etcd_server_is_leader == 1) > 1
for: 1m
labels:
severity: critical
annotations:
summary: "Multiple etcd leaders - Split-Brain!"
# Redis Cluster Slot txtlict
- alert: RedisClusterSlottxtlict
expr: redis_cluster_slots_fail > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Redis cluster has failing slots - possible Split-Brain"
# Patroni Multiple Primaries
- alert: PatroniMultiplePrimaries
expr: count(pg_replication_is_replica == 0) > 1
for: 1m
labels:
severity: critical
annotations:
summary: "Multiple PostgreSQL primaries - Split-Brain!"
Grafana Dashboard:
{
"panels": [
{
"title": "Cluster Leaders",
"targets": [
{
"expr": "sum(etcd_server_is_leader)",
"legendFormat": "etcd Leaders"
},
{
"expr": "count(elasticsearch_cluster_health_status{role=\"master\"})",
"legendFormat": "ES Masters"
}
],
"thresholds": [
{ "value": 1, "color": "green" },
{ "value": 2, "color": "red" } // Multiple leaders!
]
},
{
"title": "Network Partition Events",
"targets": [
{
"expr": "rate(node_network_transmit_drop_total[5m])",
"legendFormat": "{{ instance }}"
}
]
},
{
"title": "Quorum Status",
"targets": [
{
"expr": "elasticsearch_cluster_health_number_of_nodes",
"legendFormat": "Active Nodes"
},
{
"expr": "elasticsearch_cluster_minimum_master_nodes",
"legendFormat": "Quorum Threshold"
}
]
}
]
}
네트워크 테스트
Chaos Engineering (네트워크 파티션 시뮬레이션):
# iptables로 네트워크 파티션 테스트
# node1과 node3 간 연결 차단
iptables -A INPUT -s node3-ip -j DROP
iptables -A OUTPUT -d node3-ip -j DROP
# 클러스터 상태 모니터링
watch -n 1 'curl -s http://localhost:9200/_cluster/health | jq'
# 예상 동작:
# - node1, node2 그룹: Quorum 미달 → Read-Only
# - node3, node4, node5 그룹: Quorum 달성 → 정상 작동
# 30초 후 연결 복구
iptables -D INPUT -s node3-ip -j DROP
iptables -D OUTPUT -d node3-ip -j DROP
# 자동 재통합 확인
tc (Traffic Control)를 이용한 네트워크 지연:
# 네트워크 지연 주입
# eth0에 300ms 지연 추가
tc qdisc add dev eth0 root netem delay 300ms
# Heartbeat 타임아웃 테스트
# (대부분 클러스터는 150-300ms Heartbeat Interval)
# 예상 동작:
# - Heartbeat 실패 → Election Timeout
# - 새 Leader 선출 프로세스 시작
# 지연 제거
tc qdisc del dev eth0 root
마치며
Split-Brain은 분산 시스템의 가장 위험한 시나리오 중 하나입니다. NVIDIA AIStore 사례처럼 단순한 설정 오류 하나가 전체 클러스터를 두 개의 독립된 그룹으로 분할시킬 수 있습니다. 이 글에서 다룬 핵심 사항들을 정리하면:
핵심 요약:
- Quorum 기반 방지: 과반수 합의 없이는 쓰기 불가 (Elasticsearch, Redis)
- Raft/Paxos Consensus: 알고리즘적으로 단일 리더 보장 (etcd, Consul)
- STONITH Fencing: 의심 노드 강제 종료로 확실한 격리 (Pacemaker)
- Generation/Epoch: 세대 번호로 Old Leader 자동 거부 (Kafka, MongoDB)
- Witness Node: 홀수 노드 구성으로 Quorum 명확화
- Watchdog: 죽은 Primary가 계속 쓰기 못하도록 강제 리부트 (Patroni)
- Monitoring: 여러 Leader 동시 감지 즉시 알람
다음 단계:
- Quorum 설정 전체 검토 ((N / 2) + 1 확인)
- Raft/Paxos 기반 시스템으로 마이그레이션 검토
- STONITH/Fencing 메커니즘 설정 (물리 서버 환경)
- Watchdog 설정 (데이터베이스 HA)
- Multiple Leader 감지 알람 구성
- 네트워크 파티션 Chaos Testing 정기 실시
- Generation/Epoch Number 검증 로직 추가
- 클러스터 노드 수 홀수로 유지 (3, 5, 7…)
NVIDIA AIStore의 교훈:
“Split-brain is Inevitable” (Split-Brain은 피할 수 없다)
하지만 올바른 Quorum 설정, Consensus 알고리즘, Fencing 메커니즘으로 Split-Brain의 피해는 완전히 방지할 수 있습니다. 단순한 net.ipv4.tcp_mem 설정 하나가 전체 클러스터를 무너뜨릴 수 있듯이, minimum_master_nodes 설정 하나가 Split-Brain을 완벽히 막을 수 있습니다. 지금 바로 클러스터 설정을 점검하고, Quorum이 (N / 2) + 1로 올바르게 설정되어 있는지 확인하세요!