Spring的优雅停机

优雅停机是指在停止应用时,执行的一系列保证应用正常关闭的操作。这些操作往往包括等待已有请求执行完成、关闭线程、关闭连接和释放资源等,优雅停机可以避免非正常关闭程序可能造成数据异常或丢失,应用异常等问题。优雅停机本质上是JVM即将关闭前执行的一些额外的处理代码。

一般来说,优雅停机主要处理

  • 池化资源的释放:数据库连接池,HTTP 连接池
  • 在处理线程的释放:已经被连接的HTTP请求
  • 隐形受影响的资源的处理:Eureka实例下线等

JVM 中的实现

编程语言都会提供监听当前线程终结的函数,比如在Java中,我们可以通过如下操作监听我们的退出事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {

public static void main(String[] args) {

Runtime.getRuntime().addShutdownHook(new ExitHook());

System.out.println("Do something, will exit");
}
}

class ExitHook extends Thread{
@Override
public void run() {
System.out.println("exiting. clear resources...");
}
}

我们会得到如下的结果:

1
2
Do something, will exit
exiting. clear resources...

聪明的你一定发现了,我们可以在 ExitHook 去处理那些资源的释放。那么,在实际应用中是如何体现优雅停机呢?

1
kill -15 pid 

通过该命令发送一个关闭信号给到jvm, 然后就开始执行 Shutdown Hook 了,但是值得注意的是不能够使用

1
kill -9 pid

如果这么干的话,相当于从OS方面直接将其所有的资源回收,类比一下好比强行断电,就没有任何进行优雅停机的机会了。不过这里是最简单的实现,在实际的工作中,我们会遇见各种情况:在清理退出的时候出现异常怎么处理?清理的时间过程怎么处理?等等问题。因此在Spring中,为了简化这样的操作已经帮助我们封装了一些。

Spring 的模式

让我们想想,Spring一个IOC容器,他能够管理的是收到其托管的对象,因此我们也可以很合理的想到我们需要定义托管对象的解构函数才能够被Spring在退出时释放,我们将问题简单化点,有三个事情是Spring需要解决的:

  • Spring 需要值得 Runtime 在退出
  • Spring 知道需要释放哪些资源
  • Spring 需要知道如何释放资源

因为 Spring 版本繁杂,以 org.springframework.boot:2.1.14.RELEASE 版本分析为例。

Spring 的 Runtime Hook

毕竟 Spring 也是 JVM 上的实现,这一切势必也依赖于 JVM 的 Shuthook,秘密就在 org.springframework.context.support.AbstractApplicationContext#registerShutdownHook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void registerShutdownHook() {
if (this.shutdownHook == null) {
// No shutdown hook registered yet.
this.shutdownHook = new Thread() {
@Override
public void run() {
synchronized (startupShutdownMonitor) {
doClose();
}
}
};
Runtime.getRuntime().addShutdownHook(this.shutdownHook);
}
}

Spring 知道需要释放哪些资源

回忆一下 Spring Bean Lifecycle
t1Pm8S.png

没错,当 Spring Context 销毁的时候,会调用 destroy() 函数,实际上这行函数也非常的简单也就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// org.springframework.context.support.AbstractApplicationContext#destroy
@Deprecated //Spring 5 即将废弃
public void destroy() {
close();
}

@Override
public void close() {
synchronized (this.startupShutdownMonitor) {
doClose();
// If we registered a JVM shutdown hook, we don't need it anymore now:
// We've already explicitly closed the context.
if (this.shutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
}
catch (IllegalStateException ex) {
// ignore - VM is already shutting down
}
}
}
}

实则我们定位到最终的释放资源处就是 org.springframework.context.support.AbstractApplicationContext#doClose 函数,我们在尽情的分析一下。

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
protected void doClose() {
LiveBeansView.unregisterApplicationContext(this);

try {
// Publish shutdown event.
publishEvent(new ContextClosedEvent(this)); ➀
}
// Stop all Lifecycle beans, to avoid delays during individual destruction.
if (this.lifecycleProcessor != null) {
try {
this.lifecycleProcessor.onClose(); ➁
}
catch (Throwable ex) {
logger.warn("Exception thrown from LifecycleProcessor on context close", ex);
}
}

// Destroy all cached singletons in the context's BeanFactory.
destroyBeans(); ➂

// Close the state of this context itself.
closeBeanFactory(); ➃

// Let subclasses do some final clean-up if they wish...
onClose(); ➄

// Reset local application listeners to pre-refresh state.
if (this.earlyApplicationListeners != null) {
this.applicationListeners.clear();
this.applicationListeners.addAll(this.earlyApplicationListeners);
}

// Switch to inactive.
this.active.set(false);

}

剪去那些无影响的代码部分,我们可以发现对于 Spring 来说,真正关闭的顺序是

  1. 发布一个关闭事件
  2. 调用生命周期处理器
  3. 销毁所有的Bean
  4. 关闭Bean工厂
  5. 调用子类的Close函数

对于 ➀➁ 是回调机制,不涉及到对象的销毁,➄ 是对于继承类的调用,所有的销毁都在 ➂ 中。受到 Spring 托管的对象繁多,不一定所有的对象都需要销毁行为。进一步定位一下,我们就发现了

1
2
3
4
5
6
7
8
9
public void destroySingleton(String beanName) {
this.removeSingleton(beanName);
DisposableBean disposableBean;
synchronized(this.disposableBeans) {
disposableBean = (DisposableBean)this.disposableBeans.remove(beanName);
}

this.destroyBean(beanName, disposableBean);
}

实际上那些需要销毁的对象都应该是 DisposableBean 对象。

那我们对于第二个问题也知道了,Spring会销毁那些 DisposableBean 类型的 Bean对象。

Spring 需要知道如何释放资源

其实这是一个不是问题的问题,对于 DisposableBean 来说仅仅需要实现一个接口

1
2
3
public interface DisposableBean {
void destroy() throws Exception;
}

对于需要实现释放资源的对象需要自行实现此接口。

组合在一起

我们已经知道我们最开始提出的 3 个问题,让我们试着用这3个问题的答案拼凑处一个 Spring Web Server 是如何优雅的停机的。那我们需要证实一件事情: Web Server内部的资源都是 DisposableBean,并且受 Spring 托管。

通过反向定位的办法,可以快速的定位到比如 数据库的资源 org.springframework.orm.jpa.AbstractEntityManagerFactoryBean#destroy 在销毁的阶段会将 Entity 对象进行销毁。对于收到 Spring 托管的对象的优雅停机的路径是:

Runtine Shutdown Hook -> Context:destory() -> DisposableBean:destroy(), 对于大部分的资源比如数据库,服务发现,等等都是这样的销毁方式。

进阶: Web 容器

一个疑问

对于普通的 Bean 的销毁我们已经完全了解,但是对于动手做实验的不知道有没有发现,其实 Web Server 并不是在 destroySingleton 阶段进行销毁的,那他是在哪里销毁的呢?回忆一下,我们除了销毁Beans 之外,是不是还有最后一个 close() 函数可以调用,没有错!对于 Tomcat ... 这些web容器来说,本身就是 ApplicationContext 的一个子类并非是 Bean 一部分,因此他们的 close() 函数在 org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext

1
2
3
4
protected void onClose() {
super.onClose();
this.stopAndReleaseWebServer();
}

这里的函数是直接调用底层代码就不做展开,但是隐约有些觉得不对? 如果我们在接收到一个远程访问的时候,触发了 Shutdown 事件,我们实际上是无法获得正确的返回的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
+------------------+          +---------------------------+         +--------------------------------------+
| controller | | services | | dao |
| +--------->+ +---------> |
| | | | 2. Query DB Entitymanger
+------------------+ +---------------------------+ +---------------------------------^----+
|
|
|
+--------------------------------------+ |1. destory()
| | |
| Destory +-----------------------------+
| |
+--------------------------------------+

如上图所示,因为我们知道 stop webserver 是在 destory context 之后的,那我们岂不是会出现一边在接受请求但是这些请求都是会失败的吗?如果是是这样的,可真是太愚蠢的设计了。

另辟蹊径

对于这样的情况,我们在 shutdown 之前让 Web server 停止接受任何的请求,但可惜的是在此版本的 tomcat 不支持此特效,需要待 9.0.33+ spring-boot-2-3-0-available-now

在早期的版本中,我们依然可以通过一些额外的方式将这件事情做到:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@SpringBootApplication
@RestController
public class Gh4657Application {

public static void main(String[] args) {
SpringApplication.run(Gh4657Application.class, args);
}

@RequestMapping("/pause")
public String pause() throws InterruptedException {
Thread.sleep(10000);
return "Pause complete";
}

@Bean
public GracefulShutdown gracefulShutdown() {
return new GracefulShutdown();
}

@Bean
public EmbeddedServletContainerCustomizer tomcatCustomizer() {
return new EmbeddedServletContainerCustomizer() {

@Override
public void customize(ConfigurableEmbeddedServletContainer container) {
if (container instanceof TomcatEmbeddedServletContainerFactory) {
((TomcatEmbeddedServletContainerFactory) container)
.addConnectorCustomizers(gracefulShutdown());
}

}
};
}

private static class GracefulShutdown implements TomcatConnectorCustomizer,
ApplicationListener<ContextClosedEvent> {

private static final Logger log = LoggerFactory.getLogger(GracefulShutdown.class);

private volatile Connector connector;

@Override
public void customize(Connector connector) {
this.connector = connector;
}

@Override
public void onApplicationEvent(ContextClosedEvent event) {
this.connector.pause();
Executor executor = this.connector.getProtocolHandler().getExecutor();
if (executor instanceof ThreadPoolExecutor) {
try {
ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
threadPoolExecutor.shutdown();
if (!threadPoolExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
log.warn("Tomcat thread pool did not shut down gracefully within "
+ "30 seconds. Proceeding with forceful shutdown");
}
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}

}

}

我们可以自定义自己的 ContextClosedEvent 事件,然后将 Tomcat 的处理线程先暂停。

又一个疑问?

Spring-Boot-2.3.0 之前我们都需要这么处理吗?我的天,很多写了 Spring 已经超过十年了,难道 Spring 一直没有解决这个问题吗?答案也是否定的。

还记得我们在 Spring 的早期阶段,我们通过 XML 来构造一个Spring项目的时候吗?那时候的方案是将我们的Web 程序作为一个 War 提供给 Tomcat 的 webapps中,此时tomcat会尝试构造我们的 DispatcherServlet 将整个系统运作起来,而在 Shutdown 阶段,这样的逻辑也是由 Tomcat 进行处理的。也就是说,对于 Embeded Web ServerSpring Boot 和 传统的 Web ServerDestory Spring Applicaion Context 这一步的时间是不一样的,Spring Boot with Embeded Web Server2.3.0 之前的版本都是先关闭 Context 上下文再关闭 Web容器,而传统的 Web Server 是先关闭 Web容器 再去关闭 Context 上下文。

对于 传统的 Web Server:

Runtime Shutdwon Hook -> org.apache.catalina.util.LifecycleBase#stop -> Spring Context Stop

因此出现优雅停机问题的集中在 Spring Boot < 2.3.0 的版本内。

优雅停机 in action

传统的 Tomcat 容器

我们执行 catalina.sh stop <WAITING SECONDS> 就可以执行 Tomcat 的优雅停机

Spring Boot > 2.3.0:内置容器

默认完成了优雅停机

Spring Boot 内置容器

Way 1: 自己实现优雅停机

请查阅参考部分,或者是上文处代码自行实现

Way 2:基于平台实现

现在的很多应用都跑在 kubernetes 这样的容器平台上,此时我们 POD 在进行 terminated 操作的时候,会首先向运行的进程发送一个 SIGTERM 指令,然后等待 30秒, 在30后没有终结的话,会再次发送一个 SIGKILL 进行强制终结,因此对于容器平台,我们要注意的这一个等待时间是否足够进行资源的回收。

但是我们还有一个问题,就是正在请求的流量问题,对于这个问题我们需要进行组合拳,还记得 kubernetes 中有 readinessProbe 的概念吗?当我们的Pod启动的时候,如果 Readiness 未就绪, kubernetes 也不会将我们的 POD 作为 SVC 的可选地址(采用SVC负债均衡的情况下)。因此我们的应用在接受到 SIGTERM 的第一时刻就将
Readiness 的地址进行失败行为,并且等待一定时间之后再进行 Spring Context Shutdown 操作。但是对于超长时间的请求依然会有失败的可能,不过对于大部分的应用来说,优雅停机本身也只是等待固定时间,因此对于超长持续的请求让其失败也是可选的方案。

Spring Cloud 下的复杂场景

我们来看下更加复杂的 Spring Cloud 下的场景,对于 Spring Cloud 体系内有一个特殊的点: Eureka ServerEureka Client 的关系,我们来看下当接入时候的 destory() 行为。

Eureka 主动下线

Context:destory() 的时候,Spring会关闭一个名为 org.springframework.cloud.netflix.eureka.serviceregistry.EurekaServiceRegistry 对象,此对象在 org.springframework.cloud.netflix.eureka.EurekaClientAutoConfiguration#eurekaAutoServiceRegistration 进行初始化的。因此在 destory() 阶段,实例就会自动从 Eureka 下线。

t1OxRs.png

Eureka Client 侧

因为 Eureka 的服务发现机制并没有通知客户端更新的机制,因此,此时的 Eureka Client 侧的实例依然会去尝试访问已经下线的 Instance,这是由于Eureka是AP系统导致的缺陷并没有解决之道。

此时当其他的服务访问到此服务的此实例的时候,会导致服务访问错误。为了避免这样的错误发生,一般来说有几种解决之道:1.我们可以为客户端制定重试机制(参考 How To Customize Feign’s Retry Mechanism), 2. 手动提前离线:我们使用 Spring Boot Admin 手动在停机之前让实例先下线,这样等到一段时间之后我们就可以将实例进行真正的销毁,Spring Boot Admin,3. 不再使用 Eureka 这样的客户端负债均衡策略,使用类似 K8s 的 SVC 机制可以将这个时间缩短。

参考