标签: 分布式共识

  • 深入 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 掉长期掉线的节点。

  • 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 周期,变相阻塞后续的心跳包发送。