分类: 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 又在不断触发新构建。