专栏文章 持续集成的开源方案攻略 (三) jenkins pipeline 与 k8s 集成

孙高飞 · 2020年02月22日 · 最后由 孙高飞 回复于 2020年04月01日 · 10511 次阅读

传统的 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 里找依赖。

感谢分享,并日常催更

16楼 已删除
17楼 已删除

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

孙高飞 回复

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

韩将 回复

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

韩将 回复

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

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

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 的变量就可以访问

fenfenzhong 回复

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

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

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册