有赞持续集成容器化实践

背景介绍

目前我厂 Jenkins CI 采用的是 Master-Slave 架构, Master 和 Slave 都是物理机搭建。主要用于跑单测,集成测试等。由于早期没有专人来管理 Jenkins ,随着业务的发展 Jenkins Job 越来越多,也带来了如下问题:

  1. 当 Job 越来越多时需要通过增加 Slave 机器来解决,新增 Slave 上的软件得重新安装。
  2. 资源分配不均衡有浪费,有的 Slave 上运行的 Job 出现排队等待,而有的 Slave 处于空闲状态。 并且当 Slave 处于空闲状态时,也不会完全释放掉资源。
  3. 每个 Slave 总有点差异维护起来比较麻烦。
  4. 当 Master 有故障时,整个流程都不可用。

整体方案设计

为了解决以上问题,减少 Jenkins 维护成本降低机器成本等。我们决定采用现下比较流行的 kubernetes Jenkins CI/CD 技术,将 Jenkins Master 和 Slave 交给 k8s 动态调度。 下图是基于 K8s 搭建 Jenkins 集群的简单示意图: k8s

从上图中可以看到 Jenkins Master 和 Jenkins Slave 以 Pod 形式运行在 K8s 集群的 Node 上,Master 运行在其中一个节点,Slave 运行在各个节点上,Slave 的运行将按照需求去动态创建。

工作流程: 当调用 Jenkins Master API 发起构建请求时,Jenkins k8s plugin 会根据 Job 配置的 Label 动态创建一个运行在 Pod 中的 Jenkins Slave 并注册到 Master 上,当 Job 结束后,这个 Slave 会被注销并且这个 Pod 也会自动删除,恢复到最初状态,这样集群资源得到充分的利用。

使用容器化和 K8s 动态创建 Slave 优势:

  • Master 服务高可用,当 Jenkins Master 出现故障时,K8s 会自动创建一个新的 Jenkins Master 容器。

  • 动态伸缩合理使用资源,每次构建 Job 时,会根据配置自动创建一个 Jenkins Slave,Job 完成后,Slave 自动注销并删除容器,资源自动释放,而且 K8s 会根据每个资源的使用情况,动态分配 Slave 到空闲的节点上创建,降低出现因某节点资源利用率高,还在该节点排队等待的情况。

  • 扩展性好,当 K8s 集群的资源严重不足而导致 Job 排队等待时,可以很容易的添加一个 Kubernetes Node 到集群中,从而实现扩展。

部署 Jenkins Master、Sonarqube

Jenkins Master 部署

由于我们采用 K8s 集群部署,首先得制作 Jenkins Master 镜像。当然也可以使用 Jenkins 官网的上镜像 jenkins/jenkins:lts,因为我们有一些需求,所以需要自己制作。下面是制作镜像中个人认为需要注意的地方:

  • 需要 EXPOSE 2个端口,Jenkins Web 访问端口和 JNLP 代理协议的 TCP 端口( jnlp-slave 连接 Master 使用的端口)。
  • JNLP 代理协议的 TCP 端口: 由于 Jenkins-Master 是在容器中启动的,所以一定要将这个端口暴露到外部,不然 Jenkins-Master 不知道 Slave 是否已经启动,会反复去创建 Pod 直到超过重试次数。

Jenkins Master 若要动态创建 Slave 需要安装配置 Kubernetes Plugin,这里可以参考 K8S 在有赞 PaaS 测试环境中的实践 里面有介绍,或在网上找资料。

Sonarqube 部署

CI/CD 中 Sonarqube 也是必不可少的,用于代码质量管理等。由于 Sonarqube 有一些规则等配置需要在启动时加载好,所以需要重新制作镜像。这里镜像制作分为 2 部分:

  • 第一部分:Mysql 镜像制作,包含 Sonar 数据库和 Sonar 用户创建,导入 Sonarqube 初始化数据,启动 Mysql。
  • 第二部分:基于上面的 Mysql 镜像再制作 Sonarqube 镜像。

这里我们是把 Mysql 和 Sonarqube 集成在一个镜像里,当然也可以分开。下面是制作 Mysql 镜像的部分 Dockerfile:

FROM mysql:5.7  
#设置免密登录
ENV MYSQL_ALLOW_EMPTY_PASSWORD yes

#将所需文件放到容器中
COPY mysqld.cnf /etc/mysql/mysql.conf.d/mysqld.cnf  
COPY setup.sh /mysql/setup.sh  
COPY privileges.sql /mysql/privileges.sql  
COPY sonar.sql /mysql/sonar.sql  
COPY init_sonar.sql /mysql/init_sonar.sql  
COPY run-entrypoint.sh /mysql/run-entrypoint.sh  
COPY start.sh /mysql/start.sh  
........
#设置容器启动时执行的命令
ENTRYPOINT ["/mysql/run-entrypoint.sh", "/mysql/setup.sh"]  
  • privileges.sql 创建 sonar 数据库,配置 SonarQube Server 访问数据库用户的权限。
  • sonar.sql sonarqube 初始化配置的数据库表和数据。
  • init_sonar.sql 将 sonar 数据库表和数据导入 sonar 数据库。
  • start.sh 启动 mysql 并执行以上 sql 文本。

Jenkins Slave 制作

Jenkins Java Slave 我们参考的官网制作并添加了一些我们自己包(官方提供的 jenkins/ssh-slave,官方文档中有说明,这个镜像安装了 JDK 和 sshd,有兴趣的同学也可以自己制作),其中 Nodejs 、Python Slave 制作和 Java Slave 类似,网上也有资料这里就不详细介绍了。

制作完的镜像需推送到镜像仓库中保存, 下面是构建和推送镜像的命令:

docker build -t [IMAGE:TAG] .  
docker tag SOURCE_IMAGE[:TAG] harbor.xxx.com/xxx/IMAGE[:TAG]  
docker push harbor.xxx.com/xxx/IMAGE[:TAG]  

系统集成

有赞QA创建容器 如上图所示,有需求的同学可以在有赞QA平台发起创建业务线容器,后台会调用 k8s api 创建 Jenkins 、 Sonarqube 容器,并返回访问地址。如下图: 有赞QA创建容器1 有赞QA-list 有赞QA-ingress 这里我们使用的k8s客户端是fabric8io/kubernetes-client项目,需要在项目的pom 文件中加入kubernetes-client依赖:

        <dependency>
            <groupId>io.fabric8</groupId>
            <artifactId>kubernetes-client</artifactId>
            <version>4.1.0</version>
        </dependency>

Create Deployment

Deployment 为 Pod 和 Replica Set(下一代Replication Controller)提供声明式更新。只需要在 Deployment 中描述你想要的目标状态是什么,Deployment controller 就会帮你将 Pod 和 ReplicaSet 的实际状态改变到您的目标状态。

Create Service

Service 通过 Label Selector 跟服务中的 Pod 绑定,为 Pod 中的服务类应用提供了一个稳定的访问入口。通过使用 Service,我们就可以不用关心这个服务下面的 Pod 的增加和减少、故障重启等,只需通过 Service 就能够访问到对应服务的容器。

Create Ingress

Service 虽然可以 LB, NodePort 对外提供服务,但是当集群服务很多的时候,NodePort 方式最大的缺点是会占用很多集群机器的端口,LB 方式最大的缺点则是每个Service一个 LB 又有点浪费和麻烦,并且需要 K8s 之外的支持, 而 Ingress 则只需要一个 NodePort 或者一个 LB 就可以满足所有 service 对外服务的需求。

注意点:

  • K8s 集群中,将图片或是文件上传到文件服务器上,如文件大于1M会报错所以 Ingress 的 Annotations 需要配置下"nginx.ingress.kubernetes.io/proxy-body-size", "600m"
  • 由于 Jenkins Master 容器起来 K8s 插件配置信息都需要初始化好,就需要知道起来后 Pod Node Ip,这里可以通过 Env 来获取 Pod Node Ip
      env:
        - name: MY_POD_IP
          valueFrom:
            fieldRef:
              fieldPath: status.podIP
        status.podIP :pod IP
  • 前端访问 Jenkins Master 时会存在跨域问题,在 Ingress 中,跨域(CORS)的配置如下:
    nginx.ingress.kubernetes.io/cors-allow-headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization
    nginx.ingress.kubernetes.io/cors-allow-methods: PUT, GET, POST, OPTIONS
    nginx.ingress.kubernetes.io/cors-allow-origin: '*'
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: 600m

K8s Web Terminal

当需要进入容器内执行一些 shell 命令时,web terminal 可以让我们更方便的访问 container,执行 shell 命令,提高工作效率。如下图: 有赞QA-web-shell

实现: 有赞QA-web-shell 前端用 xterm.js 库,它是模拟一个 terminal 在浏览器中,此时并没有通讯能力。需要在后端搭建 k8s-websocket 服务。前端建立 websocket,连到后台搭建的 k8s-websocket 服务端。服务端会基于 k8s 的 remotecommand 包,建立与 container 的ssh长连接。我们将输入输出写入到 Websocket 流中即可,当浏览器中 terminal 大小改变了,前端应该把最新的 terminal 大小发给服务端,服务端模拟终端也要相应的 resize。

遇到的问题:

由于我们使用的 kubernetes-client 当时只提供了 pod 启动时,初始化 terminal 大小的功能,未实现 resize 功能。当浏览器中的 terminal 的大小改变时,由于与初始化时传递的行列数不同,导致显示不全或显示区域过小的问题。在查阅资料的过程中发现 k8s 的 remotecommand 实际上是提供了该功能的(详情可见remotecommand.go)。此文件中,定义 resizeChannel 为4,即将发送的命令 byte 数组的第一位修改为4,就可发送 resize 的相关命令。

后记

测试的时候发现 K8s Slave 调度速度比较慢,尤其是多个同类型的 Slave 并行需要等待比较长的时间,上网查询发下默认情况下 Jenkins 保守地生成代理。如果队列中有2个构建,不会立即生成2个执行程序。会产生一个执行器并等待一段时间看第一个执行器有没有被释放,然后再决定产生第二个执行器。以确保产生的每个执行者都得到最大限度的利用。如果要覆盖此行为并立即为队列中的每个构建生成执行程序,可以在 Jenkins Mater 启动时参加一下参数:

  • -Dhudson.slaves.NodeProvisioner.initialDelay=0
  • -Dhudson.slaves.NodeProvisioner.MARGIN=50
  • -Dhudson.slaves.NodeProvisioner.MARGIN0=0.85

总而言之 K8s 博大精深,在 CI/CD 容器化的道路上还有很多知识点需要去学习。

欢迎关注我们的公众号