标签: Raft

  • 深入 Raft 幽灵节点排查:单向网络隔离引发的 Term 飞涨与 PreVote 拦截实战

    排查自研分布式 KV(基于 go.etcd.io/etcd/raft/v3 v3.5.0)频繁无故切换 Leader 导致 QPS 跌零时发现,单向网络隔离会导致“幽灵节点”无法接收心跳,从而不断自增 Term 发起选举。高版本 Term 的投票请求会穿透隔离,强制合法 Leader 降级引发选举风暴。核心解法是全量开启 Raft PreVote(预投票)机制,并在配合 CheckQuorum,在自增 Term 前验证网络连通性,从协议层阻断脑裂假象。

    0x00 故障现场:毫无征兆的 Leader Flapping

    排查过程中,监控面板上出现了一个极为诡异的现象:集群整体流量不高,CPU/内存均无压力,但 API Server 报出大量 503 Service Unavailable

    调出 Prometheus 监控,发现两个核心指标极度异常:

    1. Leader 切换频繁: rate(raft_leader_changes_total[1m]) 出现规律性尖刺。

    2. Term 飞涨: 集群的 raft_term 指标像脱缰的野马,短时间内从 142 飙升到了 15403

    拉取当前 Leader(节点 A)的核心报错日志,发现其被强制逼退:

    {"level":"info","ts":"...","caller":"raft/raft.go:1004","msg":"[raft] node A stepped down to follower since error or received message with higher term","term":15403}
    

    紧接着,节点 A 重新发起选举,拿回 Leader 身份,但没过几秒,再次被逼退。整个集群陷入了无休止的“选举-当选-被逼退”的死亡循环中,此时 I/O 停滞,业务读写全被阻塞。

    0x01 定位元凶:单向网络隔离引发的“毒药”

    顺着日志,我将目光锁定在节点 C。节点 C 一直处于 Follower 状态,但它的 raft_term 却是全场最高的。

    登录节点 C 宿主机,通过 tcpdump 抓包分析发现了一个典型的单向网络隔离(One-way Partition)现象:

    # 在节点 C 上抓取与节点 A (Leader) 的 Raft 通信
    tcpdump -i eth0 host <Node_A_IP> and port 2380 -nn -vv
    

    抓包结果显示:节点 C 能向外发送数据包,但接收不到任何来自节点 A 的数据包。 检查网络层发现,是某次变更不慎在节点 A 所在宿主机的 iptables 的 OUTPUT 链中,针对节点 C 的 IP 配置了 DROP

    协议教科书里往往假设网络是完全断开的双向隔离,但在实际物理机房中,非对称路由、交换机单播风暴拦截、iptables 误配引发的单向隔离才是最致命的毒药。

    0x02 为什么单向网络隔离会引发全局选举风暴?

    在标准 Raft 协议中,一切以 Term(任期)为尊。单向隔离彻底击穿了标准 Raft 的防线,其演变过程如下:

    1. 心跳超时与 Term 膨胀: Leader A 正常发送心跳(MsgHeartbeat),但节点 C 收不到。节点 C 的选举定时器超时,根据协议,它将自身转为 Candidate,Term 加 1(变为 143),并向全网广播 MsgVote

    2. 毒药广播: 因为是单向隔离,节点 C 的 MsgVote 成功发送到了 A 和 B。

    3. 强制降级: Leader A 收到节点 C 的 MsgVote,虽然节点 C 的日志可能不是最新的,但 Raft 的强规则是:一旦收到 Term 大于自身当前 Term 的消息,当前节点必须无条件转为 Follower 并更新自己的 Term

    4. 无法当选与死循环: A 降级后集群无 Leader,开始新一轮选举。A 和 B 互相通信,A 重新当选(Term=144)。但节点 C 依然收不到心跳,再次超时,Term 变为 145,再次发送 MsgVote 逼退 A。

    节点 C 就像一个幽灵,自己永远无法当选(因为收不到其他节点的投票响应),但却能通过不断自增的 Term 作为“毒药”,把正常运行的 Leader 拉下马。

    0x03 PreVote 源码剖析:在拔剑前先确认身份

    为了解决这个标准 Raft 的缺陷,etcd/raft 引入了 PreVote(预投票)机制。其核心思想非常克制:在正式增加 Term 之前,先发起一次模拟投票;只有在确保自己能获得多数派选票时,才真正增加 Term 发起正式选举。

    翻开 go.etcd.io/etcd/raft/v3 的底层源码(raft.go),我们可以看到状态切换的区别:

    // tickElection 在选举超时后被调用
    func (r *raft) tickElection() {
        // ... 
        if r.preVote {
            // 开启了 PreVote:先进入 PreCandidate 状态,不增加 Term
            r.Step(pb.Message{From: r.id, Type: pb.MsgHup})
        } else {
            // 未开启 PreVote:直接进入 Candidate 状态,Term + 1 (危险行为)
            r.campaign(campaignElection)
        }
    }
    
    func (r *raft) campaign(t CampaignType) {
        // ...
        if t == campaignPreElection {
            r.becomePreCandidate() // 注意:这里调用后,r.Term 不会增加
            voteMsg = pb.MsgPreVote
        } else {
            r.becomeCandidate()    // 这里调用后,r.Term 会 +1
            voteMsg = pb.MsgVote
        }
        // 发送投票请求
        for _, id := range r.prs.Voters.IDs() {
            if id == r.id { continue }
            r.send(pb.Message{Term: term, To: id, Type: voteMsg, ...})
        }
    }
    

    PreVote 拦截的精妙之处在于其他节点的响应逻辑: 当正常节点 A(Leader)收到节点 C 的 MsgPreVote 时,因为 MsgPreVote 携带的是节点 C 当前的 Term(并没有加1),A 会判断自己当前仍然是合法的 Leader(未过 Lease 期/选举超时时间),因此会直接拒绝给节点 C 投预选票。 节点 C 拿不到多数派的预选票,就永远无法进入 Candidate 状态,Term 也永远不会增加,集群脑裂假象被彻底扼杀。

    0x04 落地实战:防御性架构的配置规范

    在自研系统的 Raft 引擎初始化阶段,必须强制开启 PreVoteCheckQuorum。这两个配置是高可用集群的“左右护法”。

    import "go.etcd.io/etcd/raft/v3"
    
    func newRaftNode(id uint64, peers []raft.Peer, storage *raft.MemoryStorage) raft.Node {
        config := &raft.Config{
            ID:                        id,
            ElectionTick:              10,
            HeartbeatTick:             1,
            Storage:                   storage,
            MaxSizePerMsg:             1024 * 1024,
            MaxInflightMsgs:           256,
    
            // 【防御性配置一】强制开启 PreVote 拦截网络孤岛引发的 Term 飞涨
            PreVote:                   true,
    
            // 【防御性配置二】强制开启 CheckQuorum
            // 允许 Leader 周期性检查自己是否仍然能连接到多数派,
            // 如果不能,Leader 会主动 stepDown,防止出现双 Leader 假象下的脏读
            CheckQuorum:               true, 
        }
    
        // 启动 Raft 状态机
        return raft.StartNode(config, peers)
    }
    

    配置下发并滚动重启集群后,我们再次通过 iptables 模拟针对单节点的网络隔离。 监控显示:被隔离的节点后台会不断发起 MsgPreVote,但被存活节点拒绝。主集群的 Leader 坚如磐石,raft_term 曲线保持绝对平稳,业务 QPS 0 抖动。

    0x05 常见问题 (Q&A)

    Q1:开启 PreVote 后,如果真实的 Leader 发生硬件宕机,选举耗时会变长吗? 会增加一次 RPC 往返(RTT)的耗时。因为候选者需要先走完 PreElection 阶段,拿到预选票后,再走正式的 Election 阶段。但在同城机房内,一次 RTT 通常在 1ms 以内,相比于默认 1000ms 的选举超时(Election Timeout),这点延迟对可用性的影响微乎其微,换来的却是极高的系统稳定性。

    Q2:如果网络完全断开(双向隔离),PreVote 还能发挥作用吗? 能。在双向隔离中,孤岛节点发不出预投票,自己也会一直处于 Follower/PreCandidate 状态,Term 不会增加。当网络恢复后,它重新接入集群时,其 Term 与主集群一致,通过正常的 MsgApp (AppendEntries) 就能无缝对齐日志,不会对现有 Leader 造成任何冲击。

    Q3:为什么不单纯依靠调大 Election Timeout 来规避网络抖动带来的频繁选举? 单纯调大 Election Timeout 是一种掩耳盗铃的做法。它确实能掩盖短暂的网络抖动,但代价是极大地延长了真实故障发生时的 MTTR(平均恢复时间)。发生真实物理宕机时,集群需要等待漫长的 Timeout 才会开始重选 Leader,这段时间内业务是完全不可用的。Raft 的调优原则是:用协议本身的严谨性(PreVote)去解决逻辑问题,而不是用粗暴的延迟(增大 Timeout)去掩盖问题。

  • Etcd 集群频繁 Leader 切换雪崩:WAL fsync 阻塞引发的 Raft 心跳饿死与选主风暴排查实战

    近期排查了一个非常经典的分布式共识层故障。K8s 集群的 API Server 频繁报 context deadline exceeded,核心控制器全线 CrashLoopBackOff。底层定位到 Etcd 集群处于极度不稳定的状态,Raft Leader 疯狂切换(Flapping)。最终查明,这是一起由于共主节点磁盘 I/O 被同机其他定时任务打满,导致 Etcd WAL (Write-Ahead Log) fsync 严重超时,进而“饿死” Raft 心跳触发的选主风暴惨案。

    在分布式共识(Raft/Paxos)的工程实践中,存储 I/O 抖动是干掉集群可用性的头号杀手。遇到这种问题,调整网络参数是缘木求鱼,必须深入底层的日志复制和状态机流转机制去开刀。

    故障现场:API Server 雪崩与疯狂的 Term 暴增

    排查期间,首先接到 Prometheus 告警,K8s API Server 的 P99 延迟直接从平时的 30ms 飙升到了 8000ms 以上。查看 Etcd 集群状态,发现 etcd_server_leader_changes_seen_total 指标呈阶梯状暴增。

    直接拉取 Etcd 的运行日志,满屏的红色 Error,核心报错就两行:

    # Leader 节点疯狂抱怨心跳发送超时
    {"level":"warn","ts":"...","caller":"etcdserver/server.go:2038","msg":"failed to send out heartbeat on time (exceeded the 100ms timeout for 2.3s)","server_id":"8211f1d0f64f3269"}
    
    # 紧接着 Leader 发现自己任期落后,被迫下台
    {"level":"info","ts":"...","caller":"raft/raft.go:825","msg":"8211f1d0f64f3269 [term: 1205] received a MsgVote with higher term from 7192f1d0f64f11a2 [term: 1206]"}
    {"level":"info","ts":"...","caller":"raft/raft.go:842","msg":"8211f1d0f64f3269 became follower at term 1206"}
    

    从日志可以看出一个典型的 Raft 状态扭转过程:

    1. 当前 Leader 因为某种原因,长达 2.3 秒没有发包。

    2. Follower 节点的 election-timeout(默认 1000ms)耗尽,认为 Leader 已死。

    3. Follower 状态转为 Candidate,将当前任期(Term)+1,并向集群广播 MsgVote

    4. 原 Leader 收到高 Term 的投票请求,瞬间认怂,StepDown 退化为 Follower。

    如此反复,集群陷入了永无止境的选主(Election Storm),导致没有任何一个节点能稳定处理外部 Client 提交的写请求(Propose)。

    原理剖析:为什么磁盘卡顿会饿死网络心跳?

    很多新人会有个疑问:磁盘 I/O 慢,大不了客户端的写请求(Put)慢一点,为什么连 Raft 节点之间的网络心跳都会发不出去?

    这就得扒一下 Etcd 底层 Raft 状态机的工程实现逻辑。在 etcd/raft 模块中,为了保证强一致性,Raft Node 处理状态机输出(Ready 结构体)的典型流程是一个同步的串行大循环:

    // Etcd Raft 核心循环的伪代码逻辑映射
    for {
        select {
        case rd := <-node.Ready():
            // 1. 将 HardState 和 Entries 写入底层 WAL 文件并强制落盘
            saveToStorage(rd.HardState, rd.Entries)
            // 注意这里的 fsync 是阻塞调用!
            wal.Fsync() 
    
            // 2. 将消息(包含 AppendEntries/心跳)发送给其他 Peer
            send(rd.Messages)
    
            // 3. 将已提交的日志应用到内存状态机(KV 存储)
            applyToStore(rd.CommittedEntries)
    
            node.Advance()
        }
    }
    

    发现致命问题了吗?WAL 落盘(wal.Fsync())和发送网络消息(send)是在同一个处理流程中的。 Raft 协议要求:日志必须先持久化到本地(保证 Crash-Safe),然后才能广播给其他节点。 如果底层磁盘 I/O 突然飙升,fsync 系统调用被内核挂起 2 秒,那么紧跟在后面的 send(rd.Messages) 就会被硬生生延迟 2 秒!

    Leader 发不出带着空 Entry 的 AppendEntries RPC(即心跳),Follower 就会准时发起叛变。

    现场缉凶:I/O 被谁吃干抹净了?

    顺着这个逻辑,直接去 Leader 宿主机上查 I/O 现场。 使用 iostat -dx 1 监控,发现系统盘(/dev/vda)的 %util 长期顶死在 100%,await 指标高达 2500ms+。

    进一步通过 iotop -ops 溯源,抓到了真凶: 宿主机上被人偷偷配了一个 Ansible 统一下发的 Cronjob,跑的是一个极度暴力的 tar -czf 日志归档脚本,且没有任何资源限制(cgroups/ionice)。这个任务瞬间榨干了云盘的 IOPS(突发型 EBS 的 Burst Balance 直接被扣光),导致同在一块盘上的 Etcd WAL 写入被内核底层 I/O 调度队列无情阻塞。

    架构避坑与防御性配置

    把这种重型 I/O 任务与对延迟极其敏感的分布式共识组件混跑,在运维界属于经典的低级失误。为了防止这类 I/O 抖动导致系统雪崩,必须做好以下防御性架构调优:

    1. 物理隔离:分离 WAL 目录

    千万不要把 Etcd 的数据和系统的 /var/log 甚至其他业务跑在同一块盘上。 Etcd 启动时强烈建议利用 --wal-dir 参数,将 WAL 单独挂载到一块独立的高性能 SSD / NVMe 盘上。 WAL 是 Append-only 的顺序写,对 IOPS 要求极高且对延迟敏感;而 DB 文件 (--data-dir) 存在随机读写和压缩。分离两者能最大程度保护心跳逻辑。

    2. 调优 Raft 超时参数 (适用于云环境)

    Etcd 默认的 heartbeat-interval=100mselection-timeout=1000ms 是为局域网低延迟裸金属服务器设计的。在存在网络虚拟化和存储网络化(EBS/Ceph)的云环境中,稍微的 I/O 抖动就会打破这个 1 秒的底线。 实战建议: 针对跨可用区(Multi-AZ)或云盘环境,适当放宽超时容忍度。

    # 启动参数调整
    --heartbeat-interval=250
    --election-timeout=2500
    

    注:election-timeout 推荐设置为 heartbeat-interval 的 10 倍,以规避网络偶发丢包。

    3. 确保 Pre-Vote 机制开启

    如果是自行维护的旧版本 Etcd 或其他 Raft 实现,务必确保 Pre-Vote 机制是开启的(Etcd 3.4+ 默认开启)。 当网络发生非对称分区(Asymmetric Partition)或节点局部 I/O 夯死时,节点会被隔离并空转 Term。一旦它恢复并重新接入集群,它的高 Term 会立刻把正常 Leader 打下台。开启 Pre-Vote 后,Candidate 在增加本地 Term 前,必须先发起一轮预投票(PreVote),如果无法获得多数派响应,则不允许增加 Term,从根本上阻断了此类选主风暴。

    排查清单:同类问题速查

    如果你的 K8s/Etcd/Consul 集群出现频繁选主或超时断连,请直接按以下清单排查:

    1. 查磁盘 fsync 延迟:查看 Prometheus 指标 etcd_disk_wal_fsync_duration_seconds,若 P99 超过 election-timeout(默认 1s),必发选主风暴。

    2. 查系统级 I/O 争抢:使用 iostat 检查 IO util 和 await,排查同节点是否有定时快照(Snapshot)、日志备份、Prometheus 压盘等耗 IO 进程。

    3. 查网络 RTT 与丢包率:排查跨 AZ 部署时的网络抖动,指标 etcd_network_peer_round_trip_time_seconds,若网络 RTT 超过心跳间隔(100ms),会导致 Follower 频繁超时。

    4. 查大 Key 写阻塞:排查业务端是否有超大体积的 KV 写入(如巨型 ConfigMap)。Raft 复制大单体 Entry 会占用整个网络与 I/O 周期,变相阻塞后续的心跳包发送。