标签: etcd

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

  • K8S 控制平面性能调优实战:如何拯救被 List-Watch 击穿的 etcd 集群

    大规模 K8S 集群中,90% 的控制平面雪崩源于野蛮的 List 请求击穿 APIServer 缓存并耗尽 etcd 磁盘 IO。本文通过配置 APF 阻断高频穿透请求,结合 etcd WAL 磁盘物理隔离与参数调优,彻底解决控制平面高延迟与假死问题。

    案发现场:慢如老牛的 APIServer 与崩溃的 etcd

    某次集群(K8S v1.26.5, etcd v3.5.7)规模扩容至 500+ Node、20000+ Pod 后,控制平面出现剧烈抖动。具体表现为:kubectl 响应极慢甚至经常 Timeout,新 Pod 处于 ContainerCreating 状态长达数分钟无法调度。

    直切要害,先看 APIServer 报错日志:

    W0824 10:12:35.123456       1 request.go:1085] Request takes too long: type=list, resource=pods, user=system:serviceaccount:monitoring:custom-operator...
    

    转头去拉 etcd 的日志,标准的重载现象:

    {"level":"warn","ts":"...","caller":"etcdserver/server.go:872","msg":"apply request took too long","took":"543.2ms","expected-duration":"100ms","prefix":"k8s.io/pods/..."}
    {"level":"warn","ts":"...","caller":"wal/wal.go:783","msg":"sync duration of file 485.4ms, expected duration is <10ms"}
    

    通过 PromQL 看一眼核心指标:

    # 查看 etcd WAL fsync 99线延迟
    histogram_quantile(0.99, rate(etcd_disk_wal_fsync_duration_seconds_bucket[5m]))
    

    查询结果显示 fsync 99线延迟竟然飙到了 600ms 以上。正常基于 NVMe SSD 的集群,这个值不该超过 10ms。控制面板的瓶颈已经很清晰了:底层 etcd 的 IO 被彻底打爆,导致 Quorum 写入超时,上层 APIServer 出现堆积。

    为什么一个外围的 Operator 能轻易干碎底层 etcd?

    在排查过程中,通过开启 APIServer 的审计日志(Audit Log),发现元凶是某个业务团队自己写的 custom-operator。它每隔几秒钟就在全局范围内发起针对 Pod 和 ConfigMap 的全量 List 操作。

    这里必须讲一下 K8S APIServer 处理 List 请求的底层逻辑。很多人以为 APIServer 有本地 Cache,所有的读请求都不会对 etcd 造成压力。这是典型的只知其一不知其二。

    当客户端发起 List 请求时,决定是否命中 APIServer 缓存的关键在于 ResourceVersionLimit 参数:

    1. ResourceVersion="0":直接从 APIServer 本地 Cache 读取数据,对 etcd 无影响,速度最快。

    2. ResourceVersion="" (未设置):默认行为,要求保证强一致性(Quorum Read)。APIServer 必须穿透缓存,向 etcd 发起请求以获取最新数据。在数据量庞大的集群中,这种全量拉取不仅消耗 etcd CPU 和内存,还会挤占网络带宽。

    3. 未设置分页参数 (Limit / Continue):如果单次拉取的数据集达到数百 MB,APIServer 在反序列化时会造成巨大的 CPU 飙升和内存消耗(OOM 诱因)。

    当时的那个 custom-operator,用的是旧版 client-go,且写法极其粗暴,未走 Informer 机制(基于 Watch 维护本地 Cache),而是直接调用原生 Client 的 List 方法,并且未带任何缓存容忍参数。这就是典型的“一脚油门把 etcd 踹进火葬场”。

    调优实战:防穿透与底层 IO 隔离

    既然找到了问题,处理思路就很直接:上层限流,底层扩容 IO

    1. APIServer 侧:启用 APF(API Priority and Fairness)进行流控

    绝对不要指望业务开发能立刻改掉拉垮的代码,运维必须从架构层面自保。K8S 自带的 API 优先级和公平性(APF)就是用来防这类 DDoS 的。

    针对这个惹祸的 Operator,我们专门下发一个 FlowSchemaPriorityLevelConfiguration 来压制它的并发数:

    # 1. 定义并发等级:限制最多只能有 2 个并发,超出直接拒绝或排队
    apiVersion: flowcontrol.apiserver.k8s.io/v1beta3
    kind: PriorityLevelConfiguration
    metadata:
      name: limit-custom-operator
    spec:
      type: Limited
      limited:
        assuredConcurrencyShares: 5
        limitResponse:
          type: Reject # 超过限额直接拒绝,不排队,快速失败
    ---
    # 2. 匹配肇事的 ServiceAccount 规则
    apiVersion: flowcontrol.apiserver.k8s.io/v1beta3
    kind: FlowSchema
    metadata:
      name: restrict-custom-operator
    spec:
      priorityLevelConfiguration:
        name: limit-custom-operator
      matchingPrecedence: 100
      rules:
      - subjects:
        - kind: ServiceAccount
          serviceAccount:
            name: custom-operator
            namespace: monitoring
        resourceRules:
        - apiGroups: ["*"]
          resources: ["pods", "configmaps"]
          verbs: ["list"]
    

    应用该策略后,该 Operator 的高频穿透读被直接按死在 APIServer 层,返回 429 Too Many Requests,etcd 的负载曲线立刻呈断崖式下降。

    2. etcd 侧:WAL 与数据盘的物理隔离

    虽然拦住了异常流量,但 etcd fsync 延迟对磁盘波动的敏感度依然极高。默认情况下,etcd 的 WAL(预写日志)和 db 数据文件都在同一块盘上。 etcd 处理一次写请求的路径是:收到请求 -> Append WAL -> fsync 落盘 -> 应用到状态机 -> 返回。如果 fsync 慢,整个集群的写入就慢。

    在生产环境中,必须将 WAL 剥离到单独的极速盘(最好是基于 PCIe 的 NVMe SSD,不与其他任何 IO 混用)。

    操作步骤: 假设新的高性能盘挂载点为 /data/etcd-wal

    1. 停止 etcd 进程。

    2. 迁移原有的 WAL 目录: bash mv /var/lib/etcd/member/wal/* /data/etcd-wal/ rm -rf /var/lib/etcd/member/wal ln -s /data/etcd-wal /var/lib/etcd/member/wal

    3. 调整文件系统挂载参数。在 /etc/fstab 中,确保存储 etcd 数据的磁盘禁用 atime 记录,减少无用元数据更新: text /dev/nvme1n1 /data/etcd-wal ext4 defaults,noatime,nodiratime,barrier=0 0 0
    4. 启动 etcd。

    3. etcd 参数调优(缓解大对象写入)

    除了存储隔离,对于 v3.5 版本的 etcd,我们还需调整以下参数,提升其在高并发场景下的生命力:

    • --snapshot-count=10000:默认 100000 次修改才做一次快照。将其调低,减少每次构建快照的内存消耗和 IO 瞬时突增。

    • --quota-backend-bytes=8589934592:默认 2G,大集群极易触顶导致 alarm:NOSPACE,直接拉满到 8G(官方建议最大上限)。

    • 开启自动压缩:--auto-compaction-retention=1 / --auto-compaction-mode=periodic,每小时清理一次历史版本,防止库文件无限膨胀。

    常见问题

    Q: APF 配置把业务请求拦掉了,业务跑异常了怎么办? A: 运维的底线是保证控制平面的可用性,而不是为烂代码买单。如果是 List 被限流返回 429,业务应该在代码中实现退避重试(Exponential Backoff),最根本的解决方法是改写代码,使用 client-go 的 SharedInformerFactory,基于 List-Watch 机制消费本地内存数据,绝不允许将 APIServer 当作通用数据库高频乱查。

    Q: 为什么 etcd 报 NOSPACE,但我看了下磁盘空间还有很多剩余? A: 这是个经典的认知误区。etcd 的 NOSPACE 通常指的不是宿主机的磁盘满了,而是 etcd 的 DB 文件大小达到了 --quota-backend-bytes 设置的硬上限(默认 2GB)。解决办法:首先用 etcdctl compact 压缩历史版本,然后执行 etcdctl defrag 释放存储碎片,最后视情况修改启动参数提高 Quota 值。

    Q: APIServer 的参数配置里,--max-requests-inflight 和 APF 有什么区别? A: --max-requests-inflight(及其相关的 mutating 参数)是全局并发限制,属于一刀切的限流。一旦触发阈值,不论是关键的 Controller 还是无用的旁路脚本,都会被无差别丢弃。而 APF 是精细化流控,支持根据资源类型、User、Namespace 等对请求进行分类、排队和熔断。在较新的 K8S 版本中,APF 是更推荐且更核心的防灾手段。