分类: 故障演练与排查

  • 深入 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)去掩盖问题。

  • 深入 Prometheus OOM 雪崩排查:动态 Label 滥用引发的高基数风暴与 TSDB WAL 夯死实战

    某次生产核心监控集群突然全线熔断,Prometheus 节点 Load Average 飙升至 100+,Pod 陷入持续的 OOMKilled 死亡循环。排查确认,业务研发在一项 HTTP 统计指标中错误注入了 trace_iduser_id 作为 Label,导致时间序列(Time Series)基数瞬间暴增千万级别。最终通过介入 metric_relabel_configs 强制丢弃高基数 Label,并物理清理内存映射的 Head 块与臃肿的 WAL(Write-Ahead Log)才得以恢复。

    结论先放在这里:监控指标(Metrics)绝对不是日志(Logs),把无边界的动态变量作为 Label 写入 Prometheus,是对 TSDB 存储引擎最无知的谋杀。

    案发现场:失控的 OOM 与堵死的 IO

    排查过程中,告警通道首先报出 Prometheus target 掉线,紧接着是 Kubernetes 节点资源耗尽告警。登录宿主机,dmesg 日志非常直白:

    [52143.123456] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=-998
    [52143.123458] Memory cgroup out of memory: Killed process 10245 (prometheus) total-vm:42949672960kB, anon-rss:34359738368kB, file-rss:0kB, shmem-rss:0kB
    

    32GB 内存的 Pod 被硬生生撑爆。由于 Pod 重启策略为 Always,Prometheus 尝试重新启动,但在恢复 WAL 阶段再次卡死,磁盘 IOPS 被打满,日志停留在:

    level=info ts=... caller=head.go:760 component=tsdb msg="Replaying WAL, this may take a while"
    level=info ts=... caller=head.go:812 component=tsdb msg="WAL segment loaded" segment=1023 maxSegment=1045
    

    重放速度极慢,且内存水位在重放过程中成级数增长,最终在启动完成前再次 OOM。这属于典型的高基数(High Cardinality)雪崩

    罪魁祸首:TSDB 的倒排索引与高基数之殇

    在处理这种无法启动的僵尸实例时,直接查 PromQL 是行不通的。直接把挂载的 PV 临时挂给一个 Debug 容器,掏出 promtool 对 TSDB 数据目录进行离线分析:

    promtool tsdb analyze /prometheus/data/
    

    输出结果直接锁定了元凶:

    Block ID: ...
    ...
    Label names with highest number of values:
    1. trace_id: 12045678
    2. user_id: 8543210
    ...
    Metrics with highest number of series:
    1. http_requests_total: 12045678
    

    一个原本只有几十个 endpoint 和 method 组合的 http_requests_total 指标,因为加上了 trace_id,硬生生裂变出了 1200 万个时间序列。

    为什么加个 Label 能把 32G 内存干爆?这要从 Prometheus TSDB 的底层机制说起:

    Prometheus TSDB 的设计前提是 Label 的组合是有限且收敛的。当前正在写入的数据存放在内存中的 Head Block,Head Block 默认保留最多 3 小时的数据。为了实现快速的多维查询,TSDB 维护了倒排索引(Inverted Index)。 每一条唯一的 Label 组合(例如 http_requests_total{method="GET", trace_id="abc"})都会被当作一个全新的 Series。

    1. 内存放大:在 Head 块中,每个活跃的 Series 都会占用几百字节到几 KB 不等的内存(包括结构体、索引缓存、Chunk 引用等)。一千万个 Series,光是基础的结构体开销就能轻易吃掉十几 GB 内存。

    2. WAL 风暴:每次出现一个新的 Series,TSDB 必须在 WAL 中写入一条 Series Record 以保证宕机不丢失。高基数意味着海量的新 Series 不断产生,WAL 写入量呈指数级上升,直接将磁盘 IO 打到饱和。

    3. Compaction 瘫痪:当 Head 块数据落盘生成持久化 Block 时,后台的 Compaction 机制需要对成千万的 Series 进行合并和索引重构,这会耗尽 CPU,并导致 Compaction 积压。

    业务将 trace_id 塞进 Label,等于把 O(N) 复杂度的存储系统当成了 O(1) 的 Key-Value 库在用。

    止血与修复实战

    既然抓到了凶手,修复逻辑就是:阻断毒流量输入,清理已中毒的数据。

    第一步:通过 relabel 丢弃高基数 Label 在不改动业务代码(或业务还没来得及回滚)的情况下,运维必须在 Prometheus 抓取阶段直接阉割掉这个恶意的 Label。在 prometheus.yml 中修改对应 Job 的配置:

    scrape_configs:
      - job_name: 'business_app'
        # 注意:必须使用 metric_relabel_configs,这作用于抓取后、落盘前的阶段
        metric_relabel_configs:
          - source_labels: [trace_id]
            regex: '.*'
            action: labeldrop
          - source_labels: [user_id]
            regex: '.*'
            action: labeldrop
    

    注:如果是客户端直接暴露了几千万行的 /metrics,那应用本身大概率也会因为构建 metrics 字符串而 OOM。此时需要业务立即回滚。

    第二步:处理无法启动的 TSDB 此时由于旧的脏数据还卡在 WAL 里,Prometheus 依然起不来。最粗暴有效的方法是放弃最近几小时的 Head 块数据(监控容忍短暂的断点,但不容忍系统不可用)。

    进入数据目录,直接清理 WAL 和 chunk_head:

    cd /prometheus/data/
    # 备份后删除(如果在乎现场的话)
    rm -rf wal/*
    rm -rf chunks_head/*
    

    清理后拉起 Prometheus,内存占用瞬间回落到正常的几 GB 水平,Load Average 恢复正常,集群起死回生。

    排查清单与同类问题速查

    1. 内存/OOM 快速定位
    2. 永远不要猜测,直接用 promtool tsdb analyze 分析本地数据块,查看 Metrics with highest number of series 排名。

    3. 区分 Relabel 阶段

    4. relabel_configs:作用于 Target 发现阶段,用于过滤抓取目标(改 IP、改端口、丢弃整个 Endpoint)。
    5. metric_relabel_configs:作用于抓取后、写入 TSDB 前,用于修改或过滤具体的 Metrics 和 Label(丢弃高基数 Label 必用)。

    6. 监控自身的监控

    7. 必须为 Prometheus 配置 prometheus_tsdb_head_seriesprometheus_target_scrapes_exceeded_sample_limit_total 的告警。当 Head 序列数突增时,能在 OOM 发生前拦截。

    8. 高基数需求替代方案

    9. 业务确实需要通过 Metrics 关联 TraceID 怎么办?使用 OpenMetrics 标准的 Exemplars。Exemplars 附着在具体的观测值上,不会被纳入倒排索引,不影响基数,完美解决 Metrics 到 Trace 的联动诉求。

    10. 防御性配置限制

    11. scrape_configs 中强制加上 sample_limitlabel_limitlabel_value_length_limit。宁可让超过阈值的抓取失败(报错 sample limit exceeded),也绝不让垃圾数据撑爆整个集群。
  • K8S API Server 被打挂的元凶:记一次 CRD Status 更新引发的 Reconcile 死循环惨案

    排查某个生产 K8S 集群异常时,发现 APIServer P99 延迟飙升至 4000ms 以上,etcd 磁盘 IOPS 直接打满。排查结论极度缺乏常识:业务团队新上线的一个 Operator 在 Reconcile 循环中毫无节制地更新 CRD 的 Status 字段(甚至注入了 time.Now()),且未配置任何 Event Filter。这导致了一个经典的死循环:更新 Status -> 触发 Update 事件 -> 进入 WorkQueue -> 再次 Reconcile -> 再次更新 Status。最终演变成针对 APIServer 的内网 DDoS,直接干碎了控制平面。

    这种低级失误在 Operator 开发中屡见不鲜。如果你连 K8S 声明式 API 的控制循环语义和 Informer 机制都没搞懂,就不要去碰 controller-runtime

    现场还原与指标雪崩

    近期监控系统疯狂报警,核心集群的 apiserver_request_duration_seconds_bucket 指标中,Mutating API 的 P99 延迟从平时的 15ms 暴涨到 4s。同时,etcd 节点的 etcd_disk_wal_fsync_duration_seconds 指标出现剧烈抖动,底层存储 IOPS 处于持续饱和状态。

    第一反应是控制平面被恶意击穿。拉取 APIServer 的审计日志和 QPS 监控(apiserver_request_total),发现某个特定资源 appconfigs.biz.example.comPUT / PATCH 请求 QPS 高达 8000+,且全集中在 /status 子资源上。

    随便抓一条 APIServer 的日志:

    I0814 10:23:45.123456       1 trace.go:205] Trace[12345678]: "Update /apis/biz.example.com/v1/namespaces/default/appconfigs/test-app/status" (started: 202x-xx-xx..., 3.5s)
    

    很明显,是新上的 Operator 出了严重 Bug。

    扒开烂代码:愚蠢的 Reconcile 逻辑

    把出问题 Operator 的代码拉下来,看一眼 Reconcile 函数和 Controller 的注册逻辑,简直是灾难现场。

    致命代码片段 1:无意义的动态 Status 更新

    func (r *AppConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var instance bizv1.AppConfig
        if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // ... 执行一些实际的业务逻辑 ...
    
        // 灾难的根源:每次 Reconcile 都无脑更新时间戳
        instance.Status.LastReconciledTime = metav1.Now()
        instance.Status.Phase = "Running"
    
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    
        return ctrl.Result{}, nil
    }
    

    致命代码片段 2:毫无防备的 Watch 注册

    func (r *AppConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&bizv1.AppConfig{}). // 没有任何 Predicate 过滤
            Complete(r)
    }
    

    底层原理解析:为什么会死循环?

    在 Kubernetes 的架构中,任何对 Object 的修改(无论是 Spec 还是 Status,甚至是 Annotations 的变动),都会导致该 Object 的 ResourceVersion 发生改变。

    当这段代码执行 r.Status().Update() 时,底层发生了什么?

    1. APIServer 接收到更新请求,持久化到 etcd,并生成一个新的 ResourceVersion

    2. Operator 内部的 Reflector 通过 List-Watch 机制感知到这个变更,将带有新 ResourceVersion 的对象推入 DeltaFIFO

    3. Informer 处理这个 Delta 事件,更新本地 Indexer 缓存,并触发 Update 事件回调。

    4. 由于 SetupWithManager 中没有配置任何过滤条件,这个 Update 事件被原封不动地转换成了一条针对该 NamespacedName 的 Reconcile Request,塞进 WorkQueue

    5. Worker 协程从队列中取出 Request,再次执行 Reconcile

    6. Reconcile 中又执行了 metav1.Now() 生成了全新的时间戳,再次发起 Update

    死循环正式确立。 Operator 的 CPU 飙升,APIServer 的连接池被耗尽,etcd 疯狂刷盘写 WAL,最终整个 K8S 控制平面的响应能力被拖垮。

    破局与防御性编程实践

    修复这个 Bug 只需要两步,但更重要的是建立防御性编程的思维。

    1. 引入 GenerationChangedPredicate 拦截无效事件SetupWithManager 中,必须明确告诉 Controller:我只关心 Spec 的变化,不关心 Status 的变化。Kubernetes 通过 metadata.generationmetadata.resourceVersion 来区分这一点。修改 Spec 会自增 generation,而仅修改 Status 只会改变 resourceVersion

    import "sigs.k8s.io/controller-runtime/pkg/predicate"
    
    func (r *AppConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&bizv1.AppConfig{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
            Complete(r)
    }
    

    注:如果你的 Controller 需要响应 Annotation 或 Label 的变化,不能简单使用 GenerationChangedPredicate,需要自定义 Predicate 逻辑。

    2. 状态对比,拒绝盲目 Update 不要在 Reconcile 中无脑塞 metav1.Now()。状态是用来反映资源当前真实情况的,不是用来做心跳上报的。在调用 Update 之前,必须做 DeepEqual 或者状态哈希校验,只有真正发生变化时才发起网络请求。

    // 好的实践:对比新老状态
    oldStatus := instance.Status.DeepCopy()
    
    // ... 计算新的 status ...
    instance.Status.Phase = "Running"
    // 取消无意义的 LastReconciledTime 更新
    
    if !reflect.DeepEqual(oldStatus, &instance.Status) {
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    }
    

    3. 利用 Client-Side Rate Limiter 兜底 哪怕业务逻辑写出了死循环,也绝不能把底层的 APIServer 打挂。在实例化 Manager 时,应当配置合理的限速器(RateLimiter),控制入队重试的指数退避频率和最大 QPS。

    排查清单:Operator Reconcile 性能与死循环速查

    1. APIServer QPS 异常突增定位: 优先检查 Prometheus apiserver_request_total 指标,按 resourceverb 分组,找出请求量异常的 CRD 和操作类型(通常是 UPDATE / PATCH status)。

    2. Controller 队列深度监控: 观察 workqueue_depthworkqueue_adds_total 指标。如果某个 Controller 的 adds_total 呈陡峭直线飙升,必然存在 Reconcile 死循环。

    3. 检查 Event Predicate 配置: 确认 SetupWithManager 是否使用了 GenerationChangedPredicate,或者是否在自定义的 Update Func 中过滤掉了 oldObj.ResourceVersion == newObj.ResourceVersion 的无效事件。

    4. 排查 Informer Cache 穿透: 绝对禁止在 Reconcile 中使用 r.Client.Get 获取对象后,直接在原对象指针上修改并绕过 Client 调用。如果强行修改 informer 缓存的对象而不提交到 APIServer,会导致本地缓存污染和不可预期的异常。始终对拿到的对象做 DeepCopy

    5. CRD Subresource 配置核对: 检查 CRD 的 YAML 定义中是否启用了 subresources: status。如果没有启用,对 Status 的更新会被当作对主对象的更新处理,极易引发锁冲突和额外的业务级混乱。