微服务在弹性云实现无损发布实践

博客 动态
0 310
羽尘
羽尘 2022-01-24 17:55:59
悬赏:0 积分 收藏

微服务在弹性云实现无损发布实践

1. 背景

原来的系统是个单体服务,导致逻辑越来越复杂,牵一发而动全身。为了提高系统的可扩展性,我们把原来的单体系统,按照功能拆分成不同的微服务。

spring-cloud

2. 弹性云配置

我们所有的微服务都是部署在弹性云上的,希望在部署服务时能够做到无损发布。要做到这一点,以下几个步骤是需要实现的:

  1. 容器销毁之前服务进程能够主动从eureka注册中心列表中删除;
  2. 在eureka注册中心列表删除实例后,该实例在一定的时间内还要能够承接一些流量,因为此时其他eureka客户端还有该实例的缓存;
  3. 最后等待其他线程全部处理完成后,再销毁容器。

下面看下如何实现上面的需求。

2.1 eureka主动下线方式

有以下几种eureka注册中心服务下线的方式:

  1. 直接kill服务

    这种方式简单粗暴,但是在这种情况下,虽然客户端已经停止服务了,但是仍然存在于注册中心列表中,会造成部分模块调用时出错,所以这个方案pass。

  2. 向Eureka service发送delete请求

    http://{eureka-server:port}/eureka/apps/{application.name}/{instance.name}

    这种方案只是取消注册服务,但是当eureka服务再一次接收到心跳请求时,会重新把这个实例注册到eureka上,所以这个方案也pass了。

  3. 客户端通知Eureka service下线

    DiscoveryManager.getInstance().shutdownComponent();

    eureka客户端可以通过上面一行代码主动通知注册中心下线,下线后也不会再注册到eureka上,这个方案符合我们的要求,但是我们需要确认这行代码需要在什么时候被调用?

2.2 下线时机

在这里我们首先需要确定从eureka注册中心删除实例的时机,有以下几种想法:

1. 自定义controller接口

@GetMapping("/shutdown")public void shutdown() {  DiscoveryManager.getInstance().shutdownComponent();}

在容器部署之前,先调用此接口下线,然后再执行部署操作。但是这样做有很大的弊端:1. 该接口不能暴露出去,同时为了避免其他人恶意调用,还需要加一些鉴权操作;2. 无法集成到部署脚本中,因为和弹性云团队的同学了解到,容器销毁前并不会执行control.sh里的stop方法,而是发送一个SIGTERM信号,所以没办法将该接口调用写到部署脚本中。因此如果采用这种方式,只能每个容器上线前手动调用该接口,风险太大,因为此方案不合适。

2. 自定义Shutdown Hook

Runtime.getRuntime().addShutdownHook(new Thread(() -> {  // 从eureka注册列表中删除实例  DiscoveryManager.getInstance().shutdownComponent();  // 休眠120S  try {  	Thread.sleep(120 * 1000);  } catch (Exception ignore) {  }}));

JVM在接收到系统的SIGTERM信号后,会调用Shutdown Hook里的方法,这样注册一个这样的Shutdown Hook是不是就可以了呢?

经过测试发现并不完美,虽然下线时能够及时通知eureka服务下线改服务,但是同时Tomcat也会拒绝接收接下来的请求,druid线程池也会close;这样其他微服务由于缓存了改实例,还会有请求打到这个实例上,导致请求报错。

3. Spring Shutdown Hook

是什么原因导致上述情况的呢?翻阅Spring源码可以发现,SpringBoot在服务启动过程中,会自动注册一个Shutdown Hook,源码如下:

// org.springframework.boot.SpringApplication#refreshContextprivate void refreshContext(ConfigurableApplicationContext context) {  this.refresh((ApplicationContext)context);  if (this.registerShutdownHook) {    try {      // 注册shutdownHook      context.registerShutdownHook();    } catch (AccessControlException var3) {    }  }}

SpringBoot在启动过程中,刷新Context之后,如果没有手动关闭registerShutdownHook(默认开启),则会注册一个Shutdown Hook。

// org.springframework.context.support.AbstractApplicationContext#registerShutdownHook@Overridepublic void registerShutdownHook() {  if (this.shutdownHook == null) {    // No shutdown hook registered yet.    this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {      @Override      public void run() {        synchronized (startupShutdownMonitor) {          // shutdownHook真正需要执行的逻辑          doClose();        }      }    };    // 注册shutdownHook    Runtime.getRuntime().addShutdownHook(this.shutdownHook);  }}

Spring Shutdown Hook的具体执行逻辑,我们稍后分析;现在来看下如果JVM注册了多个Shutdown Hook,那么它们的执行顺序是怎么样的?

// java.lang.Runtime#addShutdownHookpublic void addShutdownHook(Thread hook) {  SecurityManager sm = System.getSecurityManager();  if (sm != null) {    sm.checkPermission(new RuntimePermission("shutdownHooks"));  }  ApplicationShutdownHooks.add(hook);}
// java.lang.ApplicationShutdownHooks/* The set of registered hooks */private static IdentityHashMap<Thread, Thread> hooks;static synchronized void add(Thread hook) {  if(hooks == null)    throw new IllegalStateException("Shutdown in progress");  if (hook.isAlive())    throw new IllegalArgumentException("Hook already running");  if (hooks.containsKey(hook))    throw new IllegalArgumentException("Hook previously registered");  hooks.put(hook, hook);}

可以看到,当我们添加一个Shutdown Hook时,会调用ApplicationShutdownHooks.add(hook),向ApplicationShutdownHooks类下的静态变量private static IdentityHashMap<Thread, Thread> hooks里添加一个hook,hook本身是一个thread对象。

// java.lang.ApplicationShutdownHooks#runHooks/* Iterates over all application hooks creating a new thread for each * to run in. Hooks are run concurrently and this method waits for * them to finish. */static void runHooks() {  Collection<Thread> threads;  synchronized(ApplicationShutdownHooks.class) {    threads = hooks.keySet();    hooks = null;  }  for (Thread hook : threads) {    hook.start();  }  for (Thread hook : threads) {    while (true) {      try {        hook.join();        break;      } catch (InterruptedException ignored) {      }    }  }}

上述源码是应用级hooks的执行逻辑,hook执行时调用的是tread类的start方法,所以多个hook是异步执行的,但是会等到所有hook全部执行完才会退出。

到这里,我们就可以确定方案2有问题的原因:虽然我们在自定义Shutdown Hook里自作聪明的sleep 120s,但是由于它和Spring Shutdown Hook执行并不是同步的,所以在自定义hook的睡眠过程中,spring同时也在做一些收尾工作,导致此时打到改实例上的请求报错。

既然自定义Shutdown Hook的方案行不通,那么是不是可以在Spring Shutdown Hook这里搞一些操作呢?接下来看下Spring Shutdown Hook的具体实现逻辑:

// org.springframework.context.support.AbstractApplicationContext#doCloseprotected void doClose() {  if (this.active.get() && this.closed.compareAndSet(false, true)) {        LiveBeansView.unregisterApplicationContext(this);    // 1. Publish shutdown event.     publishEvent(new ContextClosedEvent(this));    // 2. Stop all Lifecycle beans, to avoid delays during individual destruction.    if (this.lifecycleProcessor != null) {      this.lifecycleProcessor.onClose();    }    // 3. Destroy all cached singletons in the context's BeanFactory.    destroyBeans();    // 4. Close the state of this context itself.    closeBeanFactory();    // 5. Let subclasses do some final clean-up if they wish...    onClose();    // 6. Reset local application listeners to pre-refresh state.    if (this.earlyApplicationListeners != null) {      this.applicationListeners.clear();      this.applicationListeners.addAll(this.earlyApplicationListeners);    }    this.active.set(false);  }}

上面源码只保留了关键代码,可以看到,Spring Shutdown Hook一共做了这些事情:

  1. 发布Context Close事件,可以让监听此事件的listener在应用关闭前执行一些自定义逻辑;
  2. 执行lifecycleProcessor的onClose方法;
  3. 销毁Context BeanFactory中所有缓存的单例;
  4. 关闭当前上下文的状态;
  5. 子类可以自己实现OnClose方法,做一些各自的清理工作;
  6. 将本地应用监听者重置为pre-refresh状态;

既然Spring Shutdown Hook执行逻辑的第一步是发布Context Close事件,那我们就可以创建一个listener监听此事件,然后在监听回调里执行从eureka注册列表中删除实例的逻辑。实现如下:

@Componentpublic class EurekaShutdownConfig implements ApplicationListener<ContextClosedEvent>, PriorityOrdered {    private static final Logger log = LoggerFactory.getLogger(EurekaShutdownConfig.class);  	@Override    public void onApplicationEvent(ContextClosedEvent event) {        try {            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline begin!"));            DiscoveryManager.getInstance().shutdownComponent();            log.info(LogUtil.logMsg("_shutdown", "msg", "eureka instance offline end!"));            log.info(LogUtil.logMsg("_shutdown", "msg", "start sleep 120S for cache!"));            Thread.sleep(120 * 1000);            log.info(LogUtil.logMsg("_shutdown", "msg", "stop sleep 120S for cache!"));        } catch (Throwable ignore) {        }    }  	@Override    public int getOrder() {        return 0;    }}

至此主动从eureka注册中心删除实例的时机就已经确定了。

2.3 其他配置

application.yml

server:	# 优雅关机策略	shutdown: graceful	# 其他配置	...

tomcat执行优雅关机的时机是在lifecycleProcessor.onClose(),在这里不详细展开说明了,可自行翻阅源码。

自定义线程池

@Configurationpublic class MyThreadTaskExecutor {    @Bean    public Executor taskExecutor() {        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();        // 线程池参数        taskExecutor.setCorePoolSize(8);        taskExecutor.setMaxPoolSize(32);        taskExecutor.setQueueCapacity(9999);        taskExecutor.setKeepAliveSeconds(60);        taskExecutor.setThreadNamePrefix("async-");        taskExecutor.setTaskDecorator(new TraceIdTaskDecorator());        // 服务停用前等待异步线程执行完成        taskExecutor.setWaitForTasksToCompleteOnShutdown(true);        // 60S后强制关闭        taskExecutor.setAwaitTerminationSeconds(60);        taskExecutor.initialize();        return taskExecutor;    }}

自定义线程池和数据库连接池的关闭是在销毁bean时执行的。

3. 总结

至此,我们可以总结下当服务接收到SIGTERM信号后的处理逻辑:

优雅关闭

如有谬误,欢迎指正。

posted @ 2022-01-24 17:46 醒也无聊 阅读(0) 评论(0) 编辑 收藏 举报
回帖
    羽尘

    羽尘 (王者 段位)

    2335 积分 (2)粉丝 (11)源码

     

    温馨提示

    亦奇源码

    最新会员