转自https://my.oschina.net/pengranxiang/blog/4297551
Java,Tomcat,SpringBoot,Docker 的优雅关闭
1 什么是优雅关闭
@RestController
public class DemoController {
@GetMapping("/demo")
public String demo() throws InterruptedException {
// 模拟业务耗时处理流程
Thread.sleep(20 * 1000L);
return "hello";
}
}
当我们流量请求到此接口执行业务逻辑的时候,若服务端此时执行关机(kill),SpringBoot默认会直接关闭容器(Tomcat等),导致此业务逻辑执行失败。在一些业务场景下,会出现业务无法回滚,导致数据不一致的情况。
优雅关闭表示:当收到kill命令后,服务将拒绝新的请求,但会继续完成已有请求的处理.
2. 如何做到优雅关闭
2.1 Linux Kill 命令
kill 命令常用的信号选项:
- kill -2 pid 向指定 pid 发送 SIGINT 中断信号, 等同于 ctrl+c.
- kill -9 pid, 向指定 pid 发送 SIGKILL 立即终止信号.
- kill -15 pid, 向指定 pid 发送 SIGTERM 终止信号.
- kill pid 等同于 kill 15 pid
SIGINT/SIGKILL/SIGTERM 信号的区别:
- SIGINT (ctrl+c) 信号 (信号编号为 2), 信号会被当前进程树接收到, 也就说, 不仅当前进程会收到该信号, 而且它的子进程也会收到.
- SIGKILL 信号 (信号编号为 9), 程序不能捕获该信号, 最粗暴最快速结束程序的方法.
- SIGTERM 信号 (信号编号为 15), 信号会被当前进程接收到, 但它的子进程不会收到, 如果当前进程被 kill 掉, 它的的子进程的父进程将变成 init 进程 (init 进程是那个 pid 为 1 的进程)
2.2 Java 进程的优雅关闭
Java 语言底层有机制能捕获到 OS 的 SIGINT (kill -2 / ctrl + c) / SIGTERM (kill -15)信号。注意 SIGKILL(kill -9)信号无法捕捉。
通过 Runtime.getRuntime().addShutdownHook() 向 JVM 中注册一个 ShutdownHook 线程,当 JVM 收到停止信号后,该线程将被激活运行。
可以在Hook线程向其他线程发出中断指令,然后等待其他线程执行完毕,进而优雅地关闭整个程序。
public class ShutDown {
public static void main(String[] args) {
System.out.println("MainThread 启动");
final Thread mainThread = Thread.currentThread();
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("接收到关闭信号");
// 给主线程发送中断信号
mainThread.interrupt();
try {
// 等待主线程正常执行完成
mainThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("优雅关闭完成");
}));
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
// 中断响应
System.out.println("主线程被中断,处理中断逻辑");
}
System.out.println("Main Thread执行完毕");
}
}
启动后,按 ctrl + c 的执行结果:
ShutdownHook 的使用注意点:
- shutdownhook的调用是不保证顺序的
- shutdownhook是JVM结束前调用的线程,所以该线程中的方法应尽量短,并且保证不能发生死锁的情况,否则也会阻止JVM的正常退出
- shutdownhook中不能执行System.exit(),否则会导致虚拟机卡住,而不得不强行杀死进程
2.3 Tomcat 的优雅关闭
2.3.1 Tomcat 启动过程
Tomcat启动的入口是Bootstrap类中的main方法,而后根据server.xml中的配置,对Server、Service、Enigin、Connector、Host、Context等组件进行初始化,之后便是启动这些组件。
2.3.2 Tomcat 关闭过程
2.3.2.1 shutdown 脚本
shutdown 主要做了两件事:
- 初始化Server组件,和Tomcat启动时类似,这一步主要是解析server.xml文件,然后根据server.xml中的属性初始化Tomcat组件的成员变量,这里主要关注Server组件的几个成员变量:port、address、shutdown,默认值分别为8005、127.0.0.1、SHUTDOWN等,需要和启动时读取的server.xml保持一致。
- 往address port所监听的Socket端口发生“SHUTDOWN”字符串。对应启动的第三种阻塞情况,"SHUTDOWN"字符串让main主线程结束了等待状态,并在接下来通过调用各组件的stop()和destroy()方法进行资源的释放。
我们的线程是在loader中被尝试停止的,而loader的stop方法在listenerStop方法之后,也就是说,即使loader成功终止了用户自己启动的线程,依然有可能在线程终止之前使用Sping框架,而此时Spring框架已经在Listener中关闭了!况且在loader的清理线程过程中只有配置了clearReferencesStopThreads参数,用户自己启动的线程才会被强制终止(使用Thread.stop()),而在大多数情况下,为了保证数据的完整性,这个参数不会被配置。也就是说,在WebApp中,用户自己启动的线程(包括Executors),都不会因为容器的退出而终止。
2.3.2.2 Kill 命令
当 server.xml
的 Server 节点的 port 属性配置为 -1 时, shutdown脚本将无法停止Tomcat。当 port 为正常端口的时候,只要用户有权限能访问该端口,并向其发送“SHUTDOWN”,则服务会被关闭,有一定的风险。所以生产环境中也经常配置为 -1。
在这种情况下,可以使用 kill pid
或 kill -15 pid
来触发 Tomcat 优雅关闭。
Tomcat的关闭钩子的定义是在Catalina类中,有一个名为CatalinaShutdownHook内部类,继承了Thread类。跟着这个线程类中的run()方法往下看,其调用了Catalina的stop()方法,而此处stop方法,除了正常去停止各组件外,还会去中断并快速结束main主线程(如果主线程还存在的话),最后再调用各组件的destroy()方法进行资源释放。
protected class CatalinaShutdownHook extends Thread {
public void run() {
if (server != null) {
try {
((Lifecycle) server).stop();
} catch (LifecycleException e) {
System.out.println("Catalina.stop: " + e);
e.printStackTrace(System.out);
if (e.getThrowable() != null) {
System.out.println("----- Root Cause -----");
e.getThrowable().printStackTrace(System.out);
}
}
}
}
}
2.4 SpringBoot 的优雅关闭
SpringBoot Web 项目, 如果使用的是外置 tomcat, 可以直接使用上面 tomcat 命令完成优雅停机. 但通常使用的是内置 tomcat 服务器, 这时就需要编写代码来支持优雅停止.
2.4.1 SpringBoot 2.3.0 之前
网上很多文章都提及 Actuator 的 shutdown 提供优雅停机功能, 官方文档也是这么宣传的, 其实并没有实现优雅停机功能, 在 github issues/4657 也有提及, 也许将来会实现, https://github.com/spring-projects/spring-boot/issues/4657
测试代码(SpringBoot版本:2.1.5.RELEASE):(摘自:https://dzone.com/articles/graceful-shutdown-spring-boot-applications )
@RequestMapping("/long-process")
public String pause() throws InterruptedException {
Thread.sleep(20*1000);
System.out.println("Process finished");
return "Process finished";
}
appication.properties 文件内容:
management.endpoint.shutdown.enabled=true
management.endpoints.web.exposure.include=*
测试步骤:
- POST http://localhost:8080/long-process , 紧接访问actuator shutdown
- 端点: POST http://localhost:8080/actuator/shutdown , 当应用程序停止时, GET请求并没有得到返回值, 可见 Actuator 并没有提供优雅停机功能.
- 服务报错
2.4.1.1 增加 GracefulShutdown Connector 监听类
当 tomcat 收到 kill 信号后, web程序先关闭新的请求, 然后等待 30 秒, 最后结束整个程序.
public 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();
}
}
}
}
2.4.1.2 注册自定义的 Connector 监听器
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
@Bean
public GracefulShutdown gracefulShutdown() {
return new GracefulShutdown();
}
@Bean
public ConfigurableServletWebServerFactory webServerFactory(final GracefulShutdown gracefulShutdown) {
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
factory.addConnectorCustomizers(gracefulShutdown);
return factory;
}
}
测试步骤:
- POST http://localhost:8080/long-process , 紧接访问actuator shutdown
- 端点: POST http://localhost:8080/actuator/shutdown ,访问立即响应,但是服务未停止,等待 long-process 执行完成
- 控制台日志:
2.4.2 SpringBoot 2.3.0 之后
在最新的 SpringBoot 2.3.0 版本中,正式内置了优雅停机功能,不需要再自行扩展线程池来处理。
当启动server.shutdown=graceful
,在 web 容器关闭时,web服务器将不再接受新请求(Tomcat、Reactor Netty : 等待超时,Undertow:直接返回503),并等待活动请求完成的缓冲时间。
# 开启优雅停机,默认值:immediate 为立即关闭
server.shutdown=graceful
# 设置缓冲期,最大等待时间,默认:30秒
spring.lifecycle.timeout-per-shutdown-phase=60s
测试代码回到 2.4.1 小节,SpringBoot版本2.3.0,测试步骤:
-
- POST http://localhost:8080/long-process , 紧接访问actuator shutdown
- POST http://localhost:8080/long-process , 紧接访问actuator shutdown
- 端点: POST http://localhost:8080/actuator/shutdown ,访问立即响应,但是服务未停止,等待 long-process 执行完成
- 控制台日志:
2.5 Docker 服务的优雅关闭
使用 docker stop 关闭容器时, 只有 init(pid 1)进程能收到中断信号, 如果容器的pid 1 进程是 sh 进程, 它不具备转发结束信号到它的子进程的能力, 所以我们真正的java程序得不到中断信号, 也就不能实现优雅关闭. 解决思路是: 让pid 1 进程具备转发终止信号, 或者将 java 程序配成 pid 1 进程.
需要说明的是, docker stop 默认是等待10秒钟, 这个时间有点太短了, 可以加 -t 参数, 比如 -t 30 等待30秒钟.
上面的 Dockerfile 的pid 1是一个 sh 命令,并不能实现优雅关闭, 需要再改进.
ENTRYPOINT 的几种写法, 会影响 pid 1 进程的产生:
- ENTRYPOINT top -b
- PID 1 是 /bin/sh -c shell top -b
- 另外有个 pid 7 是 top -b
- ENTRYPOINT exec top -b
- PID 1 是 top -b
- ENTRYPOINT ["top", "-b"]
- PID 1 是 top -b