分类: 容器与云原生

  • 深入 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 断联和任务报错。强规则: 绝对禁止在有核心业务构建运行时热重载安全相关配置。

  • 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 是更推荐且更核心的防灾手段。