今天我们主要来介绍如何结合 Jenkins 完成持续化集成和自动化测试的案例。

在微服务开发团队中,一般会采用敏捷开发这类增长式的开发方式,这能有效提高各个微服务的迭代效率。为了让完成的代码能够尽快得到反馈,我们建议尽早将完成的代码提交到代码库中被集成部署,每天一次甚至一天多次,通过自动构建和自动化测试,尽早检测出集成的错误,从而确保错误被尽快发现和纠正。

在上一课时中,我们已经学习了如何通过 Docker 和 Kubernetes 部署和运行微服务;在本课时,我们将介绍如何使用 Jenkins 进行持续集成和自动化测试。

持续集成与 Jenkins Pipeline

在敏捷开发中,持续集成(CI,Continuous Integration)是为了更快地发现和修复系统集成遇到的各类问题,它建议开发人员一天最少提交一次或者多次代码到代码库中,让自动化工具对提交的代码进行集成部署,并使用自动化测试工具检验代码是否正常运行,从而更快地发现代码中存在的问题并进行修复。

CI 简易流程图

一般来说,业务系统经过微服务划分后,每一个微服务都是由独立的小团队进行开发和维护,在系统集成时,考虑到微服务之间存在大量的互相调用,这就要求我们不仅要验证微服务内模块的集成结果,还需要验证微服务之间的集成结果。因此,持续集成能够加快各个小团队之间的协作,及早发现系统集成中遇到的问题,进而提升整个项目的开发效率。

Jenkins 是常用的持续集成工具。它采用 Java 开发,提供 Web 界面简化操作,并支持插件式扩展,可以处理几乎任何类型的构建和持续集成。Jenkins 中提供多种方式进行构建工作,其中 Pipeline 是最为常用的方式之一。

Pipeline 是一套运行在 Jenkins 上的工作框架。它能够将多个节点中的任务连接起来,实现单个节点难以完成的复杂流程的编排和可视化工作。Pipeline 以代码的形式实现,它将一个流水线划分为多个 Stage,每个 Stage 代表了一组操作,比如构建、测试、部署等;而 Stage 内部又由多个 Step 组成,每一个 Step 就是基本的操作命令,比如打印日志 “echo” 等命令。

在本课时的后半部分,我们将通过一个 Pipeline 完成 user 服务从 GitHub 中拉取代码,到编译打包成镜像,再到部署到 Kubernetes 的流程。

Go 的单元测试

在前面的“CI 简易流程图”中,我们可以看到服务在经过构建和部署之后,会进行相应的测试来验证部署的代码是否合理。Go 本身提供了一套轻量级的测试框架,用于对 Go 程序进行单元测试和基准测试。go test 命令是一个按照一定的约定和组织来测试代码的程序,它执行的文件都是以“_test.go” 作为后缀,这部分文件不会包含在 go build 的代码构建中。

在测试文件中主要存在以下三种函数类型:

以 Test 作为函数名前缀的测试函数,一般用作单元测试,测试函数的逻辑行为是否正确;

以 Benchmark 作为函数名前缀的基准测试函数,一般用来衡量函数的性能;

以 Example 作为函数名前缀的示例函数,主要用于提供示例文档。

接下来我们通过 user_dao_test.go 测试文件介绍如何编写 Go 的单元测试用例,代码如下所示:

 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
package dao 
import ( 
	"testing" 
) 
func TestUserDAOImpl_Save(t *testing.T) { 
	userDAO := &UserDAOImpl{} 
	err := InitMysql("127.0.0.1", "3306", "root", "123456", "user") 
	if err != nil{ 
		t.Error(err) 
		t.FailNow() 
	} 
	user := &UserEntity{ 
		Username:"aoho", 
		Password:"aoho", 
		Email:"aoho@mail.com", 
	} 
	err = userDAO.Save(user) 
	if err != nil{ 
		t.Error(err) 
		t.FailNow() 
	} 
	t.Logf("new User ID is %d", user.ID) 
} 
func TestUserDAOImpl_SelectByEmail(t *testing.T) { 
	userDAO := &UserDAOImpl{} 
	err := InitMysql("127.0.0.1", "3306", "root", "123456", "user") 
	if err != nil{ 
		t.Error(err) 
		t.FailNow() 
	} 
	user, err := userDAO.SelectByEmail("aoho@mail.com") 
	if err != nil{ 
		t.Error(err) 
		t.FailNow() 
	} 
	t.Logf("result uesrname is %s", user.Username) 
} 

一般来说,测试文件会以“待测试文件名 + _test.go” 的方式命名,比如 user_dao_test.go,说明是对 user_dao.go 文件的测试用例。类似的,测试函数也是以 “Test + 待测试函数”的方式进行命名,比如 TestUserDAOImpl_SelectByEmail 是对 UserDAOImpl 结构体的 SelectByEmail 方法进行测试,你也可以根据测试路径的不同,添加额外的修饰语。

测试文件需要导入 testing 包,测试函数中的 *testing.T 参数用于报告测试结果和附加的日志信息。我们可以通过 go test 命令运行测试用例,在 user_dao_test.go 所在目录下执行 go test 命令,即可执行 user_dao_test.go 内所有的测试函数,并在命令行打印相应的执行结果。

使用 Pipeline 构建部署服务

在部署 Pipeline 服务之前,我们首先将 user 服务依赖的 MySQL 和 Redis 独立部署到 Kubernetes 上,这里我们以 Redis 的 yaml 配置为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: apps/v1 
kind: Deployment 
metadata: 
  name: user-redis 
  labels: 
    name: user-redis 
spec: 
  replicas: 1 
  strategy: 
    type: RollingUpdate 
  selector: 
      matchLabels: 
        name: user-redis 
  template: 
    metadata: 
      labels: 
        name: user-redis 
    spec: 
      containers:                    #定义Redis容器,开放6379端口 
        - name: user-redis 
          image: redis:5.0 
          ports: 
            - containerPort: 6379 
          imagePullPolicy: IfNotPresent 

user-redis.yaml 文件通过 Deployment Controller 管理 Pod,当 Controller 中的 Pod 出现异常被重启时,很可能导致 Pod 的 IP 发生变化。如果此时 user 服务通过固定 IP 的方式访问 Redis,很可能会访问失败。为了避免这种情况,我们可以为 user-redis Pod 定义一个 Service,配置文件描述如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: v1  
kind: Service 
metadata:  
  name: user-redis-service 
spec: 
  selector:  
    name: user-redis 
  ports:   
  - protocol: TCP 
    port: 6379 
    targetPort: 6379 
    name: user-redis-tcp 

在创建好 Pod 后,再执行 kubectl create -f user-redis-service.yaml 命令,即可为 user-redis Pod 生成一个 Service。Service 定义了一组 Pod 的逻辑集合和一个用于访问它们的策略,Kubernetes 集群会为 Service 分配一个固定的 Cluster IP,用于集群内部的访问。我们可以通过以下命令查看 Service 的信息,包括 Cluster IP 等信息:

1
kubectl get services 

通过 Cluster IP 访问 MySQL 和 Redis 等服务,我们就无须担心 Pod IP 的变化。

通过 Pipeline 部署服务到 Kubernetes 集群,主要有以下步骤:

从 GitHub 中拉取代码;

构建 Docker 镜像;

上传 Docker 镜像到 Docker Hub;

将应用部署 Kubernetes;

接口测试。

在 Pipeline 中,我们将上述步骤组织成相应的 Stage,让 Jenkins 为我们完成服务的持续集成和自动化测试,接下来我们以 user 服务的部署作为例子。

Pipeline 脚本是由 Groovy 语言实现,支持 Declarative(声明式)和 Scripted(脚本式)语法,我们接下来的演示就基于脚本式语法进行介绍。

第一步,拉取代码。Stage 的声明如下:

1
2
3
4
5
6
7
stage('clone code from github') { 
    echo "first stage: clone code" 
    git url: "https://github.com/longjoy/micro-go-course.git" 
    script { 
        commit_id = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim() 
    } 
} 

我们通过 git url 命令从 GitHub 中获取 user 服务的代码,并将本次提交记录的 commit_id 提取出来作为变量使用。

接下来是第二步,使用 user 服务中的 Dockfile 定义构建相应的 user 镜像。Stage 声明如下:

1
2
3
4
stage('build image') { 
    echo "second stage: build docker image" 
    sh "docker build -t aoho/user:${commit_id} section11/user/" 
} 

为了方便在排查问题时可以根据对应的代码记录定位代码,我们采用了 GitHub 的提交记录 commit_id 作为镜像的 tag。同时为了将 MySQL 和 Redis 的地址作为参数传入,修改 user 服务的 Dockerfile 为如下:

1
2
3
4
5
6
7
FROM golang:latest 
WORKDIR /root/micro-go-course/section10/user 
COPY / /root/micro-go-course/section10/user 
RUN go env -w GOPROXY=https://goproxy.cn,direct 
RUN go build -o user 
EXPOSE 10086 
ENTRYPOINT ./user -mysql.addr $mysqlAddr -redis.addr $redisAddr 

mysqlAddr 和 redisAddr 将在 user.yaml 配置文件中以环境变量的方式指定 MySQL 和 Redis 的地址。

第三步,为了方便 Kubernetes 拉取服务的镜像,我们将第二步构建好的 Docker 镜像推送到镜像仓库中。如下声明所示:

1
2
3
4
5
stage('push image') { 
    echo "third stage: push docker image to registry" 
    sh "docker login -u eoho -p xxxxxx" 
    sh "docker push aoho/user:${commit_id}" 
} 

Docker 中默认的镜像仓库为 Docker Hub,上述声明中就将 user 镜像推送到 Docker Hub 中,当然你也可以选择将镜像推送到私有仓库中。往 Docker Hub 中推送镜像需要提交账号密码,这需要我们预先注册申请一个 Docker Hub 账户。

然后在第四步中,我们使用 kubectl 将 user 服务部署到 Kubernetes 中。为了保证部署到正确版本的镜像,我们需要将 commit_id 替换到 user.yaml 文件中,以及将 mysqlAddr 和 redisAddr 作为环境变量输入,user.yaml 的配置如下:

 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
apiVersion: apps/v1 
kind: Deployment 
metadata: 
  name: user-service 
  labels: 
    name: user-service 
spec: 
  replicas: 1 
  strategy: 
    type: RollingUpdate 
  selector: 
    matchLabels: 
      name: user-service 
  template: 
    metadata: 
      labels: 
        name: user-service 
    spec: 
      containers:                    #定义User容器,开放10086端口 
        - name: user 
          image: aoho/user:<COMMIT_ID_TAG> 
          ports: 
            - containerPort: 10086 
          imagePullPolicy: IfNotPresent 
          env: 
            - name: mysqlAddr 
              value: <MYSQL_ADDR_TAG> 
            - name: redisAddr 
              value: <REDIS_ADDR_TAG> 

在上述配置文件中,我们使用 Deployment Controller 来管理 Pod,创建 Pod 的模板为第二步中构建的 user 镜像。构建第四步的 Stage 声明如下:

1
2
3
4
5
6
7
stage('deploy to Kubernetes') { 
    echo "forth stage: deploy to Kubernetes" 
    sh "sed -i 's/<COMMIT_ID_TAG>/${commit_id}/' user.yaml" 
    sh "sed -i 's/<MYSQL_ADDR_TAG>/${mysql_addr}/' user.yaml" 
    sh "sed -i 's/<REDIS_ADDR_TAG>/${redis_addr}/' user.yaml" 
    sh "kubectl apply -f user.yaml" 
} 

在上述声明中,我们首先使用 sed 命令将 yaml 文件中标识替换为对应的变量,再通过 kubectl apply 命令重新部署了 user-service Pod。

为了让 user 服务的接口在 Kubernetes 集群外也能够访问,我们通过 NodePort 的方式将 user 服务的端口暴露到 Node 节点的相应端口,定义 user-service.yaml 配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: v1 
kind: Service 
metadata: 
 name: user-service-http 
spec: 
 selector: 
  name: user-service 
 type: NodePort 
 ports: 
  - protocol: TCP 
    port: 10086 
    targetPort: 10086 
    nodePort: 30036 
    name: user-service-tcp 

在上述配置中,我们指定的 Service 的类型为 NodePort,并将 user 服务的接口通过 Node 节点的 30036 暴露出去,对此,我们就可以在集群外部通过 NodeIP:NodePort 的方式访问 user 服务了。

最后一步,我们通过 go test 对 user 中的 HTTP 接口进行接口测试,验证代码集成的效果。Stage 声明如下:

1
2
3
4
stage('http test') { 
    echo "fifth stage: http test" 
    sh "cd section11/user/transport && go test -args ${user_addr}" 
} 

上述 Stage 中 user_addr 变量即 NodeIP:NodePort,user 服务通过 NodePort 暴露到 Kubernetes 集群外的可访问端口。我们使用 go test 命令运行了 transport 包下的测试文件用于测试 HTTP 接口。

到此,我们通过 Pipeline 对 user 服务进行持续集成和测试的整个流程就已经完成了,其完整的 Pipeline 脚本如下:

 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
node { 
    script { 
        mysql_addr = '127.0.0.1' // service cluster ip 
        redis_addr = '127.0.0.1' // service cluster ip 
        user_addr = '127.0.0.1:30036' // nodeIp : port 
    } 
    stage('clone code from github') { 
        echo "first stage: clone code" 
        git url: "https://github.com/longjoy/micro-go-course.git" 
        script { 
            commit_id = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim() 
        } 
    } 
    stage('build image') { 
        echo "second stage: build docker image" 
        sh "docker build -t aoho/user:${commit_id} section11/user/" 
    } 
    stage('push image') { 
        echo "third stage: push docker image to registry" 
        sh "docker login -u aoho -p xxxxxx" 
        sh "docker push aoho/user:${commit_id}" 
    } 
    stage('deploy to Kubernetes') { 
        echo "forth stage: deploy to Kubernetes" 
        sh "sed -i 's/<COMMIT_ID_TAG>/${commit_id}/' user-service.yaml" 
        sh "sed -i 's/<MYSQL_ADDR_TAG>/${mysql_addr}/' user-service.yaml" 
        sh "sed -i 's/<REDIS_ADDR_TAG>/${redis_addr}/' user-service.yaml" 
        sh "kubectl apply -f user.yaml" 
    } 
    stage('http test') { 
        echo "fifth stage: http test" 
        sh "cd section11/user/transport && go test  -args ${user_addr}" 
    } 
} 

我们可以在 Jenkins 中创建一个 Pipeline 任务,将上述脚本复制到 Script 区域中,保存后触发构建,不过在这之前需要在 Jenkins 中安装和配置好 Kubernetes Plugin 和 Docker Plugin。在实际的开发中,我们可以将上述 Pipeline 脚本放入到 Jenkinsfile 中,与代码一同提交到代码库,将 Pipeline 任务的脚本配置类型修改为 Pipeline Script from SCM,引用代码库中 Pipeline 脚本进行构建。

下图为在 Pipeline 中构建 user 服务的结果视图,绿色表示该 Stage 执行成功。

Pipeline 中构建 user 服务的结果视图

Pipeline 中构建 user 服务的结果视图

小结

持续集成和自动化测试能够对开发代码进行快速校验和反馈,帮助开发人员更早地发现代码中的集成 Bug 并进行修改,有效提高团队的开发效率。

在本课时,我们主要介绍了如何通过 Jenkins 对服务进行持续集成和自动化测试。我们借助了 Jenkins Pipeline 的能力, 把 user 服务的代码从代码库拉取出来打包成 user 镜像,并将镜像部署到 Kubernetes 集群,最后还通过 go test 对 user 服务中提供的 HTTP 接口进行测试。

其实除了手动触发构建外,Jenkins 中还支持多种触发器,比如通过 Webhook 监听代码库中代码的变化,在代码库发生提交或者合并时自动触发一次构建任务,这能大大提升持续集成的效率。自动化测试也存在其他多种多样的方式,比如借助 JMeter 和 Jenkins 对服务进行性能测试等。

希望通过本课时的学习能够帮助你了解持续集成和自动化测试的基本流程,并掌握使用 Jenkins 进行持续集成的能力。

最后,关于该课时的内容,如果你有什么独到的见解,欢迎在留言区与我分享。

-– ### 精选评论 ##### **龙: > 持续集成用github或者gitlab的ci也可以做,也支持根据配置文件进行构建。 ##### **伟: > 公司在用阿里的codeup,流程基本差不多,实践下才能真正了解。