title: “突破单机极限:在 Hetzner + Docker 上搭建 Qdrant 集群实战”
tags:
- Qdrant
- Vector Database(向量数据库)
- Docker
- Docker Compose
- Hetzner
- Distributed Systems(分布式系统)
- RAG(检索增强生成)
-
Cluster(集群)
如果你一直把 Qdrant 当成单个 Docker 容器在跑,那等到向量数突破几百万、embedding 维度又稍微像样一点的那一天,两件事会同时发生:你的 RAG 流水线召回率开始变差,单机的 docker stats 输出也开始呈现出”内存泄漏恐怖片”的观感。一台 128GB 的 Hetzner CCX53,装着 2100 万条向量、1536 维 embedding,能轻轻松松吃掉 65GB 物理内存再加 4GB swap,然后就开始变慢了。
这篇文章就是一份实战记录,把单节点 Qdrant 改造成一个 3 节点 Raft 共识集群,把现有数据迁过去,再看看横向扩容到 6 节点时会出什么事。文章大量参考了 Nova Kwok 那份在生产环境走完同样迁移流程的笔记 —— 链接放在文末。结构、命令、踩坑点,以及”每一步之后集群状态到底长什么样”的输出截图,全都来自那份笔记,再按 tux.fan 的读者口味重新整理了一遍。
起点:一台 Docker 容器里装着 2100 万条向量
最初的配置大概也是大多数人起步的样子:
# docker-compose.yml on the standalone host
services:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
ports:
- 6333:6333
- 6334:6334
volumes:
- ./volumes/qdrant:/qdrant/storage宿主是一台 Hetzner CCX53(32 核、128GB 内存)。qdrant 目录大概 200GB,装着 21,297,932 条已建索引的向量,总共 21,337,945 个 points,分在 10 个 segment 里。集群状态接口返回 "status": "yellow"、"optimizer_status": "ok" —— 算偏绿吧,但单节点 Qdrant 上的 yellow 只是意味着”这个 collection 配了副本,但副本节点还不存在”,暂时没事,等哪天你想把这台机器下线维护的时候才会卡住。

上面这张是那台机器上的 htop —— Qdrant 是唯一一个值得看的进程,占了 64.3% 的物理内存,VIRT 大约 78GB。32 核的机器,load average 飙到 25。这台机器你还能再撑一阵子,但有两件事已经是板上钉钉的事实了:
- 单机是所有向量的唯一一份拷贝。硬盘一坏,整条 RAG 流水线直接挂。
- 你没法做滚动升级 Qdrant,除非提前安排维护窗口。今天镜像还是 1.14.0,明天就要上 1.15.x,而 Raft 集群上的滚动升级,恰恰就是你只有先有集群才能享受的那种功能。
所以:上集群吧。
在 Hetzner 上搭三节点集群
Qdrant 的分布式模式用 Raft 作为共识协议,所以最小可行部署就是 3 个节点。下面是测试环境的配置 —— 用的是 Hetzner 内网 IP 10.0.0.6、10.0.0.7、10.0.0.9。
每个节点跑同一个 Qdrant 镜像、同一份 compose 模板,只有一个区别:--bootstrap 和 --uri 这两个 flag。其中一个节点是 bootstrap —— 这是你最先启动的那个。另外两个通过指向 bootstrap 的 p2p 端口来加入集群。
节点 1(bootstrap,10.0.0.6)
services:
qdrant_node1:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --uri http://10.0.0.6:6335"注意这里的 command: —— ./qdrant --uri ... 是容器内 Qdrant 二进制文件的路径;你不需要单独的 entrypoint,因为官方镜像的默认 entrypoint 本来就是执行这个二进制文件。你只是把它和集群 flag 拼在了一起。
节点 2(10.0.0.7)
services:
qdrant_node2:
image: qdrant/qdrant:v1.14.0
restart: always
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335"节点 3(10.0.0.9)
services:
qdrant_node3:
image: qdrant/qdrant:v1.14.0
restart: always
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335"先 docker-compose up -d 启节点 1,等大约 10 秒让 p2p 端口开始监听,再启节点 2 和 3。在任意节点上查一下集群状态:
curl http://localhost:6333/cluster预期输出(截断):
{
"result": {
"status": "enabled",
"peer_id": 5395257186314509,
"peers": {
"3095816753490206": { "uri": "http://10.0.0.9:6335/" },
"5395257186314509": { "uri": "http://10.0.0.6:6335/" },
"4182395837949771": { "uri": "http://10.0.0.7:6335/" }
},
"raft_info": {
"term": 1,
"commit": 41,
"pending_operations": 0,
"leader": 5395257186314509,
"role": "Leader",
"is_voter": true
},
"consensus_thread_status": {
"consensus_thread_status": "working",
"last_update": "2025-05-17T02:31:10Z"
},
"message_send_failures": {}
},
"status": "ok"
}三个 peer,一个 leader(这里就是 10.0.0.6),查询节点上 is_voter: true。这就是一个健康的 Raft 集群。
创建 collection:shard 数量很关键
这是大多数人踩进去的第一个坑。Qdrant 文档写得很明确:
当你启用分布式模式、扩容到两个或更多节点时,原有的数据并不会自动迁移到新节点;新节点一上来是空的。
就像 ClickHouse 一样 —— 跑集群并不意味着你的数据已经自动分片、自动复制。你得自己告诉 Qdrant 怎么切分数据。
控制这一点的两个参数:
| 参数 | 含义 |
|---|---|
shard_number |
collection 被切成几片。必须能整除你的节点数。 |
replication_factor |
每个 shard 有几份副本。每份副本落在不同的 peer 上。 |
原来的单节点 collection 是 shard_number=1, replication_factor=1(因为只有一台机器)。对于新的 3 节点集群,Qdrant 文档推荐 12 个 shard,前提是你预期后续会有大量增长:
If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard.
这能成立是因为 12 可以被 2、3、6、12 整除。3 节点集群下每个节点能分到 4 个 shard,6 节点下每个节点 2 个,12 节点下每个节点 1 个,整个横向扩容过程你都不用重新分片。
副本数方面,replication_factor=2 是标准选择 —— 每个 shard 一个主本、一个副本,这样任何一个单点故障都不会丢数据。
from qdrant_client import QdrantClient
import qdrant_client.http.models as models
collection_name = "new_collection"
client = QdrantClient(host="10.0.0.6", port=6333)
vectors_config = models.VectorParams(
size=1536, distance=models.Distance.COSINE, on_disk=True
)
client.create_collection(
collection_name=collection_name,
vectors_config=vectors_config,
shard_number=12,
replication_factor=2,
# Note: HNSW config left out at creation time — see "speed up the import" below
)这样 collection 就建好了。空集合,12 个 shard,每个 shard 2 副本,准备接收数据。
迁移数据:snapshot 导入是行不通的(用迁移工具)
集群空架子搭起来之后,下一步就是把那 2100 万条向量搬过来。最朴素的想法是在源 collection 上打 snapshot,然后到集群上 restore。这条路会失败,原因是版本不兼容:
{
"status": {
"error": "Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"
},
"time": 1107.142566774
}源 collection 是 1 个 shard,目标 collection 是 12 个。Snapshot restore 拒绝悄悄帮你重新分片。
Qdrant 为这种场景专门提供了一个迁移工具:github.com/qdrant/migration。它是个小巧的 Docker 镜像,会从源 collection 流式读取 points,往目标 collection 里写,一边写一边重新批量化、重新分片。
docker run --net=host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant
--source-url 'http://localhost:6334'
--source-collection 'new_collection'
--target-url 'http://10.0.0.6:6334'
--target-collection 'new_collection'Nova 那篇笔记里提到了两个实操细节:
registry.cloud.qdrant.io/library/qdrant-migration是比较老的镜像。 想拿到最新行为的话,去 GitHub 仓库拉最新源码自己 build。- 改一下
grpc.MaxCallRecvMsgSize,再把batch-size调大。 默认 batch size 是 50,遇到几百万条向量的 collection 就跟爬一样。建议调到大约 20000。相关讨论见 qdrant/migration#30。
加速导入:bulk insert 期间关掉 HNSW
这是一个独立的小优化,可以和 batch size 调大叠加生效。导入期间你肯定不希望 Qdrant 每来一批就建 HNSW 索引 —— 这件事会吃掉整个导入时间的 90% 以上。导入期间先把 HNSW 关掉,全部进来之后再重新打开:
curl -X PATCH http://10.0.0.6:6333/collections/your_collection
-H "Content-Type: application/json"
-d '{
"hnsw_config": { "m": 0 },
"optimizer_config": { "indexing_threshold": 10000 }
}'m: 0 是告诉 Qdrant 在导入期间不要建 HNSW 索引。indexing_threshold: 10000 让 optimizer 每攒到 1 万条向量就 flush 一次到磁盘,而不是让它们在内存里堆着、有节点 OOM 的风险。导入完成后,重新打开 HNSW,让它后台慢慢建索引就行。
迁移完之后检查 shard 分布
集群起来了,数据也导入了。怎么知道它确实在节点之间摊开了?/cluster 端点给你的是 peer 信息,并不告诉你哪个 shard 在哪个 peer 上。按 collection 维度的端点 /collections/<name>/cluster 才能给出全貌:
curl http://10.0.0.6:6333/collections/your_collection/cluster原始 JSON 又臭又长,不太适合人眼扫。下面是个小 Python 脚本,把它摊平到你一眼能看懂的样子:
import requests
base_url = "http://10.0.0.6:6333"
cluster_endpoint = "/cluster"
collections_endpoint = "/collections/<collection_name>/cluster"
def get_data_from_api(endpoint):
response = requests.get(base_url + endpoint)
return response.json()
def parse_cluster_peers(cluster_data):
peers = cluster_data.get("result", {}).get("peers", {})
ip_peer_map = {}
for peer_id, peer_info in peers.items():
uri = peer_info.get("uri", "")
ip_address = uri.split("//")[-1].split(":")[0]
ip_peer_map[ip_address] = int(peer_id)
return ip_peer_map
def parse_shards(collections_data):
local_shards = collections_data.get("result", {}).get("local_shards", [])
remote_shards = collections_data.get("result", {}).get("remote_shards", [])
peer_shard_map = {}
for shard in local_shards:
peer_id = collections_data.get("result", {}).get("peer_id")
peer_shard_map.setdefault(peer_id, []).append(shard.get("shard_id"))
for shard in remote_shards:
peer_shard_map.setdefault(shard.get("peer_id"), []).append(shard.get("shard_id"))
return peer_shard_map
def main():
cluster_data = get_data_from_api(cluster_endpoint)
collections_data = get_data_from_api(collections_endpoint)
ip_peer_map = parse_cluster_peers(cluster_data)
peer_shard_map = parse_shards(collections_data)
ip_shard_map = {
ip: peer_shard_map.get(peer_id, [])
for ip, peer_id in ip_peer_map.items()
}
for ip, shard_ids in ip_shard_map.items():
print(f"IP: {ip}, Peer ID: {ip_peer_map[ip]}, Shard IDs: {shard_ids}")
if __name__ == "__main__":
main()健康 3 节点集群的输出:
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]3 个节点上各 8 个 shard —— 总共 24 个 shard 实例,正好对上 12 shards × 2 replicas = 24。每个 shard 的两个副本都落在不同节点上,所以任何一个单点故障都不会丢 shard。
横向扩容到 6 节点 —— 然后,什么都没发生
如果业务涨了,3 个节点扛不住,最自然的做法就是再加 3 个(10.0.0.10、10.0.0.11、10.0.0.12)。每个新节点的 compose 文件和之前一样,指向 bootstrap 就行:
services:
qdrant_node4:
image: qdrant/qdrant:v1.14.0
# ...same volume + port + env config as before...
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.10:6335"在每个新节点上 docker-compose up -d。这时候再查 cluster 端点,会看到 6 个 peer。
然后 —— 数据那边一点动静都没有。
再跑一次检查脚本:
IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11]
IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11]
IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: []
IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10]
IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: []
IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: []三个新节点(10.0.0.10、10.0.0.11、10.0.0.12)已经是 Raft 集群的成员了,但手里一个 shard 都没有。全部 24 个 shard 实例还蹲在原来那三个节点上。
如果你是从 GlusterFS 那边过来的,第一反应肯定是 gluster volume rebalance VOLNAME start。Qdrant 没有这个命令。 官方文档原话:
Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters).
开源版本的 Qdrant 让你自己动手搬 shard。完全自动化的版本也有,但只在 Qdrant Cloud(官方托管服务)里才有:

scale-out 之后手动重新均衡
重新均衡的 API 是有的,就是得你自己来调。端点是 /collections/<name>/cluster,body 里带 move_shard 指令:
curl -X POST http://localhost:6333/collections/collection_name/cluster
-H "Content-Type: application/json"
-d '{
"move_shard": {
"shard_id": 1,
"to_peer_id": 1000000,
"from_peer_id": 1000000
}
}'调用之前先算一下账:每个节点应该分到多少个 shard。
shards_per_node = (total_shards × replication_factor) / node_count
= (12 × 2) / 6
= 46 个节点每个应该装 4 个 shard 实例(这里”shard 实例”指的是 12 个逻辑 shard 中的某一个的主本或者副本)。在 3 节点集群上,每个节点是 8 个。换到 6 节点,就意味着从原来 3 个节点上各搬走 4 个 shard 实例到 3 个新节点上。
动手排计划的时候有三条要注意:
- 永远不要把同一个 shard 的两个副本搬到同一个节点上。 那节点一挂,shard 就没了。
- 一次只搬一个 shard,等这次传输完了再发下一次。
/collections/.../cluster端点会报告shard_transfers—— 这个字段为空的时候,你就可以开始下一次 move 了。 - 别并发发 12 个
move_shard。 每次传输都会在源和目标两端吃掉带宽和磁盘 I/O。一次一个、每次之间 check 一下,稳妥得多。
就是这一步把人逼去用 Qdrant Cloud 的。数学很简单,API 也干净,操作也不难,但凌晨两点在生产集群上手动搬 12 次 shard,这种事没人愿意干。Qdrant Cloud 的卖点就是把这一整套循环自动化掉。
ROSE:四步式扩容模式
Nova 那篇笔记里指了一个挺好用的框架,叫 ROSE,专门用来组织集群扩容的操作。四个字母分别代表:
| 阶段 | 你要做的事 |
|---|---|
| Resuscitation(救援) | 让新加入的节点在集群里健康起来(在 /cluster 里作为一个 peer,被 Raft 投票通过,如果拥有 shard 就能正常服务读请求) |
| Optimization(优化) | 按正确的分布把 shard 搬到新节点上,注意副本的放置位置 |
| Stabilization(稳定) | 观察集群一两个 Raft term,确认没有任何 shard 处于副本不足的状态、没有任何 pending_operations 卡住 |
| Evacuation(撤离) | 如果你是在退役一个节点(不只是新增),把它身上所有 shard 都搬走,再把它从集群里移除 |
R 最简单,就是把新节点启起来。O 才是重头戏。S 是大多数人跳过去、然后又奇怪为什么有个 shard 状态是”Active”但实际不健康的阶段。E 就是把 O 反着再来一遍。
Hetzner 上的 Cloud-init:集群一键拉起
如果你打算多搞几次这种迁移,手敲三份几乎一样的 compose 文件很快就会腻。Nova 那篇笔记里提到用 Hetzner Cloud-init 来拉起三(或六)个节点,Qdrant 配置直接在开机时烘进镜像里,节点之间用 Hetzner 内网走 p2p 端口。
模板大致长这样:
#cloud-config
write_files:
- path: /opt/qdrant/docker-compose.yml
content: |
services:
qdrant:
image: qdrant/qdrant:v1.14.0
restart: always
volumes:
- ./qdrant_storage:/qdrant/storage
- ./qdrant_snapshots:/qdrant/snapshots
ports:
- "6333:6333"
- "6334:6334"
- "6335:6335"
environment:
QDRANT__CLUSTER__ENABLED: "true"
command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://THIS_NODE_IP:6335"
runcmd:
- cd /opt/qdrant && docker-compose up -d通过 Hetzner 的 metadata server(curl http://169.254.169.254/hetzner/v1/metadata/instance-id 或类似接口)把 THIS_NODE_IP 渲染成实际地址,你就能批量起三台或六台一模一样的 VM,让它们第一次开机就自动加入集群。这套套路一旦跑通,横向扩容就变成了”再用同一份 Cloud-init 起三个 VM,等它们 join 进来,跑手动 rebalance 脚本”。
TL;DR
- 128GB 的 Hetzner CCX53 在 ~2000 万条 1536 维向量时到顶了。 还能跑,但你既没有故障切换、也没有滚动升级的能力。
- 在 Hetzner 上搭 3 个节点,每个节点跑 Qdrant 并设
QDRANT__CLUSTER__ENABLED=true。其中一个是 bootstrap;剩下两个用--bootstrap http://<bootstrap>:6335加入。 - 用
shard_number=12, replication_factor=2创建新 collection —— 12 个 shard 可以干净地扩到 2、3、6 或 12 个节点,全程不需要重新分片。 - 用专门的 qdrant/migration Docker 镜像来迁移 —— snapshot restore 拒绝当场给你重新分片。自己 build 最新镜像;把
batch-size调到 20000;patchgrpc.MaxCallRecvMsgSize。导入期间关掉 HNSW(m: 0),把indexing_threshold设成 10000,导入完成后再重新打开 HNSW。 - 用检查脚本验证分布 —— 3 节点集群下每个节点上应该有 8 个 shard,分布均匀。
- 横向扩容到 6 个节点,新加的节点是空的。 Qdrant 不会自动重新均衡。在
/collections/<name>/cluster上用move_shardAPI 一个个串行搬,每次搬一个 shard,永远不要把两个副本放到同一个节点上。跟着 ROSE 模式走:Resuscitate、Optimize、Stabilize、Evacuate。
Credits
本文是 Nova Kwok 的 “A Quick Note on Setting Up a Qdrant Cluster on Hetzner with Docker and Migrating Data” 一文的改写(CC BY-NC-SA 许可)。其中的命令、集群状态的 JSON 输出、检查脚本、move_shard 示例、batch size 调优小贴士,以及 qdrant.png / money.jpg 这两张图,全都来自那篇笔记 —— 这里是按 tux.fan 读者口味重新编排的,并补充了 htop 内存压力、导入期间关闭 HNSW 的优化思路,以及 ROSE 模式这些上下文。
评论