测试开发之路 持续集成的开源方案攻略 (三) jenkins pipeline 与 k8s 集成

孙高飞 · February 22, 2020 · Last by 孙高飞 replied at April 01, 2020 · 3808 hits

传统的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比较核心的安全认证机制。很多同学在实践这套工具的时候都会卡在这里痛不欲生。 所以我下一次就专门写一篇帖子讲改怎么配置。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 24 条回复 时间 点赞

iOS的打包只能老老实实的用mac mini做slave了

也可以考虑 jenkins slave 和 k8s 的机器混部,当 slave 负载低的时候,k8s 的调度器把其他的计算任务调度到 jenkins 混部的集群上。不过本质要看测试团队的资源是否是独立的,如果团队独立有一些机器,可能k8s的 roi 不一定很高,把测试执行的任务调度规划好,单 slave 的资源利用率提升上去就好了。

我们测IE浏览器的时候也没办法, 老老实实在windows上部署~

单slave最大的缺陷就是没有高可用能力, 出现单点故障以后整个流程就会不可用的。

我在实践过程中最大的问题有两个:
1.jenkins拉起k8s pod的时间太慢,往往要几十秒才能开始流水线
2.缓存的问题,虽然我们用了rook来缓存maven等项目的依赖,但效率感觉还是不够高

gitlab域名我们是在core dns中绑定ip来解决的

regend 回复

你这两个问题其实我也还没解决~~~ 好在我们对时间的要求不大

孙高飞 回复

理解了,多谢

regend 回复

不过我们之前使用搭建一个ceph, 然后在k8s启动pod的时候, 把maven的本地缓存仓库挂载出来, 放到ceph里来解决动态依赖的问题。 但是我python很渣, 还不知道pip安装的依赖怎么挂载出来

希望看看后续您的CD部分是怎么做到,CI部分我和您一样,但是后续的应用发布,如何和ingress连起来,自动申请域名填入dnsmasq,想知道该怎么做,对了,您下载依赖哪一步,可以考虑NFS挂载,这样可以减少耗时 @高飞 

恒温 回复

后续如何跟ingress集成起来是这样的。 首先需要到你们的dns中添加一个泛域名解析。 解析地址填写你ingress controller的地址。 比如我们做的凡是以testenv.4pd.io为结尾的域名全部解析成我们ingress controller的ip地址。 然后为每一个环境创建一个ingress。 比如我们曾经做的:

def create_ingress_yaml(config):
document = """
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: %s
annotations:
nginx/client_max_body_size: 10240m
nginx.org/client-max-body-size: "
10240m"
ingress.kubernetes.io/proxy-body-size: 10240m
spec:
rules:
- host: %s.testenv.4pd.io
http:
paths:
- path: /
backend:
serviceName: %s
servicePort: 8888
- host: %s.preditor.testenv.4pd.io
http:
paths:
- path: /
backend:
serviceName: %s
servicePort: 8090
- host: %s.history.testenv.4pd.io
http:
paths:
- path: /
backend:
serviceName: %s
servicePort: 18080
"""
% (config.pht_pod_name, config.name_prefix, config.pht_pod_name, config.name_prefix, config.pht_pod_name,
config.name_prefix, config.pht_pod_name)
data = yaml.load_all(document)
with open(config.ingress_conf_path, 'w') as stream:
yaml.dump_all(data, stream)

在部署环境的时候就把ingress创建好。 然后配合泛域名解析, 就可以达到每个环境都自动的有一个域名对应上了。

孙高飞 回复

java 好解决,主要是nodejs 和旧版本的go的依赖往往在workspace下,分布式存储通过网络传输,一边读一边写,效率极慢.

regend 回复

go就直接用vendor吧, 直接在项目里把依赖打成vendor就好了。 不要用go mod下载或去worksapce里找依赖。

感谢分享,并日常催更

16Floor has been deleted
17Floor has been deleted

jnlp用了sonarqube镜像。当然会报错啦。jnlp这个name不能乱用哦,Jenkins代码里写死了,就是用来jnl连接的

孙高飞 回复

高飞,请问cephfs和ceph RBD该如何选择呢?我和你一样,也有挂载缓存的需求,我看到官网的介绍,ceph RBD不支持readwritemany。但是看到网上又有说ceph RBD的速度快,想请教你们是选择哪一个呢

韩将 回复

不是哦,我的镜像是这样的

孙高飞 #21 · March 18, 2020 作者
韩将 回复

我们很无脑的用的nfs 哈哈哈哈。 ceph 现在没在用了。 因为我们没那么高的性能要求

pod的yaml加上- command: cat后,会出现覆盖ENTRYPOINT ["jenkins-slave"] 命令的问题,导致pod slave 在jenkins一直显示未在线的状态,请问博主这边没有出现这种情况吗?我这里遇到了。

孙高飞 #23 · March 20, 2020 作者
yen8890 回复

不要在jnlp容器里用cat命令~~~ 加cat命令的目的是让其他容器能一直持续运行~~ jnlp是slave容器, 不用加

我看了文章后不知道是否理解正确:
1、jnlp在不同代码下要单独自定义Dockerfile,不能单纯一味的使用官方镜像,我看那个镜像只是一个jnlp协议而已
2、在十来个项目的情况下,jnlp有必要吗?
3、基于K8S部署jenkins、gitlab、gerrit、sonarqube这一套,为什么我就觉还不如人单机部署呢,多了一个K8S后又多了一个坑位
……
好多问题,我还是没理解K8S的价值,哈哈

@ycwdaaaa 请教一下楼主,你们定制一个 kubernetes pod 的时候有没有试过在里面挂上env,然后在 pipeline 里的 steps 里面成功访问到这个 env?
拿你举例的 python3 这个 container 来说,在 agent 定义里给这个 container 加上一个 env FOO,value 是 BAR,在某个 step 里,进入 container('python3'), echo "${FOO}", 我这边试了各种貌似都不行,但默认的jnlp container注入的那些JENKINS_XXX 的变量就可以访问

孙高飞 #26 · April 01, 2020 作者
fenfenzhong 回复

用一段我写故障注入的注释来说明一下吧。

基本上就是jenkins也是用exec这个方式来像容器发送命令的,基本上是读取不到你定义的环境变量的。 这个问题我至今还没有找到原因。 所以你还是用jenkins pipeline中的环境变量语法,来搞这些事把

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up