【Spring Boot】【优雅停机二】Spring Boot 停机的正确方式
1 前言
我们接着上节来看看,我们都知道 SpinrgBoot背后有我们的Web服务器啊,那它是怎么停的呢?往下看。
2 SpringBoot应用的优雅停机
除了 微服务的无损下线,作为 SpringBoot应用, 还有 单体服务优雅停机的需求:
- 处理没有完成的请求,注意,不再接收新的请求
- 池化资源的释放:数据库连接池,HTTP 连接池
- 处理线程的释放:已经被连接的HTTP请求
这些前面介绍到 ,咱们就先用放在 JVM 处理钩子方法里边去了。
SpringBoot应用的优雅停机,实际上指的是内嵌WEB服务器的优雅停机。
目前Spring Boot已经发展到了2.3.4.RELEASE,伴随着2.3版本的到来,优雅停机机制也更加完善了。
2.1 什么是Web 容器优雅停机行为
Web 容器优雅停机行为指的是在关闭容器时,让当前正在处理的请求处理完成或者等待一段时间,让正在处理的请求完成后再关闭容器,而不是直接强制终止正在处理的请求。这样可以避免正在处理的请求被中断,从而提高系统的可用性和稳定性。
一般来说,Web 容器的优雅停机行为需要满足以下几个条件:
- 等待正在处理的请求完成,不再接受新的请求。
- 如果等待时间超过了一定阈值,容器可以强制关闭。
- 在容器关闭之前,需要给客户端一个响应,告知他们当前正在关闭容器,不再接受新的请求
2.2 优雅停机的目的
如果没有优雅停机,服务器此时直接直接关闭(kill -9),那么就会导致当前正在容器内运行的业务直接失败,在某些特殊的场景下产生脏数据。
2.3 优雅停机具体行为
在服务器执行关闭(kill -2)时,会预留一点时间使容器内部业务线程执行完毕,
增加了优雅停机配置后, 此时容器也不允许新的请求进入。
目前版本的Spring Boot 优雅停机支持Jetty, Reactor Netty, Tomcat和 Undertow 以及反应式和基于 Servlet 的 web 应用程序都支持优雅停机功能。
新请求的处理方式跟web服务器有关,Reactor Netty、 Tomcat将停止接入请求,Undertow的处理方式是返回503.
具体行为,如下表所示:
web 容器名称 |
行为说明 |
tomcat 9.0.33+ |
停止接收请求,客户端新请求等待超时。 |
Reactor Netty |
停止接收请求,客户端新请求等待超时。 |
Undertow |
停止接收请求,客户端新请求直接返回 503。 |
不同的 Web 容器实现优雅停机的方式可能会有所不同,但是一般都会提供相关的配置选项或者 API 接口来实现这个功能。
另外,和SpringBoot内嵌的WEB服务器类似,其他的非SpringBoot内嵌WEB服务器,也可以进行设置。
下面是 Nginx 和 Apache 的优雅停机配置:
- Nginx 可以通过配置文件中的 worker_shutdown_timeout 选项来设置等待时间
- Apache 可以通过 graceful-stop 命令来实现优雅停机。
2.4 优雅停机的使用和配置
新版本配置非常简单,server.shutdown=graceful 就搞定了(注意,优雅停机配置需要配合Tomcat 9.0.33(含)以上版本)
server: port: 6080 shutdown: graceful #开启优雅停机 spring: lifecycle: timeout-per-shutdown-phase: 20s #设置缓冲时间 默认30s
在设置了缓冲参数 timeout-per-shutdown-phase 后,在规定时间内如果线程无法执行完毕则会被强制停机。
下面我们来看下停机时,加了优雅停日志和不加的区别:
//未加优雅停机配置 Disconnected from the target VM, address: '127.0.0.1:49754', transport: 'socket' Process finished with exit code 130 (interrupted by signal 2: SIGINT)
加了优雅停机配置后,日志可明显发现的 Waiting for active requests to cpmplete, 此时容器将在ShutdownHook执行完毕后停止。
2.5 优雅退出原理
前面讲到,SpringBoot 的优雅退出,最终在 Java 程序中可以通过添加钩子,在程序退出时会执行钩子方法,从而实现关闭资源、平滑退出、优雅退出等功能。
SpringBoot 在启动过程中,则会默认注册一个JVM Shutdown Hook,在应用被关闭的时候,会触发钩子调用 doClose()方法,去关闭容器。
那么对于我们的Web服务,则是在创建 webserver 的时候,会创建一个实现smartLifecycle的 bean,用来支撑 server 的优雅关闭。
// 注册webServerGracefulShutdown用来实现server优雅关闭 this.getBeanFactory().registerSingleton("webServerGracefulShutdown", new WebServerGracefulShutdownLifecycle(this.webServer)); }
可以看到 WebServerGracefulShutdownLifecycle 类实现SmartLifecycle接口,重写了 stop 方法,stop 方法会触发 webserver 的优雅关闭方法(取决于具体使用的 webserver 如 tomcatWebServer)。
// 优雅关闭server this.webServer.shutDownGracefully((result) -> callback.run());
@Override public void shutDownGracefully(GracefulShutdownCallback callback) { if (this.gracefulShutdown == null) { // 如果没有开启优雅停机,会立即关闭tomcat服务器 callback.shutdownComplete(GracefulShutdownResult.IMMEDIATE); return; } // 优雅关闭服务器 this.gracefulShutdown.shutDownGracefully(callback); }
至此,优雅退出的代码,通过smartLifecycle的Bean 的stop 方法实现退出的回调注册。
2.6 smartLifecycle回调执行的流程和时机
smartLifecycle的Bean 的stop 方法什么时候被执行呢?
上文提到JVM钩子方法被调用后,会执行 doColse()方法,
而这个 doColse()方法, 在关闭容器之前,会通过 lifecycleProcessor 调用 lifecycle 的方法。
protected void doClose() { if (this.active.get() && this.closed.compareAndSet(false, true)) { LiveBeansView.unregisterApplicationContext(this); // 发布 ContextClosedEvent 事件 publishEvent(new ContextClosedEvent(this)); // 回调所有实现Lifecycle 接口的Bean的stop方法 if (this.lifecycleProcessor != null) { this.lifecycleProcessor.onClose(); } // 销毁bean, 关闭容器 destroyBeans(); closeBeanFactory(); onClose(); if (this.earlyApplicationListeners != null) { this.applicationListeners.clear(); this.applicationListeners.addAll(this.earlyApplicationListeners); } // Switch to inactive. this.active.set(false); } }
关闭 Lifecycle Bean 的入口:
org.springframework.context.support.DefaultLifecycleProcessor
具体的代码如下:
public class DefaultLifecycleProcessor implements LifecycleProcessor, BeanFactoryAware { @Override public void onClose() { stopBeans(); this.running = false; } private void stopBeans() { //获取所有的 Lifecycle bean Map<String, Lifecycle> lifecycleBeans = getLifecycleBeans(); //按Phase值对bean分组, 如果没有实现 Phased 接口则认为 Phase 是 0 Map<Integer, LifecycleGroup> phases = new HashMap<>(); lifecycleBeans.forEach((beanName, bean) -> { int shutdownPhase = getPhase(bean); LifecycleGroup group = phases.get(shutdownPhase); if (group == null) { group = new LifecycleGroup(shutdownPhase, this.timeoutPerShutdownPhase, lifecycleBeans, false); phases.put(shutdownPhase, group); } group.add(beanName, bean); }); if (!phases.isEmpty()) { List<Integer> keys = new ArrayList<>(phases.keySet()); //按照 Phase 值倒序 keys.sort(Collections.reverseOrder()); // Phase值越大优先级越高,先执行 for (Integer key : keys) { phases.get(key).stop(); } } }
DefaultLifecycleProcessor 的 stop 方法执行流程:
- 获取容器中的所有实现了 Lifecycle 接口的 Bean。 由于 smartLifecycle 接口继承了 Lifecycle, 在这里被获取到了。
- 再对包含所有 bean 的 List 分组按 phase 值倒序排序,值大的排前面。 (没有实现 Phased 接口, Phase 默认为0)
- 依次调用各分组的里 bean 的 stop 方法 ( Phase 越大 stop 方法优先执行)
stop 方法的实现,在这里就终于被执行了。
完成 tomcat的优雅退出行为,执行完之前接收到的请求,实现优雅退出。
2.7 SpringBoot 优雅停机的执行流程总结
- SpringBoot 通过 Shutdown Hook 来注册 doclose() 回调方法,在应用关闭的时候触发执行。
- SpringBoot 在创建 webserver的时候,会注册实现 smartLifecycel 接口的 bean,用来优雅关闭 tomcat
- doClose()在销毁 bean, 关闭容器之前会执行所有实现 Lifecycel 接口 bean 的 stop方法,并且会按 Phase 值分组, phase 大的优先执行。
- WebServerGracefulShutdownLifecycle,Phase=Inter.MAX_VALUE,处于最优先执行序列,所以会先触发优雅关闭 tomcat ,并且tomcat 关闭方法是异步执行的,主线会继续调用执行本组其他 bean 的关闭方法,然后等待所有 bean 关闭完毕,超过等待时间,会执行下一组 Lifecycle bean 的关闭。
3 SpringBoot应用的优雅停机如何触发
通过源码分析,大家也发现了,SpringBoot应用的优雅停机,是注册了 JVM 优雅退出的钩子方法
JVM 优雅退出的钩子方法如何触发的呢?
常见的触发方式有:
- 方式一:kill PID
- 方式二:shutdown端点
3.1 方式一:kill PID
使用方式:kill java进程ID
kill命令的格式是 kill -Signal pid,其中 pid 就是进程的编号,signal是发送给进程的信号,默认参数下,kill 发送 SIGTERM(15)信号给进程,告诉进程,你需要被关闭,请自行停止运行并退出。
kill、kill -9、kill -3的区别
kill 会默认传15代表的信号为SIGTERM,这是告诉进程你需要被关闭,请自行停止运行并退出,进程可以清理缓存自行结束,也可以拒绝结束。
kill -9代表的信号是SIGKILL,表示进程被终止,需要立即退出,强制杀死该进程,这个信号不能被捕获也不能被忽略。
kill -3可以打印进程各个线程的堆栈信息,kill -3 pid 后文件的保存路径为:/proc/${pid}/cwd,文件名为:antBuilderOutput.log
其他的Kill信号清单如下:
信号 |
取值 |
默认动作 |
含义(发出信号的原因) |
SIGHUP |
1 |
Term |
终端的挂断或进程死亡 |
SIGINT |
2 |
Term |
来自键盘的中断信号 |
SIGQUIT |
3 |
Core |
来自键盘的离开信号 |
SIGILL |
4 |
Core |
非法指令 |
SIGABRT |
6 |
Core |
来自abort的异常信号 |
SIGFPE |
8 |
Core |
浮点例外 |
SIGKILL |
9 |
Term |
杀死 |
SIGSEGV |
11 |
Core |
段非法错误(内存引用无效) |
SIGPIPE |
13 |
Term |
管道损坏:向一个没有读进程的管道写数据 |
SIGALRM |
14 |
Term |
来自alarm的计时器到时信号 |
SIGTERM |
15 |
Term |
终止 |
SIGUSR1 |
30,10,16 |
Term |
用户自定义信号1 |
SIGUSR2 |
31,12,17 |
Term |
用户自定义信号2 |
SIGCHLD |
20,17,18 |
Ign |
子进程停止或终止 |
SIGCONT |
19,18,25 |
Cont |
如果停止,继续执行 |
SIGSTOP |
17,19,23 |
Stop |
非来自终端的停止信号 |
SIGTSTP |
18,20,24 |
Stop |
来自终端的停止信号 |
SIGTTIN |
21,21,26 |
Stop |
后台进程读终端 |
SIGTTOU |
22,22,27 |
Stop |
后台进程写终端 |
SIGBUS |
10,7,10 |
Core |
总线错误(内存访问错误) |
SIGPOLL |
Term |
Pollable事件发生(Sys V),与SIGIO同义 |
|
SIGPROF |
27,27,29 |
Term |
统计分布图用计时器到时 |
SIGSYS |
12,-,12 |
Core |
非法系统调用(SVr4) |
SIGTRAP |
5 |
Core |
跟踪/断点自陷 |
SIGURG |
16,23,21 |
Ign |
socket紧急信号(4.2BSD) |
SIGVTALRM |
26,26,28 |
Term |
虚拟计时器到时(4.2BSD) |
SIGXCPU |
24,24,30 |
Core |
超过CPU时限(4.2BSD) |
SIGXFSZ |
25,25,31 |
Core |
超过文件长度限制(4.2BSD) |
SIGIOT |
6 |
Core |
IOT自陷,与SIGABRT同义 |
SIGEMT |
7,-,7 |
Term |
|
SIGSTKFLT |
-,16,- |
Term |
协处理器堆栈错误(不使用) |
SIGIO |
23,29,22 |
Term |
描述符上可以进行I/O操作 |
SIGCLD |
-,-,18 |
Ign |
与SIGCHLD同义 |
SIGPWR |
29,30,19 |
Term |
电力故障(System V) |
SIGINFO |
29,-,- |
与SIGPWR同义 |
|
SIGLOST |
-,-,- |
Term |
文件锁丢失 |
SIGWINCH |
28,28,20 |
Ign |
窗口大小改变(4.3BSD, Sun) |
SIGUNUSED |
-,31,- |
Term |
未使用信号(will be SIGSYS) |
3.2 方式二:shutdown端点
Spring Boot 提供了/shutdown端点,可以借助它实现优雅停机。
使用方式:在想下线应用的application.yml中添加如下配置,从而启用并暴露/shutdown端点:
management: endpoint: shutdown: enabled: true endpoints: web: exposure: include: shutdown
发送 POST 请求到/shutdown端点
curl -X http://ip:port/actuator/shutdown
该方式本质和方式一是一样的,也是借助 Spring Boot 应用的 Shutdown hook 去实现的。
shutdown端点的源码分析:actuator 都使用了SPI的扩展方式,先看下AutoConfiguration,可以看到关键点就是ShutdownEndpoint
@Configuration( proxyBeanMethods = false ) @ConditionalOnAvailableEndpoint( endpoint = ShutdownEndpoint.class ) public class ShutdownEndpointAutoConfiguration { public ShutdownEndpointAutoConfiguration() { } @Bean( destroyMethod = "" ) @ConditionalOnMissingBean public ShutdownEndpoint shutdownEndpoint() { return new ShutdownEndpoint(); } }
ShutdownEndpoint,的核心代码如下:
@Endpoint( id = "shutdown", enableByDefault = false ) public class ShutdownEndpoint implements ApplicationContextAware { @WriteOperation public Map<String, String> shutdown() { if (this.context == null) { return NO_CONTEXT_MESSAGE; } else { boolean var6 = false; Map var1; try { var6 = true; var1 = SHUTDOWN_MESSAGE; var6 = false; } finally { if (var6) { Thread thread = new Thread(this::performShutdown); thread.setContextClassLoader(this.getClass().getClassLoader()); thread.start(); } } Thread thread = new Thread(this::performShutdown); thread.setContextClassLoader(this.getClass().getClassLoader()); thread.start(); return var1; } } private void performShutdown() { try { Thread.sleep(500L); } catch (InterruptedException var2) { Thread.currentThread().interrupt(); } this.context.close(); //这里才是核心 } }
在调用了 this.context.close() ,其实就是AbstractApplicationContext 的close() 方法 (重点是其中的doClose()):
/** * Close this application context, destroying all beans in its bean factory. * <p>Delegates to {@code doClose()} for the actual closing procedure. * Also removes a JVM shutdown hook, if registered, as it's not needed anymore. * @see #doClose() * @see #registerShutdownHook() */ @Override public void close() { synchronized (this.startupShutdownMonitor) { doClose(); //重点:销毁bean 并执行jvm shutdown hook // 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 } } } }
doClose() 方法,又回到了前面的 Spring 的核心关闭方法。
doClose() 在销毁 bean, 关闭容器之前会执行所有实现 Lifecycel 接口 bean 的 stop方法,并且会按 Phase 值分组, phase 大的优先执行。
4 SpringCloud + SpringBoot优雅退出总结
Spring Boot、Spring Cloud应用的优雅停机,平时经常会被问到,这也是实际应用过程中,必须要掌握的点,
这里简单总结下以前我们一般在实现的时候要把握的几个要点:
- 关闭命令方面,一定要杜绝 kill -9 操作
- 多线程采用线程池实现,并且线程池要优雅关闭,从而保证每个异步线程都可以随Spring的生命周期完成正常关闭操作
- 有服务注册与发现机制下的时候,优先通过自定义K8S的POD的preStop钩子接口,来保障实例的主动下线。
5 小结
好啦,关于优雅停机的我们就看到这里哈,有理解不对的地方欢迎指正哈。