6. 인스턴스 서버 failover 의 split-brain 과 보상 중복
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 인스턴스 서버 failover 의 split-brain 과 보상 중복
난이도: 상
요약
헬스체크 한 번 실패만으로(C) 인스턴스를 백업 노드에서 다시 띄우고 소유권을 넘긴다(A). 그러나 "무응답"이 곧 "죽음"은 아니다. 원 노드가 긴 GC 멈춤·네트워크 순단으로 잠깐 응답을 못 했을 뿐 살아 있으면, 백업이 같은 인스턴스를 동시에 돌려 split-brain(한 인스턴스·두 노드) 이 된다. 두 노드가 보스 막타·보상을 각자 처리해 이중 지급된다. 또 마지막 체크포인트 이후 진행은 유실되고(B), 체크포인트에 안 들어간 보상이 백업에서 재지급된다(멱등성 부재).
문제점
(1) [split-brain] 펜싱 없는 소유권 이전 — (A)
- 증상: 원 노드와 백업이 같은 인스턴스를 동시에 활성. 보상·아이템·랭킹이 두 곳에서 DB 에 써져 충돌·이중 지급.
- 재현: 원 노드가 2초 GC 멈춤 → 헬스체크 실패 → 백업 기동 → 원 노드가 깨어나 자기가 여전히 주인인 줄 알고 계속 처리.
- 근본원인: 새 주인을 세울 때 옛 주인을 무력화(fencing) 하지 않음. 소유권이 단순 맵 갱신일 뿐, 옛 노드의 쓰기를 막을 토큰/리스가 없다.
(2) [감지 오판] 단발 타임아웃으로 죽음 단정 — (C)
- 증상: 일시적 지연을 장애로 오인해 불필요한 failover 가 빈발(플래핑).
- 근본원인: 연속 실패 횟수·리스 만료가 아니라 한 번의 무응답으로 판정.
(3) [멱등성/체크포인트] 보상 재지급·진행 유실 — (B)
- 증상: 백업이 마지막 체크포인트로 시작 → 그 이후 진행 손실. 체크포인트 전에 지급된 보상이 "아직 안 준 것"으로 보여 다시 지급.
- 근본원인: 보상에 멱등 키가 없고, 체크포인트와 외부 효과(보상 지급)가 원자적으로 묶이지 않음.
수정안 — 리스 + 펜싱 토큰 + 멱등 보상
1) 단조 증가 펜싱 토큰을 동반한 리스(lease)
// 소유권 = (nodeId, epoch). 인계할 때마다 epoch 를 +1 (단조 증가).
public bool TryFailover(long instanceId, string deadNode)
{
// 리스 저장소(예: etcd/Redis)에서 원자적으로 소유권 획득 + epoch 증가
if (!_lease.TryAcquire(instanceId, expectedOwner: deadNode,
out string backup, out long newEpoch))
return false; // 원 노드가 아직 리스를 들고 있으면 인계 거부
var state = LoadLastCheckpoint(instanceId);
_nodes[backup].StartInstance(instanceId, state, newEpoch); // 새 토큰 부여
return true;
}
2) 모든 영속 쓰기에 펜싱 토큰 검사 — 옛 주인 무력화
// DB/저장소는 instance 의 현재 epoch 보다 낮은 토큰의 쓰기를 거부한다.
// UPDATE ... WHERE instance_id=@id AND epoch <= @token -- 토큰이 오래됐으면 0행
void PersistReward(long instanceId, long token, Reward r)
{
int rows = _db.Execute(
"UPDATE instance_state SET ... WHERE instance_id=@id AND current_epoch=@token",
instanceId, token);
if (rows == 0) throw new FencedException(); // 나는 더 이상 주인이 아님 → 자진 종료
}
- 원 노드가 깨어나도 토큰이 옛 epoch 라 모든 쓰기가 거부(fenced) → split-brain 무해화. fenced 를 감지하면 인스턴스를 스스로 내린다.
3) 감지는 연속 실패 + 리스 만료로
- N 회 연속 하트비트 실패 그리고 리스 TTL 만료일 때만 인계. 원 노드는 리스를 갱신 못 하면 스스로 인스턴스를 정지(자기 펜싱)한다.
4) 보상 멱등성
- 보상마다
reward_id(결정적 생성), 지급은INSERT ... ON CONFLICT DO NOTHING. 체크포인트 이후 재지급해도 중복 없음. 가능하면 체크포인트와 보상 지급을 같은 트랜잭션/아웃박스로 묶는다.
더 나은 설계 (트레이드오프)
- 합의(consensus) 기반 리더십: etcd/ZooKeeper/Raft 로 인스턴스 소유권 리스를 관리하면 펜싱 토큰이 공짜로 나온다. 운영 복잡도 ↑.
- CAP 관점: split-brain 을 막으려면 분단 시 가용성을 희생(CP)해야 한다. "두 노드가 도는 것보다 잠깐 멈추는 게 낫다"는 트레이드오프를 받아들이는 것.
면접 포인트
- "무응답 ≠ 죽음": 타임아웃은 장애를 확정하지 못한다(비동기 네트워크의 근본 한계). 그래서 새 주인을 세울 땐 반드시 옛 주인을 펜싱한다.
- 펜싱 토큰(단조 증가 epoch)을 모든 쓰기 경로에서 검사 → 늦게 깨어난 옛 리더의 쓰기를 저장소가 거부.
- failover 의 외부 효과(보상)는 멱등하게: 재기동·재인계가 정상 경로가 되도록.
해설 · C++
해설 — 인스턴스 서버 failover 의 split-brain 과 보상 중복 (C++)
난이도: 상
요약
헬스체크 한 번 실패만으로(C) 인스턴스를 백업 노드에서 다시 띄우고 소유권을 넘긴다(A). "무응답"이 곧 "죽음"은 아니다. 원 노드가 긴 STW·네트워크 순단으로 잠깐 응답을 못 했을 뿐 살아 있으면, 백업이 같은 인스턴스를 동시에 돌려 split-brain 이 되고 보스 보상·아이템이 이중 지급된다. 또 마지막 체크포인트 이후 진행은 유실되고(B), 체크포인트에 안 들어간 보상이 백업에서 재지급된다.
문제점
(1) [split-brain] 펜싱 없는 소유권 이전 — (A)
- 증상: 원 노드와 백업이 같은 인스턴스를 동시에 활성. 두 곳의 영속 쓰기가 충돌·이중 지급.
- 재현: 원 노드 2초 멈춤 → 헬스체크 실패 → 백업 기동 → 원 노드가 깨어나 여전히 자기 인스턴스라 여기고 계속 처리.
- 근본원인: 새 주인을 세울 때 옛 주인을 무력화(fencing) 하지 않음.
소유권이
owner_맵 갱신일 뿐, 옛 노드 쓰기를 막을 토큰/리스가 없다.
(2) [감지 오판] 단발 타임아웃 — (C)
- 증상: 일시 지연을 장애로 오인 → 불필요한 failover 플래핑.
- 근본원인: 연속 실패·리스 만료가 아니라 한 번의 무응답으로 판정.
(3) [멱등성/체크포인트] 보상 재지급·진행 유실 — (B)
- 증상: 백업이 마지막 체크포인트로 시작 → 이후 진행 손실, 체크포인트 전 지급 보상을 다시 지급.
- 근본원인: 보상에 멱등 키가 없고, 체크포인트와 외부 효과가 비원자적.
수정안 — 리스 + 펜싱 토큰 + 멱등 보상
1) 단조 증가 펜싱 토큰을 동반한 리스
struct Ownership { std::string node; uint64_t epoch; }; // 인계마다 epoch 단조 증가
bool tryFailover(int64_t instanceId, const std::string& deadNode) {
Ownership o;
// 외부 리스 저장소(etcd/Redis)에서 원자적으로 소유권 획득 + epoch 증가
if (!lease_.tryAcquire(instanceId, /*expected*/ deadNode, o))
return false; // 원 노드가 아직 리스 보유 → 인계 거부
auto state = loadLastCheckpoint(instanceId);
nodes_[o.node]->startInstance(instanceId, state, o.epoch); // 새 토큰 부여
return true;
}
2) 모든 영속 쓰기에 펜싱 토큰 검사 — 옛 주인 무력화
// 저장소는 현재 epoch 보다 낮은 토큰의 쓰기를 거부한다.
void persistReward(int64_t instanceId, uint64_t token, const Reward& r) {
int rows = db_.exec(
"UPDATE instance_state SET ... "
"WHERE instance_id = ? AND current_epoch = ?", instanceId, token);
if (rows == 0) throw FencedError{}; // 나는 더 이상 주인이 아님 → 자진 종료
}
- 원 노드가 깨어나도 토큰이 옛 epoch → 모든 쓰기 거부(fenced) → split-brain 무해화. fenced 감지 시 인스턴스를 스스로 내린다.
3) 감지는 연속 실패 + 리스 만료로
- N 회 연속 하트비트 실패 그리고 리스 TTL 만료에만 인계. 원 노드는 리스 갱신 실패 시 스스로 정지(자기 펜싱).
4) 보상 멱등성
reward_id(결정적) +INSERT ... ON CONFLICT DO NOTHING. 재인계·재기동이 정상 경로가 되도록 모든 외부 효과를 멱등화. 가능하면 체크포인트와 보상 지급을 한 트랜잭션/아웃박스로 묶는다.
더 나은 설계 (트레이드오프)
- 합의 기반 리더십(etcd/ZooKeeper/Raft): 리스·펜싱 토큰을 표준으로 얻음. 운영 복잡도 ↑.
- CAP: split-brain 을 막으려면 분단 시 가용성 희생(CP). "두 노드가 도는 것보다 잠깐 멈추는 게 낫다"는 트레이드오프를 받아들이는 설계.
면접 포인트
- "무응답 ≠ 죽음" — 타임아웃은 장애를 확정 못 한다(비동기 네트워크의 한계). 새 주인을 세울 땐 반드시 옛 주인을 펜싱한다.
- 펜싱 토큰(단조 증가 epoch) 을 모든 쓰기 경로에서 검사 → 늦게 깨어난 옛 리더의 쓰기를 저장소가 거부.
- failover 외부 효과는 멱등하게: 재기동·재인계를 정상 경로로 본다.