URL
date
slug
status
tags
summary
type

优雅停机之Spring Cloud Gateway

引言

我们的网关是基于Spring Cloud Gateway做的二次开发,采用ZooKeeper来做注册中心(spring-cloud-discovery-zookeeper)。用ZooKeeper做注册中心的主要考虑是实际使用情况和成本,可以和dubbo的注册中心复用的一套集群。
本篇文章使用的Spring Cloud Gateway版本是2.2.6-RELEASE
和dubbo服务一样,Spring Cloud Gateway也需要考虑优雅停机。这里有2个优雅停机的对象:
  1. Spring Cloud Gateway
  1. upstream
本篇文章我们主要讨论upstream的优雅停机。涉及到服务发现的优雅停机的顺序一般为:
  1. 从注册中心下线
  1. 关闭服务端口
  1. 其他bean销毁、回收等动作

线上问题

我们来看看没做优雅停机的一个线上upstream应用发布时的报错:
notion image

增加一次重试?

我们首次解决这个问题的思路并不是优雅停机,而是想:线上环境至少两个实例,是不是可以通过一次重试,来优雅的解决这个问题。不过尝试了一下发现问题没这么简单,因为Spring Cloud Gateway的重试机制默认没有“记忆”,也就是它并不会记住上一次调用报错的实例信息,下一次调用还是有一定的概率调用到正在重启的实例,并且不管你增加多少次重试都无法彻底解决这个问题。

upstream停机时优先下线

这里猜测报错的原因肯定是先upstream把端口关了,然后再从注册中心下线的。我们来找找相关的代码:
notion image
notion image
可以看到,ZooKeeperAutoServiceRegistration是有考虑优雅停机的。只不过将自身从注册中心下线的逻辑走了普通Bean的Destory流程(也就是上图里@PreDestory),太靠后了。所以造成了web端口已经关闭,但是注册中心还未下线的情况。
改法也很简单,我们只需要让服务下线逻辑在停机的时候以最高优先级执行。根据之前文章分析的关于Spring停机步骤,直接监听ContextClosedEvent事件,并且将order设为高优先级即可。
public class ZookeeperAutoServiceRegistrationListener implements ApplicationListener<ContextClosedEvent>, Ordered { @Resource private ZookeeperAutoServiceRegistration zookeeperAutoServiceRegistration; @Override public void onApplicationEvent(ContextClosedEvent event) { zookeeperAutoServiceRegistration.stop(); log.info("stop zookeeperAutoServiceRegistration first"); } @Override public int getOrder() { return PriorityOrdered.HIGHEST_PRECEDENCE + 500; } }
虽然上述的改法会导致destory()方法在停机时执行两次,但是stop()方法里面有幂等处理,在原来@PreDestory阶段调用的时候依然不影响,只是我们把stop的流程提前了而已
// org.springframework.cloud.client.serviceregistry.AbstractAutoServiceRegistration#stop public void stop() { if (this.getRunning().compareAndSet(true, false) && isEnabled()) { deregister(); if (shouldRegisterManagement()) { deregisterManagement(); } this.serviceRegistry.close(); } }
我们验证了改完之后的停机流程是先从注册中心下线,再关闭web端口,但是报错仍旧出现了。

服务列表缓存

原来,Spring Cloud Gateway的服务列表并不是实时从注册中心读取的。上面的改动只是确保注册中心里对应的实例被下线了,但是缓存在Spring Cloud Gateway本地的服务列表里依然还有那个已经被下线的实例。
所以,Spring Cloud Gateway需要监听对应ZooKeeper节点的变化,失效掉本地缓存。
public class CustomerZookeeperServiceWatch implements ApplicationListener<InstanceRegisteredEvent<?>>, TreeCacheListener { @Resource private CuratorFramework curator; @Resource private ZookeeperDiscoveryProperties properties; @Resource private ApplicationContext context; private LoadBalancerCacheManager cacheManager; private TreeCache cache; public TreeCache getCache() { return this.cache; } @Override public void onApplicationEvent(InstanceRegisteredEvent<?> event) { log.info("zookeeper:{}", curator.getZookeeperClient().getCurrentConnectionString()); ObjectProvider<LoadBalancerCacheManager> cacheManagerProvider = this.context .getBeanProvider(LoadBalancerCacheManager.class); if (cacheManagerProvider.getIfAvailable() != null) { this.cacheManager = cacheManagerProvider.getIfAvailable(); } this.cache = TreeCache.newBuilder(this.curator, this.properties.getRoot()) .build(); this.cache.getListenable().addListener(this); try { this.cache.start(); } catch (Exception e) { ReflectionUtils.rethrowRuntimeException(e); } } @PreDestroy public void stop() throws Exception { if (this.cache != null) { this.cache.close(); } } @Override public void childEvent(CuratorFramework client, TreeCacheEvent event) { if (event.getType().equals(TreeCacheEvent.Type.NODE_ADDED) || event.getType().equals(TreeCacheEvent.Type.NODE_REMOVED) || event.getType().equals(TreeCacheEvent.Type.NODE_UPDATED)) { String[] split = event.getData().getPath().split("/"); if (split.length > 2 && "services".equals(split[1])) { String serviceName = split[2]; Cache cache = this.cacheManager.getCache(SERVICE_INSTANCE_CACHE_NAME); boolean result = cache.evictIfPresent(serviceName); log.info("service:{} changed, result:{}", serviceName, result); } } } }
至此,upstream在发布时就没有再出现过上述的报错了。问题总算得到了解决。

思考

不过仔细思考一下,这个方案还是存在一些问题的。当注册中心的推送存在一定的延迟或者网络延迟较大时,虽然此时服务已经从注册中心下线了,但是客户端的缓存还是没有及时更新,可能会影响客户端的调用。
dubbo server端优雅停机时,会等待全部client断开连接再关闭dubbo端口。因为dubbo服务端和客户端是通过tcp长连接通信的,并且维护了活跃通道。如果对于netty和Spring Cloud Gateway的底层机制有一个更好的了解,相信我也可以把它的停机流程改造的更加优雅。
诡异的RocketMQ消费日志分析Docker端口映射的实现机制