第5部分 部署Spring

在第5部分中,我们将会介绍如何为应用的部署做好准备,并且会学习如何进行部署。第16章介绍Spring Boot Actuator。这是Spring Boot的一个扩展,以REST端点和JMX MBean的形式暴露正在运行中的应用的内部状况。在第17章中,我们将会看到如何使用Spring Boot Admin。它基于Actuator提供了一个用户友好的、基于浏览器的管理型应用。我们将会看到如何注册客户端应用以及如何保护Admin Server。第18章将讨论如何以JMX MBean的形式暴露和消费Springbean。在最后的第19章中,我们将会看到如何将Spring应用部署到各种生产环境中。部署过基于Java应用的人可能认为这轻而易举,但是Spring Boot和相关的Spring项目有很多特性,使得Spring Boot应用的部署有些不同。

15.5 小结

  • 断路器模式能够优雅地进行失败处理。
  • Hystrix实现了断路器模式,能够在某个方法失败或执行太慢的时候启用后备行为。
  • Hystrix提供的每个断路器都会以数据流的方式发布指标信息,以便于监控应用的健康状况。
  • Hystrix可以被Hystrix Dashboard消费,这是一个可视化断路器指标的Web应用。
  • Turbine能够将多个应用的Hystrix流聚合到一个流中,以便在HystrixDashboard中统一进行可视化展现。

15.4 聚合多个Hystrix流

Hystrix dashboard一次只能监控一个流。因为每个微服务实例都发布它们自己的Hystrix,所以几乎不可能对整个应用的健康状况历史有一个整体的了解。

幸运的是,Netflix的另一个项目Turbine提供了将所有微服务的所有Hystrix流聚合到一个Hystrix流中的办法,这样Hystrix dashboard就能对其进行监控了。SpringCloud Netflix支持以类似于创建其他Spring Cloud服务的方式创建Turbine服务。要创建Turbine服务,我们需要创建一个新的Spring Boot项目并将Turbine starter依赖添加到构建文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-turbine</artifactId>
</dependency>
注意:作为一个新项目,最简单的方式是在创建新Spring Boot项目的时候在Initializr中选中Turbine复选框。

在创建完新项目之后,我们需要启用Turbine。为了实现这一点,我们需要在应用的主配置类上添加@EnableTurbine注解:

1
2
3
4
5
6
7
@SpringBootApplication
@EnableTurbine
public class TurbineServerApplication {
public static void main(String[] args) {
SpringApplication.run(TurbineServerApplication.class, args);
}
}

在开发阶段,我们会和Taco Cloud应用的其他服务一起在本地运行Turbine。为了避免端口冲突,我们需要为Turbine选择一个唯一的端口,这样就不会与其他的服务产生冲突了。你可以选择任意的端口,不过我倾向于选择8989:

1
2
server:
port: 8989

Turbine会消费多个微服务的流并将它们的断路器指标合并到一个流中。它会作为Eureka的客户端,发现那些需要聚合到自己的流的服务。但是,Turbine并不想聚合Eureka中注册的所有流,所以我们必须配置Turbine,告诉它都要使用哪些服务。

turbine.app-config属性会接受一个由逗号分隔的服务名称列表,Turbine会在Eureka中查找它们并聚合它们的Hystrix流。对Taco Cloud应用来讲,我们需要注册在Eureka中的4个服务,即ingredient-service、taco-service、order-service和user-service。如下的application.yml配置条目展现了如何设置turbine.app-config:

1
2
3
turbine:
app-config: ingredient-service,taco-service,order-service,user-service
cluster-name-expression: "'default'"

注意,除了turbine.app-config之外,我们还将turbine.cluster-name-expression属性设置成了“’default’”。这表明Turbine会收集名为default的集群中的所有聚合流。设置这个属性是非常重要的,否则Turbine中不会包含任何特定应用的聚合流数据。

现在,启动Turbine服务器并让Hystrix dashboard访问http://localhost:8989/turbine.stream地址上的流,特定应用的所有断路器都将会展现在断路器dashboard上,如图15.6所示。

image-20211023223822490

图15.6 当访问聚合的Turbine流时Hystrix dashboard会显示所有服务的所有断路器

Hystrix dashboard能够展现所有服务的所有断路器要归功于Turbine。这样,我们就能够一站式地监控Taco Cloud应用所有断路器的健康状况了。

15.3 监控失败

每当断路器保护的方法被调用时,它都会收集一些调用相关的数据,并将其发布到一个HTTP流中,这些数据可以实时监控正在运行中的应用的健康状况。在每个断路器收集的数据中,Hystrix流包括如下内容:

  • 方法被调用了多少次;
  • 调用成功了多少次;
  • 后备方法调用了多少次;
  • 方法超时了多少次。

Hystrix流是由Actuator端点提供的。在第16章中,我们会更详细地讨论Actuator,现在只需要将Actuator依赖添加到所有服务的构建文件中,以便于启用Hystrix流即可。在Maven pom.xml文件中,如下的starter依赖会将Actuator添加到项目中:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Hystrix流端点会通过“/actuator/hystrix.stream”路径对外暴露。默认情况下,大多数的端点都是禁用的。我们可以通过在每个应用的application.yml文件中添加如下的配置启用Hystrix端点:

1
2
3
4
5
management:
endpoints:
web:
exposure:
include: hystrix.stream

我们还可以将management:endpoints:web:exposure:include属性放到ConfigServer对外提供配置属性的application.yml文件中,这样全局所有的服务就都可以使用它了。

应用启动之后,将会暴露Hystrix流(这个流可以被任意的REST端点消费)。在编写自定义的REST端点之前,我们需要注意HTTP流的每个条目都包含了各种类型的JSON数据,客户端需要大量的工作才能解析这些数据。尽管编写自定义的Hystrix流展现层并非不可能完成的任务,但是在花费大量工夫编写自己的dashboard之前我们可以考虑一下使用Hystrix的dashboard。

15.3.1 Hystrix Dashboard简介

要使用Hystrix Dashboard,我们首先创建一个Spring Boot应用并添加对Hystrixdashboard starter的依赖。如果使用Spring Boot Initializr来创建项目,就可以选择Hystrix Dashboard复选框;否则,我们需要添加如下的<dependency>到项目的Maven pom.xml文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix-dashboard</artifactId>
</dependency>

项目初始化之后,我们可以通过为主配置类添加@EnableHystrixDashboard注解来启用Hystrix dashboard:

1
2
3
4
5
6
7
@SpringBootApplication
@EnableHystrixDashboard
public class HystrixDashboardApplication {
public static void main(String[] args) {
SpringApplication.run(HystrixDashboardApplication.class, args);
}
}

在开发阶段,我们可能会让Hystrix Dashboard与其他服务一起在本地机器运行,还包括Eureka和Config Server。因此,为了避免端口冲突,我们需要为HystrixDashboard选取一个唯一的端口。在Dashboard应用的application.yml文件中,我们可以将server.port设置成任意唯一的值,我通常会将其设置为7979,如下所示:

1
2
server:
port: 7979

现在,我们就可以启动Hystrix Dashboard并查看其效果了。运行之后,打开浏览器并访问http://localhost:7979/hystrix ,我们将会看到如图15.2所示的HystrixDashboard主页。

image-20211023222932777

图15.2 Hystrix Dashboard主页

在Hystrix流监视器中,我们可以设置延迟和标题。延迟的默认值是2秒,指的是轮询周期的间隔,它实际上会延缓流。标题输入域的值会以标题的形式显示在监控页中。对于我们的需求来说,默认值就可以了。

点击Monitor Stream按钮,我们就可以进入Hystrix流的监视器页面了,如图15.3所示。

image-20211023222949317

图15.3 Hystrix流监控页面会显示每个应用的断路器指标

每个断路器都会显示为一个图表并且还带有一些有用的指标数据。图15.3中只显示了getAllIngredients()的断路器,因为到目前为止我们只定义了这一个断路器。

如果看不到断路器的任何图表,只是看到一个单词“Loading”,那么可能是断路器方法都还没有被调用。我们必须向服务发送一个请求,触发断路器保护该方法,这样方法的断路器指标才会显示在Dashboard上。我近距离观察了一个断路器的监视器(见图15.4),并对其中显示的所有数据都进行了标注。

image-20211023223003422

图15.4 每个断路器的监视器都提供了该断路器当前状态的有用信息

在监视器中,最引人注目的是左上角的图表。折线图代表了指定方法过去两分钟的流量,简要显示了该方法的繁忙情况。

折线图的背景是一个大小和颜色会出现波动的圆圈。圆圈的大小表示当前的流量,圆圈越大,流量越大。圆圈的颜色表示它的健康状况:绿色表示健康的断路器,黄色表示偶尔发生故障的断路器,红色表示故障断路器。

在监视器的右上角,以3列的形式显示各种计数器。在最左边的一列中,从上到下,第一个数字(绿色——在本书的电子版中会看出各种颜色)表示当前成功调用的数量,第二个数字(蓝色)表示短路请求的数量,最后一个数字(蓝绿色)表示错误请求的数量。中间一列显示超时请求的数量(黄色)、线程池拒绝的数量(紫色)和失败请求的数量(红色)。第三列显示过去10秒内错误的百分率。

计数器下面有两个数字,代表每秒主机和集群的请求数量。这两个请求率下面是断路器的状态。监视器的底部显示了延迟的中位数和平均值,以及第90、99和99.5百分位的延迟。

15.3.2 理解Hystrix的线程模型

假设某个方法要耗费大量的时间才能完成其任务。这个方法可能向其他的服务发起了HTTP请求,而该服务响应很慢。在服务响应之前,Hystrix会阻塞线程,等待响应。

如果这个方法执行时与调用者在同一个线程上下文中,那么调用者将会一直在这个长时间运行的方法上进行等待。另外,如果被阻塞的线程来自一组数量有限的线程集,比如Tomcat的请求处理线程,而且这种情况一直持续,那么当所有线程耗尽并全部等待响应时,就会影响到可扩展性。

为了避免这种现象,Hystrix会为每项依赖(比如,带有一个或多个Hystrix命令方法的每个Spring bean)指派一个线程池。当Hystrix命令调用时,它将会在来自Hystrix托管的线程池的某个线程中执行,这样会将其与调用者线程隔离开。如果被调用的方法要执行较长时间,就能够允许调用线程不用一直等待,将潜在的线程耗尽隔离在Hystrix托管的线程池中。

你可能会发现在图15.3中除了断路器的监视器外,在页面底部还有另一个监视器,位于“Thread Pools”标题之下。这个区域是Hystrix托管的每个线程池的监视器。图15.5展示了一个线程池监视器,并对其中的数据进行了标注。

image-20211023223028828

图15.5 线程池监视器显示Hystrix托管的线程池的重要统计信息

与断路器的监视器类似,每个线程池监视器在左上角都含有一个圆圈。圆圈的大小和颜色代表了线程池的活跃状态以及它的健康状况。与断路器的监视器不同的是,线程池的监视器没有显示过去几分钟线程池活动的折线图。

右上角显示线程池的名称,其下方是线程池中的线程每秒钟处理请求的数量。线程池监视器的左下角显示如下信息。

  • 活跃线程:当前活跃线程的数量。
  • 排队线程:当前有多少线程在排队。默认情况下,队列功能是禁用的,所以这个值始终为0。
  • 线程池的大小:线程池中有多少线程。

在右下角显示线程池的其他信息:

  • 最大活跃线程:在当前的采样周期中,活跃线程的最大数量。
  • 执行次数:线程池中的线程被调用执行Hystrix命令的次数。
  • 线程队列大小:线程池队列的大小。线程队列功能默认是禁用的,所以这个值没有什么意义。

值得一提的是,作为Hystrix线程池的替代方案,我们可以选择使用信号量隔离(semaphore isolation)。然而,信号量隔离是Hystrix的更高级用法,超出了本章的范围。有关它的更多信息,请参考Hystrix的文档。

现在,我们已经看到了Hystrix dashboard是如何运行的。接下来,我们考虑一下如何处理多个断路器数据流,以及如何将它们聚合到一个流中,以便在Hystrixdashboard中查看。

15.2 声明断路器

在声明断路器之前,我们需要添加Spring Cloud Netflix Hystrix starter依赖到每个服务的构建文件中。在Maven pom.xml文件中,依赖如下所示:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

作为Spring Cloud套件的一部分,我们需要在构建文件中声明Spring Cloudrelease train的依赖管理。在我编写本书的时候,最新的release train版本为Finchley.SR1。所以,应该将Spring Cloud的版本设置为一个属性,如下的条目应该出现在pom.xml文件的<dependencyManagement>代码块中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<properties>
...
<spring-cloud.version>Finchley.SR1</spring-cloud.version>
</properties>
...
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
注意:在创建项目的时候,starter依赖也可以在Initializr中通过名为Hystrix的复选框来进行添加。如果使用Initializr添加Hystrix到项目的构建文件中,那么依赖管理代码块会自动创建。

Hystrix starter就绪之后,接下来的事情就是启用Hystrix。为了实现这一点,我们可以在应用的主配置类上添加@EnableHystrix。例如,为了在配料服务上启用Hystrix,我们可以按照如下的方式为IngredientServiceApplication添加注解:

1
2
3
4
5
@SpringBootApplication
@EnableHystrix
public class IngredientServiceApplication {
...
}

这样,在我们的应用中就启用Hystrix了,也就意味着声明断路器的所有准备工作都做完了。在我们的代码中还没有声明任何一个断路器,这时@HystrixCommand注解就能够发挥作用了。

任何使用@HystrixCommand注解的方法都会为其声明一个断路器切面。例如,如下的方法使用支持负载均衡的RestTemplate从配料服务中获取一个Ingredient对象的列表:

1
2
3
4
5
6
7
public Iterable<Ingredient> getAllIngredients() {
ParameterizedTypeReference<List<Ingredient>> stringList =
new ParameterizedTypeReference<List<Ingredient>>() {};
return rest.exchange(
"http://ingredient-service/ingredients", HttpMethod.GET,
HttpEntity.EMPTY, stringList).getBody();
}

对exchange()的调用可能会遇到问题。如果Eureka没有注册名为ingredient-service的服务或者由于某种原因请求失败了,那么将会抛出RestClientException(非检查型异常)。因为异常没有在try/catch代码块中进行处理,所以调用者必须要处理这个异常。如果调用者不处理,那么它将沿着调用栈往上抛出;如果它根本没有得到处理,那么这个错误会级联到所有上游微服务或客户端。

在任何应用中,未捕获的异常都是一项艰巨的挑战,在微服务中尤为如此。当遇到失败的时候,微服务应该应用维加斯规则(Vegas Rule):在微服务中发生的事情,就留在微服务中。在getAllIngredients()方法上声明断路器将会满足该规则。

按照最少的要求,我们只需要为该方法添加@HystrixCommand注解并为其提供一个后备方法即可。首先,我们添加@HystrixCommand注解到getAllIngredients()方法上:

1
2
3
4
@HystrixCommand(fallbackMethod="getDefaultIngredients")
public Iterable<Ingredient> getAllIngredients() {
...
}

断路器为getAllIngredients()提供了失败防护,所以在遇到失败时它是安全的。如果由于某种原因getAllIngredients()抛出了未捕获的异常,那么断路器将会捕获它们并将方法调用重定向到名为getDefaultIngredients()的方法上。

你可以让后备方法做任何事情,但是它们的本意是当原始的方法无法履行职责时提供后备行为。后备行为方法的唯一规则是它们要与原始方法具有相同的签名(除了方法名称之外)。

为了满足该要求,getAllIngredients()也不能接受任何参数并要返回List<Ingredient>。如下的getAllIngredients()实现满足该规则,并且返回一个默认的配料列表:

1
2
3
4
5
6
7
8
9
10
private Iterable<Ingredient> getDefaultIngredients() {
List<Ingredient> ingredients = new ArrayList<>();
ingredients.add(new Ingredient(
"FLTO", "Flour Tortilla", Ingredient.Type.WRAP));
ingredients.add(new Ingredient(
"GRBF", "Ground Beef", Ingredient.Type.PROTEIN));
ingredients.add(new Ingredient(
"CHED", "Shredded Cheddar", Ingredient.Type.CHEESE));
return ingredients;
}

现在,如果因为某种原因导致getAllIngredients()失败,那么断路器将会调用备用的getDefaultIngredients(),调用者将会接收到默认的配料列表(尽管非常有限)。

你可能会想,如果备用方法本身有断路器又会怎样呢。尽管按照我们的写法,getDefaultIngredients()几乎不可能会出问题,但是更有意思的是getDefaultIngredients()可能会有潜在的失败点。如果是这样,那么我们可以在getDefaultIngredients()上添加@HystrixCommand注解并提供另一个备用方法。实际上,需要的话,我们可以堆积任意数量的备用方法。唯一的要求就是必须要在后备方法的底部有一个不会失败的方法,该方法不需要使用断路器。

15.2.1 缓解延迟

断路器还能缓解延迟。如果某个方法需要较长的时间才能返回,断路器会将它设置为超时。默认情况下,所有带有@HystrixCommand注解的方法都会在1秒之后超时,并调用它们所声明的后备方法。这意味着,如果因为某种原因配料服务响应缓慢,那么对getAllIngredients()调用会在1秒之后超时,而且会调用getDefaultIngredients()作为替代方案。

1秒超时是一个合理的默认值,适用于大多数的场景。我们也可以通过Hystrix命令属性将其调整为更大或更小的限制值。设置Hystrix命令属性可以通过@HystrixCommand注解的commandProperties属性来实现。commandProperties属性是一个或多个@HystrixProperty注解所组成的数组,指定了要设置的属性名和值1

为了调整断路器的超时值,我们需要设置Hystrix命令属性execution.isolation.thread.timeoutInMilliseconds。例如,为了将getAllIngredients()的超时时间更加严格地设置为0.5秒,那么我们可以将超时设置为500,如下所示:

1
2
3
4
5
6
7
8
9
10
@HystrixCommand(
fallbackMethod="getDefaultIngredients",
commandProperties={
@HystrixProperty(
name="execution.isolation.thread.timeoutInMilliseconds",
value="500")
})
public Iterable<Ingredient> getAllIngredients() {
...
}

这里设置的值是毫秒数。如果我们希望放松限制,那么可以将其设置成一个更大的值。或者,如果你认为这里不应该使用超时功能,那么可以将execution.timeout.enabled属性设置为false,直接将超时功能移除:

1
2
3
4
5
6
7
8
9
10
@HystrixCommand(
fallbackMethod="getDefaultIngredients",
commandProperties={
@HystrixProperty(
name="execution.timeout.enabled",
value="false")
})
public Iterable<Ingredient> getAllIngredients() {
...
}

将execution.timeout.enabled为false的话就没有延迟防护了。在本例中,getAllIngredients()方法不管是耗用1秒、10秒还是30分钟,它都不会超时。这可能会导致级联的延迟效果,所以在禁用执行超时的时候要非常小心。

15.2.2 管理断路器的阈值

默认情况下,如果断路器保护的方法调用超过20次,而且50%以上的调用在10秒的时间内发生失败,那么断路器就会进入打开状态。所有后续的调用都将会由后备方法处理。在5秒之后,断路器进入半开状态,将会再次尝试调用原始的方法。

我们可以通过设置Hystrix命令属性调整失败和重试的阈值。如下的命令属性将会影响断路器的行为。

  • circuitBreaker.requestVolumeThreshold:在给定的时间范围内,方法应该被调用的次数。
  • circuitBreaker.errorThresholdPercentage:在给定的时间范围内,方法调用产生失败的百分比。
  • metrics.rollingStats.timeInMilliseconds:控制请求量和错误百分比的滚动时间周期。
  • circuitBreaker.sleepWindowInMilliseconds:处于打开状态的断路器要经过多长时间才会进入半开状态,进入半开状态之后,将会再次尝试失败的原始方法。

如果在metrics.rollingState.timeInMilliseconds设定的时间范围内超出了circuitBreaker.requestVolumeThreshold和circuitBreaker.errorThresholdPercentage设置的值,那么断路器将会进入打开状态。在circuitBreaker.sleepWindowInMilliseconds限定的时间范围内,它会一直处于打开状态,在此之后将进入半开状态,进入半开状态之后,将会再次尝试失败的原始方法。

例如,我们调整失败的设置:将其变更为在20秒的时间范围内调用超过30次且失败率超过25%。为了实现这一点,我们需要按照如下的方式调整Hystrix命令属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@HystrixCommand(
fallbackMethod="getDefaultIngredients",
commandProperties={
@HystrixProperty(
name="circuitBreaker.requestVolumeThreshold",
value="30"),
@HystrixProperty(
name="circuitBreaker.errorThresholdPercentage",
value="25"),
@HystrixProperty(
name="metrics.rollingStats.timeInMilliseconds",
value="20000")
})
public List<Ingredient> getAllIngredients() {
// ...
}

另外,我们还决定处于打开状态之后断路器必须保持1分钟,然后才进入半开状态,那么我们还需要设置circuitBreaker.sleepWindowInMilliseconds命令属性:

1
2
3
4
5
6
7
8
@HystrixCommand(
fallbackMethod="getDefaultIngredients",
commandProperties={
...
@HystrixProperty(
name="circuitBreaker.sleepWindowInMilliseconds",
value="60000")
})

除了优雅地处理方法调用失败和延迟之外,Hystrix还为应用中的每个断路器提供了一个指标流。接下来,我们看一下如何通过Hystrix流监控启用Hystrix功能的应用的监控状况。

15.1 理解断路器模式

断路器模式是随着Michael Nygard的Release It!(第2版,PragmaticBookshelf,2018)一书流行起来的,解决了我们所编写的代码可能会失败的问题。很重要的一点在于,即便是失败,它也能够优雅地失败。这个强大的模式在微服务环境中会更加关键,因为在这种环境下避免跨调用堆栈产生级联失败非常重要。

相对来讲,断路器模式的理念很简单,非常类似于现实世界中的电路断路器,这也是它得名的由来。在电路断路器中,当开关处于闭合位置时,电流能够流过断路器,为房间中的电灯、电视、电脑和其他设备供电。如果线路中出现故障,比如功率骤增,断路器就会打开,在电流损坏电子设备或房屋失火之前切断电流。

与之类似,软件中的断路器起初会处于关闭状态,允许进行方法的调用。如果因为某种原因,方法调用失败了(比如时间超出了定义的阈值),断路器就会打开,就不会对失败的方法再执行调用了。软件断路器的区别在于它提供了后备(fallback)行为和自校正功能。

如果被保护的方法在给定的失败阈值内发生了失败,那么可以调用一个后备方法代替它的位置。在断路器处于打开状态之后,几乎始终都会调用后备方法。处于打开状态的断路器偶尔会进入半开状态,并尝试调用发生失败的方法:如果依然失败,断路器就恢复为打开状态;如果调用成功,它会认为问题已经解决,断路器会回到闭合状态。图15.1阐述了软件断路器的流程。

image-20211023212429519

图15.1 断路器模式能够实现优雅的失败处理

按照我的阐述,断路器是应用到方法上的。这样,在给定的一个微服务中,很容易就能达到数十个(甚至更多)断路器。决定在代码的什么地方声明断路器其实就是识别哪些方法易于出现失败。如下的几类方法肯定是添加断路器的首选。

  • 调用REST的方法:这些方法可能会因为远程服务不可用或者返回HTTP 500响应而失败。
  • 执行数据库查询的方法:这些方法可能会因为数据库不响应或者模式变更破坏了应用而导致失败。
  • 可能会比较慢的方法:它们不一定会失败,但是如果耗费太长时间才能完成工作就可能会被视为失败。

最后一项强调了除处理故障之外断路器的另一项收益。在微服务中,延迟也是非常重要的,某个执行缓慢的微服务不能拖慢整个微服务的性能,避免上游的服务产生级联延迟是非常重要的。

我们可以看到,断路器模式是在代码中优雅处理故障和延迟的强大方法。那么该如何将断路器用到我们的代码中呢?幸运的是,Netflix开源项目通过Hystrix为我们提供了答案。

Netflix Hystrix是断路器模式的Java实现。简而言之,Hystrix断路器实现为一个切面,会在目标方法发生失败的时候触发后备方法。为了实现断路器模式,这个切面还会跟踪目标方法失败的频率;如果失败率超过了某个阈值,那么所有的请求都会转发至后备方法。

关于Hystrix名称的一点逸事

当Netflix的开发人员为他们的断路器实现起名字的时候,他们想要这个名字能够体现出需要提供的弹性、防御能力和容错能力。最终,他们选择了Hystrix(Hystrix是古代豪猪的一种,豪猪是一种能够使用长刺进行自卫的动物)。此外,正如Hystrix FAQ中所解释的,这是一个听起来很酷的名称。当我们在15.3.1小节中查看Hystrix dashboard时,我们就会在项目的Logo位置处看到一个豪猪的图案。

Spring Cloud Netflix包含对Hystrix的支持,提供了一个简单的编程模型。Spring和Spring Boot开发人员都应该很熟悉这个模型。为方法添加@HystrixCommand注解并提供一个后备方法,就可以为该方法声明断路器。下面让我们看看如何在Taco Cloud代码中声明断路器,从而优雅地使用Hystrix来处理失败。

14.7 小结

  • Spring Cloud Config Server提供了中心化的配置数据源,能够用于微服务架构应用中的所有微服务。
  • Config Server提供的属性是通过后端的Git或Vault仓库维护的。
  • 除了暴露给所有Config Server客户端的全局属性,Config Server还能提供特定profile和特定应用的配置。
  • 敏感数据能够保持私密,这可以在后端Git仓库中通过对其进行加密来实现,也可以通过在Vault后端存储私密信息来实现。
  • Config Server客户端能够借助手动或自动刷新得到新的属性,前者通过Actuator端点来实现,后者通过Spring Cloud Bus和Git webhooks来实现。

14.6 在运行时刷新配置属性

在编写本章的时候,我正在一架飞机上,因为维护问题,飞机被重新拉回了登机口。情况并不严重,你正在读本章的内容,就说明机械工程师的工作完成得还是很令人满意的。即便如此,关于飞机维护,最有意思的事情是它要求飞机必须要在地面上。如果飞机正在飞行,那么能做的事情就太少了。

相比之下,在《星球大战》(Star Wars)电影中,如果Luke Skywalker或PoeDameron的X翼战机需要维护,舰载机械机器人(mech droid)就可以派上用场了,即使X翼战机正在作战,它也可以开展工作。

传统上,应用程序维护,包括配置更改,都需要重新部署或至少重新启动应用。可以说,由于缺少一个“机械机器人”来调整哪怕是最小的配置属性,因此我们每次都需要将应用程序拉回“登机口”。这对云原生应用来说是不可接受的。我们希望能够动态地更改配置属性,而不需要关闭应用程序。

幸运的是,Spring Cloud Config Server能够刷新正在运行的应用程序的配置属性,而不需要停机。一旦变更推送到支撑的Git仓库或Vault私密仓库,应用中的每个微服务就都可以立即通过以下两种方式的某一种进行刷新。

  • 手动刷新:Config Server客户端启用一个特殊的“/actuator/refresh”端点,对每个服务的这个端点发送HTTP POST请求将会强制配置客户端从Config Server的后端检索最新的配置。
  • 自动刷新:Git仓库上的提交hook会触发所有Config Server客户端服务的刷新操作。这涉及Spring Cloud的另一个项目,名为Spring Cloud Bus,它能够用于Config Server及其客户端之间的通信。

每种方案都有其优点和缺点。手动刷新能够更精确地控制服务何时更新最新配置,但是它需要向每个微服务实例发送一个HTTP请求。自动更新能够让应用中的每个微服务即时使用最新的配置,但它是由配置仓库的提交自动触发的,对于有些项目来说过于危险。

我们接下来看一下这两种方案,然后你就可以自行选择哪种方式更适合你的项目了。

14.6.1 手动刷新配置属性

在第16章中,我们将会介绍Spring Boot Actuator。它是Spring Boot的基本元素之一,能够探查应用运行时的状况并且允许对运行时进行一些有限的操作,比如修改日志级别。现在先看一个特殊的Actuator特性,只有配置为Spring CloudConfig Server客户端的应用,这个特性才有效。

当我们将应用设置为Config Server客户端的时候,自动配置功能会配置一个特殊的Actuator端点,用来刷新配置属性。为了使用该端点,在项目的构建文件中除了Config Client依赖,我们还需要添加Actuator starter依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

我们可以猜到,这项依赖也可以在Spring Initializr中通过选中Actuator复选框添加进来。

在Config Server客户端应用中添加Actuator之后,我们可以在任意时间发送HTTPPOST请求到“/actuator/refresh”,通知它从后端仓库刷新配置属性。

我们看一下它是如何实现的。假设我们有一个带有@ConfigurationProperties注解的类,名为GreetingProps:

1
2
3
4
5
6
7
8
9
10
11
@ConfigurationProperties(prefix="greeting")
@Component
public class GreetingProps {
private String message;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

另外,我们可以编写一个控制器类。GreetingProps会注入其中,当它在处理GET请求时,返回message属性的值:

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class GreetingController {
private final GreetingProps props;
public GreetingController(GreetingProps props) {
this.props = props;
}
@GetMapping("/hello")
public String message() {
return props.getMessage();
}
}

在我们的Git配置仓库中有一个application.yml文件,含有如下的属性设置:

1
2
greeting:
message: Hello World!

Config Server和这个简单的hello-world配置客户端运行起来之后,我们对“/hello”发送HTTP GET请求,将会产生如下的响应:

1
2
$ curl localhost:8080/hello
Hello World!

现在,我们对Config Server和hello-world都不进行重启,而是修改application.yml文件并推送至后端Git仓库,这样greeting.message属性将会变成如下形式:

1
2
greeting:
message: Hiya folks!

即便在Git中配置已经发生变化,如果我们发送GET请求到hello-world应用,得到的结果依然是“Hello World!”响应。但是,我们可以对刷新端点发送一个POST请求,强制使其刷新:

1
2
$ curl localhost:53419/actuator/refresh -X POST
["config.client.version","greeting.message"]

注意,响应中包含一个JSON数组,列出了发生变更的属性名。这个数组包含greeting.message属性,还包含config.client.version属性(当前配置对应的Git提交的哈希值)的变化。因为现在的配置基于一个新的Git提交,所以每当后端的配置仓库有变化时,这个值都会跟着变化。

POST请求的响应告诉我们greeting.message已经发生变化了。但是,真正的证据还是要靠再次向“/hello”路径发送GET请求:

1
2
$ curl localhost:8080/hello
Hiya folks!

无须重启应用,甚至无须重启Config Server,应用现在就能向我们提供greeting.message属性的全新值。

如果我们能够完全控制何时对配置属性进行更新,那么“/actuator/refresh”端点是很不错的选择。如果我们的应用由多个微服务组成(可能每个服务都有多个实例),那么将配置传播到所有服务可能是一项非常乏味的工作。接下来,我们看一下如何一次性地将配置变更自动用到所有服务上。

14.6.2 自动刷新配置属性

Config Server能够借助名为Spring Cloud Bus的Spring Cloud项目将配置变更自动通知到每个客户端,作为手动刷新应用中每个Config Server客户端属性的替代方案。图14.7阐述了它是如何运行的。

image-20211023215219588

图14.7 Config Server与Spring Cloud Bus能够对应用广播变更(应用会在属性发生变化的时候,自动刷新它们的属性)

可以简要概括图14.7中的属性刷新流程。

  • 在配置Git仓库上创建一个webhook,当Git仓库有任何变化(比如所有的推送)时,都会通知Config Server。很多的Git实现都支持webhook,比如GitHub、GitLab、Bitbucket和Gogs。
  • Config Server会对webhook POST请求做出响应,借助某种消息代理以消息的方式广播该变更。
  • 每个Config Server客户端应用订阅该通知,对通知消息做出响应,也就是会使用Config Server中的新属性值刷新它们的环境。

这样做的结果就是,在配置属性变更推送到后端的Git仓库之后,所有的ConfigServer客户端应用能够立即获取最新的配置属性值。

在使用Config Server的自动属性刷新功能时,会有多个部件在发挥作用。我们回顾一下要做的变更,这样对需要做的事情会有一个整体的了解。

  • 我们需要有一个消息代理,用来处理Config Server及其客户端之间的消息传递,可以选择RabbitMQ或Kafka。
  • 在后端Git仓库上需要创建一个webhook,将各种变更通知给ConfigServer。
  • Config Server需要启用Config Server监控依赖(提供了处理Git仓库webhook请求的端点)以及RabbitMQ或Kafka的Spring Cloud Stream依赖(用于发布属性变更消息给代理)。
  • 除非消息代理在本地按照默认设置运行,否则,我们要在Config Server及其所有的客户端上配置连接至代理的详细信息。
  • 每个Config Server的客户端应用需要Spring Cloud Bus依赖。

假设预先需要的消息代理(不管是RabbitMQ、Kafka,还是你选择的其他方案)已经处于运行状态,并且为传送属性变更消息做好了准备,我们首先从将属性变更应用于Config Server开始,让它处理webhook的更新请求。

创建webhook

很多Git服务都支持创建webhook,从而能够将Git仓库的变更信息通知给应用,这些变更包括推送。不同实现之间创建webhook的操作有所差异,我们很难对它们一一描述。在这里,我会介绍如何为Gogs仓库创建webhook。

我选择Gogs的原因在于它非常易于在本地运行,并且支持将webhook POST用到本地运行的应用上(对于GitHub来说,这非常难以实现)。同时,在Gogs上创建webhook的过程与GitHub几乎完全相同,因此描述Gogs的过程能够间接让你知道为GitHub创建webhook都需要哪些步骤。

首先,在Web浏览器中访问配置仓库并点击Settings链接,如图14.8所示。(GitHub上Settings链接的位置略有差异,但是它们的外观很相似。)

image-20211023215249586

图14.8 在Gogs或GitHub上点击Settings开始创建webhook

这会将我们带到仓库的设置页面,在左侧包含了一个设置分类的菜单。在菜单中选择Webhooks,将会出现如图14.9所示的页面。

image-20211023215304730

图14.9 Webhooks页面中的Add Webhook按钮会打开创建webhook的表单

在Webhooks设置页面,点击Add Webhook按钮,在Gogs中会生成一个下拉列表,用来选择不同类型的webhook。选择Gogs选项,如图14.9所示。这样,我们会看到一个创建新webhook的表单,如图14.10所示[^1]。

Add Webhook表单有多个输入域,重要的是Payload URL和Content Type。我们马上将会配置Config Server来处理webhook的POST请求。在实现该功能的时候,Config Server将会在“/monitor”路径下处理webhook请求。因此,我们需要将Payload URL输入域设置成引用Config Server的“/monitor”端点的URL。因为我是在一个Docker容器中运行Gogs的,所以在图14.10中将URL设置成http://host.docker.internal:8888/monitor,它的域名为host.docker.internal。这个域名让Gog服务器能够跨越容器的边界访问宿主机器上的Config Server[^2]。

image-20211023215423231

图14.10 创建webhook时需要指定Config Server的“/monitor”URL和JSON载荷

我还将Content Type输入域设置成了application/json。这一点非常重要,因为Config Server的“/monitor”端点并不支持Content Type的另一个选项application/x-www- form-urlencoded。

如果设置Secret输入域,就可以在webhook POST请求中新增一个名为X-Gogs-Signature(在GitHub中名为X-Hub-Signature)的头信息,包含给定私密信息的HMAC-SHA256摘要(在GitHub中是HMAC-SHA1)。此时,Config Server的“/monitor”端点并不识别这个签名头信息,因此我们可以将这个输入域设置为空。

最后,我们只关心配置仓库的推送请求,另外,我们当然希望这个webhook处于活跃状态,所以需要确保Just the push event单选框和Active复选框处于选中状态。点击表单底部的Add Webhook按钮,webhook就创建完成了。每当仓库有推送的时候,就会向Config Server发送POST请求。

现在,我们必须要启用Config Server的“/monitor”端点来处理这些请求。

在Config Server中处理webhook更新

要启用Config Server的“/monitor”端点非常简单,我们只需添加spring-cloud-config-monitor依赖到Config Server的配置文件即可。在Maven的pom.xml文件中,如下的依赖就会完成该项工作:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-monitor</artifactId>
</dependency>

这项依赖添加完成之后,自动配置功能会发挥作用,从而启用“/monitor”端点。但是,除非Config Server本身有广播变更通知的方法,否则不会带来任何好处。为了实现这一点,我们需要添加对Spring Cloud Stream的依赖。

Spring Cloud Stream是另一个Spring Cloud项目。借助它,我们能够创建通过底层绑定机制通信的服务,这种通信机制可能是RabbitMQ或Kafka。服务在编写的时候并不会关心如何使用这些通信机制,只是接受流中的数据,对其进行处理,并返回到流中,由下游的服务继续处理。

“/monitor”端点使用Spring Cloud Stream发布通知消息给参与的ConfigServer客户端。为了避免硬编码特定的消息实现,监控器会作为Spring CloudStream的源,发布消息到流中并让底层的绑定机制处理消息发送的特定功能。

如果使用RabbitMQ,就需要将Spring Cloud Stream RabbitMQ绑定依赖添加到Config Server的构建文件中:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>

如果你更喜欢Kafka,那么需要添加如下的Spring Cloud Stream Kafka依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-kafka</artifactId>
</dependency>

依赖准备就绪之后,Config Server几乎就可以参与属性自动刷新功能了。实际上,如果RabbitMQ或Kafka在本地运行并且使用默认配置,Config Server就已经可以运行了。如果消息代理在其他地方运行,而不是在localhost,或者使用了非默认端口,又或者我们修改了访问代理的凭证信息,就需要在Config Server本身的配置中添加一些属性了。

如果采用RabbitMQ绑定,那么application.yml中的如下条目可以用来重写默认值:

1
2
3
4
5
6
spring:
rabbitmq:
host: rabbit.tacocloud.com
port: 5672
username: tacocloud
password: s3cr3t

虽然我们在这里设置了所有的属性,但是在你的RabbitMQ代理中只需要设置与默认值不同的属性即可。

如果使用Kafka,可以使用类似的属性:

1
2
3
4
5
6
spring:
kafka:
bootstrap-servers:
- kafka.tacocloud.com:9092
- kafka.tacocloud.com:9093
- kafka.tacocloud.com:9094

你会发现,这些属性来源于第8章我们学习Kafka消息时的配置。实际上,配置自动刷新功能的RabbitMQ和Kafka后端与在Spring中使用代理的其他场景非常相似。

创建Gogs的通知提取器

对于每个Git实现来说,webhook POST请求所携带的内容会有所不同。所以,对于“/monitor”端点来说,很重要的一点就是在处理webhook POST请求时能够理解不同的数据格式。在幕后,“/monitor”端点会有一组组件来检查POST请求,试图弄清楚请求来自哪种Git服务器,然后将请求数据映射为通用的通知类型,并发送至每个客户端。

Config Server对多个流行的Git实现提供了开箱即用的支持,比如GitHub、GitLab和Bitbucket。如果你使用其中的某一个实现,那么不需要任何额外的操作。在我编写本书的时候,Gogs还没有得到官方支持[^3]。因此,使用Gogs作为Git实现的话,我们需要在项目中提供一个Gogs的通知提取器。

程序清单14.1为Taco Cloud集成Gogs时我所使用的通知提取器。

程序清单14.1 Gogs的通知提取器实现

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package tacos.gogs;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.cloud.config.monitor.PropertyPathNotification;
import
org.springframework.cloud.config.monitor.PropertyPathNotificationExtractor;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 300)
public class GogsPropertyPathNotificationExtractor
implements PropertyPathNotificationExtractor {
@Override
public PropertyPathNotification extract(
MultiValueMap<String, String> headers,
Map<String, Object> request) {
if ("push".equals(headers.getFirst("X-Gogs-Event"))) {
if (request.get("commits") instanceof Collection) {
Set<String> paths = new HashSet<>();
@SuppressWarnings("unchecked")
Collection<Map<String, Object>> commits =
(Collection<Map<String, Object>>) request
.get("commits");
for (Map<String, Object> commit : commits) {
addAllPaths(paths, commit, "added");
addAllPaths(paths, commit, "removed");
addAllPaths(paths, commit, "modified");
}
if (!paths.isEmpty()) {
return new PropertyPathNotification(
paths.toArray(new String[^0]));
}
}
}
return null;
}
private void addAllPaths(Set<String> paths,
Map<String, Object> commit,
String name) {
@SuppressWarnings("unchecked")
Collection<String> files =
(Collection<String>) commit.get(name);
if (files != null) {
paths.addAll(files);
}
}
}

GogsPropertyPathNotificationExtractor如何运行的细节与我们的讨论没有太大关系,并且在Spring Cloud Config Server内置对Gogs的支持之后,就更加无关紧要了。所以,我不会对它进行过多的介绍,将它放在这里只是为了让你在使用Gogs的时候,可以作为参考。

在Config Server的客户端中启用自动刷新

在Config Server客户端启用属性的自动刷新比Config Server本身会更加简单。我们需要添加一项依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>

这样会添加AMQP(如RabbitMQ)Spring Cloud Bus starter到构建文件中。

如果使用Kafka,就需要添加如下的依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bus-kafka</artifactId>
</dependency>

对应的Spring Cloud Bus starter准备就绪之后,启动应用的时候,自动配置功能就会发挥作用,应用会自动将自己绑定到本地运行的RabbitMQ代理或Kafka集群上。如果你的RabbitMQ或Kafka在其他地方运行,那么我们需要在每个客户端应用上像Config Server本身那样配置它们的详细信息。

Config Server及其客户端都配置成了支持自动刷新。将它们启动起来,并对application.yml做一下修改(任意修改都可以),当将该文件提交至Git仓库的时候,我们会立即看到它在客户端应用中生效。

[^1]: GitHub没有可选webhook的下拉列表。在点击Add Webhook按钮之后,会直接出现创建webhook的表单。
[^2]: 在Docker容器中。localhost指的是容器本身,而不是Docker宿主机。
[^3]: 作者给Config Server项目提交了一个支持Gogs的pull request。在它合并进去之后,本书的这个章节就没有必要关注了。目前,作者的这个pull request经修改后,已经合并到了Config Server中。——译者注

14.5 保持配置属性的私密性

Config Server提供的大多数配置可能并不是私密的。但是,我们可能需要ConfigServer提供一些包含敏感信息的属性,比如密码或安全token,在后端仓库中,它们最好保持私密。

Config Server提供了两种方式来支持私密的配置属性。

  • 在Git存储的属性文件中使用加密后的值。
  • 使用HashiCorp Vault作为Config Server的后端存储,补充(或替代)Git。

我们将会依次看一下这两种方案是如何与Config Server组合使用保证配置属性私密性的。首先,我们看一下如何在Git后端中写入加密的属性。

14.5.1 在Git中加密属性

除了提供非加密值以外,Config Server也可以借助存储在Git中的属性文件提供加密值。处理存储在Git中的加密数据的关键在于一个秘钥(key),即加密秘钥(encryption key)。

为了启用加密属性功能,我们使用一个加密秘钥来配置Config Server,在将属性值提供给客户端应用之前,Config Server要使用这个秘钥对属性值进行解密。Config Server支持对称秘钥和非对称秘钥。要设置对称秘钥,我们可以在ConfigServer自己的配置中将encrypt.key属性设置为加密和解密秘钥的值:

1
2
encrypt:
key: s3cr3t

很重要的一点需要注意,这个属性要设置到bootstrap配置中(例如,bootstrap.properties或bootstrap.yml)。这样的话,在自动配置功能启用Config Server之前,这个属性就会加载和启用。

为了更加安全一些,我们可以让Config Server使用非对称的RSA秘钥对或引用一个keystore。要创建这样的秘钥,我们可以使用keytool命令行工具:

1
2
3
keytool -genkeypair -alias tacokey -keyalg RSA \
-dname "CN=Web Server,OU=Unit,O=Organization,L=City,S=State,C=US" \
-keypass s3cr3t -keystore keystore.jks -storepass l3tm31n

这样形成的keystore会写入到名为keystore.jks的文件中。我们可以将这个keystore.jks文件放到文件系统中或者放到应用本身。不管使用哪种方式,我们都需要在Config Server的bootstrap.yml文件中配置keystore的位置和凭证信息。

注意:为了在Config Server中使用加密功能,我们需要要安装JavaCryptography Extensions Unlimited Strength策略文件。参见Oracle的JavaSE页面了解详细信息。

例如,假设我们要将keystore打包到应用本身,将其放到类路径的根目录下,那么我们可以配置如下的属性,让Config Server使用该keystore:

1
2
3
4
5
6
encrypt:
key-store:
alias: tacokey
location: classpath:/keystore.jks
password: l3tm31n
secret: s3cr3t

秘钥和keystore就绪之后,我们需要对某些数据进行加密。Config Server暴露了一个“/encrypt”端口会帮助我们实现该功能。我们需要做的就是提交一个POST请求到“/encrypt”端点,其中包括要加密的数据。例如,我们要加密连接至MongoDB数据库的密码。借助curl,我们可以按照如下的方式加密密码:

1
2
$ curl localhost:8888/encrypt -d "s3cr3tP455w0rd"
93912a660a7f3c04e811b5df9a3cf6e1f63850cdcd4aa092cf5a3f7e1662fab7

在提交POST请求之后,我们会接收到一个加密的值作为响应。接下来,需要做的就是复制这个值并粘贴到Git仓库托管的配置文件中。

为了设置MongoDB,在Git仓库的application.yml文件中添加spring.data.mongodb. password属性:

1
2
3
4
spring:
data:
mongodb:
password: '{cipher}93912a660a7f3c04e811b5df9a3cf6e1f63850...'

需要注意,spring.data.mongodb.password被一个单括号(’)括了起来,并且带有{cipher}前缀。这样就会告诉Config Server,这是一个加密的值,而不是简单的未加密值。

在将这个变更提交并推送到Git仓库中的application.yml文件之后,Config Server就可以对外提供加密的属性了。如果要实际看一下,就使用curl命令伪装成ConfigServer的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ curl localhost:8888/application/default | jq
{
"name": "app",
"profiles": [
"prof"
],
"label": null,
"version": "464adfd43485182e4e0af08c2aaaa64d2f78c4cf",
"state": null,
"propertySources": [
{
"name": "http://localhost:10080/tacocloud/tacocloudconfig/
application.yml",
"source": {
"spring.data.mongodb.password": "s3cr3tP455w0rd"
}
}
]
}

我们可以看到,spring.data.mongodb.password的值是以解密后的形式提供的。默认情况下,Config Server提供的所有加密值只是在后端Git仓库中处于加密的状态,它们在对外提供之前会解密。这意味着,消费这个配置的客户端应用并不需要任何特殊的代码和配置就能接收Git中已加密的属性。

如果你想要让Config Server以未解密的形式对外提供加密属性,那么可以将spring.cloud.config.server.encrypt.enabled属性设置为false:

1
2
3
4
5
6
7
8
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
encrypt:
enabled: false

这样导致的结果就是Config Server在提供所有的属性值的时候完全按照Git仓库设置的样子进行发送,包括已加密的属性值。我们再次伪装成一个客户端,利用curl命令展示禁用解密的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl localhost:8888/application/default | jq
{
...
"propertySources": [
{
"name": "http://localhost:10080/tacocloud/tacocloudconfig/
application.yml",
"source": {
"spring.data.mongodb.password": "{cipher}AQA4JeVhf2cRXW..."
}
}
]
}

当然,如果客户端接收到了未解密的属性值,那么客户端需要自行解密。

尽管可以通过Config Server在Git中保存已加密的私密信息,但是我们可以看到加密并不是Git的原生特性。它需要我们自己对写入Git仓库的数据进行加密。另外,除非将解密的任务推给Config Server的客户端应用,否则对于任何请求配置的客户端,Config Server API对外提供的私密信息都是解密之后的形式。我们接下来看一下另一个Config Server后端方案,它能够只向已授权的用户提供私密信息。

14.5.2 在Vault中存储私密信息

HashiCorp Vault是一个私密管理工具。这意味着与Git相比,Vault的核心特性就是原生地处理私密信息。对于敏感的配置数据,Vault是一个更有吸引力的ConfigServer后端支撑方案。

为了开始使用Vault,我们需要参考Vault Web站点的安装指南下载并安装vault命令行工具。在本小节中,我们将会使用vault命令管理私密信息和启动Vault服务器。

启动Vault服务器

在使用Config Server写入和对外提供私密信息之前,我们需要启动一个Vault服务器。对于我们来讲,最简单的方式就是在开发模式下使用如下的命令启动服务器:

1
2
3
$ vault server -dev -dev-root-token-id=roottoken
$ export VAULT_ADDR='http://127.0.0.1:8200'
$ vault status

第一条命令会在开发模式下启动一个Vault服务器,其中根token(root token)的ID为roottoken。顾名思义,开发模式意味着它是一个更简单但并不完全安全的Vault运行时。它不应该在生产环境中使用,但是在开发的工作流程中,这种使用Vault的方式会非常便利。

注意:Vault是一个功能完备且健壮的私密管理工具。除了开发模式下的简单使用之外,本章没有足够的篇幅完整介绍Vault服务器的运行。我强烈建议你在尝试生产环境中使用Vault之前,通过阅读Vaul文档来更详细地了解Vault。

对Vault服务器的所有访问都需要向服务器提供一个token。根token是一个管理token,这意味着除了其他功能之外,它允许我们创建其他的token。它还能够用于读取和写入私密信息。如果在开发模式启动服务器的时候未指定根token,那么Vault服务器会为我们创建一个token并在启动的时候写入日志中。为了便于使用,建议将根token设置成一个易于记忆的值,比如roottoken。

开发模式的服务器启动之后,它将会监听本地机器的8200端口。所以,要让vault命令行知道Vault服务器在什么地方,设置VAULT_ADDR环境变量是非常重要的,这也是上述代码片段第二行所做的事情。

最后,vault status命令会校验之前的两条命令是否已经按照预期运行。你大致会看到描述Vault服务器的6个属性,包括Vault是否密闭(在开发模式下,它不应该处于密闭状态)。

使用Vault 0.10.0或之后的版本的话,Vault与Config Server协作使用之前还有其他的两条命令需要执行。Vault运行方式的一些变更会导致一个标准的私密后端与Config Server不兼容。以下两个命令会重新创建名为secret的后端,以兼容Config Server:

1
2
$ vault secrets disable secret
$ vault secrets enable -path=secret kv

如果使用更早版本的Vault,就不需要这些步骤。

写入私密信息到Vault中

借助vault命令,可以很容易将私密信息写入Vault中。例如,假设我们想要将访问MongoDB的密码(也就是spring.data.mongodb.password)存储到Vault中,而不是存储到Git里面,就可以通过vault命令完成:

1
$ vault write secret/application spring.data.mongodb.password=s3cr3t

图14.6拆分了vault write命令,阐述每个组成部分在将私密信息写入Vault的过程中扮演了什么角色。

image-20211021180046275

图14.6 通过vault命令将私密信息写入Vault

现在,我们最需要关注的就是私密信息的路径、key和值。私密信息的路径就像文件系统中的路径那样,允许我们将相关的私密信息放到一个给定的路径中,而将其他的私密信息放到不同的路径中。路径的前缀“secret/”用来识别Vault后端,在这里使用了一个key-value的后端,名为“secret”。

私密信息的key和值是我们实际要写入Vault的内容。当Config Server要对外提供已写入的私密信息时,很重要的一点在于私密信息的key要和配置属性保持一致。

我们可以使用vault read命令校验私密信息是否已经写入Vault中:

1
2
3
4
5
$ vault read secret/application
Key Value
--- -----
refresh_interval 768h
spring.data.mongodb.password s3cr3t

在将私密信息写入到指定路径的时候,需要注意每次往给定路径中写入时都会覆盖之前在该路径下写入的私密信息。例如,假设我们还想要往Vault的上述路径中写入MongoDB用户名,我们不能简单地写入spring.data.mongodb.usernamesecret私密信息本身,如果这样做就会导致spring.data.mongodb.password私密信息丢失。我们需要同时将这两个属性写进去:

1
2
3
% vault write secret/application \
spring.data.mongodb.password=s3cr3t \
spring.data.mongodb.username=tacocloud

现在,我们已经往Vault中写入了一些私密信息。接下来,我们看一下如何让Vault作为Config Server的后端属性源。

在Config Server中启用Vault后端

为了将Vault添加为Config Server的后端,我们至少需要将Vault添加为激活的profile。在Config Server的application.yml文件中,将会如下所示:

1
2
3
4
5
spring:
profiles:
active:
- vault
- git

如上所示,vault和git profile均处于激活状态,允许Config Server同时从Vault和Git获取配置。一般而言,我们会将敏感的配置属性写入Vault,对于不需要私密性的属性则继续使用Git作为后端。如果你希望将所有配置都写到Vault中或者没有必要使用Git后端,那么可以将spring.profiles.active设置为vault,完全放弃Git后端。

默认情况下,Config Server会假定Vault运行在localhost并监听8200端口。但是,我们可以在Config Server的配置中修改这种默认行为,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
config:
server:
git:
uri: http://localhost:10080/tacocloud/tacocloud-config
order: 2
vault:
host: vault.tacocloud.com
port: 8200
scheme: https
order: 1

Config Server对Vault的默认假定都可以通过spring.cloud.config.server.vault.*相关的属性重写。在这里,我们告诉Config Server,Vault的API可以通过https://vault.tacocloud.com:8200 来访问。

注意,我们保留了Git配置,假定Vault和Git分担了提供配置相关的职责。order属性表明Vault提供的私密属性要优先于Git提供的属性。

在配置完Config Server使用Vault作为后端之后,我们可以使用curl命令伪装成一个客户端尝试一下:

1
2
3
4
5
6
7
8
[habuma:habuma]% curl localhost:8888/application/default | jq
{
"timestamp": "2018-04-29T23:33:22.275+0000",
"status": 400,
"error": "Bad Request",
"message": "Missing required header: X-Config-Token",
"path": "/application/default"
}

噢,不!似乎出现问题了。实际上,这个错误表明Config Server提供来自Vault的私密信息,但是请求中没有包含Vault token。

很重要的一点需要注意,对Vault的所有请求都要包含一个X-Vault-Token头信息。我们不会在Config Server本身中配置这个token,而是让每个Config Server客户端在向Config Server发送请求的时候在请求中包含X-Config-Token头信息。Config Server会接收到X-Config-Token头信息,然后将其转换成发送给Vault的X-Vault-Token头信息。

我们可以看到,因为在请求中缺少这个token,所以Config Server拒绝提供任何属性,甚至连Git中的属性都不可用了,因为在暴露私密的信息之前需要一个token。这是组合使用Vault和Git的一个有趣的副作用,除非提供一个合法的token,否则连Git属性都会被Config Server间接隐藏。

我们可以再尝试一下,在请求中添加一个X-Config-Token头信息:

1
2
$ curl localhost:8888/application/default
-H"X-Config-Token: roottoken" | jq

请求中的这个X-Config-Token头信息应该会产生更好的结果,响应中将会包含我们写入到Vault中的私密信息。这里给出的token是在我们以开发模式启动Vault的时候设置的根token,但实际上Vault服务器创建的所有合法、未过期且具有访问Vault私密后端的token都是可以的。

在Config Server客户端设置Vault token

显然,在每个微服务中,我们不能使用curl来指定消费Config Server属性的token。相反,我们应该在服务应用的本地配置中添加一点配置信息:

1
2
3
4
spring:
cloud:
config:
token: roottoken

spring.cloud.config.token属性会告诉Config Server客户端在每次向ConfigServer发送请求的时候都要带上给定的token。这个属性必须设置到应用的本地配置中(而不能存放到Config Server的Git或Vault中),Config Server才能够将其传递到Vault上,从而访问私密属性。

写入特定应用和特定profile的私密信息

在为Config Server提供服务的时候,写入application路径的属性适用于所有的应用,不管它们的名字是什么。如果我们想要写入针对给定应用的私密属性,就需要将路径中的application部分改成应用的名称。例如,如下的vault write命令会为名为ingredient-service的应用(通过其spring.application.name属性指定)写入专有的私密信息:

1
2
$ vault write secret/ingredient-service \
spring.data.mongodb.password=s3cr3t

类似的,如果我们不指定profile,写入Vault的私密信息就会成为默认profile属性的一部分。也就是说,不管哪个profile处于激活状态,客户端都能收到这些私密信息。我们可能想要将私密信息写入到特定的profile中,如下所示:

1
2
3
% vault write secret/application,production \
spring.data.mongodb.password=s3cr3t \
spring.data.mongodb.username=tacocloud

这种方式写入的私密信息只对激活profile为production的应用有效。