文章目录
  1. 1. 问题
  2. 2. 产生原因
  3. 3. 解决方案
    1. 3.1. 缩短缓存刷新时间
    2. 3.2. 方案一:基于部署脚本实现
      1. 3.2.1. 第一步:修改实例状态为 DOWN
      2. 3.2.2. 第二步:等待其他调用者的缓存刷新
      3. 3.2.3. 第三步:实例部署
    3. 3.3. 解决方案二:基于服务自身代码实现

问题

互联网产品高速迭代,通常伴随着高频次的版本发布。部署新版上线需要重启服务,直接 kill 服务进程可能会造成服务短暂不可用,从而影响到正在使用的用户。

Spring Cloud 项目中一般会用到 Ribbon 作为负载均衡,那么是不是只要保证每个服务部署多台服务器,发布时采用 Rolling Update 分批次部署,保证一部分服务器正常提供服务的同时发布另一部分服务器,Ribbon 就能自动切换,保证服务的不间断?然而并不是。

产生原因

所有服务的状态保存在注册中心,即 Eureka Server。一个服务要想获取其他服务的实例列表和状态,需要通过 Eureka Client 定时从 Eureka Server 中获取并缓存下来,默认时间间隔是30秒。Eureka Client 和 Eureka Server 是通过 HTTP 协议通信,请求由 Eureka Client 发起,而不是基于长连接或者 Eureka Server 主动推送,所以无法立即知道其他服务状态变更。

即使同一个服务部署多台机器,每台机器依次发布,当其中一个服务实例重启时,服务调用方是无法第一时间知道的,所以还是会调用到这台暂时无法提供服务的实例上。这样会造成短暂的访问失败,这段时间也会对正在使用产品的用户造成一定的影响。

解决方案

基于以上的原因,在部署应用时应该按照以下步骤进行(为了简单起见,假设一个应用部署两个实例):

  1. 将服务的一个实例在注册中心的状态设置为 DOWN
  2. 等待一段时间,直到其他服务缓存刷新,不再调用到这台服务器上
  3. 停止服务,更新代码,重新启动,等待,直到启动成功

完成后,再重复以上步骤部署另一个实例。

缩短缓存刷新时间

上面第 2 步中,需要等待一段时间,直到不再有新的请求调用到某个实例上。具体要等多久,其他调用者的请求才会不再访问到这台状态为 DOWN 的实例呢?这里涉及到三个配置项:

  • eureka.client.registryFetchIntervalSeconds Eureka 客户端每隔多久去 Eureka 服务器拉取最新的注册信息,默认值 30(秒)。
  • ribbon.ServerListRefreshInterval Ribbon 的缓存刷新间隔时间,默认 30000(毫秒)。Eureka 客户端拉取到最新注册信息后,Ribbon、Feign 等组件不会立即生效,是因为 Ribbon 还有一层缓存。
  • eureka.server.responseCacheUpdateIntervalMs Eureka Server 返回最新的注册信息的接口缓存刷新时间间隔,默认 30000(毫秒)。有时候会看到 Eureka 页面和 /eureka/apps 接口的服务状态不一致,就是因为 /eureka/apps 接口默认会有 30 秒缓存。

在默认情况下,当一个服务状态改为 DOWN,最长可能需要 30+30+30 秒,所有的缓存才会刷新,其他调用者才不会调用到这个状态为 DOWN 的实例。这就意味着修改服务实例状态为 DOWN 后需要等待 90 秒,才能进行下一步操作。

为了让部署时间缩短,可以将以上三个配置项都修改为 5 秒:

Eureka Server:

1
2
3
eureka:
server:
responseCacheUpdateIntervalMs: 5000

Eureka Client(即各个服务):

1
2
3
4
5
ribbon:
ServerListRefreshInterval: 5000
eureka:
client:
registryFetchIntervalSeconds: 5

此时部署时将实例状态设为 DOWN 后,只需要等待 15 秒即可停止进程。

完成以上配置后,下面给出了两种方案实现服务 Zero-downtime 部署:

方案一:基于部署脚本实现

第一步:修改实例状态为 DOWN

有两种方案可以修改实例的状态,选择其一即可:

  1. 直接调用 Eureka Server API 修改:PUT /eureka/apps/{appID}/{instanceID}/status?value=DOWN
  2. 调用服务实例对应的 actuator endpoint:/service-registry

我更偏向使用方法二,对应的命令:

1
curl -H "Content-Type:application/json" -X POST http://{host:port}/actuator/service-registry?status=DOWN

如果 actuator endpoint 加了 Spring Security Basic 认证,则还需要加上用户名和密码:

1
curl -H "Content-Type:application/json" -X POST -u {username}:{password} http://{host:port}/actuator/service-registry?status=DOWN

第二步:等待其他调用者的缓存刷新

这里直接通过 sleep 来等待 15 秒:

1
sleep 15s

第三步:实例部署

这一步主要需要注意:

  • 尽量不要使用 kill -9 pid 强制杀掉进程,而应该使用 kill pid 或者 kill -15 pid 关闭进程。使用 kill pid 或者 kill -15 pid 关闭进程之前,Eureka Client 会给 Eureka Server 请求删除自己,后续服务再次启动后会重新注册为 UP 状态。如果使用 kill -9 pid 强制杀掉进程,Eureka Client 没有办法注销自己,Eureka Server 就不知道该实例已下线,直到长时间收不到心跳才会删除该实例。如果在 Eureka Server 删除实例之前实例启动了,那么它的状态还是会保持 DOWN 状态。如果确实需要用到 kill -9 pid 强制杀掉进程,那么服务重启后需要再通过第一步的方式将实例状态设为 UP。
  • 服务启动后,需要等待并确认启动成功后,才可以开始部署下一台服务器。这里我们可以定时去请求 Spring Boot 提供的 actuator endpoint /health 接口,例如每隔 1 秒请求一次,直到接口可以正常访问,即可认为服务启动成功。

解决方案二:基于服务自身代码实现

上面一种基于部署脚本实现的方案,把设置服务在 Eureka 注册中心的状态为 DOWN 以及等待 15 秒的步骤放到了部署脚本中。这里再提供另一种方案,将这两个步骤放在服务的代码中。

具体的方法是添加一个 EventListener,用于监听 ContextClosedEvent,即 Spring 的 ApplicationContext 关闭的事件,例如使用 kill pid 或者 kill -15 pid 关闭进程就会触发该事件。在监听到事件之后,代码中通过 Thread.sleep(15000) 来实现等待 15 秒。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class EurekaGracefulShutdownListener implements ApplicationListener<ContextClosedEvent> {

@Autowired
private ApplicationContext applicationContext;

@Override
public void onApplicationEvent(ContextClosedEvent event) {
// Spring Cloud 中会有两个 ApplicationContext,这里排除掉 Spring Cloud 创建的 “bootstrap” context,否则此处会触发两次
if (applicationContext == event.getApplicationContext()) {
try {
Thread.sleep(15000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

大家可能会发现以上代码中并没有将该服务在 Eureka 注册中心的状态设置为 DOWN,这是因为 spring-cloud-netflix-eureka-client 组件中已经有一个 EventListener 监听了 ContextClosedEvent 事件,并且在我们的 EventListener 触发之前将状态置为 DOWN,所以我们在上面的代码中就没必要多此一举了。相关源码: EurekaAutoServiceRegistration

添加以上代码后,如果使用 kill pid 或者 kill -15 pid 关闭进程,进程实际上不会立即关闭,而是继续提供服务 15 秒后,进程才会真正被杀死。所以同样不能使用 kill -9 pid 强制杀掉进程。

本文基于 Spring Boot 2.1.x 及 Spring Cloud Greenwich 版本

文章目录
  1. 1. 问题
  2. 2. 产生原因
  3. 3. 解决方案
    1. 3.1. 缩短缓存刷新时间
    2. 3.2. 方案一:基于部署脚本实现
      1. 3.2.1. 第一步:修改实例状态为 DOWN
      2. 3.2.2. 第二步:等待其他调用者的缓存刷新
      3. 3.2.3. 第三步:实例部署
    3. 3.3. 解决方案二:基于服务自身代码实现