标签: CI/CD

  • 深入 Jenkins 陷阱排查:共享库全局变量滥用引发的并行构建串量与生产发布雪崩实战

    近期处理了一起极其荒谬且破坏力极强的 P0 级生产事故。核心业务团队在做 Monorepo(单体代码仓库)微服务大版本发布时,15 个微服务通过 Jenkins Pipeline parallel 并发构建部署,结果上线后核心链路全面崩溃,Nginx Ingress 瞬间爆出海量 502。

    排查发现,根本不是 K8s 调度问题或 Harbor 镜像源拉取故障,而是 订单服务(Order)的 Pod 里,居然跑着用户服务(User)的镜像。最终定位的罪魁祸首:Jenkins Shared Library(共享库)中滥用了脚本级全局变量,在 parallel 并发执行下触发了典型的线程安全问题,导致变量相互覆盖(串量)。

    一句话结论:在 Jenkins vars/ 共享库脚本中定义方法外部的全局变量,其实例在同一个 Pipeline 运行期的 parallel 阶段是完全共享的。并发读写这些变量必然导致数据污染。修复方案:必须将所有状态变量严格限定在 call() 方法内部,强制实现共享库的无状态化。

    事故现场与离谱的报错

    排查过程中,我们查看了 K8s 集群的实时状况。订单服务(Order)的 Pod 陷入了 CrashLoopBackOff:

    $ kubectl logs order-deployment-5f6b9c9d-8x2a -n prod-ns
    Error: Invalid or missing Spring Boot configuration. 
    Application 'user-service' failed to start.
    

    日志显示这个 Pod 内部启动的居然是 user-service。去检视 Jenkins 构建日志,发现了令人窒息的一幕:

    [Pipeline] // 并行分支: build-order-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/order-service:v1.2.3-45 .
    ...
    [Pipeline] // 并行分支: build-user-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/user-service:v1.2.3-45 .
    ...
    [Pipeline] sh (helm upgrade) # 此时 order 分支的 helm 部署命令
    + helm upgrade order-service ./chart --set image=harbor.corp.com/prod/user-service:v1.2.3-45
    

    注意看最后一行,order-service 的分支在执行 Helm 部署时,注入的 Image 参数竟然变成了 user-service 的镜像!

    原理剖析:全局变量是万恶之源

    去翻看负责 CI/CD 脚本的开发人员写的 Jenkins Shared Library 代码,简直触目惊心:

    // 错误代码示例:vars/standardBuilder.groovy
    def currentImage = ""       // 【致命错误】脚本级全局变量
    def deployYaml = ""         // 【致命错误】脚本级全局变量
    
    def call(Map config) {
        currentImage = "${config.imageName}:${config.tag}"
        deployYaml = "${config.appName}-deployment.yaml"
    
        // 模拟耗时构建操作
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
    
        // 部署阶段
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    而在他们的 Jenkinsfile 中,调用方式是这样的:

    parallel(
        'order': { standardBuilder(appName: 'order', imageName: 'order-service', tag: 'v1.2') },
        'user':  { standardBuilder(appName: 'user',  imageName: 'user-service',  tag: 'v1.2') }
    )
    

    为什么这绝对不可原谅?

    很多刚接触 Jenkins Pipeline 的人把它当成简单的 Bash 脚本写,缺乏基本的“防御性编程”思维。在 Jenkins CPS(Continuation Passing Style)引擎的底层实现中,vars/standardBuilder.groovy 并不是每次调用都重新实例化的纯函数。 整个文件在编译后会成为继承自 CpsScript 的一个类。在同一个 Pipeline Run 中,vars/ 下的单例对象是被缓存的,文件顶层的 def currentImage 变成了这个单例对象的一个实例属性(Field)。

    当 parallel 启动多个分支(在 Jenkins 中是底层的绿色线程 Green Threads)并发调用 standardBuilder() 时,执行时序就变成了:

    1. 分支 A (Order):写入 currentImage = "order-service:v1.2"

    2. 分支 B (User):写入 currentImage = "user-service:v1.2" (此时实例属性被覆盖)

    3. 分支 A (Order):执行完 docker push,准备执行 helm upgrade,读取 currentImage,读到了 "user-service:v1.2"。

    这不仅导致了严重的生产环境发版错乱,如果是带有危险操作(如 DB 变更脚本)的流水线,这种串量能直接把数据库干废。

    进阶踩坑:CPS 序列化风暴与 OOM

    除了上述的并发污染问题,在脚本级定义变量还会引发另一个经典灾难:NotSerializableException 报错与 Jenkins Master 内存泄漏。

    如果有人在这个全局作用域放了一些非序列化对象(比如 JSON 解析结果、HTTP Client 实例):

    import groovy.json.JsonSlurper
    
    def parsedConfig = null // 脚本级变量
    
    def call() {
        def slurper = new JsonSlurper() // JsonSlurper 返回的是非序列化的 LazyMap
        parsedConfig = slurper.parseText(readFile('config.json'))
        sh "echo 'Building...'"
    }
    

    Jenkins 的 CPS 引擎为了保证在 Master 节点重启后能够恢复 Pipeline 的执行状态,会在遇到每一个 Pipeline Steps(如 sh, echo, sleep)时,暂停当前线程,并将整个 CpsScript 对象及其绑定的变量树完整序列化并落盘(存储到 build.xml 中)。 如果 parsedConfig 是一个不可序列化的对象,流水线会当场 Crash。如果是一个体积庞大的 Map,并发数一高,每次执行 sh 都要把几 MB 的数据做深拷贝和 XML 序列化,会导致 Jenkins Master 的 CPU 瞬间打满,触发频繁的 GC 停顿甚至 OOM(OutOfMemoryError)。

    最佳实践与防御性加固

    对于重度依赖 Jenkins 驱动核心 CI/CD 的团队,必须在架构层面掐断这种低级失误。

    1. 强制无状态化共享库(Pure Functions)

    所有共享库 vars/ 下的方法,必须是纯函数,严禁在 call() 之外定义任何非 final 变量。 修正后的代码:

    // 正确代码:vars/standardBuilder.groovy
    def call(Map config) {
        // 变量作用域严格限制在方法内部(闭包内本地变量),每次调用独立分配栈空间
        def currentImage = "${config.imageName}:${config.tag}"
        def deployYaml = "${config.appName}-deployment.yaml"
    
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    2. IO 操作与数据解析严格剥离(@NonCPS)

    如果必须进行复杂的 JSON 解析或遍历操作,必须封装为独立的函数并打上 @NonCPS 注解,确保这部分逻辑由 JVM 原生线程直接执行,不参与 CPS 的序列化。 注意: @NonCPS 方法内部绝对不能调用任何 Jenkins Pipeline 原生步骤(如 sh, readFile)。

    @NonCPS
    def parseMyConfig(String jsonStr) {
        // 纯 Groovy/Java 原生执行,无需考虑序列化问题
        return new groovy.json.JsonSlurperClassic().parseText(jsonStr) 
    }
    
    def call() {
        def jsonContent = readFile('config.json')
        def configMap = parseMyConfig(jsonContent)
        // 继续后续流程...
    }
    

    3. K8s Dynamic Agent 的工作空间硬隔离

    除了内存里的变量,物理层面的 Workspace 同样容易发生并发冲突。在 parallel 执行阶段,尽量确保每个并行任务申请独立的 K8s Pod (Agent),或者显式使用 dir 指令分配子目录,避免同一节点上的 I/O 踩踏:

    parallel(
        'order': { 
            node('k8s-agent') { // 分配独立的 Pod
                checkout scm
                standardBuilder(...)
            }
        }
    )
    

    同类问题速查(排查清单)

    1. Pipeline 并行分支行为异常/串数据:立刻检查 vars/ 目录下的共享库文件,全局搜索是否存在未被包含在方法内的 def、String、Map、List 定义,或误用了 @Field。

    2. 构建日志报 java.io.NotSerializableException:检查脚本中是否将 Matcher(正则匹配对象)、JsonSlurper 返回结果、Connection 等非序列化对象保留在了方法外层,或跨越了 sh/echo 等 Pipeline Step 的边界。

    3. Jenkins Master CPU 极高或 Full GC 频发:检查 Jenkins 系统管理中的线程转储(Thread Dump),搜索 CpsThread 和 xstream 相关的堆栈,大概率是 Pipeline 定义了超大数组/Map并在执行 Steps 时引发了海量序列化开销。

    4. 并发构建文件损坏/拉错代码:检查 parallel 代码块,是否多个并发逻辑复用了同一个 node 并且没有使用 dir('sub-dir') 隔离 Workspace。

  • 深入 Jenkins 陷阱排查:K8S 动态 Agent JNLP 寻址失败引发的 Pod 闪退风暴与 APIServer 瘫痪实战

    近期排查了一起极其惨烈的生产 K8S 集群级故障。监控大屏上,APIServer 的 99th percentile 延迟直接突破 8 秒,etcd 的 wal_fsync_duration_seconds 告警狂飙,部分核心业务的健康检查因 API 响应超时被误判驱逐。

    直接抛出结论:这是典型的由于 Jenkins Kubernetes Plugin 基础网络配置错误,叠加无脑并发重试,最终演变成的微型 DDoS 攻击。开发团队在 Jenkins K8S Agent 配置中,将 Jenkins URL 填成了挂着 WAF 和七层鉴权的公网 Ingress 域名。动态拉起的 Pod 无法建立 JNLP 长连接导致闪退,而 Jenkins Master 在积压任务的驱动下疯狂重试,一小时内在集群里发起近 8 万次 Pod 创建请求,活生生把 APIServer 和 Kubelet 拖垮。

    遇到网络不通,第一反应不是去查 DNS 和网络 ACL,而是在 Jenkinsfile 里强行加 retry(5) 甚至利用插件自身的重试机制去“碰运气”,这种用高频重试来掩盖网络隔离的草台班子做法,是系统稳定性的头号杀手。

    现场还原:被击穿的 APIServer 与满屏的 Error

    排查过程中,第一步自然是看 K8S 的核心指标。 通过 PromQL 查询 APIServer 的创建请求速率,数据触目惊心:

    sum(rate(apiserver_request_total{resource="pods", verb="create"}[2m])) by (namespace)
    

    结果显示 jenkins-build 命名空间的 QPS 高达几百,且持续了将近半小时。

    切到命令行,看一眼现场:

    $ kubectl get pods -n jenkins-build | awk '{print $3}' | sort | uniq -c
        12 Running
       180 Pending
      4592 Error
      1208 Terminating
    

    随便抓一个 Error 的 Pod 看日志,熟悉的 JNLP 报错:

    INFO: Locating server among [https://jenkins.company.com/]
    SEVERE: Failed to connect to https://jenkins.company.com/tcpSlaveAgentListener/: java.net.ConnectException: Connection refused (Connection refused)
    ...
    INFO: Protocol JNLP4-connect encountered an unexpected exception
    java.util.concurrent.ExecutionException: org.jenkinsci.remoting.protocol.impl.ConnectionRefusedException: Connection closed before acknowledgment sent
    

    这就是问题的万恶之源:Agent Pod 启动后,试图通过 JNLP (TCP 50000 端口) 或 WebSocket 连接 Jenkins Master。但 https://jenkins.company.com 是外部域名,流量出集群绕一圈走到公网网关,不仅被 WAF 拦截了 TCP 长连接,由于缺乏认证,HTTP 升级 WebSocket 的请求也被干掉。

    Agent 连不上,容器退出(Exit Code 255)。Jenkins K8S 插件一看:“哎呀,Agent 没注册成功”,同时任务队列里还堆积着上百个等候编译的 Job。于是,调度循环再次触发,向 K8S 申请拉起新的 Pod。 建 Pod -> 网络不通 -> 闪退 -> 重新申请。 一个完美的死循环诞生了。海量的短生命周期 Pod 导致 kube-controller-manager 和 kubelet 的垃圾回收(GC)根本来不及清理,系统负载直线上升。

    深层剖析:为什么 K8S Plugin 变成了攻击者?

    如果仅仅是网络不通,顶多是 CI/CD 瘫痪,为什么会拖垮整个集群?这涉及到几个底层的缺失与滥用。

    1. JNLP 寻址逻辑的错误

    在 Jenkins K8S Plugin 中,Master 负责调用 APIServer 生成 Pod,但 Pod 必须主动向 Master 注册。 配置项中有两个致命参数:jenkinsUrl 和 jenkinsTunnel。 如果没有明确指定 jenkinsUrl,Plugin 会使用系统全局的 Jenkins Location。这通常是为了发送邮件通知配置的外网域名。Agent 在集群内部去解析外网域名,不仅增加了 CoreDNS 压力,还会受到外部防火墙规则的约束。

    2. 并发与重试失控 (Lack of Circuit Breaking)

    默认情况下,Jenkins 并没有严格的、针对 K8S API 调用的全局熔断机制(Circuit Breaker)。当队列里有 100 个任务等待时,Plugin 就会并发去创建 100 个 Pod。当这些 Pod 全部异常退出时,调度逻辑会认为这是“短暂的环境抖动”,立刻安排下一轮调度。 整个过程就像是没有任何限流措施的恶性重试风暴,K8S APIServer 成了最终的受害者。

    3. 缺乏防御性的 Namespace 资源配额 (ResourceQuota)

    整个 jenkins-build 命名空间处于完全无限制(Unbounded)状态。没有任何的 ResourceQuota 限制其最大 Pod 数量。Kubelet 在频繁创建和销毁容器时,底层的 containerd/runc 需要频繁操作 Cgroup 树和挂载 overlayfs,直接导致 Node 节点的 System CPU 耗尽,Load Average 飙升至 50 以上。

    防御性重构:如何把 Jenkins 关进笼子里

    处理这种问题,止血只是第一步(删掉堆积的队列,停止 K8S 调度),更重要的是从架构层面切断这种重试雪崩的可能。

    1. 强制走集群内网寻址 (JCasC 固化配置)

    既然 Jenkins Master 和 Agent Pod 都运行在同一个 K8S 集群内(或通过专线打通的集群),绝对不应该走公网绕行。直接使用 K8S 的内部 DNS (Service 发现) 解决连通性。 通过 JCasC (Jenkins Configuration as Code) 强制约束配置:

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            serverUrl: "https://kubernetes.default"
            # 核心修复点:使用内网 Service FQDN
            jenkinsUrl: "http://jenkins-master.jenkins-sys.svc.cluster.local:8080"
            jenkinsTunnel: "jenkins-master.jenkins-sys.svc.cluster.local:50000"
            # 全局容器并发上限,防止雪崩
            containerCap: 50
            podRetention: "Never"
    

    利用 jenkins-master.jenkins-sys.svc.cluster.local,流量直接走 kube-proxy 转发,不仅绕过了外部网关,还大幅降低了延迟。

    2. 设置 Pipeline 防死锁与超时机制

    在全局和单个 Pipeline 级别,必须引入严格的超时策略(Timeout),同时禁止在外部资源申请阶段使用无限重试。

    pipeline {
        agent {
            kubernetes {
                yaml '''
                apiVersion: v1
                kind: Pod
                spec:
                  activeDeadlineSeconds: 1200 # 防御性配置:Pod 绝对最大存活时间
                  containers:
                  - name: maven
                    image: maven:3.8-eclipse-temurin-17
                    resources:
                      requests:
                        cpu: "1"
                        memory: "2Gi"
                      limits:
                        cpu: "4"
                        memory: "4Gi"
                '''
            }
        }
        options {
            // 全局超时,防止僵尸 Job 挂起耗尽 Jenkins 线程池
            timeout(time: 30, unit: 'MINUTES')
            // 严禁在此处滥用 retry()
        }
        stages { ... }
    }
    

    特别是 activeDeadlineSeconds 属性,这是 K8S 原生的硬中断。即便 Jenkins Master 假死,K8S 也会在 1200 秒后强行清理该 Pod,防止僵尸 Agent 占用节点资源。

    3. 命名空间级别的强硬隔离

    永远不要相信 CI/CD 系统的自我控制能力。在运维层面,必须给 K8S 加上硬性 Quota。 应用如下配置到 CI/CD 命名空间:

    apiVersion: v1
    kind: ResourceQuota
    metadata:
      name: jenkins-build-quota
      namespace: jenkins-build
    spec:
      hard:
        pods: "100"               # 绝对禁止超过 100 个 Pod
        requests.cpu: "200"
        requests.memory: 400Gi
    

    当系统进入异常循环,创建到第 101 个 Pod 时,APIServer 会直接返回 403 Forbidden,这比让 kubelet 去硬扛崩溃要好得多。

    排查清单:同类问题速查

    1. APIServer 负载突增排查:立刻执行 kubectl get events -n --sort-by='.metadata.creationTimestamp',如果发现满屏同名/同规则 Pod 密集创建和 Failed 记录,大概率是外部调度器陷入死循环。

    2. Jenkins JNLP 寻址校验:检查 Jenkins Master 系统设置中的 Jenkins URL,若配置为外部域名,确保该 Agent 所在网络可以直接解析并连通该域名的 8080 及 50000 端口,强烈推荐改为内部 svc.cluster.local。

    3. 僵尸 Pod 排查:执行 kubectl get pods --field-selector status.phase=Failed -A,如果存在大量 Failed 或长期处于 ContainerCreating 的 Pod,检查是否缺少 activeDeadlineSeconds 约束。

    4. Agent Pod 无限 Pending 分析:检查是否在 Jenkinsfile 中请求了集群无法满足的资源(如单 Pod 申请 32核 64G),导致调度器无法找到合适节点,同时 Jenkins 又在不断触发新构建。

  • 深入 GitLab CI 陷阱排查:非确定性缓存失效引发的流水线 IO 风暴与 BuildKit 分层缓存提效实战

    CI/CD 流水线构建耗时从 5 分钟恶化至 40 分钟,根因往往不是 CPU 算力瓶颈,而是粗放式缓存策略引发的重度 IO 阻塞。本文直击大体量工程在 GitLab CI 中因依赖 Hash 抖动与 Runner 漂移引发的缓存穿透,通过重构确定性制品分层缓存与引入 Docker BuildKit 远程镜像缓存,彻底消除无效网络拉取与重复编译损耗。

    故障现场:iowait 飙升与流水线雪崩

    排查过程中接到开发团队反馈:某核心业务的 Node.js/Go 混合微服务项目,合并 MR 时的流水线耗时屡创新高。查看 GitLab 监控面板,该项目的 Pipeline p99 耗时已突破 42 分钟。

    登录其中一台 K8S 动态拉起的 GitLab Runner 节点(版本 16.2.0),查验系统指标发现极为反常的现象:

    • CPU 利用率不足 20%,但 Load Average 高达 8.5(节点为 4C8G)。

    • top 命令显示 wa (iowait) 长期盘踞在 40%-60% 区间。

    • 网络出入带宽长时间跑满 1Gbps 上限。

    抽取慢流水线的 Job Log,立刻锁定了两个极其致命的耗时段:

    片段一:无脑的全量缓存解压(耗时 9 分钟)

    Checking cache for master-3...
    Downloading cache.zip from https://minio.internal.svc/runner-cache/xxx...
    Successfully extracted cache.zip to /builds/project/node_modules (5.2 GB) - took 540 seconds.
    

    片段二:镜像层全量 Miss(耗时 18 分钟)

    Step 5/12 : RUN npm ci --only=production
     ---> Running in a3b2c1d4e5f6
    npm WARN tar zlib error: unexpected end of file
    ...
    (2908 packages added in 850s)
    

    为什么你的 CI/CD 缓存总是“看似命中,实则穿透”?

    很多团队的 .gitlab-ci.yml 充斥着极其随意的配置,导致缓存机制不仅没加速,反而成了 IO 毒药。

    1. 非确定性的 Cache Key 设计

    大部分旧流水线使用分支名作为 Cache Key:key: ${CI_COMMIT_REF_SLUG}。 这在逻辑上是灾难性的。只要分支不断更新,任何一次微小的代码提交都会触发巨大的 node_modules 或 go/pkg/mod 重新打包上传。Runner 在 Job 结束时执行 Creating cache... 消耗大量 CPU 和磁盘 IO,并在下一次执行时产生 GB 级别的网络下载。

    2. K8S Executor 的本地缓存“幽灵”

    在静态 Runner 时代,Docker Executor 会在宿主机留下 /cache 目录。但在云原生环境下,Runner Pod 是按需拉起的(Ephemeral),本地磁盘随 Pod 销毁而消失。此时缓存强依赖远端 S3/MinIO。如果打包引擎效率低下(GitLab 默认使用标准 zip),GB 级碎文件的打包解包足以压垮 Runner 节点的 IOPS。

    3. Docker Daemon 缓存的漂移失效

    在微服务构建阶段,常规的 docker build 强依赖当前节点 Docker Daemon 的 Layer 缓存。当任务被调度到全新的 Node 节点时,本地无 Layer,必定触发从头构建(npm ci / go build 重新执行),产生双重的时间损耗。

    改造实战一:制品缓存的确定性重构与底层加速

    缓存应该只在依赖声明文件发生变化时才更新。我们通过声明式的 Hash 计算来重构 GitLab CI 的缓存策略。

    优化后的 .gitlab-ci.yml 缓存片段:

    variables:
      # 开启 GitLab Runner 的 FastZip 特性,大幅提升碎文件打包解压速度
      FF_USE_FASTZIP: "true"
      ARTIFACT_COMPRESSION_LEVEL: "fast"
      CACHE_COMPRESSION_LEVEL: "fast"
    
    cache:
      # 防御性配置:当且仅当 package-lock.json 变更时,才生成新缓存
      key:
        files:
          - package-lock.json
      paths:
        - .npm/
      # Fallback 策略:如果精确匹配失败,尝试使用默认主干缓存兜底
      fallback_keys:
        - master-default-cache
    
    build_deps:
      stage: build
      script:
        # 避免直接缓存 node_modules,改为缓存 npm 的全局 cache 目录
        - npm ci --cache .npm --prefer-offline
    

    底层加速原理: 开启 FF_USE_FASTZIP (Feature Flag) 后,GitLab Runner (要求 >= v15.0) 会调用底层的 fastzip 库,利用多核 CPU 进行并发压缩与解压,对包含几十万个小文件的依赖目录,IO 耗时可骤降 70% 以上。

    改造实战二:Docker BuildKit 远程分层缓存

    解决 Runner 漂移导致镜像重新构建的终极方案是脱离本地 Daemon 缓存,引入 Docker BuildKit(要求 Docker >= 24.0.0,Buildx >= 0.11)。将缓存层直接与 Registry(如 Harbor)联动,实现跨节点的 Layer 共享。

    修改 Dockerfile,利用 BuildKit 特有的 mount=type=cache 进行编译期缓存:

    # syntax=docker/dockerfile:1.4
    FROM node:18-alpine AS builder
    WORKDIR /app
    COPY package*.json ./
    # 挂载缓存到容器内部,隔离且高效
    RUN --mount=type=cache,target=/root/.npm \
        npm ci --prefer-offline
    
    COPY . .
    RUN npm run build
    

    在流水线中全面启用 BuildKit 的 Inline Caching 或 Registry Cache 导出:

    package_image:
      stage: package
      image: docker:24.0.5-cli
      variables:
        DOCKER_BUILDKIT: 1
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - >
          docker buildx build
          --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache
          --cache-to type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max
          --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
          --push .
    

    执行结果验证: 上线上述双层优化后,即使 Runner 被调度到全新的 K8S 节点,构建过程会首先从 Harbor 拉取 buildcache 对应的 Layer,npm ci 阶段完美命中 --mount=type=cache,总计 Pipeline 耗时从 42 分钟垂直跌落并稳定在 3分15秒。

    常见问题

    Q1:使用 files 作为 cache key 时,依赖未变但代码变更了,会触发缓存更新吗? 不会。这是我们期望的防御性行为。只要 package-lock.json 或 go.sum 的 SHA 值不变,GitLab Runner 在任务结束时会比对 Hash,发现无变更即跳过 Creating cache... 上传阶段,彻底阻断无效网络写 IO。

    Q2:Docker Buildx 的 mode=max 和 mode=min 有什么区别? mode=min(默认)只缓存最终镜像中包含的 Layer。而在多阶段构建(Multi-stage build)中,构建过程的中间层(如编译环境包)如果不加缓存,下次还会重来。mode=max 会将所有阶段(含 Builder 阶段)产生的 Layer 全部推送到远端 Cache 中,这是大幅降低微服务编译耗时的核心参数。

    Q3:分布式 MinIO 缓存长期累积导致磁盘爆满如何处理? CI 缓存属于可丢弃的临时数据,千万不要手动清理。必须在 MinIO 存储桶层面配置 Lifecycle 策略,强行对 .gitlab-runner/ 目录下的对象设置 TTL(例如 14 天过期)。同时结合 GitLab 本身的 clear runner caches API,在业务低峰期执行自动化清理。

    Q4:为什么我的 FF_USE_FASTZIP 没有生效? 检查你的 GitLab Runner 配置文件 config.toml。环境变量必须注入到 Runner 的执行环境中,或者直接在 Runner 配置的 [runners.feature_flags] 区块中硬编码声明,否则 K8S executor 启动 Pod 时可能未继承项目内的 Variables。

  • 深入 Jenkins 陷阱排查:Shared Library 隐式类加载泄漏引发的 Metaspace OOM 与 K8S Agent 孤儿 Pod 实战

    Jenkins Controller 频繁发生 Metaspace OOM 且伴随 K8s 出现大量孤儿 Agent Pod,根因往往是 Shared Library 中非标准化 Groovy 脚本引发 GroovyClassLoader 内存泄漏,导致 Remoting 线程卡死。修复方案:精简共享库闭包逻辑,限制 -XX:MaxMetaspaceSize=2G,并在 K8s Pod Template 中强制配置 activeDeadlineSeconds 进行防御性兜底。

    近期在排查一个日均承载数万次 Pipeline 构建的核心 Jenkins 集群(版本 2.440.1,Kubernetes Plugin 4136.v40097782b,JDK 17)时,遇到一个极其顽固的级联故障。

    监控大盘显示,Jenkins Controller 的 P99 响应时间周期性飙升至 30 秒以上,Load Average 突破 50。更棘手的是,K8s 集群中遗留了数百个状态为 Running 的 JNLP Agent Pod,但 Jenkins 控制台却显示这些节点处于 Offline 状态。典型的“脑裂”现场。

    现场取证与链路溯源

    登录 Controller 宿主机,首先抓取进程指标。通过 jstat 观察 JVM 内存分布,直接锁定了肇事者:

    $ jstat -gcutil $(pgrep -f jenkins.war) 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
      0.00   0.00   2.34  89.45  99.99  98.12   4123  124.321   145  432.145  556.466
      0.00   0.00   1.21  89.45  99.99  98.12   4123  124.321   146  435.210  559.531
    

    注意 M(Metaspace)这一列,使用率死死卡在 99.99%,FGC(Full GC)次数在短时间内疯狂上涨,且单次 Full GC 耗时极长。Controller 已经陷入了 Full GC 死亡螺旋。

    查看 Jenkins 的 catalina.out 或系统日志,证实了推断: java.lang.OutOfMemoryError: Metaspace

    为什么 Metaspace OOM 会导致 K8s 孤儿 Pod 泛滥?

    Jenkins 依赖 TCP/WebSocket 上的 Remoting 协议与 K8s Agent 里的 agent.jar 通信。当 Controller 陷入持续的 Full GC 停顿时,所有的工作线程(包括负责心跳检测的 Channel Reader/Writer 线程)全部被挂起。

    1. JNLP 心跳超时,Controller 单方面标记 Agent 为 Offline。

    2. 由于 Controller 处于假死状态,Kubernetes Plugin 无法及时向 K8s APIServer 发出 DELETE Pod 的请求。

    3. K8s 侧的 Pod 因为没有设置超时机制,其内部的 agent.jar 陷入无限等待,最终导致集群计算资源被僵尸 Pod 占满,引发调度雪崩。

    为什么 Shared Library 的高频调用会引发 Metaspace 膨胀?

    Jenkins Pipeline 的底层核心是 Groovy CPS(Continuation Passing Style)引擎。在处理 Shared Library 时,Jenkins 使用 CpsGroovyShell 将 Groovy 代码编译为 Java 字节码。

    通常情况下,JVM 会在类加载器失去所有引用时回收其加载的类(Class Unloading)。但在 Jenkins 的复杂环境里,容易踩到以下暗坑:

    1. AST 转换与动态生成类:如果在 Shared Library 中重度使用闭包(Closures)、动态方法拦截(methodMissing)或隐式类型转换,Groovy 编译器会在运行时生成大量的匿名内部类(例如 CallSiteArray)。

    2. CPS 序列化链条泄漏:Jenkins 会在每次 Pipeline 暂停(如执行 sh、sleep 等 block 步骤)时,将整个执行上下文序列化到磁盘(program.dat)。如果你的 Groovy 脚本在全局作用域持有大对象引用,或者通过 @Field 定义了静态缓存,不仅会导致序列化文件暴增,还会导致对应的 GroovyClassLoader 实例被 CPS 引擎死死抓住,无法被 GC Roots 释放。

    每一次新的 Pipeline 构建,都会触发新的类加载。随着构建量增加,Metaspace 被这些永远无法卸载的“一次性”类塞满,最终引爆 OOM。

    架构级防御与修复方案

    运维和架构的职责不仅是找到 Bug,更要在系统层面构建防御纵深。面对这类问题,需要从 JVM、配置管理(JCasC)和流水线规范三个维度进行整改。

    1. JVM 边界隔离与调优

    绝对不能让 JVM 的 Metaspace 无限增长(JDK 8+ 默认 MaxMetaspaceSize 是无上限的)。必须给 Controller 划定严格的物理边界,并开启 G1 的类卸载特性。

    修改 Jenkins 启动参数(针对 JDK 17):

    JAVA_OPTS="-server \
      -Xms8G -Xmx8G \
      -XX:MetaspaceSize=1024m \
      -XX:MaxMetaspaceSize=2048m \
      -XX:+UseG1GC \
      -XX:MaxGCPauseMillis=200 \
      -XX:+UnlockExperimentalVMOptions \
      -XX:+ParallelRefProcEnabled \
      -Xlog:gc*=info,gc+metaspace=trace:file=/var/log/jenkins/gc.log:time,uptime,level,tags:filecount=10,filesize=50M \
      -Dgroovy.use.classvalue=true" 
    

    注:groovy.use.classvalue=true 是针对 Groovy 2.4+ 的一个关键开关,能有效缓解内存泄漏,但在某些旧版 Jenkins 插件中可能引发兼容性问题,需在预发环境验证。

    2. K8s Pod Template 兜底机制(JCasC 防御性配置)

    绝对不要信任外部系统的状态同步。在通过 JCasC 定义 Kubernetes Pod Template 时,必须加入 activeDeadlineSeconds 参数。这样即使 Jenkins Controller 完全宕机,K8s 也会在超时后由 Kubelet 亲手超度这些孤儿 Pod。

    核心 jenkins.yaml JCasC 片段提取:

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins-agents"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            templates:
              - name: "golang-builder"
                label: "golang"
                nodeUsageMode: EXCLUSIVE
                # 核心防御1:无论构建成功与否,强制清理 Pod
                podRetention: "never"
                # 核心防御2:Pod 在 K8s 侧的绝对存活上限(例如 7200 秒/2小时)
                activeDeadlineSeconds: 7200
                # 核心防御3:如果 Controller 失去心跳,Agent 主动退出
                idleMinutes: 10
                containers:
                  - name: "jnlp"
                    image: "jenkins/inbound-agent:3192.v713e3b_039fb_e-4"
                    args: "^${computer.jnlpmac} ^${computer.name}"
                    envVars:
                      - envVar:
                          key: "JENKINS_AGENT_WORKDIR"
                          value: "/home/jenkins/agent"
    

    3. Pipeline 代码降维打击

    Jenkins Pipeline 的定位是“调度器”,而不是“计算引擎”。 强制推行规范:严禁在 Shared Library 中编写复杂的纯 Groovy 数据处理逻辑(如大规模 JSON 解析、复杂的集合遍历、正则轮询)。 反模式:在 Groovy 中使用 JsonSlurper 解析 10MB 的依赖分析报告。 最佳实践:使用 sh 步骤调用预置在容器镜像里的 Python/Go 脚本进行解析,仅通过标准输出(stdout)将极简的结果(如 Exit Code 或一个轻量级状态字符串)回传给 Pipeline 控制流。

    常见问题 (Q&A)

    Q1:K8s 动态 Agent Pod 经常在拉起时握手超时(JNLP connection timeout),但 Pod 本身是 Running 状态,如何排查? 通常有两个原因:一是 K8s APIServer 到 Jenkins Master 的网络存在高延迟或 Ingress 代理配置了不合理的 Timeout;二是 Controller 侧的 TCP Backlog 已满。建议在 JCasC 中开启 WebSocket 协议(websocket: true),替代传统的基于单独 TCP 端口的 JNLP 协议,复用 HTTP(S) 链路可穿透绝大多数网络屏障,并在 Ingress 侧配置 WebSocket 长连接支持。

    Q2:在使用 Shared Library 时,加上 @NonCPS 注解能解决内存泄漏吗? 不能直接解决 Metaspace 泄漏,但能解决序列化报错(NotSerializableException)。@NonCPS 告诉 Jenkins 引擎不要对该方法进行 CPS 转换和挂起状态记录。但必须极其小心:绝不能在 @NonCPS 方法内部调用任何 CPS 步骤(如 sh, echo, build),否则会破坏 CPS 状态机,导致 Pipeline 莫名其妙地卡死(Hang)在执行节点,且没有任何报错日志。

    Q3:通过 JCasC 动态重新加载配置(Reload Configuration)时,会中断正在运行的 K8s 动态 Agent 吗? 不会直接中断。JCasC 的 Reload 过程在内存中重建了 Cloud 和 PodTemplate 对象。已经调度并在运行中的 Pod 会维持与 Master 的 Remoting 连接继续执行完毕。但如果此时修改了 JCasC 中的 Jenkins URL 或 WebSocket 设置,依赖这些配置进行重连的离线 Agent 恢复机制可能会失效。

  • 深入 Jenkins 陷阱排查:滥用 CPS 序列化引发的 Master 停顿与 K8s 动态 Agent 调度雪崩实战

    近期排查了一起极其惨烈的 CI/CD 生产事故。故障现象非常直观:Jenkins Master 节点 CPU 长期飙升至 100%,UI 疯狂报 502 Bad Gateway;与此同时,后端的 K8s 构建集群遭到“洪泛攻击”,短时间内被调度了数千个 Pending 状态的 JNLP Agent Pod,直接耗尽了 Node 的 IP 资源池,甚至引发了 K8s API Server 的短暂限流。

    直接抛出结论:这是一起由于在 Jenkins Pipeline 中滥用 Groovy 原生方法处理海量数据,导致 CPS(Continuation Passing Style)状态机序列化引发 JVM Full GC 风暴,进而拖垮 JNLP 心跳机制,最终触发 K8s 动态 Agent 疯狂重试调度的连环雪崩。

    一句话避坑指南:Jenkins Pipeline 是流程控制器,绝不是数据处理引擎。永远不要在标准的 Pipeline 步骤中用 JsonSlurper 解析大体积 JSON 文件,请把脏活累活交给 sh + jq 或者 Python 脚本,或者将其封装在严格隔离的 @NonCPS 共享库函数中。

    故障现场:Master 假死与 K8s 的哀嚎

    排查过程中,告警系统的 99 线延迟指标已经没法看了。登录 Jenkins Master 宿主机,Load Average 直接干到了 80+。老规矩,先看 JVM 状态:

    # 抓取 Jenkins 进程 GC 状态
    jstat -gcutil $(pgrep -f jenkins.war) 1000
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
      0.00   0.00 100.00 100.00  98.12  95.43   4520  120.450  856  1542.120 1662.570
    

    老年代(Old Gen)死死卡在 100%,FGC 次数狂飙,典型的内存泄漏或瞬时巨型对象分配。去捞 Jenkins 系统日志,满屏的 JNLP 断连报错:

    WARNING: Ping thread for channel JNLP4-connect connection from 10.244.x.x:54321 is not responding. Terminating.
    java.util.concurrent.TimeoutException: Ping started at 168xxxxxx hasn't completed by 168xxxxxx
        at hudson.remoting.PingThread.ping(PingThread.java:132)
    

    再切到 K8s 集群,看一眼动态 Agent 的惨状:

    kubectl get pods -n jenkins-agents | awk '{print $3}' | sort | uniq -c
        15 Running
       854 Pending
       230 Terminating
    

    将近一千个 Pod 堆积在调度队列里。由于前一个 Agent 意外断开连接,Jenkins 的 K8s Plugin 认为构建任务尚未结束且 Agent 丢失,于是疯狂向 API Server 发起重新创建 Pod 的请求。

    愚蠢的根因:让 Jenkins 扛下所有

    把 Jenkins Master 堆内存 Dump 下来用 MAT 分析,发现占据 80% 内存的竟然是 java.util.LinkedHashMap 的海量实例,且引用链全部指向 org.jenkinsci.plugins.workflow.cps.CpsThreadGroup。

    去翻最近提交的 Jenkinsfile,看到一段令人窒息的“神仙操作”。某个业务研发为了在 CI 阶段进行微服务全量依赖检查,直接用 HTTP 客户端拉取了一个高达 15MB 的 SBOM(软件物料清单)JSON 文件,然后直接用 Groovy 代码进行解析和循环过滤:

    // 典型的作死代码片段
    def response = sh(script: "curl -s http://internal-api/sbom/full.json", returnStdout: true)
    def jsonSlurper = new groovy.json.JsonSlurperClassic()
    def result = jsonSlurper.parseText(response) // 15MB 的 JSON 字符串!
    
    result.dependencies.each { dep ->
        if (dep.vulnerable) {
            // ...复杂的业务逻辑
        }
    }
    

    为什么这段代码不可原谅?

    很多刚接触 DevOps 的人根本不理解 Jenkins Pipeline 的底层原理。Jenkins 为了实现“即使 Master 重启,Pipeline 也能从中断处恢复”的特性,魔改了 Groovy 的执行引擎,这就是 CPS 转换。

    在 CPS 模式下,Pipeline 脚本中定义的每一个变量、每一个闭包状态,都会在每一步(Step)执行完毕后,被强行序列化成 XML 或二进制数据,写入到 builds//program.dat 文件中。

    当你用 JsonSlurper 把 15MB 的 JSON 转换成嵌套的 Map/List 对象树,并在 .each 循环中迭代时,CPS 引擎会在每一次循环都尝试将这个庞大的对象树克隆、序列化并持久化。 这就意味着,Jenkins Master 的 CPU 正在疯狂地将几百万个小对象进行序列化操作,JVM 堆内存瞬间被撑爆。

    雪崩链路复盘

    这套组合拳最终引发了整个 CI/CD 体系的瘫痪,其技术逻辑链路如下:

    1. CPS 序列化阻塞:巨型 Map 对象导致 program.dat 写入耗时极长,触发 JVM Full GC(STW)。

    2. PingThread 假死:由于 JVM 挂起长达数分钟,Jenkins Remoting 层的 PingThread 无法按时响应 JNLP Agent 的心跳。

    3. Agent 误判掉线:Master 认为 K8s Pod (Agent) 已死亡,主动切断 TCP 连接,并标记 Node 为 Offline。

    4. 调度死循环:Pipeline 并未结束,K8s 动态调度插件(Kubernetes Plugin)根据重试策略,立刻向 K8s 申请新的 Pod。

    5. K8s 资源耗尽:由于 Master 处于濒死状态,新启动的 Pod 即使连上 Master 也会很快再次因心跳超时被踢掉。无限循环,直到 K8s Namespace 的 Quota 爆满或 Node IP 耗尽。

    修复与防御性改造方案

    要解决这个问题,不仅要改掉愚蠢的代码,还要在架构层面进行防御性配置。

    1. 斩断 CPS 序列化链条(推荐做法)

    最符合 Unix 哲学的做法是:把数据处理交给专门的工具。将 JSON 解析逻辑移出 Jenkins JVM,放到 Agent 的容器里执行。

    // 修复后的代码:使用 JQ 在 Shell 层面处理
    def vulnerableDeps = sh(
        script: "curl -s http://internal-api/sbom/full.json | jq -r '.dependencies[] | select(.vulnerable==true) | .name'",
        returnStdout: true
    ).trim()
    

    2. 隔离 Groovy 引擎(妥协做法)

    如果非要在 Jenkinsfile 里写复杂逻辑,必须把这段代码抽离到共享库(Shared Library)中,并加上 @NonCPS 注解。这会告诉 Jenkins:“这段函数不需要持久化状态,当成普通 Java 代码一口气执行完即可。”

    @NonCPS
    def parseLargeJson(String jsonStr) {
        // 这里面的变量不会被持久化到 program.dat
        def jsonSlurper = new groovy.json.JsonSlurperClassic()
        return jsonSlurper.parseText(jsonStr) 
    }
    

    注意:@NonCPS 方法内部严禁调用任何 Jenkins Pipeline 原生 Step(如 sh, echo),否则直接报错。

    3. 限制 K8s 插件的无脑调度(防雪崩配置)

    在 JCasC (Jenkins Configuration as Code) 或者 K8s Plugin 配置中,必须强制设定并发上限,防止雪崩。

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            containerCapStr: "50" # 绝对不能留空,强制限制该集群最大并发 Pod 数
            podRetention: "never" 
    

    4. 优化 JNLP 心跳超时参数

    为了在网络抖动或短时 GC 时不至于轻易踢掉 Agent,可以在 Jenkins Master 启动参数中适当放宽心跳容忍度(视集群情况而定,作为兜底策略):

    -Dhudson.remoting.PingThread.pingInterval=30000 
    -Dhudson.remoting.PingThread.pingTimeout=120000
    

    排查清单 (Troubleshooting Checklist)

    1. Jenkins Master CPU/内存异常飙升:优先检查正在执行的 Pipeline 是否读取了超大文件、是否在使用循环处理复杂对象树。排查目标文件:$JENKINS_HOME/jobs//builds//program.dat 大小是否异常。

    2. K8s Pod 异常堆积 (Pending/Terminating):检查 K8s Plugin 的 containerCap 限制是否生效,确认是否有死循环 Pipeline 不断触发 Agent 重建。

    3. JNLP 频繁断连报错 (Ping timeout):排查 Master 节点 JVM 是否发生长时间的 FGC (Stop-The-World),或者 Agent 节点与 Master 之间的 K8s Node 网络/Ingress 代理是否丢包。

    4. 共享库安全审计:检查 vars/ 或 src/ 目录下的 Groovy 脚本,凡是涉及 HTTP 请求、JSON/XML 反序列化的工具类函数,是否遗漏了 @NonCPS 注解。

  • 深入 Jenkins 动态构建雪崩排查:Kubernetes 插件 QPS 限流引发的 JNLP 断连与 Pod 孤儿风暴实战

    Jenkins 动态 Agent 架构在处理高并发构建时极易触发系统雪崩。核心元凶通常是 kubernetes-plugin 默认极低的 Client-Go QPS 限制引发 API 节流与 Pod 调度积压,叠加 NAT 网关静默丢弃 JNLP 空闲连接导致断连风暴。破局的关键在于:切换 Agent 通信至 WebSocket 协议,利用底层 System Properties 强行拉高 K8S 客户端 QPS/Burst 阈值,并通过 JCasC 实施防御性的超时与重试固化配置。

    故障现场:几百个 Pipeline 瞬间卡死,Master 线程池耗尽

    某次在应对业务大版本集中发布时,Jenkins(版本 2.426.1 LTS,kubernetes-plugin 版本 4136.v7233)出现突发性大面积卡顿。

    现场症状:

    1. 构建积压:超过 300 个 Pipeline 任务处于 pending 状态,卡在 Jenkins doesn’t have label XXX。

    2. 僵尸 Pod 泛滥:K8S 集群中存在大量状态为 Terminating 或 Running 但未在执行任务的 Jenkins-Agent Pod。

    3. Master 假死:Jenkins Web UI 响应极其缓慢,Load Average 飙升至 80+,JVM 老年代内存使用率长期处于 95% 以上,频繁触发 Full GC。

    通过 jstack 抓取 Jenkins Master 的线程快照,发现大量线程阻塞在 Kubernetes 客户端的 HTTP 请求调度上,同时伴随疯狂报错的系统日志:

    # 报错一:JNLP Ping 超时风暴
    WARNING: Ping thread for channel JNLP4-connect connection from 10.244.5.122:38912 failed.
    java.util.concurrent.TimeoutException: Ping started at 171xxxxxxx hasn't completed by 171xxxxxxx+240000
        at hudson.remoting.PingThread.ping(PingThread.java:132)
    
    # 报错二:Kubernetes Plugin API 限流
    WARNING: Failed to provision a new node. 
    io.fabric8.kubernetes.client.KubernetesClientException: too many requests (429)
        at io.fabric8.kubernetes.client.dsl.internal.OperationSupport.requestFailure(OperationSupport.java:694)
    

    为什么 Jenkins Master 会被 K8S 动态 Agent 拖垮?

    表象是 Jenkins 性能不足,底层其实是通信协议缺陷与默认配置短板在并发场景下的集中爆发。

    1. K8S 插件 Client-Go QPS 限流导致的调度饥饿

    Jenkins Kubernetes 插件底层依赖 fabric8io/kubernetes-client。在缺乏显式配置的情况下,该客户端继承了极低的默认流控阈值(早期版本 QPS=5,Burst=10)。 当瞬间涌入几百个动态 Agent 申请时,Jenkins 向 Kube-APIServer 发起大量的 Pod Create/Watch 请求。触发限流(HTTP 429)后,客户端会指数退避重试。这不仅导致 Pod 迟迟无法拉起,还会使 Master 端负责 Provisioning 的专属线程被长时间挂起,最终耗尽线程池资源,引发 Web UI 卡死。

    2. NAT 网关静默丢弃引发 JNLP 断连风暴

    传统的 JNLP 代理协议基于 TCP长连接(默认端口 50000)。在容器化部署中,Agent Pod 通常经过 NodePort、Ingress 或云厂商的 NAT 网关与 Master 通信。 许多 NAT 网关/防火墙对空闲 TCP 连接有严格的存活期限制(如 5 分钟或更短),若无数据传输会静默丢弃(Drop)连接,且不发送 RST。 Jenkins 默认的 PingThread 检测周期是 4 分钟。当构建任务处于长时间的纯本地编译(如 make -j16)且没有向 Master 输出日志时,TCP 连接会被 NAT 掐断。此时 Master 仍在等待 Ping 回应,直到超时报错终止构建。随后 Master 尝试销毁 Pod,但由于上述的 API 限流,Delete 请求失败,直接产生大量“孤儿 Pod”。

    3. Pipeline CPS 转换引发的 Master CPU 燃烧

    部分研发在 Pipeline 的共享库(Shared Library)中编写了复杂的 for/while 循环或对大体积 JSON 进行了反序列化,且未加 @NonCPS 注解。Jenkins Pipeline 的 Continuation Passing Style (CPS) 引擎会将这些逻辑转换成成百上千个小的状态机对象存储到 Heap 中。大量的状态变更叠加 Agent 断连引发的异常处理逻辑,导致 Master 的 CPU 被 GC 线程和 CPS 引擎彻底吃光。

    极客实战:防御性配置与底层调优

    拒绝修修补补,直接从网络协议、K8S 客户端参数和不可变基础设施层面彻底重构。

    调优 1:废弃 TCP JNLP,全面启用 WebSocket 通道

    WebSocket 基于 HTTP/HTTPS 进行协议升级,复用 80/443 端口。标准 L7 Ingress/LB 对 WebSocket 的保活支持远好于裸 TCP 端口,有效穿透各类严格的防火墙。

    需要在 Jenkins System 中开启 WebSocket 并在 K8S Agent 模板中强制指定。通过 JCasC (Jenkins Configuration as Code) 固化配置如下:

    jenkins:
      cloud:
        kubernetes:
          name: "kubernetes"
          serverUrl: "https://kubernetes.default"
          # 开启 WebSocket 连接
          webSocket: true
          containerCapStr: "200" # 限制最大并发 Pod 数,防止打爆集群
          templates:
            - name: "base-agent"
              label: "base-agent"
              nodeUsageMode: EXCLUSIVE
              containers:
                - name: "jnlp"
                  image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                  # JNLP 容器的防御性资源限制
                  resourceRequestCpu: "500m"
                  resourceLimitCpu: "1000m"
                  resourceRequestMemory: "512Mi"
                  resourceLimitMemory: "1024Mi"
    

    调优 2:暴力破解 K8S 客户端并发限制

    直接通过 JVM 启动参数(System Properties),向 Kubernetes 客户端注入高并发阈值配置,并缩短 JNLP 的 Ping 超时窗口以尽早发现死连接。

    在 Jenkins Master 的 Deployment/StatefulSet 中注入以下 JAVA_OPTS:

    # 提升 fabric8 k8s client 并发上限 (根据 API Server 承载能力调整)
    -Dorg.csanchez.jenkins.plugins.kubernetes.clients.Qps=50
    -Dorg.csanchez.jenkins.plugins.kubernetes.clients.Burst=100
    
    # 优化 JNLP Ping 机制:2分钟 Ping 一次,超时时间设为 1 分钟 (默认 4 分钟太迟钝)
    -Dhudson.remoting.PingThread.pingIntervalSecs=120
    -Dhudson.remoting.PingThread.pingTimeoutSecs=60
    
    # 优化 GC:大内存下启用 G1GC 并开启字符串去重 (缓解 CPS 转换导致的字符串常量泛滥)
    -XX:+UseG1GC -XX:+UseStringDeduplication -Xms8g -Xmx8g
    

    调优 3:Pipeline 共享库死锁的防御拦截

    针对耗时的 JSON 解析和复杂的集合遍历,强制在共享库代码层面引入 @NonCPS 注解,将计算任务剥离出 Jenkins Master 的状态机保存机制,交由原生 JVM 栈执行:

    import groovy.json.JsonSlurper
    import com.cloudbees.groovy.cps.NonCPS
    
    // 错误示范:在 CPS 块中解析大 JSON,极易导致 Master OOM 或 CPU 100%
    // def parseJson(String text) { return new JsonSlurper().parseText(text) }
    
    // 正确实战:防御性声明,计算完毕后直接返回结果,不保留中间状态
    @NonCPS
    def parseJsonFast(String text) {
        def slurper = new JsonSlurper()
        return slurper.parseText(text)
    }
    

    常见问题 (FAQ)

    Q1:Pipeline 卡在 “Waiting for next available executor”,但 K8S 集群明明有充足的 CPU/Memory 资源? A: 检查 Jenkins Master 是否达到了 containerCap 上限(默认 100)。即使集群有资源,Jenkins Kubernetes 插件也会拒绝发起新的 Pod 创建请求。另外,确认 Agent 模板中的 label 是否与 Pipeline 中声明的一致,拼写错误会导致无限期等待。

    Q2:通过 JCasC 更新了共享库 (Shared Library) 的分支,为什么重新构建时没有立刻生效? A: Jenkins 针对 Shared Library 默认开启了基于 Workspace 的缓存机制。如果在短时间内连续触发构建,可能会复用上一次 clone 的旧版本代码。可以在共享库配置中勾选 Include @Library changes in job recent changes 或在 JCasC 中显式关闭库的深度缓存(调整 retriever 的 timeout 策略),同时确认 Jenkins 服务器本地时间与 Git 仓库时间没有出现钟摆漂移。

    Q3:Pipeline 运行中抛出 java.io.NotSerializableException: java.util.regex.Matcher 报错,如何排查? A: 这是极其典型的 CPS 污染问题。Jenkins Pipeline 遇到 sh、sleep 等步骤时,会将当前所有的局部变量序列化保存到磁盘。如果在上述步骤前定义了不可序列化的对象(如 Regex Matcher、Socket 连接、I/O 流),序列化就会崩溃。 解法: 将对 Matcher 的操作封装到一个使用 @NonCPS 修饰的函数中执行,或者在使用完该对象后立即将其设为 null,确保其在跨越 Node/Agent 边界或进入挂起状态前被抛弃。

  • 深入 Jenkins Pipeline 雪崩排查:CPS 转换引发的 Master JVM OOM 与 Shared Library 全局变量污染实战

    核心结论:Jenkins Pipeline 中的 Groovy 并非标准 Groovy,底层强制执行 CPS(Continuation Passing Style)转换以支持跨节点和重启的断点续跑。在 Shared Library 中滥用大对象、复杂闭包或未实现 Serializable 的原生 Java 类,会使 Master JVM 在状态序列化时直接 OOM。必须通过 @NonCPS 隔离重度计算逻辑,并结合 JCasC 实现基础设施不可变。

    故障现场:Master 的静默死亡

    排查过程中接到告警,某核心业务构建集群的 Jenkins Master(版本 2.440.1 LTS,JDK 17)Load Average 突然飙升至 80+,UI 完全无响应,所有挂载在 K8S 上的动态 Agent 任务卡死在 Pending 或执行态断联。

    登机排查,直接看 JVM 指标:

    # jstat -gcutil <pid> 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
      0.00 100.00 100.00  99.98  95.21  92.14  14521  124.512   512  1421.112 1545.624
    

    Old Gen 打满,FGC 极其频繁且回收率几乎为 0。查看业务监控看板,并发构建数仅为平时的高峰期均值(~150 concurrent builds),排除了纯粹的并发量冲击。

    立刻通过 jmap -dump:format=b,file=heap.hprof 抓取现场,并重启服务恢复业务。 MAT 分析 Heap Dump 显示,com.cloudbees.groovy.cps.Next 和 java.util.LinkedHashMap 对象占据了 85% 的堆内存。进一步展开引用链,发现全部指向 workflow-cps 插件的 ProgramData 对象。这说明:Pipeline 的状态持久化机制正在吞噬内存。

    为什么简单的 Groovy 循环会拖垮 Jenkins Master?

    很多人习惯把 Jenkins 当成一个能够运行 Groovy 脚本的普通 Cron Server,这在 Pipeline As Code 时代是致命的认知误区。

    为了实现 Pipeline 可以在 Master 重启后从中断处继续执行(Resiliency),Jenkins 引入了 CPS(Continuation Passing Style)转换。当你编写一段看似普通的 for 循环时,Jenkins 在编译期会对抽象语法树(AST)进行劫持和重写:

    1. 每执行一行代码,CPS 引擎都会将当前作用域内的所有局部变量、调用栈打包成一个 Continuation 对象。

    2. 这些对象会被序列化(基于 XStream)并持久化到磁盘(通常是 builds//program.dat),同时缓存在内存中。

    问题代码最终定位在业务团队近期提交的 Shared Library vars/deployK8s.groovy 中:

    // 典型的夺命代码:全局作用域的大字典解析 + 跨节点持有
    def call(String env) {
        // 1. 读取并解析一个高达 5MB 的 Kubernetes Manifest 集合字典
        def hugeManifestMap = readYaml(file: "manifests/all-services.yaml")
    
        // 2. 在外层作用域遍历
        hugeManifestMap.each { svcName, config ->
            node('k8s-agent') { // 3. 跨越节点上下文
                sh "echo Deploying ${svcName}"
                // ... 复杂的 YAML 替换与 kubectl apply
            }
        }
    }
    

    底层原理解析: 当上述代码执行到 node('k8s-agent') 触发跨节点调度时,Pipeline 会挂起当前线程。此时,CPS 必须保存当前的环境状态以便稍后恢复。而 hugeManifestMap 是闭包外层的局部变量,CPS 不得不把这个 5MB 的嵌套 LinkedHashMap 及其对应的迭代器对象完整序列化。 在 150 个并发任务叠加下,这导致了极其可怕的写放大和内存膨胀:每次 sh 步骤执行,CPS 都要在内存里克隆并序列化这个巨大的上下文,最终瞬间撑爆 Master 的 JVM 堆。

    防御性架构重构与最佳实践

    针对这种滥用 Shared Library 引发的雪崩,必须在代码规范和基础设施配置两方面做防御。

    1. 使用 @NonCPS 隔离不可序列化与重度逻辑

    对于不需要断点续跑的纯计算、数据转换、大对象解析逻辑,强制使用 @NonCPS 注解。被 @NonCPS 标记的方法会在普通的 Java 线程池中作为原生代码执行,不会进行状态序列化。

    修复后的 Shared Library 实践:

    import com.cloudbees.groovy.cps.NonCPS
    
    def call(String env) {
        // 仅在局部获取所需的小数据集合,避免整个大字典逃逸到 CPS 上下文
        List<String> svcNames = extractServiceNames("manifests/all-services.yaml")
    
        for (int i = 0; i < svcNames.size(); i++) {
            def svc = svcNames[i]
            node('k8s-agent') {
                sh "echo Deploying ${svc}"
                // 每次部署仅传递当前需要的字符串对象
            }
        }
    }
    
    @NonCPS
    List<String> extractServiceNames(String filePath) {
        // 这里使用标准的 Java/Groovy 解析逻辑
        // 不会被 CPS 劫持,执行极快,不占用 Pipeline 持久化内存
        def parser = new org.yaml.snakeyaml.Yaml()
        def rawMap = parser.load(new File(filePath).text)
        return rawMap.keySet().toList()
    }
    

    注:在 @NonCPS 方法中绝对不能调用任何 Pipeline Step(如 sh, echo, node),否则会导致 IllegalStateException 或静默失败。

    2. JCasC 声明式治理 Shared Library

    为了避免通过 Jenkins UI 手工配置 Shared Library 带来的不可追溯和版本混乱,我们全面采用 Jenkins Configuration as Code (JCasC) 来固化基础设施。 将全局 Shared Library 配置下沉到不可变的代码仓库中(jcasc/jenkins.yaml):

    unclassified:
      globalLibraries:
        libraries:
        - defaultVersion: "v1.5.2" # 严禁使用 master/main 分支,必须绑定 Tag
          name: "ops-shared-lib"
          retriever:
            modernSCM:
              scm:
                git:
                  credentialsId: "git-bot-token"
                  id: "shared-lib-scm"
                  remote: "https://gitlab.internal.com/devops/jenkins-shared-library.git"
                  traits:
                  - gitBranchDiscovery()
          # 开启缓存以减轻拉取对 Master 的 I/O 压力
          cachingConfiguration:
            refreshTimeMinutes: 1440
    

    配合 K8S Helm Chart 部署 Jenkins,任何配置变更只能通过提交 MR 修改此 YAML 来触发 Pod 滚动更新,彻底掐断了手工污染配置的可能。

    常见问题 (FAQ)

    Q1:Pipeline 中经常出现 java.io.NotSerializableException: java.util.regex.Matcher 报错,如何根治? 这是由于正则表达式的 Matcher 对象内部包含 native 指针引用,无法通过 XStream 序列化。如果代码写成 def matcher = text =~ /pattern/,且该变量跨越了 CPS 步骤(例如在 sh 之前定义并在其后使用),就会报错。 解决思路:将正则匹配逻辑封装到 @NonCPS 方法中返回基础类型(String/Boolean),或者在需要跨步骤时主动置空:matcher = null。

    Q2:Jenkins Master 异常重启后,K8S 上会有大量状态为 Running 的僵尸 Agent Pod,如何自动清理? 在动态 Agent 架构中,Master 宕机会导致 JNLP 长连接断开。如果不做处理,这些 Pod 将长期挂起。 在 JCasC 的 podTemplate 配置中,务必显式设置 activeDeadlineSeconds,并通过 kubernetes-plugin 的清理策略来兜底:

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            serverUrl: "https://kubernetes.default"
            # 定义全局 Agent Pod 的最长存活时间(例如 2 小时)
            podRetention: "never" 
    

    同时在业务的 yaml 中确保 activeDeadlineSeconds: 7200 兜底,防止挂起任务长期吃空节点计算资源。

    Q3:如何本地单元测试 Jenkins Shared Library,避免每次都要上生产环境试错? 强推 JenkinsPipelineUnit 框架。可以在本地使用 Spock/JUnit 编写测试用例,框架会模拟 CPS 引擎和所有的内置步骤(sh, node, readYaml)。通过模拟返回结果并断言调用栈,可以在本地完成 90% 的逻辑校验,彻底告别在 Jenkins 上盲目触发几十次构建来 debug 的窘境。

  • 深入 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、或是非序列化的自定义类)。 解决: 永远不要在 node、stage 闭包跨越处传递这类对象;如果必须在代码块中使用复杂逻辑,请将该逻辑抽取为独立函数,并打上 @NonCPS 注解,让其在标准 JVM 堆栈中执行,而非被 CPS 引擎拦截。

    Q2:更新了 Jenkins Shared Library 的代码,但在已缓存的 Job 中不生效,必须重启 Jenkins 吗? 不需要。如果是隐式加载(Global Shared Libraries),Jenkins 默认会开启基于分支/标签的缓存。如果在 JCasC 中配置了 Library,务必检查 implicit: true 和 defaultVersion: "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 断联和任务报错。强规则: 绝对禁止在有核心业务构建运行时热重载安全相关配置。

  • Jenkins 生产环境雪崩排查实战:Groovy CPS 陷阱引发的 Metaspace 溢出与 K8S Agent 调度风暴

    结论先行:Jenkins Pipeline 复杂的 Groovy 闭包会导致 CPS(Continuation Passing Style)频繁进行 AST 转换,耗尽 Master Metaspace 触发 OOM。同时,K8S 插件在 Master 假死断连时产生的 Agent 创建风暴,会瞬间击穿 K8S API Server。本文通过重构 Shared Library 剥离 CPS 逻辑,并引入 JCasC 固化 K8S 动态 Agent 限流配置,彻底解决百级别并发构建下的系统雪崩问题。

    1. 故障现场:Master 假死与 K8S API Server 告警

    排查过程中接到告警,CI/CD 集群 P99 构建排队时间从平时的 5 秒飙升至 30 分钟以上。登录控制台发现 Jenkins UI 响应极其缓慢,部分页面直接 502。 联动监控大盘,发现了两个极度异常的指标:

    1. Jenkins Master JVM:Metaspace 使用率在两小时内呈阶梯式上涨,直至 100% 触发 Full GC,单次 GC 停顿(STW)超过 12 秒。

    2. K8S 控制平面:API Server QPS 突增,尤其是针对 namespaces/jenkins/pods 的 POST 和 DELETE 请求,导致 API Server CPU 飙升,etcd 出现选主告警。

    进入 Jenkins Master 容器抓取现场:

    # 查看 JVM 内存状态
    jstat -gcutil $(pgrep java) 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT    GCT   
      0.00 100.00  32.14  89.45  99.98  98.71  23412  145.312  425  312.411  457.723
    
    # 生成 Heap Dump 和 Thread Dump(保留案发现场)
    jcmd $(pgrep java) GC.heap_dump /tmp/jenkins_oom.hprof
    jcmd $(pgrep java) Thread.print > /tmp/jenkins_threads.txt
    

    日志中大量抛出 java.lang.OutOfMemoryError: Metaspace,同时伴随着 Kubernetes client: failed to create pod ... Read timeout。很明显,JVM 已经处于频繁 GC 的濒死状态。

    2. 为什么 Groovy CPS 机制会吃光 Master 的 Metaspace?

    把 Heap Dump 拖到 MAT(Memory Analyzer Tool)里分析,发现 ClassLoader 数量异常庞大,且绝大多数是由 com.cloudbees.groovy.cps.NonCPS 和 Pipeline 脚本动态生成的类。

    Jenkins Pipeline 的底层运行机制基于 CPS(Continuation Passing Style)。为了让 Pipeline 在 Jenkins Master 重启后还能从断点恢复继续执行,Jenkins 必须能够将当前执行的堆栈状态序列化到磁盘。 这就导致了一个致命陷阱:你在 Jenkinsfile 里写的每一行看似普通的 Groovy 代码,都会被 CPS 转换引擎解析重写为可以被序列化的 AST(抽象语法树)对象。

    在某次业务线提交的 Shared Library 中,发现了一段类似这样的代码:

    // 反面教材:在 CPS 方法中进行大量不可序列化对象的循环操作
    def processComplexJson(String jsonStr) {
        def jsonSlurper = new groovy.json.JsonSlurperClassic()
        def data = jsonSlurper.parseText(jsonStr)
        // 这里的 data 树结构非常复杂,且在循环中调用了 pipeline step
        data.items.each { item ->
            if (item.name.matches(".*-service-.*")) { // 正则 Matcher 不可序列化
                echo "Processing ${item.name}"       // 调用了 CPS 步骤
                // 复杂的处理逻辑...
            }
        }
    }
    

    原理解析:

    1. JsonSlurper 解析出的复杂对象模型、java.util.regex.Matcher 等对象是不可序列化的。

    2. 当闭包 .each {} 内部混合调用了 Pipeline 原生 step(如 echo, sh)时,Jenkins 会尝试保存整个上下文。

    3. 每次执行构建,CPS 引擎为了处理这些无法直接解析的代码,会动态生成大量的匿名类加载到 Metaspace 中。由于这些类持有 Pipeline 的执行上下文(强引用),无法被 GC 快速回收。

    4. 并发一高,Metaspace 迅速被打爆。Master 发生长达十几秒的 STW。

    雪崩链条: Master STW -> JNLP Agent (运行在 K8S Pod 中) 的心跳超时 -> Jenkins 认为 Agent 已死,触发重连或重新分配 -> K8S Plugin 疯狂向 API Server 发起创建 Pod 请求 -> API Server 被打满 -> 旧 Agent 还在跑,新 Pod 不断创建 -> K8S 节点资源耗尽。

    3. 核心修复:Shared Library 与 K8S Agent 调优实践

    针对上述问题,我们从代码重构和配置加固两方面进行落地。当前环境为 Jenkins 2.414.3 LTS,Kubernetes Plugin 4136.v464303c7379d。

    3.1 剥离 CPS:使用 @NonCPS 与纯粹的 Java 类

    对于 Shared Library 中的数据处理逻辑,必须将纯粹的代码计算与Pipeline 执行步骤隔离开。使用 @NonCPS 注解,让 Jenkins 跳过 AST 转换,按标准 JVM 字节码执行。

    import com.cloudbees.groovy.cps.NonCPS
    import groovy.json.JsonSlurperClassic
    
    // 1. 将耗时的、涉及不可序列化对象的纯计算逻辑标记为 @NonCPS
    @NonCPS
    List<String> getServicesToProcess(String jsonStr) {
        def services = []
        def jsonSlurper = new JsonSlurperClassic()
        def data = jsonSlurper.parseText(jsonStr)
    
        for (item in data.items) {
            if (item.name.matches(".*-service-.*")) {
                services.add(item.name)
            }
        }
        return services // 只返回可序列化的基本类型或标准集合
    }
    
    // 2. 在 Pipeline 步骤中通过标准 for 循环调用(不要用 .each 闭包混合 pipeline step)
    def call(String jsonStr) {
        List<String> targetServices = getServicesToProcess(jsonStr)
        for (int i = 0; i < targetServices.size(); i++) {
            def svc = targetServices[i]
            echo "Processing ${svc}"
            // 执行实际的 pipeline steps...
        }
    }
    

    3.2 阻断雪崩:JCasC 固化 K8S Agent 限流配置

    为了防止 Jenkins 在网络抖动或自身 GC 时向 K8S 发起 API DDOS 攻击,必须严格配置 K8S Plugin 的容量上限,并改用 WebSocket 代替 TCP JNLP 端口直连。 我们通过 JCasC (Jenkins Configuration as Code) 强制注入以下安全配置:

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            # 【核心防御】开启 WebSocket,复用 HTTP 端口,避免 K8S LoadBalancer 断流导致心跳丢失
            webSocket: true 
            # 【核心防御】限制全局并发 Pod 数,保护 K8S API Server 和节点资源
            containerCapStr: "200"
            # 限制 API 请求超时时间
            readTimeout: 15
            connectTimeout: 5
            maxRequestsPerHostStr: "32"
            templates:
              - name: "base-maven"
                namespace: "jenkins"
                label: "maven-agent"
                # 限制单种模板的最大并发数
                instanceCapStr: "50" 
                containers:
                  - name: "jnlp"
                    image: "jenkins/inbound-agent:3148.v532a_7e715ee3-1"
                    workingDir: "/home/jenkins/agent"
                    resourceRequestCpu: "500m"
                    resourceLimitCpu: "2"
                    resourceRequestMemory: "1Gi"
                    resourceLimitMemory: "2Gi"
    

    同时,调整 Jenkins Master 启动参数,增大 Metaspace 并限制其无序扩张: JAVA_OPTS="-Xms8G -Xmx8G -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=1G -XX:+UseG1GC"

    应用上述修复后,P99 排队时间回落至 3 秒,Master 内存泄漏彻底消除,API Server 平稳运行。

    4. 常见问题 (FAQ)

    Q1: K8S 动态 Agent 频繁出现 JNLP connection timeout 或 offline,是什么原因? 通常有两个原因:一是中间的 Ingress/LoadBalancer 对长连接(默认 50000 TCP 端口)有 idle timeout 清理机制,导致静默断连;二是 Master 的 CPU 或内存被跑满,无法及时响应心跳。 建议解决: 启用 Kubernetes 插件的 webSocket: true 选项,让 Agent 通过 标准的 HTTP 8080 端口使用 WebSocket 与 Master 通讯,这样不仅穿透性好,还能复用 HTTP 的负载均衡和 KeepAlive 策略。

    Q2: 在动态 K8S Agent 中构建 Docker 镜像,推荐 DinD (Docker in Docker) 还是 Kaniko? 坚决抵制在 K8S 生产环境中大规模使用 DinD。DinD 需要开启 Pod 的 privileged: true 特权模式,这在任何有底线的运维体系中都是不被允许的,极易引发容器逃逸。 建议解决: 使用 Google 提供的 Kaniko。它完全在用户态执行,无需特权,直接通过解析 Dockerfile 在容器内层层构建镜像文件系统,最后 push 到 Harbor。

    Q3: 如何安全地在 JCasC YAML 中管理集群密码和 Secret? 禁止在 JCasC 的 yaml 文件里明文写 Token! 建议解决: 利用 Jenkins 的 Secret 机制结合 K8S 环境变量。在 JCasC 中使用 ${MY_SECRET} 占位符,然后在 Jenkins Master 的 Deployment 中通过 K8S Secret 挂载到环境变量。启动时 JCasC 会自动将其替换,实现配置与凭据解耦。

  • Jenkins K8S 动态 Agent 疯狂重启劫难:被隐式降级击穿的 JNLP 通信防线

    某次排查过程中,核心业务线的 CI/CD 流水线彻底瘫痪,Jenkins 任务队列(Queue)积压突破 500。与此同时,底层 Kubernetes 集群告警群炸锅,API Server 出现严重的请求限流(Throttling),P99 延迟飙升至 3 秒以上。

    最终排查结论:架构团队在做 Jenkins 迁移与高可用改造时,仅配置了 Layer 7 的 Ingress 规则,却遗漏了 Jenkins Remoting 通信依赖的 Layer 4 TCP(50000)端口。导致 K8S 动态 Agent Pod 启动后无法与 Master 建立 JNLP 连接。Jenkins Kubernetes 插件因此陷入了致命的“申请 Pod -> Agent 注册超时 -> 销毁 Pod -> 无限重试”死循环,硬生生把集群 API Server 给打穿了。

    把 Jenkins 当成一个普通的无状态 Web 服务去搞云原生改造,而不去深究其底层 Master-Agent 的心跳与通信模型,这种粗暴的操作在生产环境中是极其致命的。

    案发现场:失控的调度器与死亡循环

    接到报障后,第一时间登录集群查看资源状态。终端里的现象令人窒息:

    $ kubectl get pods -n jenkins | grep jnlp-agent | wc -l
    842
    
    $ kubectl get pods -n jenkins | grep jnlp-agent | head -n 5
    jnlp-agent-8f73b-5x9qp   0/1     ContainerCreating   0          12s
    jnlp-agent-8f73b-9m2kx   1/1     Terminating         0          1m45s
    jnlp-agent-8f73b-p2v1l   0/1     ContainerCreating   0          8s
    jnlp-agent-8f73b-x8c4d   1/1     Terminating         0          1m45s
    

    数百个 Agent Pod 处于 ContainerCreating 或 Terminating 状态。再去查看 Jenkins Master 的系统日志,满屏都是类似下面的报错:

    INFO: Kubernetes pod jnlp-agent-8f73b-9m2kx started
    WARNING: Failed to connect to agent jnlp-agent-8f73b-9m2kx within 100 seconds. 
    INFO: Terminating node jnlp-agent-8f73b-9m2kx
    INFO: Queue task #4023 still pending, provisioning a new agent...
    

    转头查看其中一个 Agent Pod 的内部日志,终于抓到了真凶:

    INFO: Locating server among [https://jenkins.company.com/]
    WARNING: Failed to connect to https://jenkins.company.com/tcpSlaveAgentListener/: Connection refused
    java.net.ConnectException: Connection refused
        at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
    ...
    INFO: Retrying in 10 seconds
    

    深度剖析:为什么缺少一个端口会导致雪崩?

    要理解这个故障,必须理清 Jenkins Kubernetes Plugin 的工作状态机。这绝不只是一个“网络不通”的简单 Bug,而是一个典型的分布式状态机不同步导致的雪崩。

    1. Remoting 协议的固执:Jenkins Master 与 Agent 之间的通信基于 Jenkins Remoting 协议,这是一个重度依赖序列化与长连接的 Java 二进制协议。默认情况下,Agent 启动后,会先通过 HTTP(S) 请求 Master 的主入口,获取 X-Jenkins-CLI-Port 或相关 TCP 端口信息(通常是 50000),随后尝试建立直连 TCP 通道。

    2. L7 Ingress 的拦截:改造期间,Jenkins Master 被放到了 Nginx Ingress 后端。Ingress 默认只处理 HTTP/HTTPS 协议(L7)。当 Agent 尝试向 jenkins.company.com:50000 建立 TCP 握手时,流量直接在网关层被丢弃或拒绝。

    3. 致命的机制错位(State Mismatch):

    4. K8S 视角:Pod 已经成功拉起,容器状态是 Running,K8S 认为任务完成。
    5. Jenkins 视角:向 K8S 发送了 Pod 创建请求,且等待 Agent 进程发起 JNLP 注册回调。
    6. 死循环触发:等待 100 秒后(默认超时时间),Jenkins Master 依然没收到 Agent 的 JNLP 注册心跳。它不仅不会认为是自己的网络配置问题,反而会固执地判定:“这个 Pod 死掉了,为了满足队列里等待的构建任务,我必须销毁它,并向 K8S 申请一个新的 Pod。”

    当并发构建任务达到 50 个,每个任务都在触发这种“申请 -> 等待 -> 销毁 -> 再申请”的循环时,K8S 的 kube-apiserver 就成了重灾区。大量的 POST /api/v1/namespaces/jenkins/pods 和 DELETE 请求瞬间填满了 API Server 的队列,触发限流,进而影响整个集群内其他核心业务 Pod 的调度与扩缩容。

    解决方案与防御性配置

    针对此类问题,修复网络通信只是第一步,更重要的是在架构层面加上防御性兜底限制。

    1. 拥抱 WebSocket,抛弃底层 TCP 直连

    既然 L4 暴露配置繁琐且容易在各种负载均衡器上踩坑,最优雅的做法是直接让 JNLP 流量复用 HTTP(S) 的 L7 通道。从 Jenkins 2.217 开始,Remoting 已经原生支持 WebSocket。

    在 JCasC (Jenkins Configuration as Code) 的配置中,必须在 K8S Cloud 配置项里显式开启 webSocket: true。

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            # 直接走集群内部 DNS 通信,绕过外部 Ingress,降低网络开销与故障点
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins-agents"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            # 开启 WebSocket,彻底解决 TCP 50000 端口穿透问题
            webSocket: true
            # 【防御性编程核心】设置全局容量上限,哪怕死循环也不会打穿 API Server
            containerCapStr: "100" 
    

    2. 配置 Kubernetes Plugin 的防雪崩限制

    永远不要假设外部系统会乖乖按预期工作。必须给 Jenkins 向 K8S 索要资源的行为加上硬性枷锁:

    • containerCapStr: 限制整个 K8S Cloud 并发存活的 Agent 总数。

    • 在每个 podTemplate 级别设置 instanceCap:防止单一异常的 Pipeline 把所有集群资源耗尽。

    3. 剥离通信链路(Cluster Internal Routing)

    如果你只是在同一个 K8S 集群内部署 Jenkins Master 和调度 Agent,Agent 连接 Master 绝对不应该 绕一圈跑到外网 Ingress 再进来。不仅增加延迟,还多引入了一层网络设备的故障风险。 强制在 jenkinsUrl 中使用 K8S 内部的 FQDN:http://..svc.cluster.local:。

    排查清单与同类问题速查

    如果你也遇到了 Jenkins Agent 疯狂重启或一直在 Pending/Terminating 之间横跳,请核对以下清单:

    1. 排查 JNLP 握手阻断:查看 Agent Pod 的日志。如果出现 Connection refused 或 Connection timed out,且指向 Master 的 50000 端口,立刻检查安全组、网络策略 (NetworkPolicy) 或 LoadBalancer 的 L4 暴露情况,或者直接开启 WebSocket。

    2. 检查 Jenkins Master URL 配置:如果 Manage Jenkins -> System -> Jenkins URL 配置错误,Agent 会拿到一个无法解析的地址。在 K8S 环境下,尽量在 Cloud 配置的 jenkinsUrl 中覆盖并强制指定 ClusterIP 或内部 DNS。

    3. 监控 ContainerCap 触顶情况:如果在 Jenkins 侧看到任务一直卡在 ‘Jenkins’ doesn’t have label ‘xxx’ 或者 Waiting for next available executor,但没有看到新 Pod 创建,检查系统日志确认是否触发了 containerCap 上限。

    4. 防御性兜底检查:确认有没有恶意的 Groovy 脚本在无限触发重试。检查 Pipeline 里的 retry() 块逻辑是否包含了环境构建阶段,避免因业务代码逻辑错误引发基础设施级别的 Ddos 攻击。