Skip to content

第39讲:使用Jenkin进行持续集成 ​

从本课时开始,我们将开始进入持续集成(Continuous Integration) 和持续部署(Continuous Deployment) 相关的内容,持续集成和部署是目前软件开发中的标准实践。在微服务架构的应用中,持续集成和部署的重要性和复杂度都提高了,因为每个服务都需要独立的集成和部署。本课时将介绍如何使用 Jenkins 进行持续集成。

我们首先要明确的是持续集成的目标,即从源代码到容器镜像,每一个源代码的提交,都应该创建出对应的不可变的容器镜像,这个构建过程是可重复的。对于同样的代码提交,无论在什么时候构建,所得到的容器镜像都应该是完全相同的,这就保证了容器镜像是可丢弃的,可以随时从源代码中构建出所需要的镜像,这使得我们可以把不同环境上的应用部署回退到任意版本。创建出来的镜像一般被发布到镜像注册表中,由 Kubernetes 在运行时拉取并运行。

下面首先介绍如何使用 Dockerfile 创建镜像。

使用 Dockerfile ​

为了部署在 Kubernetes 上,我们需要为每个微服务创建各自的容器镜像。

创建简单的 Docker 镜像并不是复杂的事情,只需要编写描述镜像内容的 Dockerfile 文件,再使用 docker build 命令来创建镜像即可。下面代码中的 Dockerfile 用来创建地址管理服务的镜像。

dockerfile
FROM adoptopenjdk/openjdk8:jre8u262-b10-alpine 
ADD target/happyride-address-service-1.0.0-SNAPSHOT.jar /opt/app.jar 
ENTRYPOINT [ "java", "-jar", "/opt/app.jar" ]

这个 Dockerfile 的内容很简单,只有 3 条指令,具体的说明如下表所示。

指令说明
FROM使用的基础镜像是 AdoptOpenJDK 的 JRE 8 的 Alpine 镜像
ADD添加服务的 JAR 文件到 /opt 目录
ENTRYPOINT设置容器镜像运行时的入口为使用 Java 命令来运行 JAR 文件

这里利用了 Spring Boot 的 Maven 插件来把整个应用打包成单一的 JAR 文件。

我们使用下面的命令来创建并运行镜像,-t 参数的作用是为镜像指定一个标签:

shell
$ docker build . -t local/address-service:1.0.0 
$ docker run local/address-service:1.0.0

在 Maven 构建过程的 package 阶段中,在 Spring Boot 的 Maven 插件产生了 JAR 文件之后,使用 Maven 的 exec-maven-plugin 插件来调用 docker build 命令。下面的代码给出了 Maven 插件的使用示例。

xml
<plugin> 
  <groupId>org.codehaus.mojo</groupId> 
  <artifactId>exec-maven-plugin</artifactId> 
  <version>3.0.0</version> 
  <executions> 
    <execution> 
      <phase>package</phase> 
      <goals> 
        <goal>exec</goal> 
      </goals> 
    </execution> 
  </executions> 
  <configuration> 
    <executable>docker</executable> 
    <arguments> 
      <argument>build</argument> 
      <argument>.</argument> 
      <argument>-f</argument> 
      <argument>${project.basedir}/src/docker/Dockerfile</argument> 
      <argument>-t</argument> 
      <argument>happyride/${project.artifactId}:${project.version} 
      </argument> 
    </arguments> 
  </configuration> 
</plugin>

在使用 Maven 插件完成构建之后,产生的容器镜像会缓存在本地,可以通过 docker images 命令来查看。

OCI 镜像规范 ​

虽然我们在谈论容器镜像时,通常会使用 Docker 镜像来代替,但两者并不是等同的。为了对容器镜像的格式进行规范化,Linux 基金会下的开放容器倡议(Open Container Initiative,OCI)组织负责维护容器镜像规范和容器运行规范。在 OCI 规范的基础上,不同的厂商可以开发自己的基于 OCI 规范的工具或产品。

OCI 镜像规范以 Docker 公司贡献的 Docker 镜像版本 2 格式作为基础。每个 OCI 镜像由下表中的几个部分组成:

组成部分说明
清单文件描述对应于特定底层架构和操作系统的容器镜像
清单文件索引清单文件的索引
层(Layer)对文件系统的改动
配置与容器运行相关的配置

在上表中,镜像中的层是开发中需要注意的概念。把镜像划分成多个层之后,可以更有效地利用缓存,从而加快构建的速度。在推送镜像到注册表时,只有改变的层才会被推送。以一个 Java 应用来说,如果把应用所依赖的第三方库和应用自身的类文件划分成不同的层,由于第三方库很少变化,在构建镜像时,只需要更新和推送类文件所在的层即可。如果整个 Java 应用的全部文件被划分在一个层中,那么每次构建镜像时,该层都必然被更新,即便其中的第三方库没有变化,这也会产生不必要的传输开销。

使用 Spring Boot 的 Buildpacks ​

Dockerfile 虽然简单易懂,但是缺乏必要的组织,造成复用起来很困难,只能复制粘贴 Dockerfile 中的部分内容。如果有很多服务都使用 Spring Boot 开发,那么每个服务中都需要复制一份大部分内容都重复的 Dockerfile。解决这个问题的一种做法是使用 Buildpacks。

Buildpacks是 CNCF 之下的一个沙盒项目,其所要解决的问题是从源代码中构建出 OCI 容器镜像。与 Dockerfile 相比,Buildpacks 的抽象层次更高,更容易理解和复用,它的基本组成单元是 Buildpack。每个 Buildpack 分成检测 和构建两个步骤:检测步骤用来判断该 Buildpack 是否应该被应用;构建步骤则负责对镜像进行修改,包括修改层的内容,或是修改配置。每个 Buildpack 只对镜像做特定的改动。

在每一个应用的镜像构建过程中,会有多个 Buildpack 按照顺序来依次检测和应用修改。通过这种方式,不同的 Buildpack 可以进行组合和复用。

Spring Boot 从 2.3.0 版本开始,支持使用 Buildpacks 来创建 OCI 镜像。在 Maven 中,只需要使用 Spring Boot Maven 插件的 build-image 命令即可。下面的代码给出了插件的基本用法。

xml
<plugin> 
  <groupId>org.springframework.boot</groupId> 
  <artifactId>spring-boot-maven-plugin</artifactId> 
  <executions> 
    <execution> 
      <goals> 
        <goal>build-image</goal> 
      </goals> 
    </execution> 
  </executions> 
</plugin>

在生成的 Spring Boot 镜像中,不仅仅包含了 JRE 和应用的 JAR 文件,还包含了一些辅助的工具,如下表所示:

工具说明
memory-calculator计算 JVM 使用的内存大小
jvmkill当无法分配内存或创建线程时,终止 JVM
class-counter计算类文件的数量
link-local-dns修改 DNS 设置
openssl-security-provider加载 JRE 的权威机构证书
security-providers-configurer配置 Java 的安全服务的提供者
java-security-propertiesJava 安全属性

这些工具的作用是优化 Java 应用在容器中的运行。下面的代码是 Spring Boot 应用的容器在运行时的输出,从中可以看到,JVM 的内存设置会根据容器的内存限制来做出调整。

java
Container memory limit unset. Configuring JVM for 1G container. 
Calculated JVM Memory Configuration: -XX:MaxDirectMemorySize=10M -XX:MaxMetaspaceSize=126109K -XX:ReservedCodeCacheSize=240M -Xss1M -Xmx410466K (Head Room: 0%, Loaded Class Count: 19851, Thread Count: 250, Total Memory: 1073741824) 
Adding 127 container CA certificates to JVM truststore

下面的代码给出了 Spring Boot 的 Buildpacks 在构建时的部分输出,从中可以看到,在检测阶段中,在 16 个 Buildpack 中, 只有 5 个会参与构建,并给出了每个 Buildpack 的名称和版本号。

java
[INFO]  > Running creator 
[INFO]     [creator]     ===> DETECTING 
[INFO]     [creator]     5 of 16 buildpacks participating 
[INFO]     [creator]     paketo-buildpacks/bellsoft-liberica 2.11.0 
[INFO]     [creator]     paketo-buildpacks/executable-jar    2.0.2 
[INFO]     [creator]     paketo-buildpacks/apache-tomcat     1.4.0 
[INFO]     [creator]     paketo-buildpacks/dist-zip          1.3.8 
[INFO]     [creator]     paketo-buildpacks/spring-boot       2.3.0

使用 Jib ​

Jib 是由 Google 维护的工具,用来对 Java 应用进行容器化。其优势在于不需要依赖 Docker 守护进程,就可以创建出 OCI 容器镜像,构建镜像也不需要编写 Dockerfile。Jib 会自动把应用分成多层,把应用依赖的第三方库和应用自身的类文件分开。

Jib 支持 3 种不同的构建方式,对应于不同的 Maven 目标,如下表所示。

Maven 目标说明
build不使用 Docker 来构建镜像,并推送到注册表
dockerBuild使用 Docker 来构建镜像
buildTar把镜像打包成 tar 文件

下面的代码给出了 jib 的 Maven 插件的基本用法,其中 from 表示基础镜像的名称,to 表示构建出来的镜像的发布地址。这里使用的是 build 目标来创建并发布 OCI 镜像,并不依赖 Docker。

xml
<plugin> 
  <groupId>com.google.cloud.tools</groupId> 
  <artifactId>jib-maven-plugin</artifactId> 
  <version>2.4.0</version> 
  <configuration> 
    <from> 
      <image>adoptopenjdk/openjdk8:jre8u262-b10-alpine</image> 
    </from> 
    <to> 
      <image> 
        docker-registry:5000/happyride/${project.artifactId}:${parsedVersion.majorVersion}.${parsedVersion.minorVersion}.${parsedVersion.incrementalVersion}-${git.commit.id.abbrev} 
      </image> 
    </to> 
    <allowInsecureRegistries>true</allowInsecureRegistries> 
    <container> 
      <format>OCI</format> 
    </container> 
  </configuration> 
  <executions> 
    <execution> 
      <phase>package</phase> 
      <goals> 
        <goal>build</goal> 
      </goals> 
    </execution> 
  </executions> 
</plugin>

与 Spring Boot 构建镜像的方式相比,Jib 的优势在于可以对任意 Java 应用进行构建,并且不依赖 Docker 运行时的支持;不足之处在于缺少对 Java 应用运行的优化。

容器镜像注册表 ​

在本地开发环境上运行 docker build 命令之后,产生的镜像会缓存在本地。当在 Kubernetes 上部署时,这些本地缓存的镜像并不能直接使用。根据部署环境的不同,可以通过相应的方式来使用镜像。

在本地开发环境中,Minikube 内置包含了 Docker 运行时,为 Kubernetes 提供容器运行时的支持。我们可以配置本地开发环境中的 docker 命令,来连接 Minikube 中的 Docker 守护进程。在本地上构建完成之后,得到的镜像会被缓存在 Minikube 的 Docker 进程中,从而可以在 Kubernetes 中直接使用。

使用下面的命令可以显示连接到 Minikube 的 Docker 守护进程的配置方式。

html
$ minikube docker-env

上述命令的输出如下所示,只是设置了一些环境变量:

sql
export DOCKER_TLS_VERIFY="1" 
export DOCKER_HOST="tcp://192.168.64.9:2376" 
export DOCKER_CERT_PATH="/Users/alexcheng/.minikube/certs" 
export MINIKUBE_ACTIVE_DOCKERD="minikube" 
# To point your shell to minikube's docker-daemon, run: 
# eval $(minikube -p minikube docker-env)

按照命令输出中的提示操作完成设置之后,在当前命令行窗口中使用 Maven 命令来构建镜像。在 Kubernetes 中,只需要重新创建 Pod,就可以使用新构建的镜像来进行测试。

如果应用安装在用户的私有环境中,并且不能自由的访问外部的网络,可以使用 docker export 命令把镜像导出成压缩文件,保存在移动存储设备中。在内部网络中,把镜像的压缩文件复制到 Kubernetes 的每个节点上,再使用 docker import 命令把镜像导入缓存中。

除了上述两种特殊情况之外,最常用的做法是使用容器镜像注册表来保存镜像。在持续集成中,容器镜像被发布到注册表中;在 Kubernetes 上运行时,容器镜像从注册表中下载到本地并运行。

云平台一般都提供各自的容器注册表服务,也可以使用独立的注册表服务。Docker 的注册表实现是开源的,可以在集群内部安装自己的私有注册表。

容器镜像的标签 ​

在持续集成中,每次构建出来的容器镜像都应该有唯一的标签,如果不指定标签,那么默认使用的是 latest 标签。在实际的开发中,并不建议使用 latest 标签,因为该标签所指向的容器镜像的内容是不固定的。如果在测试或生产环境中使用了 latest 标签,那么一段时间之后重新运行测试或再次部署时,所得到的结果可能完全不同,因为对应的镜像可能被更新了。为了保证测试和部署的可重复性,所有的测试和部署都应该使用带标签的形式来引用镜像。

镜像标签最常用的格式是使用语义化版本号,目前绝大部分的公开镜像都使用版本号作为标签。在实际开发中,单纯使用版本号并不足以区分不同的构建版本,因为同一个版本在开发过程中可能多次构建。通常的做法是在版本号之后添加后缀,作为附加的区分信息。常用的后缀包括构建时间、构建编号和 Git 提交的标识符,如下表所示:

后缀说明
构建时间使用构建完成时的时间戳
构建编号持续集成服务为每个构建分配的标识符
Git 提交标识符触发持续集成的 Git 提交的标识符

在上表的这 3 种后缀形式中,并不推荐使用构建时间,因为时间本身并不能提供更多有用的信息。使用构建编号的好处是方便与已有的项目管理系统和 bug 追踪系统进行集成。测试团队一般工作在特定的构建编号之上。推荐的做法是使用 Git 提交的标识符作为后缀,从使用的镜像标签就可以快速定位到产生该镜像的代码。

我们可以使用 Maven 的 git-commit-id-maven-plugin 插件来获取 Git 中提交的标识符,并在构建过程中引用。

下面的代码给出了该插件的使用示例。该插件会提供一系列与 Git 相关的属性,比如 git.commit.id.abbrev 属性表示 Git 提交标识符的缩略形式。

xml
<plugin> 
  <groupId>pl.project13.maven</groupId> 
  <artifactId>git-commit-id-plugin</artifactId> 
  <version>4.0.1</version> 
  <executions> 
    <execution> 
      <id>get-the-git-infos</id> 
      <goals> 
        <goal>revision</goal> 
      </goals> 
      <phase>initialize</phase> 
    </execution> 
  </executions> 
</plugin>

最终生成的镜像标签类似与于 1.0.0-3228a39。

使用 Jenkins ​

示例应用使用 Jenkins 作为持续集成的服务。Jenkins 使用 Helm 安装,运行在 Kubernetes 上。当需要运行构建任务时,Jenkins 会启动一个新的 Pod 来执行。

下面的代码是 Jenkins 中流水线的声明。构建的容器运行的是 Maven 的镜像,从 GitHub 获取源代码之后,通过 Maven 来执行构建过程,构建过程中会发布镜像到注册表中。

dart
podTemplate(yaml: ''' 
apiVersion: v1 
kind: Pod 
spec: 
  securityContext: 
    fsGroup: 1000 
  containers: 
  - name: maven 
    image: maven:3.6.3-jdk-8 
    command: 
    - sleep 
    args: 
    - infinity 
    resources: 
      requests: 
        cpu: 1 
        memory: 1Gi 
    volumeMounts: 
      - name: dockersock 
        mountPath: "/var/run/docker.sock" 
  volumes: 
    - name: dockersock 
      hostPath: 
        path: /var/run/docker.sock 
''') { 
    node(POD_LABEL) { 
        git 'https://github.com/alexcheng1982/happyride' 
        container('maven') { 
            sh 'mvn -B -ntp -Dmaven.test.failure.ignore deploy' 
        } 
        junit '**/target/surefire-reports/TEST-*.xml' 
        archiveArtifacts '**/target/*.jar' 
    } 
}

这里需要着重介绍的是对 Docker 的使用。在构建过程中,单元测试和镜像发布都需要依赖 Docker 进程。在流水线的定义中,通过卷绑定的方式,把 Kubernetes 节点上的 /var/run/docker.sock 文件传到构建的 Pod 中,使得 Pod 中的容器可以访问节点上的 Docker 进程。通过这种方式,避免了在 Maven 容器中启动额外的 Docker 进程。

其他构建镜像的工具 ​

目前所介绍的构建容器镜像的方式是使用 Docker,这也是目前比较主流的方式。Docker 在本地开发时很方便,但在持续集成中也有一些不足之处。

构建时需要有 Docker 守护进程存在,这就意味着需要在持续集成服务器上安装 Docker,并保持 Docker 在后台运行。如果在 Kubernetes 上运行,那么可以复用 Kubernetes 节点上的 Docker 进程。

另外一个问题来自 Docker 自身。随着多年的开发,Docker 自身已经过于臃肿,提供了非常多的功能。在持续集成中,我们只需要能够构建镜像,并推送到容器注册表即可,并不需要 Docker 提供的其他功能。

OCI 镜像规范的出现,使得容器镜像的格式不再锁定于特定的公司,从而促进了容器镜像构建相关工具的发展。目前已经有一些工具可以创建出 OCI 镜像,如下表所示。

名称支持者
BuildKitDocker Inc
buildahRed Hat
umociSUSE
KanikoGoogle
MakisuUber

在这些工具中,值得一提的是 Docker 的 BuildKit,其支持并发的构建,可以更高效地进行缓存,因此构建的速度更快。BuildKit 从 18.06 版本开始已经被集成到 Docker 中,可以通过下面的命令来启用 BuildKit。

html
DOCKER_BUILDKIT=1 docker build

有一些其他工具对 BuildKit 进行了封装,可以简化它的使用,包括 img、阿里巴巴的 pouch 和 Rancher 的 k3c 等。本课时不对这些工具进行具体的介绍,详细的使用请参考相关文档。

总结 ​

在微服务架构中,持续集成可以保证每一个代码提交都可以有与之对应的容器镜像,容器镜像是不可变的,而且构建的过程是可重复的。通过本课时的学习,你可以了解如何从 Dockerfile 中创建出 Docker 容器镜像,以及如何使用 Spring Boot 插件和 Google 的 Jib 工具来创建 Java 应用的容器镜像,还可以了解如何使用 Jenkins 来进行持续集成。

最后呢,成老师邀请你为本专栏课程进行结课评价,因为你的每一个观点都是我们最关注的点。点击链接,即可参与课程评价。