构建容器镜像

在云中部署各种应用时使用Docker已经成为事实标准。很多云环境都接受以Docker容器的形式部署应用,包括AWS、Microsoft Azure、Google Cloud Platform等。

容器化应用程序(比如使用Docker创建的应用程序)的概念借鉴了现实世界中的联运集装箱。在运输过程中,不管里面的东西是什么,所有的联运集装箱都有一个标准的尺寸。正因为如此,联运集装箱才能够很容易地码放在船上、火车上、卡车上。按照类似的方式,容器化的应用程序遵循通用的容器格式,可以在任何地方部署和运行,而人们不必关心里面的应用是什么。

从Spring Boot应用程序创建镜像的最常见的方法是使用docker build命令和Dockerfile文件,将项目构建的可执行JAR文件复制到容器镜像中。如下的Dockerfile就能实现这一点(它真的很简短):

FROM openjdk:11.0.12-jre
ARG JAR_FILE = target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

Dockerfile文件描述了如何创建容器镜像。由于它非常简短,让我们逐行分析这个Dockerfile。

  • 第一行:声明我们要创建的镜像要基于一个预定义的容器镜像,该镜像(除了其他内容之外)提供了Open JDK 11 Java运行时。

  • 第二行:创建一个变量来引用项目中target/目录下所有的JAR文件。对于大多数Maven构建来说,应该只有一个JAR文件。不过,通过使用通配符,我们可以使Dockerfile的定义与JAR文件的名称和版本解耦。此时我们假定Dockerfile文件在Maven项目的根目录下,从而得出JAR文件相对Dockerfile的路径。

  • 第三行:将项目中“target/”目录下的JAR文件复制至容器中,并命名为app.jar。

  • 第四行:定义入口点,描述了基于镜像创建的容器启动时要执行的命令。在本例中,它使用 java -jar 命令来运行可执行的 app.jar。

有了这个Dockerfile文件,我们就可以使用Docker命令行工具创建镜像,如下所示:

$ docker build . -t habuma/tacocloud:0.0.19-SNAPSHOT

该命令中的“.”是指Dockerfile文件位置的相对路径。如果要从不同的路径运行docker build,则要使用Dockerfile的路径(不包括文件名)替换“.”。例如从项目的父路径运行docker build时,需要像这样使用docker build。

$ docker build tacocloud -t habuma/tacocloud:0.0.19-SNAPSHOT

在-t参数后面给出是镜像标签值,由名称和版本组成。在本例中,镜像的名字是habuma/tacocloud,版本是0.0.19-SNAPSHOT。如果想尝试一下,可以使用docker run来运行这个新创建的镜像:

$ docker run -p8080:8080 habuma/tacocloud:0.0.19-SNAPSHOT

-p8080:8080参数会将主机(也就是运行Docker的机器)上8080端口的请求转发到容器的8080端口(Tomcat或Netty正在监听请求的端口)。

如果我们已经有了一个可执行的JAR文件,用这种方式构建Docker镜像是很容易的,但这并不是为Spring Boot应用创建镜像的最简单方法。从Spring Boot的2.3.0版本开始,我们可以在不添加任何特殊的依赖或配置文件,也不以任何方式改变项目的情况下构建容器镜像。这是因为Spring Boot的Maven和Gradle构建插件都支持直接构建容器镜像。可以使用Spring Boot Maven插件的build-image goal把基于Maven构建的Spring项目构建为容器镜像,如下所示:

$ mvnw spring-boot:build-image

类似地,Gradle构建的项目也可以构建为容器镜像:

$ gradlew bootBuildImage

这会使程序根据pom.xml文件中的<artifactId>和<version>属性创建一个具有默认标签的镜像。对于Taco Cloud应用程序,标签的值类似于library/tacocloud:0.0.19-SNAPS HOT。稍后我们会看到如何指定自定义的镜像标签。

Spring Boot的构建插件依靠Docker来创建镜像。因此,我们需要在构建镜像的机器上安装Docker运行时环境。镜像创建完成后,就可以像这样运行它:

$ docker run -p8080:8080 library/tacocloud:0.0.19-SNAPSHOT

这样可以运行镜像,并将镜像的8080端口(嵌入式Tomcat或Netty服务器正在监听的端口)暴露给主机的8080端口。

标签的默认格式是docker.io/library/ ${project.artifactId}:${project.version},这解释了为什么标签以library开头。如果我们只在本地运行该镜像,那没什么问题。但我们很可能希望将镜像推送到DockerHub等镜像注册仓库,并需要在构建镜像时使用一个引用镜像注册仓库名称的标签。

例如,我们的组织在DockerHub镜像仓库中的名称是tacocloud。在这种情况下,我们希望镜像的名字是tacocloud/tacocloud:0.0.19-SNAPSHOT,用tacocloud替换默认前缀library。为了实现这一点,只需在构建镜像时指定一个构建属性。对于Maven构建,需要使用JVM系统属性spring-boot.build-image.imageName来指定镜像名称:

$ mvnw spring-boot:build-image \
    -Dspring-boot.build-image.imageName = tacocloud/tacocloud:0.0.19-SNAPSHOT

对于Gradle构建的项目,操作会稍微简单一些,可以使用 --imageName参数来指定镜像的名称,如下所示:

$ gradlew bootBuildImage --imageName = tacocloud/tacocloud:0.0.19-SNAPSHOT

无论是采用哪种指定镜像名称的方式,都需要我们在构建镜像时记住名称,并且不能出现错误。为了简化操作,我们可以把镜像名称指定为构建的一部分。

在Maven构建中,可以将镜像名称作为Spring Boot Maven插件的一个配置项来指定。例如,项目pom.xml文件中如下的代码片段展示了如何通过<configuration>块来指定镜像名称:

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <name>tacocloud/${project.artifactId}:${project.version}</name>
    </image>
  </configuration>
</plugin>

注意,相对于硬编码artifact ID和版本信息,我们可以利用构建变量引用已经在构建脚本的其他地方设定的值。这样,我们就不需要随着项目的发展而在镜像名称中手动调整版本号了。对于Gradle构建的项目,build.gradle中的以下条目可以实现同样的效果:

bootBuildImage {
  imageName = "habuma/${rootProject.name}:${version}"
}

在项目构建规范中设置了这些配置后,我们就可以在命令行中构建镜像,而不用像前面那样指定镜像名称。此时,可以像以前一样用docker run来运行镜像(用新的名字引用镜像),也可以使用docker push来推送镜像到镜像注册仓库,比如DockerHub,如下所示:

$ docker push habuma/tacocloud:0.0.19-SNAPSHOT

将镜像推送到镜像注册仓库之后,就能够在任何可以访问该注册仓库的环境中拉取并运行它了。我们越来越倾向于在Kubernetes中运行镜像,所以接下来看一下如何在Kubernetes中运行镜像。

部署至Kubernetes

Kubernetes是一个了不起的容器编排平台,它可以运行镜像,可以在必要时处理容器的扩展和伸缩,也可以调整(reconcile)损坏的容器以提高健壮性。它还有很多其他的功能。

Kubernetes是一个强大的平台,我们可以在上面部署应用程序。实际上,因为它过于强大,本章不可能详细介绍它。在本章,我们只关注将Spring Boot应用(已经构建成了容器镜像)部署到Kubernetes集群涉及的工作。如果想要详细了解Kubernetes,请查阅Marko Lukša撰写的Kubernetes in Action, 2nd Edition。

Kubernetes往往背负着难以使用的坏名声(尽管这也许是不公平的),但在Kubernetes中部署已构建成容器镜像的Spring应用程序真的很容易。鉴于Kubernetes提供的各种好处,这种部署工作是值得的。

我们需要一个Kubernetes环境,以便将应用程序部署到里面。这方面我们有多种可选方案,包括亚马逊的AWS EKS和谷歌的Kubernetes Engine(即GKE)。对于本地实验,我们也可以使用各种Kubernetes实现来运行Kubernetes集群,如MiniKube、MicroK8s,以及我个人最喜欢的Kind。

我们需要做的首要工作是创建一个deployment清单(manifest)文件。deployment清单是一个YAML文件,描述了部署镜像的方法。作为一个简单的例子,请参考如下的deployment清单,它会在Kubernetes集群中部署我们之前创建的Taco Cloud镜像:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taco-cloud-deploy
  labels:
    app: taco-cloud
spec:
  replicas: 3
  selector:
    matchLabels:
      app: taco-cloud
  template:
    metadata:
      labels:
        app: taco-cloud
    spec:
      containers:
      - name: taco-cloud-container
        image: tacocloud/tacocloud:latest

该清单可以任意命名。但为了方便讨论,我们将其命名为deploy.yaml,并将其放置在项目根路径下的k8s目录中。

在不深入讲解Kubernetes deployment规范文件如何运行的情况下,在这里我们需要注意几个关键点。

首先,我们的deployment命名为taco-cloud-deploy,并且(在结尾处)设置为使用名称为tacocloud/tacocloud:latest的镜像来部署和启动容器。通过使用latest标签(而不是之前的0.0.19-SNAPSHOT),可以知道这会使用推送到容器注册仓库的最新镜像。

其次,replicas属性设置为3,这告诉Kubernetes要运行3个容器实例。如果某种原因导致3个实例中的一个失败了,那么Kubernetes将自动调整问题实例,启动新的实例将其替换。为了应用该deployment,可以使用kubectl命令行工具执行如下的命令:

$ kubectl apply -f deploy.yaml

稍等片刻,我们就可以使用kubectl get all命令来查看正在进行的deployment,其中包括3个pod,每个pod运行一个容器实例。如下是我们可能看到的输出:

$ kubectl get all
NAME                                      READY    STATUS    RESTARTS    AGE
pod/taco-cloud-deploy-555bd8fdb4-dln45  1/1      Running   0           20s
pod/taco-cloud-deploy-555bd8fdb4-n455b  1/1      Running   0           20s
pod/taco-cloud-deploy-555bd8fdb4-xp756  1/1      Running   0           20s

NAME                                READY   UP-TO-DATE    AVAILABLE    AGE
deployment.apps/taco-cloud-deploy  3/3     3            3           20s

NAME                                           DESIRED CURRENT   READY AGE
replicaset.apps/taco-cloud-deploy-555bd8fdb4  3       3         3     20s

第一部分展示了3个pod,这些是我们在replicas属性中设置的。第二部分是deployment资源本身的信息。第三部分是ReplicaSet资源,这是一个特殊的资源,Kubernetes用它来记住应该维护的应用程序副本数量。

如果想试用一下这个应用程序,需要从机器上的某个pod中暴露一个端口。如下的kubectl port-forward命令就可以很方便地做到这一点:

$ kubectl port-forward pod/taco-cloud-deploy-555bd8fdb4-dln45 8080:8080

在本例中,我选择了kubectl get all的输出中列出的第一个pod,并设置将请求从主机(运行Kubernetes集群的机器)的8080端口转发到该pod的8080端口。有了这些,通过浏览器访问http://localhost:8080,我们就应该能够看到在指定pod上运行的Taco Cloud应用程序。

启用优雅关机功能

我们有多种方法可以保持Spring应用对Kubernetes环境友好,但有两件重要的事情要做:启用优雅关机、使用存活和就绪状态探针。

在任意时刻,Kubernetes都可能决定关闭我们的应用程序正在运行的一个或多个pod。这可能是因为它感知到了问题,也可能是因为我们人为地要求它关闭或重新启动该pod。不管是什么原因,如果该pod上的应用程序正在处理请求,那么立即关闭该pod会导致程序置未处理的请求于不顾。这是很糟糕的做法,会向客户端产生错误的响应,并要求客户端再次发出请求。

我们可以简单地将Spring应用程序中的server.shutdown属性设置为 "graceful",以启用优雅关闭功能,从而避免错误响应给客户端造成负担。这可以在第6章中的任何一个属性源中配置实现,在application.yml中如下所示:

server:
  shutdown: graceful

通过启用优雅关机,Spring将使应用程序关闭暂缓最多30秒,并在此期间内允许继续处理正在进行中的请求。在所有待处理的请求完成或达到关机超时时间后,应用程序将被允许关闭。

关机超时时间默认为30秒,但可以通过设置spring.lifecycle.timeout-per-shutdown- phase属性来覆盖这个值。例如,我们可以通过这样设置该属性,将关机超时时间改为20秒:

spring:
  lifecycle.timeout-per-shutdown-phase: 20s

等待关机时,嵌入式服务器将停止接受新的请求。这使得所有在途的请求能够在关机之前全部处理完毕。

关机并不是造成请求无法处理的唯一场景。例如,在启动期间,应用程序可能需要一些时间才能准备好处理请求的流量。Spring应用向Kubernetes表示它还没有准备好处理流量的方法之一就是使用就绪状态探针。接下来,我们看看如何在Spring应用程序中启用存活和就绪状态探针。

处理应用程序的存活和就绪状态

正如我们在第15章看到的,Actuator的健康端点会提供应用程序的健康状态。但这个健康状况只与应用程序依赖的外部环境的健康状况有关,比如数据库或消息代理。应用程序即使在数据库连接方面是完全健康的,也不一定已经准备好处理请求,我们甚至不能保证它能够在当前的状态下继续运行。

Kubernetes支持存活和就绪状态探针的概念。它是应用程序的健康指示器,帮助Kubernetes确定是否应该向应用程序发送流量,或者是否应该重新启动应用程序以解决某些问题。Spring Boot支持通过Actuator健康端点实现存活和就绪状态探针。作为健康端点的子集,它们称为健康组(health groups)。

存活状态是一个指示器,可以表明应用程序是否足够健康、足以继续运行而不会被重新启动。如果应用程序声明它的存活指示器已不可用,那么Kubernetes在运行时可以终止该应用程序正在运行的pod并重新启动一个新的pod,以此作为对存活状态的反应。

就绪状态则会告诉Kubernetes该应用程序是否已经准备好处理流量。例如,在启动期间,应用程序可能需要执行一些初始化工作,然后才能开始处理请求。在这段时间里,应用程序的就绪状态可能会显示它是不可用的,但应用程序仍然是存活的。Kubernetes不会重新启动它,但会遵照就绪状态指示,不向该应用发送请求。应用程序一旦完成了初始化,就可以将就绪状态探针设置为可用,Kubernetes也就可以将流量路由到它了。

启用存活和就绪状态探针

要在Spring Boot应用程序中启用存活和就绪探针,必须将management.health.probes.enabled设置为true。在application.yml文件中,设置方法如下所示:

management:
  health:
    probes:
      enabled: true

探针启用后,对Actuator的健康端点的请求如下所示(假设应用程序是完全健康的):

{
  "status": "UP",
  "groups": [
    "liveness",
    "readiness"
  ]
}

就其本身而言,基础的健康端点并不能告诉我们太多关于应用存活或就绪状态的信息。不过,对“/actuator/health/liveness”或“/actuator/health/readiness”的请求会提供应用程序的存活或就绪状态。如果状态是可用的,那么它们的响应都会如下所示:

{
  "status": "UP"
}

如果存活或就绪状态是不可用的,那么它们的响应都会如下所示:

{
  "status": "DOWN"
}

就绪状态为不可用时,Kubernetes不会将流量路由至该应用程序。如果存活状态端点显示为不可用,Kubernetes会试图通过删除pod并重新启动一个新的实例来解决问题。

在Deployment中配置存活和就绪状态探针

Actuator已经在这两个端点上提供了存活和就绪状态探针,我们现在需要做的就是在Deployment清单中告知Kubernetes。如下Deployment清单的结尾展示了如何让Kubernetes检查存活和就绪状态探针的配置:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taco-cloud-deploy
  labels:
    app: taco-cloud
spec:
  replicas: 3
selector:
  matchLabels:
    app: taco-cloud
template:
  metadata:
    labels:
      app: taco-cloud
  spec:
    containers:
    - name: taco-cloud-container
      image: tacocloud/tacocloud:latest
      livenessProbe:
        initialDelaySeconds: 2
        periodSeconds: 5
        httpGet:
          path: /actuator/health/liveness
          port: 8080
      readinessProbe:
        initialDelaySeconds: 2
        periodSeconds: 5
        httpGet:
          path: /actuator/health/readiness
          port: 8080

这会告诉Kubernetes,对于每个探针,都要向8080端口的指定路径发出GET请求,以获取存活和就绪状态。按照配置,第一次请求会在应用程序pod运行2秒后发送,此后每5秒发送一次。

管理存活和就绪状态

存活和就绪状态是如何设置的呢?在应用内部,Spring本身或应用程序依赖的一些库可以通过发布可用状态变更事件来设置状态。这种能力并不是Spring和它的库特有的,我们也可以在应用程序中编写代码来发布这些事件。

例如,假设我们想把应用程序的就绪状态推迟到某些初始化工作完成之后。在应用程序生命周期的早期,可能是在ApplicationRunner或CommandLineRunner bean中,我们可以像这样发布一个就绪状态来拒绝流量:

@Bean
public ApplicationRunner disableLiveness(ApplicationContext context) {
  return args -> {
    AvailabilityChangeEvent.publish(context,
      ReadinessState.REFUSING_TRAFFIC);
  };
}

在这里,ApplicationRunner的@Bean方法被赋予一个Spring应用上下文实例作为参数。这个应用上下文是必须的,因为静态的publish()方法需要使用它来发布事件。初始化完成后,就可以按照类似的方式更新应用程序的就绪状态,如下所示:

AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);

存活状态的更新方式与此基本相同。关键的区别在于,我们不是发布Readiness State.ACCEPTING_TRAFFIC或ReadinessState.REFUSING_TRAFFIC事件,而是发布LivenessState.CORRECT或LivenessState.BROKEN事件。例如,如果在应用程序代码中探测到一个无法恢复的致命错误,应用就可以通过发布LivenessState.BROKEN事件,请求Kubernetes停止它并重新启动:

AvailabilityChangeEvent.publish(context, LivenessState.BROKEN);

在这个事件发布后不久,存活状态端点就会指示该应用程序已不可用,Kubernetes将采取行动,重新启动该应用程序。我们只有很短的时间来发布LivenessState.CORRECT事件。但是,我们如果能确定应用程序已经是健康的,那么可以通过发布一个新的事件来撤销原事件,如下所示:

AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);

只要Kubernetes没有在我们将状态设置为BROKEN之后访问存活状态端点,应用程序就可以“逃过一劫”,并继续处理请求。