在三节点 etcd 集群中停掉两个成员制造失去多数派,观察写失败、leader 选举与恢复顺序

演练目标

环境准备

隔离边界:只使用本机 Docker Compose 的 lab-etcd 项目,三个容器不映射宿主机端口;不要对生产 etcd 执行 stop、snapshot restore 或 --force-new-cluster。需要 Docker Compose v2,预留约 500 MB 内存。

docker version --format '{{.ServerVersion}}' && docker compose version
mkdir -p /tmp/lab-etcd
cat > /tmp/lab-etcd/docker-compose.yml <<'EOF'
name: lab-etcd
x-etcd: &etcd
  image: quay.io/coreos/etcd:v3.5.15
  entrypoint: /usr/local/bin/etcd
services:
  etcd1:
    <<: *etcd
    container_name: lab-etcd1
    command: ["--name=etcd1", "--data-dir=/etcd-data", "--listen-client-urls=http://0.0.0.0:2379", "--advertise-client-urls=http://etcd1:2379", "--listen-peer-urls=http://0.0.0.0:2380", "--initial-advertise-peer-urls=http://etcd1:2380", "--initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380", "--initial-cluster-token=lab-etcd", "--initial-cluster-state=new"]
  etcd2:
    <<: *etcd
    container_name: lab-etcd2
    command: ["--name=etcd2", "--data-dir=/etcd-data", "--listen-client-urls=http://0.0.0.0:2379", "--advertise-client-urls=http://etcd2:2379", "--listen-peer-urls=http://0.0.0.0:2380", "--initial-advertise-peer-urls=http://etcd2:2380", "--initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380", "--initial-cluster-token=lab-etcd", "--initial-cluster-state=new"]
  etcd3:
    <<: *etcd
    container_name: lab-etcd3
    command: ["--name=etcd3", "--data-dir=/etcd-data", "--listen-client-urls=http://0.0.0.0:2379", "--advertise-client-urls=http://etcd3:2379", "--listen-peer-urls=http://0.0.0.0:2380", "--initial-advertise-peer-urls=http://etcd3:2380", "--initial-cluster=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380", "--initial-cluster-token=lab-etcd", "--initial-cluster-state=new"]
EOF
docker compose -f /tmp/lab-etcd/docker-compose.yml up -d
sleep 8
E=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E endpoint status --write-out=table
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E put lab/quorum before
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 snapshot save /tmp/snapshot.db
docker cp lab-etcd1:/tmp/snapshot.db /tmp/lab-etcd/snapshot.db

endpoint status 应列出 3 个成员且只有一个 leader。快照保存在宿主机 /tmp/lab-etcd/snapshot.db,恢复前先确认它存在。

注入故障

停掉 etcd2 和 etcd3,只留下 etcd1。三节点集群的多数派是 2,剩 1 个成员后无法提交写请求,也无法完成需要 quorum 的线性读:

docker compose -f /tmp/lab-etcd/docker-compose.yml stop etcd2 etcd3
sleep 5
docker compose -f /tmp/lab-etcd/docker-compose.yml ps

观察现象

docker compose -f /tmp/lab-etcd/docker-compose.yml logs --tail=15 etcd1
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 endpoint status --write-out=table
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 endpoint health; echo "health_exit=$?"
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 put lab/quorum after; echo "put_exit=$?"
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 --consistency=s get lab/quorum; echo "serializable_exit=$?"
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=http://etcd1:2379 get lab/quorum; echo "linearizable_exit=$?"

预期 etcd1 日志出现 leader 丢失和 election timeout;endpoint status 可能报超时或显示没有 leader;endpoint health 退出码非 0;put 失败,报 etcdserver: no leader 或 context deadline exceeded,put_exit 非 0。串行读 --consistency=s 仍可能返回旧值 before,但线性读 get 失败。串行读成功只说明本地状态可读,不代表集群有 quorum。

判断与定位

  1. 先写假设:失去多数派、leader 选举进行中、单个成员磁盘或网络慢、客户端 endpoint 配错,还是认证/TLS 问题。先确认成员数和 quorum,再看客户端。
  2. 用 endpoint status --write-out=table 看每个成员的 leader、raftIndex 和 raftTerm;用 member list 看成员是否仍在集群中。三节点允许坏 1 个,坏 2 个即失去多数派。
  3. 区分请求类型:写请求和默认线性读需要 quorum;--consistency=s 串行读可由本地成员返回,可能读到旧值。不要把串行读成功当成集群健康。
  4. 容易误判:etcdctl member list 或 endpoint status 能返回本地信息,不代表能提交写;另一个误判是看到 no leader 就立刻重启所有节点。三个节点同时重启会延长不可用时间,也不能解决数据或网络问题。
  5. 检查 etcd 日志中的 lost the leader、election timeout、failed to get leader 和磁盘/网络错误。若只有部分客户端失败,先核对 endpoint 列表、DNS 和 TLS,而不是直接判定 quorum 丢失。
  6. 数据安全:quorum 丢失后失败写入不会提交;恢复时让成员通过 Raft 追赶,不要单独在某个成员上写数据。除非确认所有成员数据不可用且接受风险,否则不要使用 --force-new-cluster。

修复

先恢复 etcd2,让集群重新达到 2/3 多数派;确认 leader 和写入恢复后,再恢复 etcd3:

docker compose -f /tmp/lab-etcd/docker-compose.yml start etcd2
sleep 5
E2=http://etcd1:2379,http://etcd2:2379
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E2 endpoint health
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E2 put lab/quorum recovered
docker compose -f /tmp/lab-etcd/docker-compose.yml start etcd3
sleep 5
E3=http://etcd1:2379,http://etcd2:2379,http://etcd3:2379
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E3 endpoint health
docker compose -f /tmp/lab-etcd/docker-compose.yml exec -T etcd1 /usr/local/bin/etcdctl --endpoints=$E3 get lab/quorum

预期 etcd2 启动后重新选出 leader,put 成功;etcd3 启动后三个 endpoint 都健康,读取到 recovered。如果 etcd2 无法恢复,可改用 etcd3 先恢复,但一次只启动一个成员,等 quorum 和日志追赶完成后再启动下一个。

清理与回滚

docker compose -f /tmp/lab-etcd/docker-compose.yml down -v --remove-orphans
rm -rf /tmp/lab-etcd

只删除本实验的 Compose 项目和容器数据;不要对生产 etcd 执行 down -v 或删除数据目录。

复盘

延伸阅读

读完自测

  1. 三节点 etcd 停掉两个成员后,为什么还能执行部分读操作?
  2. --consistency=s 与默认读在 quorum 丢失时分别是什么行为?
  3. 恢复 quorum 时为什么不应该同时重启全部成员?
答案
  1. 串行读可以由本地成员直接返回,不需要多数派确认,但数据可能过期。
  2. 串行读可能成功并返回旧值,默认线性读需要 quorum,通常会失败或超时。
  3. 同时重启会延长不可用时间,且不利于观察 leader 选举和成员追赶;应先恢复一个成员重建多数派,再逐个恢复。