分类: 架构与高可用

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

  • 深入 Jenkins 动态 Agent 调度延迟:K8S Pod 启动风暴引发的 JNLP 连接超时与 Master 线程耗尽排查实战

    高并发 CI/CD 场景下,Jenkins K8S 动态 Agent 极易因 Pod 启动风暴引发雪崩。本文核心结论:当并发构建量突增时,基于传统的 TCP 50000 端口进行 JNLP 通信会导致大量半连接和路由超时;通过将 Remoting 协议切换为 WebSocket,并调优 fabric8 客户端并发数与 K8S Cloud 的 containerCap,可彻底根治 Agent 频繁掉线与 Master 线程耗尽问题。

    故障现场:Agent 陷入“创建-离线-销毁”的死循环

    某次核心业务线进行大版本多分支并发验证,短时间内触发了超过 300 个 Pipeline 构建任务。监控大盘显示,Jenkins Master(版本 2.426.3-lts)的 Load Average 瞬间飙升至 40+,大量构建任务处于 Pending 状态。

    观察 K8S 集群发现,Kubernetes Plugin 确实在疯狂下发 Pod 创建请求,但现象极为诡异:

    1. Pod 能够被 K8S 调度并启动,进入 Running 状态。

    2. Pod 内的 jnlp 容器存活约 100 秒后,打印 Terminated 异常并自动退出。

    3. Jenkins Master 认为 Agent 离线,再次向 K8S 申请新建 Pod。

    4. 整个集群陷入了毫无意义的资源消耗死循环,API Server QPS 异常突增。

    提取出错 Agent Pod 内 jnlp 容器的日志:

    INFO: Locating server among [http://jenkins-master.cicd.svc.cluster.local:8080/]
    INFO: Trying protocol: JNLP4-connect
    WARNING: Could not connect to jenkins-master.cicd.svc.cluster.local:50000
    java.net.ConnectException: Connection timed out (Connection timed out)
        at java.base/sun.nio.ch.Net.connect0(Native Method)
        at hudson.remoting.Engine.connect(Engine.java:544)
        at hudson.remoting.Engine.innerRun(Engine.java:375)
    

    深度追踪:为什么 K8S Agent 能够正常拉起,却始终无法完成 JNLP 注册?

    从日志来看,这是一个典型的网络连通性报错,但问题并没有那么表面。Jenkins 的 Master-Agent 架构依赖 Remoting 协议,其传统的握手流程如下:

    1. Agent 启动时,通过 HTTP(S) 请求 Master 的 TCP port API,获取 JNLP 加密凭证(Secret)和专用的 TCP 通信端口(默认 50000)。

    2. Agent 与 Master 的 50000 端口建立长连接,维持心跳并接收 Pipeline 执行指令。

    1. 传统 TCP 50000 端口的架构缺陷

    在 K8S 环境中,Master 通常隐藏在 Ingress 或 Service 之后。如果仅仅暴露 HTTP 8080 端口,而没有在 Ingress 上透传 50000 端口的 TCP 流(需配置 Ingress Nginx 的 tcp-services ConfigMap),Agent 在第二步就会直接被拒绝。

    即便 Service 层开放了 50000 端口,当数百个 Agent 同时发起 TCP 握手时,若底层网络 CNI 插件(如 Calico 或 Cilium)遇到 iptables/eBPF 规则更新延迟,也会导致 SYN 报文被 Drop,进而引发 Connection timed out

    2. Jenkins Master 线程池耗尽

    排查过程中,直接在 Jenkins Master 宿主机抓取 jstack,发现大量 Jetty HTTP 线程处于 BLOCKED 状态:

    "qtp12345678-100" prio=10 tid=0x00007f8a1c000000 nid=0x1a2b waiting for monitor entry [0x00007f8a11234000]
       java.lang.Thread.State: BLOCKED (on object monitor)
        at org.csanchez.jenkins.plugins.kubernetes.KubernetesCloud.provision(KubernetesCloud.java:650)
        - waiting to lock <0x00000007a1b2c3d0> (a java.lang.Object)
        at hudson.model.NodeProvisioner.update(NodeProvisioner.java:310)
    

    Kubernetes Plugin(版本 4136.vca_b_3203a_5103)底层使用 fabric8 K8S 客户端。默认情况下,fabric8 的 HTTP 客户端(OkHttp)对同一 Host 的并发连接数有严格限制。当并发创建 Pod 请求积压时,不仅阻塞了 Jenkins NodeProvisioner 的调度线程,更拖垮了 Master 响应 Agent HTTP JNLP 请求的能力,导致即使网络是通的,Agent 也因 Master 响应超时而注册失败。

    防御性架构重构与 JCasC 落地

    要从根本上解决高并发下的 Agent 调度雪崩,必须切断对独立 TCP 端口的依赖,并对 K8S Plugin 进行限流防爆。

    1. 抛弃独立 TCP 端口,全面启用 WebSocket

    Jenkins 2.222+ 已原生支持通过 WebSocket 传输 Remoting 协议。启用后,Agent 的通信将直接复用 HTTP(S) 的 8080/443 端口,无需额外配置 TCP 转发,完美穿透 Ingress 与负载均衡器,且极大降低了网络组件的连接跟踪(Conntrack)压力。

    2. JCasC (Jenkins Configuration as Code) 最佳实践

    通过 JCasC 固化 Kubernetes Cloud 的防御性配置。以下为排查后的标准配置片段,重点关注 webSocket 与容量控制参数:

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            serverUrl: "https://kubernetes.default"
            # 强制启用 WebSocket 复用 HTTP 端口
            webSocket: true 
            # Master 并发创建 Agent 的上限,避免 API Server 与 fabric8 线程池被击穿
            containerCapStr: "100" 
            # 连接超时与读取超时调优
            connectTimeout: 5
            readTimeout: 15
            templates:
              - name: "base-agent"
                namespace: "jenkins-agents"
                label: "k8s-agent"
                # 故障排查关键:任务失败后保留 Pod 10分钟,以便抓取现场日志
                podRetention: "OnFailure" 
                containers:
                  - name: "jnlp"
                    image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                    workingDir: "/home/jenkins/agent"
                    resourceRequestCpu: "500m"
                    resourceLimitCpu: "1000m"
                    resourceRequestMemory: "512Mi"
                    resourceLimitMemory: "1024Mi"
    

    3. JVM 与底层客户端参数调优

    为了防止 fabric8 客户端在极端并发下卡死,需要在 Jenkins Master 的启动参数(JAVA_OPTS)中注入以下调优指令,突破 OkHttp 的并发瓶颈:

    # 提升 Kubernetes Client 对单个后端(API Server)的并发连接数限制
    -Dkubernetes.client.maxConcurrentRequests=200
    -Dkubernetes.client.maxConcurrentRequestsPerHost=100
    # 禁用 Jenkins 旧版 Remoting 协议,减少安全面攻击和不必要的协议回退
    -Djenkins.slaves.JnlpSlaveAgentProtocol3.enabled=false
    -Djenkins.slaves.JnlpSlaveAgentProtocol4.enabled=true
    

    常见问题 (FAQ)

    Q1:Pipeline 执行时频繁报 NotSerializableException,如何解决? 这是由于 Jenkins 的 CPS(Continuation Passing Style)引擎在持久化 Pipeline 状态时,遇到了无法序列化的 Java 对象(如 java.util.regex.Matcher、数据库 Connection、或是非序列化的自定义类)。 解决: 永远不要在 nodestage 闭包跨越处传递这类对象;如果必须在代码块中使用复杂逻辑,请将该逻辑抽取为独立函数,并打上 @NonCPS 注解,让其在标准 JVM 堆栈中执行,而非被 CPS 引擎拦截。

    Q2:更新了 Jenkins Shared Library 的代码,但在已缓存的 Job 中不生效,必须重启 Jenkins 吗? 不需要。如果是隐式加载(Global Shared Libraries),Jenkins 默认会开启基于分支/标签的缓存。如果在 JCasC 中配置了 Library,务必检查 implicit: truedefaultVersion: "master" 的设置。如果是通过 @Library('[email protected]') _ 显式加载,建议采用基于 Git Tag 或 Commit Hash 的不可变版本号,而不是依赖分支名(如 master),以彻底规避 Classloader 缓存未刷新的问题。

    Q3:通过 JCasC 动态 Reload 配置时,会导致正在运行的 Pipeline 中断吗? 绝大多数配置(如 Views, Jobs 模板, Cloud 设置)的 Reload 是平滑的。但如果你在 JCasC 中修改了 securityRealm(安全域认证机制)或 authorizationStrategy,Jenkins 会销毁当前所有的安全上下文,这会直接导致正在执行的 Remoting Channel 被强行终止,引发 Agent 断联和任务报错。强规则: 绝对禁止在有核心业务构建运行时热重载安全相关配置。