传统的 Jenkins Slave 方式存在的问题
传统的 Jenkins Slave 一主多从式会存在一些痛点。比如:
- slave 节点发生故障时,可能会造成整个流程不可用
- 每个 Slave 的配置环境不一样,来完成不同语言的编译打包等操作,但是这些差异化的配置导致管理起来非常不方便,维护起来也是比较费劲;
- 资源分配不均衡,没有一个好的调度算法来调度 job 去最空闲的 slave 上运行。导致有些 slave 很空闲,有些很忙碌
- 资源浪费,每台 Slave 可能是实体机或者 VM,当 Slave 处于空闲状态时,也不会完全释放掉资源。
- 如果没有容器化部署 slave,那么制作 slave 的成本很高,迁移也很麻烦。 因为 salve 上的依赖可能很多很复杂
- 每台 slave 节点都需要安装 jdk,配置 ssh 服务等,即便我只是想运行一个 python 任务。 这样即便我们容器化部署,但也使我们的 dockerfile 中增加了额外的步骤造成额外的维护成本,并且镜像制作速度也会下降。
解决方案
基于上面的问题我们之前的改造方式是把 salve 做成镜像部署到 k8s 中, 比如我们的 UI 自动化所需要的 slave 就是测试 k8s 集群中一个 pod。 这样解决了我们上面说的第一个问题,就是如果节点出现故障,那么 k8s 会帮我们把这个 pod 迁移到其他可用节点,但是它无法很好的解决其他几个问题。 所以最终我们希望将 jenkins 和 k8s 进行整合。 达到如下的效果
上面是我再 jenkins 官网上下载的图。 抛开 master 节点的实现 (我们的 jenkins mater 没有部署在 k8s 中),在这里 jenkins 能调用 k8s 的接口,动态的在 k8s 中创建 pod 并作为 slave 运行我们的测试任务, 任务运行完毕后删除 pod。 并且它充分利用了 k8s 的特性, 创建的 pod 中负责与 jenkins master 连接的 slave 容器是 jenkins 团队发布的 docker 镜像 jenkins/jnlp-slave, 而且 jenkins 的 k8s 插件会自动帮助我们定义 pod 的配置,我们只需要定义一个基本的 pod 信息,jenkins 会把我们的配置与它自己的配置进行 merge。 比如, 在 sage-sdk-test 的 pipeline 中,我们是这么做的:
在 agent 部分跟以往不同的是我们提供了一个 k8s 的 pod 模板, 这样就是告诉 jenkins 我们这个 job 要跑在 k8s 上, 需要在 k8s 上运行这个 pod 然后当做 slave 节点,运行我们的 job。 其他的 pipeline 配置跟以前基本一样,这里只是把 slave 容器从以前的方式变成了现在的动态创建 pod 的方式。
技术细节
那么接下来我们看一下这里面的技术细节。 这个 pod 里面定义了两个容器, 一个是 jnlp, 这里需要注意一下。 jenkins 默认把名字叫 jnlp 的容器当做 slave 容器,所以如果你想要更换这个镜像的话, 就像上面做的一样即可。 也就是我们自己写一个 dockerfile 然后继承 jenkins/jnlp-slave 对它进行扩展。 这样我们既保留了 slave 的能力又扩展了自己的运行依赖。 比如下面我做的:
之所以要扩展 jenkins/jnlp-slave 主要有三个原因:
- 默认的 jenkins/jnlp-slave 镜像启动容器的时候是使用 jenkins 这个用户,而不是 root 用户,这样会导致我们后续与其他容器协作的时候出现权限问题
- 默认的镜像不知道什么原因没办法解析我们公司的 gitlab 域名, 我到现在也不知道什么原因。只有我集成它并安装自己的镜像就可以。 很诡异的问题。
- 直接安装 maven,配置未我们公司的私服。 这样我们就可以直接把这个镜像也作为 java 的 slave 容器来运行, 一举两得。
那么在 sdk 这个测试项目中, 我们需要测试 python 的 sdk, 所以需要一个 python3.7 的镜像来启动容器。 dockerfile 如下:
很简单, 直接使用 python3.7 的官方镜像,我们的扩展只是在镜像里安装一些 pytest 的依赖,这样容器启动后不用实时下载,运行速度会更快。 这样我们通过 python 容器和 jnlp 容器组合成了我们跑 pipeline 需要的运行环境了。 我们看看运行起来后启动的 pod 是什么样子的:
---
apiVersion: "v1"
kind: "Pod"
metadata:
annotations:
buildUrl: "http://m7-qa-test03:8081/job/sage-sdk-test/109/"
labels:
qa: "python3"
jenkins: "slave"
jenkins/label: "sage-sdk-test_109-hpf67"
name: "sage-sdk-test-109-hpf67-tr47k-95sch"
spec:
containers:
- command:
- "cat"
image: "registry.4paradigm.com/qa/python3"
name: "python3"
tty: true
volumeMounts:
- mountPath: "/home/jenkins/agent"
name: "workspace-volume"
readOnly: false
- env:
- name: "JENKINS_SECRET"
value: "********"
- name: "JENKINS_AGENT_NAME"
value: "sage-sdk-test-109-hpf67-tr47k-95sch"
- name: "JENKINS_NAME"
value: "sage-sdk-test-109-hpf67-tr47k-95sch"
- name: "JENKINS_AGENT_WORKDIR"
value: "/home/jenkins/agent"
- name: "JENKINS_URL"
value: "http://m7-qa-test03:8081/"
image: "registry.4paradigm.com/tester_jenkins_slave:v1"
name: "jnlp"
volumeMounts:
- mountPath: "/home/jenkins/agent"
name: "workspace-volume"
readOnly: false
imagePullSecrets:
- name: "docker4paradigm"
nodeSelector:
beta.kubernetes.io/os: "linux"
restartPolicy: "Never"
securityContext: {}
volumes:
- emptyDir:
medium: ""
name: "workspace-volume"
这个是从启动的 pod 的配置信息。 这里我们要关注以下几点:
- 这个 pod 的配置不全是我们定义的,我们定义的 pod 模板是比较简单的, 其他的 pod 配置都是 jenkins 帮我们加上的, 就像上面说的 jenkins 有自己的 pod 模板,它会把我们的模板和它自己的进行 merge。 这样才既能满足我们的需求, 也能满足它的需求
- 默认情况下 jenkins 会在 pod 中添加一个叫 jnlp 的容器作为 slave 容器,像我们刚才说的这个容器是使用 jenkins/jnlp-slave 启动的,这是 jenkins 官方的 slave 镜像, jenkins 在启动它的时候会自动加上对应的环境变量,比如 jenkins master 的 URL, 工作目录,鉴权的 JENKINS_SECRET 等,而一旦我们自己的模板中有一个叫 jnlp 的容器,那么 jenkins 也会触发 merge 机制, 它会使用我们定义的这个镜像来替换它的 jenkins/jnlp-slave 镜像来启动这个 slave 容器。 而这个机制就是我们用来替换默认镜像, 扩展自己想要的能力的方式
- 我们在自己定义的 pod 模板里可以指定多个容器, 而 jenkins 会自动的把所有的容器都挂载同样的目录。 这里的细节是: 我们可以看到 jenkins 为我们创建了一个 emtpyDir, 这个 emptydir 的名字是 worksapce-volume。 而所有的容器都挂载了这个 volumes 到/home/jenkins/agent 下。 这样就达到了一个效果,那就是所有的容器都共享了这个 jenkins job 的 worksapce,也就是不论你在 pipeline 中切换到哪一个容器里,实际上你都可以对整个 workspace 做任何操作。 这是一个非常棒的设计, 这样可以让我们在各个容器间无缝切换获取运行依赖而不会影响任务运行。
- python 的容器启动时我制定了一个 cat tty-=true 这么个命令, 这是为了能让容器持续运行,而不是一上来就运行完毕。 当然也可以通过制作镜像的时候在 entrypoint 里写一个死循环。 这里我们一定要注意,一定要保持容器一直处于运行状态。 否则 jenkins 就会发现容器运行状态不是 running 然后不停重启, 这个 job 也就一直是 pending 状态.
在 pipeline 中切换运行容器
在这里我们依然用 sage sdk 项目为例子看一下, 其实非常简单。 只需要在 pipeline 中的 steps 里使用 container 指令就可以了。
最佳实践
OK, 通过以上技术细节我们就可以总结出一些最佳实践。
- 不要在像以前做一个大而全的 slave 镜像, 这样镜像维护的成本太高,一旦有一点改动就要重新 build 好久,中间出一点错重新 build 的时间成本太高。 取而代之的是我们每个场景定义一个小而美的镜像。 比如要测试 python sdk,那就用官方的 python 镜像就可以了, 如果需要 go 语言的依赖,就做一个 golang 的镜像就可以了。 我们只需要在运行时在不同的阶段切换到对应的镜像运行就可以了。 反正他们是共享 worksapce 目录的, 所以不论怎么切换都没问题的。
- 只有在具体运行测试任务的时候切换到对应语言的容器中去,其他的都在 jnlp 这个 slave 容器中运行。 这是因为 jenkins 与其他容器通信的机制走的是 k8s client 的 exec 。这个方式有很大的坑,就是这种方式运行的 shell 环境没有设置很多的环境变量,网络设置等等。 我在高可用测试工具中也使用的这个方式去与其他容器通信,经常遇到这个问题。 所以如果你在其他容器中执行 git 这种拉取代码操作。 有可能会遇见诸如 canot resolve hostname 这种网络请求问题, 我也在执行 make build 命令的时候出现过由于没有 预先设置 LANG=utf-8 这种问题 而导致 java 字符转义失败。而一个正常的系统是有这些预先设置这些变量的。 所以为了防止被坑,那就只在必要的时候切换到其他容器中运行,平时我们只在 jnlp 容器中操作。
下面列一下我们在做 sdk 的兼容性测试的实践方式。
library 'qa-pipeline-library'
pipeline{
parameters {
choice(name: 'PLATFORM_FILTER', choices: ['python352', 'python368', 'python376','all'], description: '选择测试的 python 版本')
}
agent{
kubernetes{
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
qa: python3
spec:
containers:
- name: python352
image: python:3.5.2
command:
- cat
tty: true
- name: python368
image: python:3.6.8
command:
- cat
tty: true
- name: python376
image: python:3.7.6
command:
- cat
tty: true
- name: jnlp
image: registry.4paradigm.com/tester_jenkins_slave:v1
imagePullSecrets:
- name: docker4paradigm
"""
}
}
stages{
stage('环境部署'){
steps{
echo 'deploy'
}
}
stage('拉取测试代码'){
steps{
checkout([$class: 'GitSCM', branches: [[name: '*/release/3.8.2']], doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'LocalBranch', localBranch: 'sage-sdk-test']], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'gaofeigitlab', url: 'https://gitlab.4pd.io/qa/sage-sdk-test.git']]])
}
}
stage('sage sdk 功能测试 '){
when { anyOf {
expression { params.PLATFORM_FILTER != 'all' }
} }
steps{
container(params.PLATFORM_FILTER){
sh """
pip3 install -i http://pypi.4paradigm.com/4paradigm/dev/ --trusted-host pypi.4paradigm.com 'sage-sdk[builtin-operators]'
pip3 install -r requirements.txt
cd test
python3 -m pytest -n 5
"""
}
}
}
stage('sage sdk 兼容性测试'){
matrix {
when { anyOf {
expression { params.PLATFORM_FILTER == 'all' }
} }
axes {
axis {
name 'PLATFORM'
values 'python352', 'python368','python376'
}
}
stages{
stage('兼容性测试开始 '){
steps{
container("${PLATFORM}"){
echo "Testing planform ${PLATFORM}"
sh """
pip3 install -i http://pypi.4paradigm.com/4paradigm/dev/ --trusted-host pypi.4paradigm.com 'sage-sdk[builtin-operators]'
pip3 install -r requirements.txt
cd test
python3 -m pytest -n 5
"""
}
}
}
}
}
}
}
post{
always{
allure commandline: 'allure2.13.1', includeProperties: false, jdk: '', results: [[path: 'test/allure-results']]
sendEmail('sungaofei@4paradigm.com')
}
}
}
上面的实践很好了诠释了 jenkins 与 k8s 集成的优势,让我为大家娓娓道来。首先这个项目的需求是测试我们为用户提供的 python sdk, 这些 sdk 可以帮助用户在脱离 UI 操控我们的产品。并且我们需要支持 python3.5.2(包括 3.5.2)以上的所有版本。 所以在测试功能之外我们仍然要进行兼容性测试。所以在上面的 pipeline 中我们使用到了 matrix 指令,该指令里定义了要测试的所有的 python 的版本 (为了简便,我这里裁剪到 3 个 python 版本) 而 matrix 的能力就是使用我们提供的这些参数并发执行测试, 也就是说他会并发的执行 python3.5.3, 3.6.8 和 3.7.6 这 3 个 python 版本的测试用例。 那么这里我们便需要这 3 个 python 版本的环境了。 所以利用上面说的与 k8s 集成的功能。 我们使用这 3 个版本的官方镜像。
如上图所示,我们直接使用官方镜像,不需要自己的任何加工。 这样在兼容性测试方案中,也就是在 matrix 指令里去切换不同的容器来执行测试即可。
这样我们在 pipeline 中的测试流程就变成了下面这个图的样子:
- 使用 jnlp 作为主容器,执行环境部署,下载测试代码的操作
- pipeline 中的 matrix 指令会切换到不同的版本的容器中分别执行测试用例。 由于所有容器都是共享工作空间的, 所以其他容器是可以看到 jnlp 主容器下载的代码而执行测试的,并且执行测试后将测试报告产出到固定目录
- 因为目录共享,jnlp 主容器同样可以获得其他容器运行的兼容性测试报告, 所以在测试结束后,我们切换回主容器合并所有的测试报告并发出邮件。
如此, 这样的实践方式除了一开始我提到的各种好处外,我还想着重的提一下通过这种方式我们的兼容性测试的环境准备降低到了一个几乎 0 成本的程度。 不管我们想测试什么样的 python 版本,只需要在 pipeline 中指定启动相应版本的官方镜像即可。 而对比以前我们要为每个 python 版本制作一个 salve 镜像 这样的方式,实在是方便了许多。
尾声
jenins pipeline 与 k8s 集成为我们带来了强大的能力帮助我们更好的完成持续集成的工作。尤其是在大型项目中,每日的构建,测试等操作数以千计。我们需要维护非常多的环境和 slave 节点。 而今天介绍的这套工具将会为我们节省非常大的运维成本。 当然今天只讲到了 jenkins pipeline 与 k8s 集成后的使用方式 ,并没有写怎么打通 jenkins 与 k8s 的通信。 其实这里也是蛮重要的, 它的配置非常的有讲究。 涉及到了 k8s 比较核心的安全认证机制。很多同学在实践这套工具的时候都会卡在这里痛不欲生。 所以我下一次就专门写一篇帖子讲改怎么配置。