Skip to content

一、背景与并发痛点 ​

在企业级持续集成与持续部署(CI/CD)实践中,当测试环境或生产集群包含数十台不同角色的服务器(如主控节点 Master、计算节点 Worker、网关节点 Gateway)时,串行执行升级与部署会导致发布耗时成倍放大。

利用 Jenkins Pipeline 的 parallel 关键字可以并行执行多节点部署,但在实际落地中极易踩入一个经典的并发陷阱:

Groovy 脚本的全局变量竞态:如果在流水线中直接使用普通的全局字典或弱类型变量来循环构建并行分支,由于 Groovy 的闭包变量延迟绑定机制,所有并行线程很可能会共享同一个变量引用,导致后遍历的节点配置覆盖先遍历的节点,造成指令发往错误的目标机器。


二、架构解法:面向对象的数据结构隔离 ​

为了杜绝跨线程变量污染,必须在每个分支启动时实例化一个完全独立的上下文对象:

groovy
// 1. 定义强类型命令上下文类,确保每个线程拥有独立对象引用
class NodeDeployContext implements Serializable {
    String host
    String role
    String targetVersion
    String repository
    List<String> cleanupCmds = []
    String startCommand
}

// 2. 避免在脚本中硬编码明文账号密码,统一从 Jenkins 凭据管理中心读取
def fetchNodeMetadata(String targetHost) {
    // 实际项目中从 Vault、Consul 或安全元数据配置中解析节点属性
    return [
        host: targetHost,
        credentialId: 'ssh-deploy-credential',
        isMaster: targetHost.endsWith('.10') // 示例:判断是否为主控节点
    ]
}

三、动态装配并发并行分支 ​

在声明式 Pipeline 中,最优雅的做法是在脚本块中遍历主机清单,动态构造一个任务 Map 传给 parallel:

groovy
def buildParallelBranches(List<String> targetHosts, String appVersion, String imageRepo) {
    def branches = [:]

    targetHosts.each { ip ->
        // 关键点:在闭包内使用本地变量固定当前 IP
        def currentIp = ip
        def branchName = "Node-${currentIp}"

        branches[branchName] = {
            stage("Deploy to ${currentIp}") {
                // 为当前节点分配独立的上下文实例
                def ctx = new NodeDeployContext()
                ctx.host = currentIp
                def meta = fetchNodeMetadata(currentIp)
                ctx.role = meta.isMaster ? 'master' : 'worker'
                ctx.targetVersion = appVersion
                ctx.repository = imageRepo

                // 装配需要执行的容器与镜像清理命令
                def imageName = "${ctx.repository}/app-service:${ctx.targetVersion}"
                def cleanupCmd = """
                    docker image inspect "${imageName}" &>/dev/null && \\
                    (docker ps -a --format '{{.Image}}' | grep -q "${imageName}" && \\
                     echo "[Info] ${ctx.host} 容器正运行,准备停机重启" || \\
                     docker image rm "${imageName}") || echo "[Info] 镜像尚未拉取"
                """
                ctx.cleanupCmds.add(cleanupCmd)

                // 借助 withCredentials 安全调用 SSH 插件执行命令
                sshagent([meta.credentialId]) {
                    echo "正在向节点 [${ctx.host}] 下发升级指令..."
                    sh """
                        ssh -o StrictHostKeyChecking=no deployer@${ctx.host} '
                            set -e
                            ${ctx.cleanupCmds.join(' && ')}
                            docker pull ${imageName}
                            cd /opt/app/deploy && bash start.sh -v ${ctx.targetVersion}
                        '
                    """
                }
                echo "✓ 节点 [${ctx.host}] 部署完成"
            }
        }
    }
    return branches
}

四、声明式流水线主干骨架 ​

将动态分支注入声明式 Pipeline 中,同时开启失败熔断:

groovy
pipeline {
    agent any

    parameters {
        string(name: 'APP_VERSION', defaultValue: 'v2.5.0', description: '待发布的版本号')
        string(name: 'IMAGE_REPO', defaultValue: 'harbor.internal.net/release', description: '私有镜像仓库')
        text(name: 'TARGET_HOSTS', defaultValue: "10.0.1.10\n10.0.1.11\n10.0.1.12", description: '目标发布节点列表')
    }

    stages {
        stage('准备与参数校验') {
            steps {
                script {
                    echo "开始验证发布版本号: ${params.APP_VERSION}"
                    if (params.APP_VERSION.trim().isEmpty()) {
                        error("APP_VERSION 参数不能为空!")
                    }
                }
            }
        }

        stage('多节点并发批量发布') {
            steps {
                script {
                    def hostList = params.TARGET_HOSTS.split('\n')*.trim().findAll { !it.isEmpty() }
                    def deployBranches = buildParallelBranches(hostList, params.APP_VERSION, params.IMAGE_REPO)

                    // 遇到任一节点执行失败时,是否快速熔断终止其他节点
                    deployBranches.failFast = false

                    // 触发并行执行
                    parallel deployBranches
                }
            }
        }

        stage('全量健康检查') {
            steps {
                echo "集群全节点发布完成,执行接口可用性验证..."
            }
        }
    }

    post {
        success {
            echo "CI/CD 流水线全部节点成功发布"
        }
        failure {
            echo "CI/CD 流水线中存在失败节点,请检查控制台输出"
        }
    }
}

测试开发工程师 · 专注自动化与系统架构 | 邮箱: hansblog@atumsoul.win