Spring boot 2.0 之优雅停机

springboot优雅关机_通用实践

spring boot 框架在生产环境使用的有一段时间了,它“约定大于配置”的特性,体现了优雅流畅的开发过程,它的部署启动方式(​​java -jar xxx.jar​​​)也很优雅。但是我使用的停止应用的方式是 ​​kill -9 进程号​​,即使写了脚本,还是显得有些粗鲁。这样的应用停止方式,在停止的那一霎那,应用中正在处理的业务逻辑会被中断,导致产生业务异常情形。这种情况如何避免,本文介绍的优雅停机,将完美解决该问题。

00 前言

什么叫优雅停机?简单说就是,在对应用进程发送停止指令之后,能保证正在执行的业务操作不受影响。应用接收到停止指令之后的步骤应该是,停止接收访问请求,等待已经接收到的请求处理完成,并能成功返回,这时才真正停止应用。

这种完美的应用停止方式如何实现呢?就Java语言生态来说,底层的技术是支持的,所以我们才能实现在Java语言之上的各个web容器的优雅停机。

在普通的外置的tomcat中,有shutdown脚本提供优雅的停机机制,但是我们在使用Spring boot的过程中发现web容器都是内置(当然也可使用外置,但是不推荐),这种方式提供简单的应用启动方式,方便的管理机制,非常适用于微服务应用中,但是默认没有提供优雅停机的方式。这也是本文探索这个问题的根本原因。

应用是否是实现了优雅停机,如何才能验证呢?这需要一个处理时间较长的业务逻辑,模拟这样的逻辑应该很简单,使用线程sleep或者长时间循环。我的模拟业务逻辑代码如下:

1. 

2.

@GetMapping(value = "/sleep/one", produces = "application/json")


3.


public ResultEntity<Long> sleepOne(String systemNo){


4.

logger.info("模拟业务处理1分钟,请求参数:{}", systemNo);


5.

Long serverTime = System.currentTimeMillis();


6.

// try {


7.

// Thread.sleep(60*1000L);


8.

// } catch (InterruptedException e) {


9.

// e.printStackTrace();


10.

// }


11.

while (System.currentTimeMillis() < serverTime + (60 * 1000)){


12.

logger.info("正在处理业务,当前时间:{},开始时间:{}", System.currentTimeMillis(), serverTime);


13.

}


14.

ResultEntity<Long> resultEntity = new ResultEntity<>(serverTime);


15.

logger.info("模拟业务处理1分钟,响应参数:{}", resultEntity);


16.

return resultEntity;


17.

}


18.



验证方式就是,在触发这个接口的业务处理之后,业务逻辑处理时间长达1分钟,需要在处理结束前,发起停止指令,验证是否能够正常返回。验证时所使用的kill指令:​​kill -2(Ctrl + C)​​、​​kill -15​​、​​kill -9​​。

01 Java 语言的优雅停机

从上面的介绍中我们发现,Java语言本身是支持优雅停机的,这里就先介绍一下普通的java应用是如何实现优雅停止的。

当我们使用​​kill PID​​的方式结束一个Java应用的时候,JVM会收到一个停止信号,然后执行shutdownHook的线程。一个实现示例如下:

1. 

2.


public class ShutdownHook extends Thread {


3.

private Thread mainThread;


4.

private boolean shutDownSignalReceived;


5.




6.

@Override



7.

public void run() {


8.

System.out.println("Shut down signal received.");


9.

this.shutDownSignalReceived=true;


10.

mainThread.interrupt();


11.

try {


12.

mainThread.join(); //当收到停止信号时,等待mainThread的执行完成



13.

} catch (InterruptedException e) {


14.

}


15.

System.out.println("Shut down complete.");


16.

}


17.




18.

public ShutdownHook(Thread mainThread) {


19.

super();


20.

this.mainThread = mainThread;


21.

this.shutDownSignalReceived = false;


22.

Runtime.getRuntime().addShutdownHook(this);


23.

}


24.




25.

public boolean shouldShutDown(){


26.

return shutDownSignalReceived;


27.

}


28.




29.

}


30.



其中关键语句​​Runtime.getRuntime().addShutdownHook(this);​​,注册一个JVM关闭的钩子,这个钩子可以在以下几种场景被调用:

  1. 程序正常退出
  2. 使用System.exit()
  3. 终端使用Ctrl+C触发的中断
  4. 系统关闭
  5. 使用Kill pid命令干掉进程

测试shutdownHook的功能,代码示例:

1. 

2.


public class TestMain {


3.

private ShutdownHook shutdownHook;


4.

public static void main( String[] args ) {


5.

TestMain app = new TestMain();


6.

System.out.println( "Hello World!" );


7.

app.execute();


8.

System.out.println( "End of main()" );


9.

}


10.

public TestMain(){


11.

this.shutdownHook = new ShutdownHook(Thread.currentThread());


12.

}


13.

public void execute(){


14.

while(!shutdownHook.shouldShutDown()){


15.

System.out.println("I am sleep");


16.

try {


17.

Thread.sleep(1*1000);


18.

} catch (InterruptedException e) {


19.

System.out.println("execute() interrupted");


20.

}


21.

System.out.println("I am not sleep");


22.

}


23.

System.out.println("end of execute()");


24.

}


25.

}


26.



启动测试代码,之后再发送一个中断信号,控制台输出:

1. 

2.

I am sleep



3.

I am not sleep



4.

I am sleep



5.

I am not sleep



6.

I am sleep



7.

I am not sleep



8.

I am sleep



9.

Shut down signal received.


10.

execute() interrupted


11.

I am not sleep



12.

end of execute()


13.

End of main()


14.

Shut down complete.


15.




16.

Process finished with exit code 130 (interrupted by signal 2: SIGINT)


17.



可以看出,在接收到中断信号之后,整个main函数是执行完成的。

02 actuator/shutdown of Spring boot

我们知道了java本身在支持优雅停机上的能力,然后在Spring boot中又发现了​​actuator/shutdown​​的管理端点。于是我把优雅停机的功能寄希望于此,开始配置测试,开启配置如下:

1. 

2.

management:


3.

server:


4.

port: 10212



5.

servlet:


6.

context-path: /


7.

ssl:


8.

enabled: false



9.

endpoints:


10.

web:


11.

exposure:


12.

include: "*"



13.

endpoint:


14.

health:


15.

show-details: always


16.

shutdown:


17.

enabled: true #启用shutdown端点



18.



测试结果很失望,并没有实现优雅停机的功能,就是将普通的kill命令,做成了HTTP端点。于是开始查看Spring boot的官方文档和源代码,试图找到它的原因。

在官方文档上对shutdown端点的介绍:

shutdown    Lets the application be gracefully shutdown.


从此介绍可以看出,设计上应该是支持优雅停机的。但是为什么现在还不够优雅,在github上托管的Spring boot项目中发现,有一个​​issue​​一直处于打开状态,已经两年多了,里面很多讨论,看完之后发现在Spring boot中完美的支持优雅停机不是一件容易的事,首先Spring boot支持web容器很多,其次对什么样的实现才是真正的优雅停机,讨论了很多。想了解更多的同学,把这个issue好好阅读一下。

这个issue中还有一个重要信息,就是这个issue曾经被加入到2.0.0的milestone中,后来由于没有完成又移除了,现在状态是被添加在2.1.0的milestone中。我测试的版本是2.0.1,期待官方给出完美的优雅停机方案。

03 Spring boot 优雅停机

虽然官方暂时还没有提供优雅停机的支持,但是我们为了减少进程停止对业务的影响,还是要给出能满足基本需求的方案来。

针对tomcat的解决方案是:

1. 

2.


package com.epay.demox.unipay.provider;


3.




4.


import org.apache.catalina.connector.Connector;


5.


import org.slf4j.Logger;


6.


import org.slf4j.LoggerFactory;


7.


import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;


8.


import org.springframework.context.ApplicationListener;


9.


import org.springframework.context.event.ContextClosedEvent;


10.


import org.springframework.stereotype.Component;


11.




12.


import java.util.concurrent.Executor;


13.


import java.util.concurrent.ThreadPoolExecutor;


14.


import java.util.concurrent.TimeUnit;


15.




16.

/**


17.

* @Author: guoyankui


18.

* @DATE: 2018/5/20 12:59 PM


19.

*


20.

* 优雅关闭 Spring Boot tomcat


21.

*/


22.




23.

@Component


24.


public class GracefulShutdownTomcat implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {


25.

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


26.

private volatile Connector connector;


27.

private final int waitTime = 30;


28.

@Override



29.

public void customize(Connector connector) {


30.

this.connector = connector;


31.

}


32.

@Override



33.

public void onApplicationEvent(ContextClosedEvent contextClosedEvent) {


34.

this.connector.pause();


35.

Executor executor = this.connector.getProtocolHandler().getExecutor();


36.

if (executor instanceof ThreadPoolExecutor) {


37.

try {


38.

ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;


39.

threadPoolExecutor.shutdown();


40.

if (!threadPoolExecutor.awaitTermination(waitTime, TimeUnit.SECONDS)) {


41.

log.warn("Tomcat thread pool did not shut down gracefully within " + waitTime + " seconds. Proceeding with forceful shutdown");


42.

}


43.

} catch (InterruptedException ex) {


44.

Thread.currentThread().interrupt();


45.

}


46.

}


47.

}


48.

}


49.


1. 

2.


public class UnipayProviderApplication {


3.

public static void main(String[] args) {


4.

SpringApplication.run(UnipayProviderApplication.class);


5.

}


6.




7.

@Autowired



8.

private GracefulShutdownTomcat gracefulShutdownTomcat;


9.




10.

@Bean



11.

public ServletWebServerFactory servletContainer() {


12.

TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();


13.

tomcat.addConnectorCustomizers(gracefulShutdownTomcat);


14.

return tomcat;


15.

}


16.

}


17.



该方案的代码来自官方issue中的讨论,添加这些代码到你的Spring boot项目中,然后再重新启动之后,发起测试请求,然后发送kill停止指令(​​kill -2(Ctrl + C)​​、​​kill -15​​)。测试结果:

  1. Spring boot的健康检查,为​​UP​​。
  2. 正在执行操作不会终止,直到执行完成。
  3. 不再接收新的请求,客户端报错信息为:​​Connection reset by peer​​。
  4. 最后正常终止进程(业务执行完成后,立即进程停止)。

从测试结果来看,是满足我们的需求的。当然如果发送指令​​kill -9​​,进程会立即停止。

针对undertow的解决方案是:

1. 

2.

package com.epay.demox.unipay.provider;


3.




4.


import io.undertow.Undertow;


5.


import io.undertow.server.ConnectorStatistics;


6.


import org.springframework.beans.factory.annotation.Autowired;


7.


import org.springframework.boot.web.embedded.undertow.UndertowServletWebServer;


8.


import org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext;


9.


import org.springframework.context.ApplicationListener;


10.


import org.springframework.context.event.ContextClosedEvent;


11.


import org.springframework.stereotype.Component;


12.




13.


import java.lang.reflect.Field;


14.


import java.util.List;


15.




16.

/**


17.

* @Author: guoyankui


18.

* @DATE: 2018/5/20 5:47 PM


19.

*


20.

* 优雅关闭 Spring Boot undertow


21.

*/


22.

@Component



23.


public class GracefulShutdownUndertow implements ApplicationListener<ContextClosedEvent> {


24.




25.

@Autowired



26.

private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;


27.




28.

@Autowired



29.

private ServletWebServerApplicationContext context;


30.




31.

@Override



32.

public void onApplicationEvent(ContextClosedEvent contextClosedEvent){


33.

gracefulShutdownUndertowWrapper.getGracefulShutdownHandler().shutdown();


34.

try {


35.

UndertowServletWebServer webServer = (UndertowServletWebServer)context.getWebServer();


36.

Field field = webServer.getClass().getDeclaredField("undertow");


37.

field.setAccessible(true);


38.

Undertow undertow = (Undertow) field.get(webServer);


39.

List<Undertow.ListenerInfo> listenerInfo = undertow.getListenerInfo();


40.

Undertow.ListenerInfo listener = listenerInfo.get(0);


41.

ConnectorStatistics connectorStatistics = listener.getConnectorStatistics();


42.

while (connectorStatistics.getActiveConnections() > 0){}


43.

}catch (Exception e){


44.

// Application Shutdown



45.

}


46.

}


47.

}


48.


1. 

2.


package com.epay.demox.unipay.provider;


3.




4.


import io.undertow.server.HandlerWrapper;


5.


import io.undertow.server.HttpHandler;


6.


import io.undertow.server.handlers.GracefulShutdownHandler;


7.


import org.springframework.stereotype.Component;


8.




9.

/**


10.

* @Author: guoyankui


11.

* @DATE: 2018/5/20 5:50 PM


12.

*/


13.

@Component


14.


public class GracefulShutdownUndertowWrapper implements HandlerWrapper {


15.

private GracefulShutdownHandler gracefulShutdownHandler;


16.

@Override



17.

public HttpHandler wrap(HttpHandler handler) {


18.

if(gracefulShutdownHandler == null) {


19.

this.gracefulShutdownHandler = new GracefulShutdownHandler(handler);


20.

}


21.

return gracefulShutdownHandler;


22.

}


23.

public GracefulShutdownHandler getGracefulShutdownHandler() {


24.

return gracefulShutdownHandler;


25.

}


26.

}


27.


1. 

2.


public class UnipayProviderApplication {


3.

public static void main(String[] args) {


4.

SpringApplication.run(UnipayProviderApplication.class);


5.

}


6.

@Autowired



7.

private GracefulShutdownUndertowWrapper gracefulShutdownUndertowWrapper;


8.

@Bean



9.

public UndertowServletWebServerFactory servletWebServerFactory() {


10.

UndertowServletWebServerFactory factory = new UndertowServletWebServerFactory();


11.

factory.addDeploymentInfoCustomizers(deploymentInfo -> deploymentInfo.addOuterHandlerChainWrapper(gracefulShutdownUndertowWrapper));


12.

factory.addBuilderCustomizers(builder -> builder.setServerOption(UndertowOptions.ENABLE_STATISTICS, true));


13.

return factory;


14.

}


15.

}


16.



该方法参考文章,采用与tomcat同样的测试方案,测试结果:

  1. Spring boot的健康检查,为​​UP​​。
  2. 正在执行操作不会终止,直到执行完成。
  3. 不再接收新的请求,客户端报错信息为:​​503 Service Unavailable​​。
  4. 最后正常终止进程(在业务执行完成后的一分钟进程停止)。

04 结束

到此为止,对Java和Spring boot应用的优雅停机机制有了基本的认识。虽然实现了需求,但是这其中还有很多知识点需要探索,比如Spring上下文监听器,上下文关闭事件等,还有undertow提供的​​GracefulShutdownHandler​​的原理是什么,为什么是1分钟之后进程再停止,这些问题等研究明白,再来一篇续。如果又哪位同学能解答我的疑惑,请在评论区留言。