Hyper-V로 Qdrant 고가용성(HA) 클러스터 구축기
— 30초 순단을 6초로 줄이기까지
RAG 시스템의 벡터 DB인 Qdrant를 2대 페일오버로 구성할 수 있을까? 내 PC의 Hyper-V 위에 랩을 만들어 직접 검증한 기록. 결론은 — 2대만으론 부족했고, witness 노드 · LB 튜닝 · Qdrant 설정 세 가지가 맞물려야 순단이 잡혔다.
0목표
- Qdrant 2대 구성에서 한 대가 죽어도 검색/업서트가 유지되는지 검증
- 장애(failover) 시 순단 시간이 얼마나 되는지 실측
- 실제 운영(물리 서버) 이전에 랩에서 함정을 미리 밟아보기
처음 떠올렸던 "공유 볼륨을 두고 A가 죽으면 B가 마운트해 띄우는" active-passive 방식은 Qdrant에선 권장되지 않는다. Qdrant는 노드별 독립 스토리지를 전제로 하며, 가용성은 클러스터 + 샤드 복제(replication)로 확보하도록 설계되어 있다.
1최종 아키텍처
- node1 · node2 — 데이터 노드.
replication_factor: 2로 모든 샤드를 양쪽에 풀 복제 - node3 — witness 노드. Raft 정족수만 채우는 역할 + nginx LB 호스팅(데이터 없음)
- 클라이언트는 LB(
:7333)만 바라본다
2Hyper-V 환경 구성
Ubuntu 22.04, 2세대(Gen2) VM 3대. 데이터 노드는 4~8GB, witness는 1~2GB면 충분하다.
네트워크 — Default Switch를 쓰면 안 된다
기본 "Default Switch"는 NAT+DHCP라 재부팅마다 VM IP가 바뀐다. Qdrant는 노드끼리 IP로 서로를 참조하므로 IP가 바뀌면 멤버십이 깨진다. 전용 Internal 스위치 + 고정 IP로 구성했다.
New-VMSwitch -Name "QdrantSwitch" -SwitchType Internal Connect-VMNetworkAdapter -VMName "qdrant-node1" -SwitchName "QdrantSwitch"
network:
version: 2
renderer: NetworkManager
ethernets:
eth0:
dhcp4: no
addresses: [0.0.0.10/24] # node2 .11, node3 .12
eth1: # 인터넷용 NIC
dhcp4: yes
Add-VMNetworkAdapter로 Default Switch 연결). 클러스터 통신은 eth0(고정 IP), 인터넷은 eth1(DHCP)로 분리.
vEthernet (QdrantSwitch) 어댑터가 169.254.x.x(APIPA)면 IP가 없는 상태. → New-NetIPAddress -InterfaceAlias "vEthernet (QdrantSwitch)" -IPAddress 0.0.0.1 -PrefixLength 24
3Qdrant 클러스터 구성
각 노드에 Docker로 Qdrant를 올린다. 핵심 플래그는 두 개다.
--uri: 다른 노드들이 나를 찾아올 주소. 반드시 호스트 실제 IP (localhost 금지)--bootstrap: 기존 클러스터의 아무 노드 주소. 이걸로 합류한다
services:
qdrant:
image: qdrant/qdrant:v1.14.1
ports: ["6333:6333", "6334:6334", "6335:6335"]
volumes: ["./storage:/qdrant/storage"]
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: ["./qdrant", "--uri", "http://0.0.0.10:6335"]
restart: unless-stopped
# node2 / node3 는 command 만 다르다:
command: ["./qdrant", "--bootstrap", "http://0.0.0.10:6335",
"--uri", "http://0.0.0.11:6335"]
node1을 먼저 띄우고, 떠 있는 상태에서 node2 · node3를 순서대로 올린다. GET /cluster에서 peers에 세 노드가 다 보이고 leader가 채워지면 성공.
/cluster가 {"status":"disabled"}를 돌려주면 환경변수가 컨테이너에 안 들어간 것. compose의 오타/들여쓰기를 확인하고, 단독 모드로 떴던 노드는 storage를 비우고 재합류시킨다(raft 상태가 단독용으로 초기화됨).
4컬렉션 — 복제 설정이 가용성을 결정한다
{
"vectors": { "size": 1024, "distance": "Cosine" },
"shard_number": 2,
"replication_factor": 2,
"write_consistency_factor": 1,
"read_fan_out_factor": 1
}
| 설정 | 값 | 이유 |
|---|---|---|
| replication_factor | 2 | 모든 샤드를 두 데이터 노드에 풀 복제. 한 대가 죽어도 전체 데이터가 남는다 |
| write_consistency_factor | 1 | 2로 두면 한 노드 죽었을 때 쓰기가 막힌다. 장애 중에도 쓰기를 받으려면 1 |
| read_fan_out_factor | 1 | 이번 검증의 숨은 주인공. 아래 실험 3에서 설명 |
참고로 replication_factor를 서버 글로벌 config로만 설정하면 복제본이 실제로 안 만들어지는 버그 사례가 있다(qdrant#5215). 컬렉션 생성 요청에 명시적으로 넣는 것이 안전하다.
5페일오버 검증 — 30초의 정체를 찾아서
1초 간격으로 LB에 scroll 요청을 날리는 PowerShell 루프를 돌리면서 노드를 강제 종료(Stop-VM -TurnOff)하는 방식으로 측정했다.
실험 1 · 2노드에서 리더를 죽이면 → 30초 순단
원인은 Raft 정족수다. 2노드에서 과반은 2/2 — 리더가 죽으면 남은 1대가 혼자서는 새 리더를 못 뽑는다. 그동안 term 값이 수백씩 폭증한다(선거 시도 → 실패 반복).
실험 2 · witness 추가 → 합의는 해결, 그런데 여전히 21초?
witness(node3)를 더해 3노드로 만들면 리더가 죽어도 남은 2대가 과반(2/3)을 이뤄 즉시 새 리더를 뽑는다. 실제로 term이 +1만 오르며 한 번에 끝났고, witness(리더 상태)를 죽였을 땐 완전 무중단이었다. 그런데 데이터 노드를 죽이면 여전히 ~21초 멈췄다. 합의는 멀쩡한데 데이터 요청만 매달리는 상황.
실험 3 · 21초의 진범 — 두 겹의 타임아웃
21초의 정체는 OS TCP 타임아웃이었고, 두 군데서 발생하고 있었다.
(1) LB 레이어. L4 LB는 클라이언트 연결을 특정 백엔드에 묶는데, 그 백엔드가 갑자기 죽으면 연결이 OS TCP 타임아웃(~21초)까지 매달린다. nginx proxy_timeout으로 잘라야 한다.
stream {
upstream qdrant_rest {
server 0.0.0.10:6333 max_fails=1 fail_timeout=3s;
server 0.0.0.11:6333 max_fails=1 fail_timeout=3s;
}
server {
listen 7333;
proxy_pass qdrant_rest;
proxy_connect_timeout 1s;
proxy_timeout 3s;
}
}
(2) Qdrant 레이어. 죽은 노드가 샤드 복제본을 들고 있으면, 살아남은 노드가 쿼리를 처리할 때 죽은 피어의 복제본에 접속을 시도하다 매달린다. 이걸 푸는 게 read_fan_out_factor다 — 필요 최소보다 복제본 1개를 더 동시에 조회하고 먼저 응답한 쪽을 반환하므로 죽은 복제본의 타임아웃을 안 기다린다.
최종 결과
09:38:54 OK points=3
09:38:58 FAIL 기본 연결이 닫혔습니다... ← 단 한 번 (nginx가 3초에 절단)
09:39:00 OK points=3
09:39:01 OK points=3
| 구성 | 데이터 노드 장애 시 순단 |
|---|---|
| 2노드 (리더 장애) | ~30초 |
| 3노드 + witness | ~21초 |
3노드 + Linux nginx proxy_timeout 3s + read_fan_out_factor | ~6초 |
남은 6초 블립은 nginx 오픈소스의 한계다 — 수동(passive) 헬스체크라 노드가 죽은 직후 첫 요청 하나는 타임아웃을 먹고 나서야 살아있는 쪽으로 넘어간다. 이것까지 없애려면 HAProxy 액티브 헬스체크(죽은 노드 사전 감지)로 교체하면 된다. 다음 과제로 남긴다.
6몇 대가 필요한가 — quorum의 본질
왜 2대로는 안 되고 3대로 갔는가? 정족수 기반 HA는 N대가 (N-1)/2개의 장애까지 견딘다. 즉 3대는 "1대만 남아도 되는" 게 아니라 "1대 장애까지" 견디는 구성이다.
| 노드 수 | 견디는 장애 수 | 의미 |
|---|---|---|
| 2대 | 0개 | 리더가 죽으면 끝 — 그래서 안 됐던 것 |
| 3대 | 1개 | 한 대 장애를 깔끔히 견딤 (이번 목표 달성) |
| 5대 | 2개 | 두 대 동시 장애까지 (replication 3 필요) |
7배운 것 정리
- 2노드 Qdrant는 반쪽짜리 HA다. 데이터는 살아남지만 리더가 죽으면 과반(2/2)을 못 채워 수십 초 멈춘다. 작은 witness 노드 하나(1~2GB)가 이 문제를 깔끔히 없앤다.
- 순단의 원인은 한 겹이 아니다. 합의(Raft) / LB(TCP 타임아웃) / Qdrant 내부(죽은 복제본 대기) — 세 레이어가 각각 수십 초를 만들 수 있고, 셋 다 잡아야 한다. 하나만 고치면 "고쳤는데 왜 그대로지?"를 겪는다.
read_fan_out_factor는 HA 구성에서 사실상 필수다. 문서에 조용히 있지만 죽은 복제본을 안 기다리게 하는 핵심 장치.write_consistency_factor는 1로. 2로 두면 노드 하나 죽는 순간 쓰기가 멈춘다 — HA 하려다 가용성을 깎는 함정.- 랩과 운영의 차이: 이 구성에서 호스트 PC는 단일 장애점이다. 물리 서버로 옮길 땐 LB를 keepalived 플로팅 VIP(양쪽 노드에 HAProxy + VRRP)로 바꾸고 witness는 별도 머신에 둔다.
🚀 마치며
"2대로 페일오버 되나?"라는 단순한 질문에서 출발했지만, 답은 "2대로는 안 되고, 3대 + LB 튜닝 + Qdrant 설정까지 가야 한다"였다. 그리고 30초 → 21초 → 6초로 순단을 좁혀가는 과정 자체가, HA가 단일 설정이 아니라 여러 레이어의 합이라는 걸 몸으로 알려줬다.
다음 과제 — nginx → HAProxy 액티브 헬스체크로 6초 블립 제거, 물리 서버 2대 + witness 구성으로 이전(keepalived VIP), RAG 파이프라인에 클라이언트 측 재시도 로직 추가.