Split-Brain 프로덕션 완벽 해결 가이드: 분산 시스템에서 두 개의 리더가 동시에 존재할 때 데이터 충돌 방지하기
한 클러스터에 두 명의 리더: 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 = $500Partition 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-clusternode.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-OnlyGroup 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 yescluster-txtig-file nodes.txtcluster-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 onPacemaker + STONITH 설정
Pacemaker Cluster 구성:
# Pacemaker 설치 (Ubuntu)apt-get install pacemaker pcs fence-agents
# Cluster 생성pcs cluster auth node1 node2 -u hacluster -p passwordpcs cluster setup --name ha-cluster node1 node2pcs 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-clustername: 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_passwordWatchdog 동작:
# 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가 유일한 LeaderKafka의 Epoch Number
Kafka Controller Epoch:
# Kafka Cluster 구성# Broker 1 (Controller, Epoch 10)# Broker 2, Broker 3
# ZooKeeper에 저장된 Controller EpochzkCli.shget /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 → 11set /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 Fixredis-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 해결!방지 전략:
# 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 yescluster-node-timeout 5000cluster-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:2181controller.socket.timeout.ms=30000
# KRaft 기반 (신규)process.roles=broker,controllercontroller.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 DROPiptables -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 DROPiptables -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로 올바르게 설정되어 있는지 확인하세요!