选主和共识是分布式绕不开的两个话题。无论是数据库主从切换、任务调度中心的 leader 选举,还是配置中心的元数据一致性,背后都需要一套可靠的共识机制来支撑。而 Docker 的出现,让这类多节点系统的搭建、测试和部署都变得轻量了许多,用一台笔记本就能模拟出过去需要三台甚至五台服务器才能完成的集群实验。这篇文章就围绕 Docker 在选主与共识中的实际使用展开,从原理到落地操作,把关键环节讲透。

选主与共识到底解决什么问题
先厘清概念。共识指的是让多个节点对某个值或某个状态达成一致,哪怕其中部分节点宕机或者网络出现分区。选主则是共识的一种典型应用:集群里的节点通过共识协议决定谁是 leader,由 leader 负责协调写入,从而避免多个节点同时决策造成的数据混乱。
目前工业界用得最多的共识算法是 Raft,它的核心流程可以概括为三步:首先所有节点初始状态都是 follower;如果 follower 在一段时间内没有收到 leader 的心跳,就会转变为 candidate 并发起投票;拿到多数派选票的 candidate 晋升为 leader,此后由它统一处理写请求并把日志复制给其他节点。多数派这个条件很关键,一个三节点的集群最多容忍一个节点故障,五节点容忍两个,这也是为什么常见部署都是三或五个奇数节点。
值得注意的是,选主算法还有一类基于分布式协调组件的实现,比如基于 ZooKeeper 或 etcd 的临时顺序节点方案:各参与者在协调组件里注册临时节点,序号最小者当选主节点,主节点失联后其临时节点自动删除,其余节点感知到变化后重新竞争。这种方式实现简单,但把可用性寄托在了协调组件本身,所以 etcd、Consul 这类软件自己内部还是靠 Raft 来保证共识。
为什么用 Docker 搭建共识集群
传统方式验证一个 Raft 集群,需要准备多台机器或虚拟机,安装配置环境,节点之间还要打通网络,整个过程繁琐且难以复现。Docker 把这些成本压缩到最低:每个节点就是一个容器,通过 docker compose 一份编排文件就能定义三节点拓扑,端口、环境变量、数据卷全部声明式管理,谁都能在你机器上一键跑起来一模一样的集群。
对开发调试来说,容器化的最大价值在于故障注入特别方便。想模拟节点宕机,直接 docker stop 一个容器;想模拟网络分区,可以用 docker network 配合 iptables 规则切断特定容器间的通信。这种可重复的实验环境对验证共识实现的正确性非常重要,毕竟脑裂、日志回滚这类问题很难在真实环境里随便复现。
不过要提醒一点,容器是无状态的,而共识节点恰恰是有状态服务,日志和快照数据必须挂载到宿主机目录或数据卷里持久化,否则容器一重建,Raft 日志全丢,节点只能以全新身份加入集群,严重时会导致集群不可用。这是新手最容易踩的坑。
实战:用 Docker Compose 搭建三节点 etcd 集群
etcd 是 CNCF 旗下经典的分布式键值存储,内部基于 Raft 实现共识,Kubernetes 的元数据就存在它里面。下面用一个完整的流程搭建本地三节点集群,先准备数据目录和一份编排文件:
#!/bin/bash # 在宿主机创建三个节点的数据目录 mkdir -p /opt/etcd-cluster/data-node1 /opt/etcd-cluster/data-node2 /opt/etcd-cluster/data-node3 cd /opt/etcd-cluster && docker compose up -d
上面的编排文件里,每个服务对应一个 etcd 节点,命令行的核心参数含义如下:--initial-cluster 定义了集群的初始拓扑,三个节点的 peer 地址互相知晓,这是静态节点发现方式,适合节点数量固定的场景;--initial-cluster-state new 表示这是首次组建集群,如果后续以已有成员身份重启则要改成 existing,否则节点可能拒绝启动;--data-dir 挂载到宿主机目录,保证容器重建后 Raft 日志不丢失。三个容器放在同一个自定义 bridge 网络里,通过容器名互相访问,避免重启后 IP 变化带来的问题。
启动后验证集群状态,看看 leader 是否已经选出:
# 查看集群成员
docker exec etcd-node1 etcdctl --endpoints=http://etcd-node1:2379 member list -w table
# 查看各节点状态,IS LEADER 列能看出谁是主
for node in etcd-node1 etcd-node2 etcd-node3; do
docker exec $node etcdctl \
--endpoints=http://$node:2379 endpoint status -w table
done
# 写入并读取一条数据验证共识与日志复制生效
docker exec etcd-node1 etcdctl --endpoints=http://etcd-node1:2379 \
put /app/config "hello-raft"
docker exec etcd-node2 etcdctl --endpoints=http://etcd-node2:2379 \
get /app/config
如果一切正常,endpoint status 输出中会有一个节点的 IS LEADER 为 true,另外两个为 false,并且三个节点写入的数据互相可见,说明共识和日志复制都在正常工作。
容错测试与常见坑点
搭好集群只是第一步,更重要的是验证它的容错能力。可以做两个实验:第一,停掉 leader 节点,观察剩余两个 follower 是否能在秒级超时后选出新 leader,业务读写是否恢复;第二,停掉两个节点,此时多数派不满足,集群会拒绝写入,这正好印证了 Raft 的多数派语义。下面是模拟故障和恢复的操作:
# 找出当前的 leader 节点 docker exec etcd-node1 etcdctl \ --endpoints=http://etcd-node1:2379 endpoint status -w table # 直接停掉 leader 所在容器,触发重新选主 docker stop etcd-node1 # 等待几秒后查看集群状态,应有新 leader 产生 docker exec etcd-node2 etcdctl \ --endpoints=http://etcd-node2:2379 endpoint status -w table # 重新拉起原 leader,它会自动以 follower 身份追平日志 docker start etcd-node1
实践中还有几个坑值得注意。其一,容器重启后 IP 会变化,所以 peer 地址一定要用容器名或域名而不是 IP,Docker 内置 DNS 会负责解析;其二,跨宿主机部署时 --advertise 系列参数配置的必须是其他节点实际可达的地址,用了 overlay 网络就要广播 overlay 地址,配置混用会导致节点间心跳不通、反复触发选主;其三,时钟漂移会影响选举超时的判断,容器内如果没有正确同步时间,可能出现 leader 频繁更迭的现象,建议宿主机开启 NTP 并让容器共享宿主机时钟。
另外一个容易被忽视的点是磁盘 IO。Raft 对日志落盘延迟非常敏感,fsync 慢会拖累心跳,进而误触发选主。如果容器跑在 IO 性能差的存储驱动或虚拟磁盘上,遇到频繁选主问题时,先排查磁盘写入延迟,再考虑调大心跳间隔和选举超时时间。
生产部署中的进一步思考
本地实验用静态发现就够了,但生产环境面临机器故障、滚动升级和扩缩容等更复杂的情况。这时一般有两种选择:继续用 Docker 但搭配 systemd 管理单机容器,或直接上 Kubernetes,用 StatefulSet 保证节点身份稳定、用 PVC 持久化日志、用 headless service 提供节点发现。另外很多系统选择基于已有共识组件做选主,比如用 Consul 的 session 与 lock 机制实现 leader 选举,应用侧代码量很小,代价是引入了一个外部依赖。
关于扩缩容要牢记一条:Raft 集群不能随便加减节点,成员变更本身也是一次共识操作,需要通过 etcdctl 的 member add 和 member remove 谨慎进行,先加新成员、等它追平日志、再移除旧成员。滚动升级时采用逐个重启的策略,确保任意时刻多数派节点存活,否则会造成服务中断甚至数据不一致。
总结一下,Docker 并不改变共识算法本身,它改变的是验证与交付共识系统的方式:一份 compose 文件即可复现整个集群拓扑,故障注入让选主逻辑可以被反复检验,数据卷和容器网络则要求我们把有状态服务的持久化与可达性设计得更加严谨。掌握了这套容器化的思路,无论是自己实现 Raft 还是使用 etcd、Consul 这样的成品组件,都能少走很多弯路。