目录

    前面的文档中,我们利用 Kubernetes 提供的弹性,在 Kubernetes 上动态创建 Jenkins Slave 。本篇文档主要是对 Jenkins 进行大规模构建的压力测试。

    1. 集群配置

    1.1 Kubernetes 版本

    这里使用的是 v1.16.7

    kubectl version
    
    kubectl version
    Client Version: version.Info{Major:"1", Minor:"16", GitVersion:"v1.16.7", GitCommit:"be3d344ed06bff7a4fc60656200a93c74f31f9a4", GitTreeState:"clean", BuildDate:"2020-02-11T19:34:02Z", GoVersion:"go1.13.6", Compiler:"gc", Platform:"linux/amd64"}
    Server Version: version.Info{Major:"1", Minor:"16", GitVersion:"v1.16.7", GitCommit:"be3d344ed06bff7a4fc60656200a93c74f31f9a4", GitTreeState:"clean", BuildDate:"2020-02-11T19:24:46Z", GoVersion:"go1.13.6", Compiler:"gc", Platform:"linux/amd64"}
    

    1.2 节点数量

    集群节点总数, 16 个

    kubectl get node |grep "Ready" | wc -l
    
    16
    

    其中 master 节点 3 个,worker 节点 13 个。

    kubectl get node |grep "master" | wc -l
    
    3
    
    kubectl get node |grep "worker" | wc -l
    
    13
    

    1.3 CI 节点

    选取其中的 10 个节点用于 CI 构建,5 个 8 核 32 G ,5 个 16 核 32 G 。给这些节点打上 Label node-role.kubernetes.io/worker=ci ,用于构建 Pod 选取 Node 使用,避免影响集群上的其他负载。

    kubectl top node -l node-role.kubernetes.io/worker=ci
    
    NAME   CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
    ci1    67m          0%     1268Mi          8%
    ci10   100m         1%     1273Mi          4%
    ci2    80m          1%     1258Mi          8%
    ci3    90m          1%     1274Mi          8%
    ci4    72m          0%     1286Mi          8%
    ci5    80m          1%     1276Mi          8%
    ci6    80m          1%     1268Mi          4%
    ci7    89m          1%     1293Mi          4%
    ci8    118m         1%     1285Mi          4%
    ci9    81m          1%     1268Mi          4%
    

    1.4 CI 资源配置

    • Pod 数量限制,足够支持 1100 Pod

    按照官网文档描述,Kubernetes 最大支持 5000 个节点,15 W 个 Pod。

    At v1.18, Kubernetes supports clusters with up to 5000 nodes. More specifically, we support configurations that meet all of the following criteria:
    
    No more than 5000 nodes
    No more than 150000 total pods
    No more than 300000 total containers
    No more than 100 pods per node
    

    除了集群 Pod 总数有上限,这里有意义的是 kubelet 对 pod 最大数量的限制。

    cat /var/lib/kubelet/config.yaml|grep max
    
    maxOpenFiles: 1000000
    maxPods: 110
    

    10 个 CI 节点,总共能提供 1100 个 Pod,除去一些系统组件占用,已经足够。

    • Memory 和 CPU,足够支持 400 条流水线并发

    每个 Pod 大约占用 500 MB Memory,CPU 是构建时瞬时值会比较高,但是维持时间较短,这里不用太多考虑。5 个 8 核 32 G ,5 个 16 核 32 G,总共有 120 核 320 G 内存,足够支撑 400 ( > 320 * 0.8 / 0.5 = 512) 条流水线同时构建。另外,由于 Jenkins Agent Pod 配置的是软亲和,当 CI 节点资源不足时,也可以调度到其他节点。

    2. Jenkins 配置

    2.1 Jenkins

    即使流水线是在 Agent 上执行,但是大量的流水线同时运行,也会对 Jenkins 产生压力,这里给 Jenkins 的 limit 为 8 核 16 GB ,也就是最大允许消耗的资源量。

    Jenkins 采用 Helm 部署,运行在 Kubernetes 上。下面是截取的部分 Deployment 信息:

    kind: Deployment
    apiVersion: apps/v1
    metadata:
      name: ks-jenkins
      namespace: ks-jenkins
      labels:
        app.kubernetes.io/managed-by: Helm
        chart: jenkins-0.19.0
    spec:
      replicas: 1
      template:
        metadata:
          labels:
            chart: jenkins-0.19.0
        spec:
          containers:
            - name: ks-jenkins
              image: 'jenkins/jenkins:2.176.2'
              env:
                - name: JAVA_TOOL_OPTIONS
                  value: >-
                    -Xms3g -Xmx6g -XX:MaxRAM=16g
                    -Dhudson.slaves.NodeProvisioner.initialDelay=20
                    -Dhudson.slaves.NodeProvisioner.MARGIN=50
                    -Dhudson.slaves.NodeProvisioner.MARGIN0=0.85
                    -Dhudson.model.LoadStatistics.clock=5000
                    -Dhudson.model.LoadStatistics.decay=0.2
                    -Dhudson.slaves.NodeProvisioner.recurrencePeriod=5000
                    -Dio.jenkins.plugins.casc.ConfigurationAsCode.initialDelay=10000
                    -verbose:gc -Xloggc:/var/jenkins_home/gc-%t.log
                    -XX:NumberOfGCLogFiles=2 -XX:+UseGCLogFileRotation
                    -XX:GCLogFileSize=100m -XX:+PrintGC -XX:+PrintGCDateStamps
                    -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintGCCause
                    -XX:+PrintTenuringDistribution -XX:+PrintReferenceGC
                    -XX:+PrintAdaptiveSizePolicy -XX:+UseG1GC
                    -XX:+UseStringDeduplication -XX:+ParallelRefProcEnabled
                    -XX:+DisableExplicitGC -XX:+UnlockDiagnosticVMOptions
                    -XX:+UnlockExperimentalVMOptions 
                - name: kubernetes.connection.timeout
                  value: '60000'
                - name: kubernetes.request.timeout
                  value: '60000'
          schedulerName: default-scheduler
          ...
    

    2.2 Jenkins Agent

    使用 Kubernetes 提供的动态 Pod 作为 Jenkins Agent 用于构建流水线,具体配置可以参考顶部的文档链接。

    Pod 中的 Maven 容器镜像 Dockerfile 主要内容如下:

    Dockerfile

    centos:7
    # java
    RUN yum install -y java-1.8.0-openjdk \
        java-1.8.0-openjdk-devel \
        java-1.8.0-openjdk-devel.i686
        ...
    

    为了减少对其他节点的影响,在 Jenkins 中配置了软亲和,将创建的动态 Pod 尽量调度到指定的 CI 节点。

    spec:
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 1
            preference:
              matchExpressions:
              - key: node-role.kubernetes.io/worker
                operator: In
                values:
                - ci
    

    2.4 Jenkins 中 Kubernetes 插件配置

    将容器数量和等待时间设置为一个较大值。

    2.5 测试用的 Pipeline Demo

    Demo 采用的是一个 Java 项目,克隆代码、执行单元测试、镜像构建。由于镜像内容都一样,这里就没有推送镜像,同时也减少了外部依赖。gitee.com 对拉取频率也有限制,建议使用自己搭建的代码仓库。

    pipeline {
      agent {
        node {
          label 'maven'
        }
      }
      environment {
            REGISTRY = 'docker.io'
            DOCKERHUB_NAMESPACE = 'shaowenchen'
            APP_NAME = 'devops-java-sample'
            TAG_NAME = "SNAPSHOT-$BRANCH_NAME-$BUILD_NUMBER"
        }
      stages {
        stage('checkout') {
          steps {
            container('maven') {
              git branch: 'master', url: 'https://gitee.com/shaowenchen/devops-java-sample.git'
            }
          }
        }
        stage('unit test') {
          steps {
            container('maven') {
              sh 'mvn clean -o -gs `pwd`/configuration/settings.xml test'
            }
    
          }
        }
        stage('build') {
          steps {
            container('maven') {
              sh 'mvn -o -Dmaven.test.skip=true -gs `pwd`/configuration/settings.xml clean package'
              sh 'docker build -f Dockerfile-online -t $REGISTRY/$DOCKERHUB_NAMESPACE/$APP_NAME:SNAPSHOT-$BRANCH_NAME-$BUILD_NUMBER .'
            }
    
          }
        }
        stage('sleep 0.5h') {
          steps {
            sh 'sleep 1800'
          }
        }
      }
    }
    

    2.6 远程触发流水线脚本

    # -*- coding: utf-8 -*-
    # import time
    import requests
    
    jenkins_job_name = "new"
    Jenkins_url = "http://jenkins.chenshaowen.com:8080"
    jenkins_user = "admin"
    jenkins_pwd = "password"
    # buildWithParameters = True  # if there are parameters
    buildWithParameters = False
    jenkins_params = {'token': 'mytoken',
                      'param1': 'valu1'}
    
    def trigger():
        try:
            auth = (jenkins_user, jenkins_pwd)
            crumb_data = requests.get(
                "{0}/crumbIssuer/api/json".format(Jenkins_url),
                auth=auth,
                headers={
                    'content-type': 'application/json'})
            if str(crumb_data.status_code) == "200":
    
                if buildWithParameters:
                    data = requests.get(
                        "{0}/job/{1}/buildWithParameters".format(
                            Jenkins_url,
                            jenkins_job_name),
                        auth=auth,
                        params=jenkins_params,
                        headers={
                            'content-type': 'application/json',
                            'Jenkins-Crumb': crumb_data.json()['crumb']})
                else:
                    data = requests.get(
                        "{0}/job/{1}/build".format(
                            Jenkins_url,
                            jenkins_job_name),
                        auth=auth,
                        params=jenkins_params,
                        headers={
                            'content-type': 'application/json',
                            'Jenkins-Crumb': crumb_data.json()['crumb']})
                print(data.status_code)
    
                if str(data.status_code) == "201":
                    print("Jenkins job is triggered")
                else:
                    print("Failed to trigger the Jenkins job")
    
            else:
                print("Couldn't fetch Jenkins-Crumb")
                raise
    
        except Exception as e:
            print("Failed triggering the Jenkins job")
            print("Error: " + str(e))
    
    if __name__ == "__main__":
        for i in range(400):
            # time.sleep(1)
            print("Trigger-" + str(i))
            trigger()
    

    3. 测试策略

    为了更好的测试 Jenkins 在 Kubernetes 上执行流水线的性能,在上面的配置中,我提供了足够 400 条流水线并发执行的资源。

    由于首次运行流水线时,需要拉取镜像、对依赖包进行缓存。在执行测试之前,执行 20 次流水线对节点进行预热。

    主要进行五组测试,分别为 50、100、200、400、800 条流水线并发。

    观察的指标

    • 触发流水线成功率
    • Jenkins UI 能否正常打开
    • Jenkins 创建 Pod 的速度
    • 流水线执行成功率

    4. 测试结果

    流水线并发数量触发成功率UI 能否正常打开全部 Pod 创建成功耗时流水线执行成功率
    5050/50可以12分钟50/50
    100100/100可以7分钟100/100
    200200/2004 秒加载7分钟178/200
    400400/40011 秒加载21分钟348/400
    800778/80017 秒加载18分钟446/800

    下面是具体的监控数据和分析

    • 50 并发

    正常执行,应该是预热不够充分,后半段速度变慢,创建时间较长。

    • 100 并发

    正常执行,创建 Pod 速度很快,3~4 秒一个

    • 200 并发

    触发正常,执行时部分流水线报错。这里的错误,主要是拉取 git 服务器代码受到了限制。错误提示如下:

    • 400 并发

    有极少量调度到非 CI 节点,同样有大量拉取 git 服务器代码提示错误。

    • 800 并发

    460、461、551、552、759-776 触发失败。有少量调度到非 CI 节点,大量流水线堆积在 Build Queue ,这些流水线长时间不被调度,尝试重启 Jenkins 依然无法执行。

    800 条流水线并发,超过了集群的负载极限。Jenkins 使用的内存达到了极限,能连接管理的 jnlp 数量也达到了极限。下面是相关的提示报错:

    INFO: Server reports protocol JNLP-connect not supported, skipping
    
    Aug 02, 2020 7:20:33 AM hudson.remoting.jnlp.Main$CuiListener error
    
    SEVERE: The server rejected the connection: None of the protocols were accepted
    
    java.lang.Exception: The server rejected the connection: None of the protocols were accepted
    
    at hudson.remoting.Engine.onConnectionRejected(Engine.java:675)
    
    at hudson.remoting.Engine.innerRun(Engine.java:639)
    
    at hudson.remoting.Engine.run(Engine.java:474)
    

    -XX:MaxRAM=16g 的配置在 400 并发时,明显吃力,到了 800 并发时,已经不够。之后,我又将最大内存使用设置为 32 g 进行测试,触发成功率有所改善,依然达不到 100% ; Pod 创建速度变快,集群资源充足的情况下,依然有部分堵在 Build Queue 中无法调度。

    后来,我找了一个 202 个节点的集群进行测试,Jenkins 内存限制设置很大。通过接口不停地发送触发请求,Pod 数量最高峰在 517(=520-3),Pod 中的 jnlp 与 Jenkins 连接出现问题。同时,也伴随着大量触发和构建错误。下图是,关于 Pod 数量监控:

    5. 测试总结和建议

    从原理上讲 Jenkins 的 Kubernetes 插件实现的功能是调用 Kubernetes 的接口,创建 Pod 用于构建。创建的 Pod 中包含 jnlp 和真正构建环境的容器。

    在高并发、高负载的场景下,瓶颈会出现在如下方面:

    • Jenkins 提供的 API
    • Jenkins 的调度算法
    • Jenkins 调用 Kubernetes API
    • Kubernetes 调度创建 Pod 的速度
    • Pod 运行时的资源消耗,CPU、IO 等
    • Jenkins 的内存和 CPU 限制

    这次测试不算特别完善,有如下问题:

    • 预热不够充分。测试 50 并发的数据有明显问题,创建速度比 100 并发还慢,说明有些节点没有相关的镜像或缓存。
    • Jenkins 内存不够充足。在 400 并发时,Jenkins 的内存使用已经接近 limit 限制,页面打开缓慢。

    配置建议:

    • 限制 Jenkins 同时连接 Pod 的数量。Jenkins 需要与每一个 Pod 中的 jnlp 通信,控制并发数量能有效减轻 Jenkins 的负担,避免触发失败的发生。
    • 使用专用的 CI 节点。让流水线的 Pod 在节点之间随意漂移,充分享受 Kubernetes 提供的弹性固然很好,但是大量并发的流水线会挤走节点上的负载,导致其他应用不稳定。
    • 构建的 Pod 需要设置合适的 request 。与创建应用负载类似,过小的 request 会导致调度成功,但是 Pod 起不来的问题。大量流水线并发时,过小的 request 可能会直接压垮节点。
    • 充足的 Jenkins 内存。Java 应用占用内存比较多。分配充足的内存给 Jenkins,可以提高触发成功率,提高 Pod 的创建效率,同时 Jenkins 也更稳定,不容易出现 Jenkins 页面打不开的情况。
    • 绑定一个专门的节点用来运行 Jenkins。当给 Jenkins 设置了较大的内存限制时,随着并发数量上升,内存使用逐渐增加,虽然 limit 很大,但是节点内存可能不够,这样可能会导致 Jenkins 被调度到其他节点。

    6. 参考