Please enable Javascript to view the contents

Kubernetes 动态创建 Jenkins Agent 压力测试

 ·  ☕ 7 分钟

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

1. 集群配置

1.1 Kubernetes 版本

这里使用的是 v1.16.7

1
2
3
4
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 个

1
2
3
kubectl get node |grep "Ready" | wc -l

16

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

1
2
3
kubectl get node |grep "master" | wc -l

3
1
2
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 使用,避免影响集群上的其他负载。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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。

1
2
3
4
5
6
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 最大数量的限制。

1
2
3
4
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 信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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

1
2
3
4
5
6
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 节点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 对拉取频率也有限制,建议使用自己搭建的代码仓库。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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 远程触发流水线脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# -*- 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 并发

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

  • 100 并发

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

  • 200 并发

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

  • 400 并发

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

  • 800 并发

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

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

1
2
3
4
5
6
7
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、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 紊乱。提示错误如下:
1
2
3
4
5
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 [email protected]
Other Jenkins:	1869668338 contextPath="" at [email protected]

6. 参考


微信公众号
作者
微信公众号