클러스터

Wippy는 기본적으로 단일 노드로 실행됩니다. 클러스터를 활성화하면 일련의 노드가 하나의 조율된 시스템으로 구성되어 멤버십, 클러스터 전체 프로세스 이름, 분산 잠금, 그리고 제한된 Raft 합의 코어 위의 프로세스 그룹 메시징을 공유합니다.

클러스터링은 cluster.enabled: true로 설정할 때까지 비활성화됩니다. 아래의 모든 내용은 단일 노드에서는 비활성 상태입니다.

클러스터링이 제공하는 것

  • 멤버십 — 모든 노드가 gossip을 통해 살아있는 피어 집합을 알고, 빠른 장애 감지를 제공합니다.
  • 클러스터 전체 프로세스 이름 — 어떤 노드에서도 해석 가능한 이름으로 프로세스를 등록하며, 일관성 보장을 선택할 수 있습니다(명명 참조).
  • 분산 잠금system.lock은 보유자가 사망할 때 자동으로 해제되는 클러스터 전체 상호 배제를 제공합니다(분산 잠금 참조).
  • 프로세스 그룹 — 모든 노드에 걸쳐 명명된 그룹의 모든 멤버에게 발행합니다(프로세스 그룹 참조).
  • 합의 코어 — 소규모의 제한된 Raft 클러스터가 명명 및 잠금 프리미티브가 구축되는 선형화 가능한 기반을 제공합니다.

아키텍처: 제한된 Raft

모든 노드를 Raft 피어로 만드는 것은 확장성이 낮습니다: 리더가 모든 로그 항목을 모든 피어에 복제하므로 유휴 리더 비용이 클러스터 크기에 따라 증가합니다. Wippy는 Raft를 고정 크기 코어로 제한하고 나머지 클러스터가 gossip을 활용하도록 합니다. 각 노드는 Raft 구성에서 세 가지 역할 중 하나를 차지합니다:

역할 수 (기본값) Raft 구성 포함 로그 복제 수신 투표
Voter 최대 5개 (max_voters, 홀수)
Standby 최대 4개 (max_standbys) 아니오
Client 무제한 아니오 아니오 아니오
  • Voter는 쿼럼을 형성합니다. voter의 과반수가 승인하면 쓰기가 커밋됩니다. voter 수는 항상 홀수여서 과반수가 명확히 정의됩니다.
  • Standby는 완전히 복제되고 준비 상태로 유지되는 비투표 멤버입니다. voter가 떠나면 리더가 가장 높은 순위의 standby를 빈 voter 슬롯으로 승격하여, 새 노드가 따라잡기를 기다리지 않고 쿼럼이 복구됩니다.
  • Clientvoters + standbys를 초과하는 모든 노드입니다. Raft 구성에 전혀 포함되지 않으므로 리더가 로그 항목을 전송하지 않습니다. gossip에 참여하고 쓰기를 Raft 멤버에게 라우팅합니다. 이를 통해 유휴 리더 CPU가 클러스터 크기에 상관없이 O(1)로 유지됩니다.

standby와 client가 나머지 플릿을 흡수할 수 있으므로, 수백 개의 노드로 구성된 클러스터도 여전히 5-voter 합의 코어를 가집니다. max_voters/max_standbys 상한이 설계를 "제한적"으로 만드는 요소입니다.

Voter 선택

리더는 모든 멤버십 변경 시(raft.reconcile_debounce로 디바운스, 기본값 2s) 어떤 노드가 voter여야 하는지 재계산하고 최소한의 승격/강등 작업을 적용하는 조정자를 실행합니다. 선택은 결정론적입니다 — 모든 노드가 동일한 gossip 뷰에서 동일한 순서를 도출합니다 — 그리고 세 가지 gossip 광고 힌트에 의해 결정됩니다:

  • raft.eligibleeligible: false인 노드는 voter로 선택되지 않습니다(클라이언트나 standby로 유지하고 싶은 노드에 사용).
  • raft.priority — voter 슬롯을 채울 때 낮은 값을 선호하며, 동률은 노드 ID로 결정합니다.
  • failure_domain — 하나의 도메인 손실이 voter 과반수를 빼앗지 못하도록 voter가 먼저 고유한 도메인(가용 영역/랙)에 걸쳐 분산됩니다.

작업은 쿼럼을 보존하는 순서로 적용됩니다: 추가 및 승격 먼저, 그 다음 강등, 그 다음 제거.

멤버십과 gossip

멤버십은 SWIM gossip(HashiCorp memberlist)을 사용합니다. 각 노드는 gossip 포트(기본값 7946)를 바인딩하고 피어와 소규모 메시지를 지속적으로 교환하여 장애를 감지하고 메타데이터를 전파합니다.

노드는 하나 이상의 기존 노드를 지정하여 참여합니다:

cluster:
  enabled: true
  name: node-2
  membership:
    join_addrs: "node-1:7946"

첫 번째 노드는 join_addrs가 필요 없습니다 — 시드로 시작합니다. 참여는 백오프와 함께 재시도되고, 고립된 노드는 주기적으로 재참여를 시도하므로 새 IP로 재시작된 노드(Kubernetes에서 일반적)가 빠르게 재수렴합니다.

Gossip은 인라인 또는 파일에서 제공되는 공유 키로 암호화할 수 있습니다:

cluster:
  membership:
    secret_file: /etc/wippy/cluster.key

멤버십 변경(NodeJoined, NodeLeft, NodeUpdated)은 Raft 부트스트랩, voter 조정, 프로세스 그룹 동기화, 그리고 이탈한 노드가 소유한 이름의 자동 정리를 구동하는 이벤트입니다.

부트스트랩

초기 클러스터는 정적 피어 목록이 아닌 gossip으로 형성됩니다. 이는 Consul/Nomad의 bootstrap_expect 패턴을 따릅니다: 각 시작 노드에 예상 노드 수를 알려주면 서로를 모두 볼 수 있을 때까지 기다린 후 함께 쿼럼을 형성합니다.

bootstrap_expect 동작
0 자체 부트스트랩 안 함; 이미 존재하는 클러스터에만 참여
1 단일 노드; 자신만을 voter로 즉시 부트스트랩
N gossip에서 N개의 eligible 피어가 안정적으로 보일 때까지 기다린 후 모두 동일한 voter 목록을 도출하고 쿼럼 형성

N개 노드 부트스트랩의 경우 모든 초기 노드에 동일한 bootstrap_expect: N을 설정합니다. 각 노드는 gossip에서 "사전 부트스트랩" 상태를 광고합니다; 정확히 N개의 피어가 짧은 안정성 창 동안 보이면, 모든 노드가 독립적으로 동일한 정렬된 voter 집합을 계산하고 클러스터를 형성합니다. 안정성 창은 일시적인 부분 뷰가 부트스트랩을 조기에 트리거하는 것을 방지합니다.

나중에 시작하는 노드는 이미 형성된 클러스터를 보고 부트스트랩을 완전히 건너뜁니다 — 리더의 조정자가 그들을 voter 또는 standby로 추가합니다.

Raft 합의 코어

합의 코어는 디스크 없는 방식입니다: Raft 로그와 스냅샷은 메모리에만 있으므로 프로비저닝할 데이터 디렉토리가 없습니다. 재시작 시 노드가 gossip에 다시 참여하고 피어로부터 상태를 재생합니다. 이는 의도적으로 온디스크 Raft가 도입하는 지속성 대 쿼럼 장애 모드를 제거하고, 메모리 내 조율 시스템(Erlang global, Akka distributed data)의 모델과 일치합니다. 트레이드오프: 클러스터의 내구성은 디스크가 아닌 살아있는 쿼럼에서 옵니다 — 복구를 참조하세요.

Raft는 자체 리슨 포트를 열지 않습니다. 노드 간 릴레이 트래픽에 사용되는 것과 동일한 TCP 연결인 인터노드 메시 — yamux로 멀티플렉싱됩니다. 인터노드 포트는 부팅 시 자동 선택(범위 7950-7959, 이후 임시 포트)되고 고정되어 gossip에서 광고되므로 피어가 도달할 수 있습니다. 일반적으로 노출해야 하는 포트는 gossip 포트뿐입니다.

Raft FSM은 글로벌 이름 레지스트리를 보유합니다: 활성 name -> PID 바인딩과 진행 중인 strong 예약. 이것이 아래 명명 프리미티브가 읽고 쓰는 대상입니다.

명명 및 이름 범위

프로세스는 이름으로 등록되고 원시 PID 대신 해당 이름으로 도달할 수 있습니다. 핵심 결정은 범위이며, 이는 일관성 보장을 선택합니다. 가장 저렴/약한 것부터 가장 강한 것까지 네 가지 범위를 사용할 수 있습니다:

범위 지원 방식 가시성 보장
Local 노드별 맵 이 노드만 즉각적, 노드-로컬; 조율 없음
Eventual gossip CRDT 클러스터 전체 결과적 일관성; gossip 라운드 후 수렴
Consistent Raft 클러스터 전체 선형화 가능한 쓰기; 클러스터 전체 고유 싱글톤
Strong Raft + 전체 노드 ack 클러스터 전체 Consistent + 모든 살아있는 노드가 이름이 활성화되기 전에 승인

선택 방법:

  • Local — 한 노드에서만 의미 있는 이름(노드별 헬퍼). 프로세스가 종료되는 순간 해제됩니다. 비용 없음.
  • Eventual — 짧은 오래된 창이 허용되는 고용량 존재/세션 이름. 매우 큰 이름 수로 확장됩니다. 두 출처가 같은 이름을 등록하면 충돌 해결이 승자를 선택하고 패배하는 프로세스가 취소 이벤트(process.event.CANCEL)를 받습니다. 이유는 name revoked: <name>이며; 계속 실행되고 재등록할 수 있습니다. 이름은 소유 노드가 떠날 때 해제됩니다.
  • Consistent — 클러스터 전체 명명된 싱글톤의 표준 선택. 선착순: 다른 PID로 같은 이름의 두 번째 등록은 "already exists"로 실패하고 현재 소유자를 반환합니다. 쓰기에는 쿼럼이 필요하므로 소수 파티션에서 중단됩니다. 읽기는 로컬 Raft 복제본에서 나오며 몇 밀리초 지연될 수 있습니다.
  • Strong — 순간적인 오래된 읽기도 위험한 소규모 컨트롤 플레인 싱글톤 집합. Consistent 보장 외에도, 등록이 모든 살아있는 노드가 이름이 권위 있게 되기 전에 승인해야 하는 예약을 여는 데, 이미 충돌하는 바인딩을 보유한 노드는 즉시 거부합니다. 데드라인 전에 모든 노드가 ack하지 않으면 등록이 만료되고 어떤 노드가 누락되었는지 보고합니다. 이것이 분산 잠금의 기반입니다.

이름은 자동으로 해제됩니다: Local은 프로세스 종료 시; Consistent와 Strong은 프로세스 종료 시(토폴로지 모니터링을 통해)와 노드 이탈 시; Eventual은 노드 이탈 시. 메시징(process.send, process.terminate 등)의 해석은 Consistent, 그 다음 Eventual, 그 다음 Local 순서로 범위를 조회하므로 Consistent 이름이 동일한 문자열의 Eventual 이름을 가립니다.

명명을 위한 Lua 인터페이스는 process.registry(범위와 함께 등록/조회/등록 해제)에 있습니다 — Process 레퍼런스를 참조하세요.

프로세스 그룹

프로세스 그룹은 Erlang의 pg를 모델로 한 클러스터 인식 발행/구독 및 멤버십 기능입니다. 프로세스가 명명된 그룹에 참여하면; 해당 그룹에 대한 브로드캐스트가 모든 노드의 모든 멤버에게 도달합니다. 그룹은 gossip 기반이고 결과적으로 일관성이 있습니다 — Raft와 독립적 — 따라서 합의 코어가 수렴하는 동안에도 계속 작동합니다.

일반적인 작업: 그룹 참여/탈퇴, 모든 멤버에게 브로드캐스트(또는 로컬 멤버만), 멤버 목록, 참여/탈퇴 이벤트를 위한 그룹 모니터링. 새 노드가 참여하면 그룹이 직접 동기화 핸드셰이크를 통해 멤버십을 조정하고, 백그라운드 안티-엔트로피 루프가 시간이 지남에 따라 발생하는 편차를 복구합니다.

Lua API는 Process Groups를, 설정은 pg.scope 엔트리 종류를 참조하세요.

분산 잠금

system.lock은 Strong 이름 범위 위에 직접 구축된 클러스터 전체 상호 배제입니다. 잠금 획득은 호출 프로세스가 소유한 Strong 범위 아래 이름을 등록합니다; 해제는 등록을 취소합니다. Strong은 모든 살아있는 노드의 승인을 요구하므로 클러스터 전체에 최대 하나의 보유자만 존재할 수 있습니다.

local ok, err = system.lock.acquire("orders.migration")
if ok then
  -- critical section: only one holder cluster-wide
  system.lock.release("orders.migration")
end

획득은 실패-즉시(비차단) 방식입니다: 잠금이 보유 중이면 즉시 반환하므로 호출자가 자체 재시도/백오프를 추가합니다. 보유자 프로세스가 종료되거나 해당 노드가 떠나면 잠금이 자동으로 해제되므로 정리는 자동입니다. 정확한 시그니처는 System 레퍼런스를 참조하세요.

설정

키별 전체 레퍼런스는 설정에 있습니다. 최소 형태:

단일 노드 (개발):

cluster:
  enabled: true
  name: dev
  raft:
    bootstrap_expect: 1

3노드 voting 클러스터:

cluster:
  enabled: true
  name: node-1
  failure_domain: us-east-1a
  membership:
    join_addrs: "node-2:7946,node-3:7946"
    secret_file: /etc/wippy/cluster.key
  raft:
    bootstrap_expect: 3

Gossip 전용 클라이언트 (명명/메시징을 위해 참여, Raft 실행 안 함):

cluster:
  enabled: true
  name: edge-7
  membership:
    join_addrs: "node-1:7946,node-2:7946"
  raft:
    role: client

포트

목적 포트 프로토콜 설정 키
Gossip (멤버십) 7946 TCP + UDP cluster.membership.bind_port
인터노드 메시 (릴레이 + Raft) 자동 TCP cluster.internode.bind_port

별도의 Raft 포트는 없습니다 — Raft는 인터노드 메시에서 멀티플렉싱됩니다. 인터노드 포트는 자동 할당되고 gossip을 통해 광고되므로 gossip 포트만 예측 가능하게 노출하면 됩니다.

관측성

클러스터 상태는 표준 Prometheus 엔드포인트와 활성 상태 헬스 체크를 통해 노출됩니다.

주요 모니터링 메트릭:

메트릭 의미
raft_state 0 = follower, 1 = candidate, 2 = leader
raft_term 현재 Raft 텀; 빠른 증가는 선거 혼란을 나타냄
raft_voters / raft_non_voters 구성의 실제 voter와 standby
raft_leader_changes_total 리더 전환; 건강한 클러스터에서는 거의 변하지 않아야 함
raft_voter_churn_burst_total voter 추가/제거 작업의 버스트; 지속적인 변동은 위험 신호
gossip_members{state} 상태별 수 (alive/suspect/dead/left)
gossip_convergence_seconds gossip 이벤트 간의 시간

내장 활성 상태 체크 (활성 엔드포인트에 연결됨):

  • gossip — 노드의 gossip 상태 점수가 낮게 유지되는 동안 정상; 재참여하는 노드가 조기에 종료되지 않도록 부팅 유예 창이 있음.
  • raft 마지막 연락 — 투표 팔로워는 최근에 리더로부터 듣지 못하면 실패; standby는 훨씬 더 긴 간격을 허용; 리더는 항상 통과.
  • 프로세스 그룹 브로드캐스트 — 그룹이 장기간 브로드캐스트 트래픽을 보지 못하면 실패하여, 막힌 이벤트 루프나 지속적인 파티션을 포착.

복구 및 장애 모드

합의 코어가 디스크 없는 방식이므로 내구성은 디스크가 아닌 살아있는 쿼럼에서 옵니다. 실질적인 규칙:

  • voter 과반수를 살아있게 유지하세요. voter 5개로 2개의 동시 voter 실패를 허용합니다; standby가 빈 슬롯을 채우도록 승격됩니다. 과반수 아래로 떨어지면 쓰기(새 Consistent/Strong 등록 및 잠금 획득)가 쿼럼이 돌아올 때까지 중단됩니다. 기존 이름과 조회는 로컬 복제본에서 계속 서비스됩니다.
  • 리더는 하트비트 침묵 상태이고 gossip에서 죽은 voter를 사전에 제거하여, 죽은 voter가 standby가 승격되는 동안 쿼럼을 영구적으로 차단하지 않습니다.
  • 쿼럼을 잃은 클러스터를 복구하려면 실패한 노드를 재시작하세요. 그들이 gossip에 다시 참여하면 살아있는 멤버가 다시 합류시킵니다. voter를 failure_domain에 걸쳐 분산하는 것이 단일 가용 영역 실패로 인한 쿼럼 손실을 방지하는 방법입니다.

참고

  • 설정 — 모든 클러스터 설정 키
  • Process — 이름으로 프로세스 등록 및 해석
  • Systemsystem.cluster, system.raft, system.node, system.lock
  • 관측성 — 메트릭 및 헬스 엔드포인트
  • 프로세스 모델 — 액터, PID, 메시징