Consul이나 Nomad 클러스터에서 노드 하나가 GC pause나 순간적인 CPU 과부하로 몇 초간 프로브에 응답하지 못했다고 하자. 나머지 노드들은 이 노드를 죽었다고 판정해야 할까? 판정 기준이 고정된 타임아웃 하나뿐이라면, 클러스터가 바쁠 때마다 멀쩡한 노드들이 연쇄적으로 죽은 것처럼 보이는 오탐(false positive)이 생긴다. Consul·Serf·Nomad가 공통으로 쓰는 hashicorp/memberlist는 SWIM 프로토콜 위에 Lifeguard라는 확장을 얹어 이 문제를 다루는데, 그 원리를 소스(state.go/suspicion.go/awareness.go)로 직접 따라가 본다.
SWIM(Scalable Weakly-consistent Infection-style Membership)은 전 노드가 서로에게 하트비트를 보내는 all-to-all 방식(메시지 O(N²))을 무작위 상대 하나에게만 프로브를 보내는 방식(노드당 O(1))으로 대체해 멤버십 관리 비용을 낮춘다. 여기서 중요한 설계는 장애 판정(detection)과 판정 결과의 전파(dissemination)를 분리했다는 점이다. "가십이 장애를 감지한다"고 오해하기 쉽지만, 실제로 죽었는지 판단하는 건 프로브-ack 서브시스템이고, 가십은 이미 내려진 판정을 클러스터 전체에 퍼뜨리는 역할만 한다.
probeNode()는 대상에게 direct UDP ping을 보내고, 타임아웃 내 ack가 없으면 곧바로 죽었다고 판정하지 않는다. 대신 무작위로 고른 k개의 다른 노드에게 "네가 대신 확인해봐"라는 간접 프로빙을 요청하는 동시에, UDP가 막혔을 가능성을 잡기 위한 TCP fallback도 병행한다. direct 1번, indirect k번, TCP 1번이 모두 실패해야 비로소 Suspect 상태로 넘어간다. 요청자 자신만 네트워크가 끊겼다면 간접 프로빙을 받은 다른 노드 중 누군가는 성공할 것이므로, 이 단계가 "내 문제"와 "저 노드의 진짜 장애"를 구분해준다.
문제는 이 프로브 타임아웃 자체가 고정값이라는 데 있다. 원 SWIM 논문은 모든 노드가 같은 타임아웃을 쓴다고 가정하는데, 실제 운영 환경에서는 특정 노드만 일시적으로 느려지는 일이 흔하다. Lifeguard는 여기에 awareness라는 자기 건강도 점수를 추가한다.
func (a *awareness) ApplyDelta(delta int) {
a.score += delta
if a.score < 0 { a.score = 0 } else if a.score > (a.max - 1) { a.score = (a.max - 1) }
}
func (a *awareness) ScaleTimeout(timeout time.Duration) time.Duration {
return timeout * (time.Duration(a.score) + 1)
}
direct 프로브가 성공하면 점수가 내려가고(건강도 개선), 간접 프로빙에서 기대한 만큼 nack이 돌아오지 않으면 그 부족분만큼 점수가 올라간다. 그리고 다음 프로브의 타임아웃은 기준시간 × (score+1)로 스케일링된다. 즉 최근에 응답이 나빴던 노드일수록 스스로 "지금은 기준을 관대하게 잡겠다"고 판단해 프로브 타임아웃을 늘린다 — 판정 기준을 클러스터가 아니라 각 노드가 자기 최근 이력에 맞춰 조정하는 것이다.
이 점수가 실제로 무엇을 막아주는지 보려면, 단발성 실패와 연속 실패를 구분해봐야 한다. 실제 delta 크기는 간접 프로빙 nack 부족분에 따라 달라지지만, 개념을 보기 위해 실패마다 +1, 성공마다 -1이라고 단순화하고 기준 타임아웃을 1초라고 가정하면 다음과 같은 궤적이 나온다.
| 라운드 | 이벤트 | score | 다음 타임아웃 |
|---|---|---|---|
| 0 | (시작) | 0 | 1.0s |
| 1 | 실패 | 1 | 2.0s |
| 2 | 실패 | 2 | 3.0s |
| 3 | 성공 | 1 | 2.0s |
| 4 | 성공 | 0 | 1.0s |
핵심은 한 번의 실패가 다음 판정을 곧바로 죽음으로 몰지 않는다는 것이다. 실패가 쌓일수록 점수가 올라 타임아웃이 늘어나므로, 노드가 일시적으로 느려진 구간에서는 오히려 판정 기준이 관대해진다. 반대로 꾸준히 응답이 좋으면 점수가 다시 내려가 다음 판정은 더 빡빡해진다. Lifeguard는 여기에 더해 의심(Suspect) 타이머 쪽도 손봤다 — 다른 노드들의 독립적인 확인(confirmation)이 쌓일수록 log(n+1)/log(k+1) 비율로 남은 시간을 줄여, 정말 죽은 노드는 여러 노드가 동시에 확인해줘서 더 빨리 확정되고 애매한 경우는 더 오래 기다리게 한다.
SWIM의 프로브 사다리(direct→indirect→TCP)가 "한 번의 실패"와 "진짜 장애"를 구분한다면, Lifeguard의 자기인식 점수는 "일시적으로 느린 노드"와 "계속 느린 노드"를 구분해 판정 기준 자체를 동적으로 조정한다 — 두 메커니즘은 서로 다른 축의 오탐을 막는다. 다음으로 파고들 만한 건 Consul처럼 SWIM류 멤버십과 Raft류 합의 프로토콜을 함께 쓰는 시스템에서 왜 두 계층을 분리해야 하는지, 그리고 Serf가 memberlist 위에 얹은 Lamport 타임스탬프 기반 이벤트 코얼레싱이다.
memberlist 소스 — state.go, suspicion.go, awareness.go