深入 Etcd 频繁切主雪崩排查:磁盘 fsync 抖动引发的 Raft 选举风暴与 Pre-Vote 防御实战

近期排查了一起极其恶心的 K8S 生产环境雪崩事故:API Server 频繁报 context deadline exceeded,核心链路的 P99 延迟阶段性飙升至 10s 以上。顺藤摸瓜排查底层,直指 Etcd 集群在疯狂进行 Leader 选举。

直接抛出排查结论:这是典型的底层磁盘 IO 抖动引发的 Raft 选主风暴。某台 Etcd 节点因宿主机共享存储争抢,导致写前日志(WAL)的 fdatasync() 系统调用延迟偶尔飙升至 1.5s 以上,触发了该节点内部的 Follower 选举超时。该节点随即带着更高的 Term(任期号)向全网发起 RequestVote,直接迫使原本完全健康的 Leader 无条件退位。最终,通过将 WAL 剥离至独立 NVMe 盘、重新校准超时参数,并强制开启 Raft Pre-Vote 机制,才彻底镇压了这场风暴。

案发现场:不要看着 CPU 告警南辕北辙

当时的监控大盘一片惨红,Prometheus 上的核心指标 etcd_server_leader_changes_seen_total 像心电图一样剧烈跳动,一小时内切主高达 40 多次。登录 Etcd 节点抓取日志,满屏都是刺眼的告警:

{"level":"warn","msg":"server is likely overloaded","take":"1.52s"}
{"level":"warn","msg":"failed to send out heartbeat on time","issue":"heartbeat timeout"}
{"level":"info","msg":"raft.node: 3a1b2c elected leader 4d5e6f at term 1234"}
{"level":"warn","msg":"apply entries took too long","took":"1.1s","expected-duration":"100ms"}

许多半吊子运维看到 server is likely overloaded 这句话,第一反应就是去给虚拟机无脑加 CPU 核心数,这纯属南辕北辙。Etcd 作为强一致性的分布式键值存储,其性能的阿喀琉斯之踵在于磁盘同步写的延迟,而非 CPU 算力。

现场的架构设计简直是把分布式共识引擎当成了垃圾桶:这套 Etcd 集群的数据目录没有独立挂载,跟业务线高吞吐的批处理应用共用同一个普通企业级 SSD 的 LVM 卷。当业务线爆发密集写入时,底层块设备的 IOPS 被榨干,Etcd 的 WAL 刷盘请求被迫排队。

原理扒皮:Raft 协议的“无情”与捣乱者难题

为什么一台 Follower 节点的磁盘变慢,会导致整个健康的集群陷入不可用?这就必须扒一扒 Raft 共识算法的底层逻辑。

在 Raft 协议中,Leader 通过定期发送心跳(Etcd 默认 heartbeat-interval=100ms)来压制手下的 Follower。Follower 内部有一个倒计时器(默认 election-timeout=1000ms),如果在 1 秒内没收到 Leader 的心跳,就会判定 Leader 已死,随时准备篡位。

关键的命门在于:Raft 认 Term(任期)不认人,且 Term 单调递增。

当那个因为磁盘慢而卡死的节点(假设为 Node B)发生 IO 阻塞超过 1 秒时,它错过了心跳处理,导致倒计时归零。Node B 从 IO 阻塞中苏醒后,第一件事就是将自己的 Term 加 1(比如从 10 升级到 11),状态切换为 Candidate,并向全网广播 RequestVote 拉票。

此时,原 Leader(Node A)和正常的 Follower(Node C)的网络完全畅通,心跳也在正常打。但是,当健康的 Leader Node A 收到来自 Node B 的 Term=11 请求时,Raft 规则的无情一面就体现出来了:任何节点,只要看到比自己当前 Term 更大的数字,必须立刻放弃抵抗,无条件降级为 Follower。

于是,Node A 乖乖交出统治权,集群立刻进入只读停顿状态,开始重新选举。由于 Node B 磁盘奇慢,它的日志大概率落后于 A 和 C,根本不可能赢得多数派选票。最终 A 或 C 重新当选 Leader。但好景不长,只要 Node B 的磁盘再卡一次,它就会生成 Term=12 再次发起冲击。

这就是分布式系统中经典的 捣乱者问题(Disruptive Server)。一个实际上已经半残的节点,通过不断自增 Term,把整个原本健康的集群拖入无尽的选举深渊。

防御与落地:Pre-Vote 与硬件隔离

修复这个架构缺陷,需要从软件防御和硬件隔离双管齐下。

1. 软件防御:强制启用 Pre-Vote 机制

Raft 论文的作者后来意识到这个设计缺陷,提出了 Pre-Vote(预投票) 扩展机制。其核心思想是:在节点真正增加 Term 并发起选举之前,先发起一轮“模拟投票”:问问其他节点“如果我发起选举,你们会投我吗?”。

在上述场景中,当 Node B 醒来发起 Pre-Vote 时,由于健康的 Node A 和 Node C 仍在正常交换心跳,它们会果断拒绝 Node B 的预投票请求。Node B 拿不到多数派许可,就不敢私自增加自己的 Term,从而完美保护了现有 Leader 的统治。

排查时发现,这个老旧的集群居然显式禁用了该机制。果断在启动参数中加上 --pre-vote=true(Etcd 3.4+ 默认已开启,但需严防老配置覆盖),从协议层面斩断了雪崩的可能。

2. 硬件与架构防御:敬畏 WAL 的落盘机制

Etcd 每次事务提交,都必须调用 fdatasync() 将 WAL 强制刷入磁盘,这一步不能有任何水分。

  • 物理隔离:通过 --wal-dir 参数,强制将写前日志挂载到独占的 NVMe 磁盘上,与普通数据 --data-dir 分开,彻底消除 IO 争抢。

  • 参数重整:不要迷信默认参数配置。在网络 RTT 存在微小抖动或 IO 无法做到极致隔离的场景,修改参数:heartbeat-interval=250election-timeout=2500。法则是:选举超时时间必须至少是心跳间隔的 10 倍以上,给系统底层留出喘息的缓冲区。

同类问题速查(排查清单)

  1. 核心指标抓取:优先排查 Prometheus 中的 etcd_disk_wal_fsync_duration_seconds_p99,如果该指标频繁超过 100ms(甚至达到秒级),必定会触发选举,立刻检查磁盘 IO 状态。

  2. 审查网络 RTT:查看 etcd_network_peer_round_trip_time_seconds,若跨 AZ 部署导致网络延迟超过 50ms,默认的 1000ms 选举超时极其危险,需按比例放大超时参数。

  3. 确认 Pre-Vote 状态:通过 Etcd 启动日志或命令 etcd --version 确认版本号,排查配置文件确保未设置 PreVote: false

  4. 清理僵尸节点:如果集群中长期存在断联的僵尸节点(Member List 存在但进程已死),一旦它复活且网络连通,极大概率会带着巨大的过期 Term 冲击当前 Leader。务必及时 member remove 掉长期掉线的节点。