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 来进行持续集成。

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