jenkins pipline拉取tag jenkins label_Pod

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

1. 集群配置

1.1 Kubernetes 版本

这里使用的是 v1.16.7

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"}

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

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

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%

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

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

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
      ...

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 主要内容如下:

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
    ...

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

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

2.4 Jenkins 中 Kubernetes 插件配置



jenkins pipline拉取tag jenkins label_Pod_02

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

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'
      }
    }
  }
}

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()

# -*- 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 创建成功耗时

流水线执行成功率

失败的原因

50

50/50

可以

12分钟

50/50

-

100

100/100

可以

7分钟

100/100

-

200

200/200

4 秒加载

7分钟

178/200

Gitee 限制了拉取频率

400

400/400

11 秒加载

21分钟

348/400

Gitee 限制了拉取频率

800

778/800

17 秒加载

18分钟

446/800

触发失败、流水线堆积无法调度

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

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

jenkins pipline拉取tag jenkins label_Jenkins_03

  • 100 并发:正常执行,创建 Pod 速度很快,3~4 秒一个

jenkins pipline拉取tag jenkins label_ci_04

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

jenkins pipline拉取tag jenkins label_Jenkins_05

jenkins pipline拉取tag jenkins label_Pod_06

jenkins pipline拉取tag jenkins label_ci_07

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

jenkins pipline拉取tag jenkins label_Pod_08

jenkins pipline拉取tag jenkins label_Jenkins_09

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

jenkins pipline拉取tag jenkins label_ci_10

jenkins pipline拉取tag jenkins label_ci_11

jenkins pipline拉取tag jenkins label_Jenkins_12

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)

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 数量监控:



jenkins pipline拉取tag jenkins label_ci_13

5. 测试总结和建议

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

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

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

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

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

配置建议:

  • 限制 Jenkins 同时连接 Pod 的数量,配置足够的情况下,200 并发是没有问题的,400 并发是可以争取的。Jenkins 需要与每一个 Pod 中的 jnlp 通信,控制并发数量能有效减轻 Jenkins 的负担,避免触发失败的发生。
  • 使用专用的 CI 节点。让流水线的 Pod 在节点之间随意漂移,充分享受 Kubernetes 提供的弹性固然很好,但是大量并发的流水线会挤走节点上的负载,导致其他应用不稳定。
  • 构建的 Pod 需要设置合适的 request 。与创建应用负载类似,过小的 request 会导致调度成功,但是 Pod 起不来的问题。大量流水线并发时,过小的 request 可能会直接压垮节点。
  • 充足的 Jenkins 内存,16 G 基本能保证系统稳定,CPU 4C 及以上即可。Java 应用占用内存比较多。分配充足的内存给 Jenkins,可以提高触发成功率,提高 Pod 的创建效率,同时 Jenkins 也更稳定,不容易出现 Jenkins 页面打不开的情况。
  • 绑定一个专门的节点用来运行 Jenkins。当给 Jenkins 设置了较大的内存限制时,随着并发数量上升,内存使用逐渐增加,虽然 limit 很大,但是节点内存可能不够,这样可能会导致 Jenkins 被调度到其他节点。
  • 使用单实例 Jenkins 。Jenkins 使用磁盘文件存储数据,多实例会让 Jenkins 紊乱。提示错误如下:
Error
Jenkins detected that you appear to be running more than one instance of Jenkins that share the same home directory '/var/jenkins_home'. This greatly confuses Jenkins and you will likely experience strange behaviors, so please correct the situation.

This Jenkins:    449134911 contextPath="" at 6@ks-jenkins-68b8949bb-mgmjc
Other Jenkins:    1869668338 contextPath="" at 6@ks-jenkins-68b8949bb-kg49k

Error
Jenkins detected that you appear to be running more than one instance of Jenkins that share the same home directory '/var/jenkins_home'. This greatly confuses Jenkins and you will likely experience strange behaviors, so please correct the situation.

This Jenkins:    449134911 contextPath="" at 6@ks-jenkins-68b8949bb-mgmjc
Other Jenkins:    1869668338 contextPath="" at 6@ks-jenkins-68b8949bb-kg49k