背景
最近在搞云化项目的启动脚本, 觉得以往 kill 方式关闭服务项目太粗暴了, 这种 kill 关闭应用的方式会让当前应用将所有处理中的请求丢弃, 响应失败. 这种形式的响应失败在处理重要业务逻辑中是要极力避免的, 所以我们需要一种更加优雅的方式关闭 springBoot 应用.
基本思路
首先我们关闭一个微服务应用可以分为两大步骤
关闭 web 应用服务器
关闭 spring 容器
我项目中使用的是内置的 tomcat 服务器, 所以本文描述的是如何平滑的关闭 tomcat 应用. SpringBoot Actuator 中提供了 shutdown 端点, 利用此端点可以 http 的方式远程关闭 spring 容器, 下文讲述了如何使用 SpringBoot Actuator 的 shutdown.
开启 Shutdown Endpoint
Spring Boot Actuator 是 Spring Boot 的一大特性, 它提供了丰富的功能来帮助我们监控和管理生产环境中运行的 Spring Boot 应用. 我们可以通过 HTTP 或者 JMX 方式来对我们应用进行管理, 除此之外, 它为我们的应用提供了审计, 健康状态和度量信息收集的功能, 能帮助我们更全面地了解运行中的应用.
引入 Actuator
本项目基于 gradle 构建, 引入 "spring-boot-starter-actuator" 如下
API('org.springframework.boot:spring-boot-starter-actuator:2.2.5.RELEASE')
开放端口
Spring Boot Actuator 采用向外部暴露 Endpoint (端点)的方式来让我们与应用进行监控和管理, 引入 spring-boot-starter-actuator 之后, 就需要启用我们需要的 Shutdown Endpoint. 在 application.YAML 中添加如下配置.
- management:
- endpoints:
- Web:
- exposure:
- include: "httptrace,health,shutdown"
- ## 健康检查根路径
- base-path: "/actuator"
- endpoint:
- shutdown:
- enabled: true
- health:
- show-details: always
建议在 include 中根据自己的需要开放对应的端口, 最好不要直接写 "*". 这里由于项目中需要健康检查, 所以添加了 health,.
添加 shutdown 过滤器
一般来说使用 shutdown 端口是需要做权限控制的, 但是由于这个项目有部署的时候, 有对应的网关, 所以这里就比较简单的增加了一个白名单功能. 根据配置文件, 来控制对应的 ip 是否可以访问此端口.
1. 添加 ActuatorFilter
- @Slf4j
- @RefreshScope
- public class ActuatorFilter implements Filter {
- public static final String UNKNOWN = "unknown";
- @Value("${shutdown.whitelist}")
- private String[] shutdownIpWhitelist;
- @Override
- public void destroy() {
- }
- @Override
- public void doFilter(ServletRequest srequest, ServletResponse sresponse, FilterChain filterChain)
- throws IOException, ServletException {
- HttpServletRequest request = (HttpServletRequest) srequest;
- String ip = this.getIpAddress(request);
- log.info("访问 shutdown 的机器的原始 IP:{}", ip);
- if (!isMatchWhiteList(ip)) {
- sresponse.setContentType("application/json");
- sresponse.setCharacterEncoding("UTF-8");
- PrintWriter writer = sresponse.getWriter();
- writer.write("{\"code\":401,\"error\":\"IP access forbidden\"}");
- writer.flush();
- writer.close();
- log.warn("ip:{}禁止 shutdown", ip);
- return;
- }
- filterChain.doFilter(srequest, sresponse);
- }
- @Override
- public void init(FilterConfig arg0) throws ServletException {
- log.info("Actuator filter is init.....");
- }
- /**
- * 匹配是否是白名单
- */
- private boolean isMatchWhiteList(String ip) {
- List<String> list = Arrays.asList(shutdownIpWhitelist);
- return list.stream().anyMatch(item -> ip.startsWith(item));
- }
- /**
- * 获取用户真实 IP 地址, 不使用 request.getRemoteAddr(); 的原因是有可能用户使用了代理软件方式避免真实 IP 地址,
- * 可是, 如果通过了多级反向代理的话, X-Forwarded-For 的值并不止一个, 而是一串 IP 值, 究竟哪个才是真正的用户端的真实 IP 呢?
- * 答案是取 X-Forwarded-For 中第一个非 unknown 的有效 IP 字符串.
- *
- * 如: X-Forwarded-For:192.168.1.110, 192.168.1.120, 192.168.1.130, 192.168.1.100
- *
- * 用户真实 IP 为: 192.168.1.110
- */
- private String getIpAddress(HttpServletRequest request) {
- String ip = request.getHeader("x-forwarded-for");
- if (StringUtils.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
- ip = request.getHeader("Proxy-Client-IP");
- }
- if (StringUtils.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
- ip = request.getHeader("WL-Proxy-Client-IP");
- }
- if (StringUtils.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
- ip = request.getHeader("HTTP_CLIENT_IP");
- }
- if (StringUtils.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
- ip = request.getHeader("HTTP_X_FORWARDED_FOR");
- }
- if (StringUtils.isBlank(ip) || UNKNOWN.equalsIgnoreCase(ip)) {
- ip = request.getRemoteAddr();
- }
- return ip;
- }
- }
这里注意不能在类 ActuatorFilter 上加注解 @Component, 加上改过滤器会过滤所有 url.
2. 添加过滤器 Config
- @Configuration
- public class WebFilterConfig extends WebMvcConfigurationSupport {
- @Bean
- public ActuatorFilter getActuatorFilter() {
- return new ActuatorFilter();
- }
- @Bean
- public FilterRegistrationBean setShutdownFilter(ActuatorFilter actuatorFilter) {
- FilterRegistrationBean<ActuatorFilter> registrationBean = new FilterRegistrationBean<>();
- registrationBean.setFilter(actuatorFilter);
- registrationBean.setName("actuatorFilter");
- registrationBean.addUrlPatterns("/actuator/shutdown");
- return registrationBean;
- }
- }
3. 添加白名单配置
application.YAML 中添加如下配置
- shutdown:
- whitelist: 0:0:0:0:0:0:0:1,127.0.0.1
到这里我们的 shutdown 配置工作就算完成了. 当启动应用后, 只能本地以 POST 方式请求对应路径的 "http://host:port/actuator/shutdown"" 来实现 springboot 容器的关闭.
关闭 Tomcat
要平滑关闭 Spring Boot 应用的前提就是首先要关闭其内置的 Web 容器, 不再处理外部新进入的请求. 为了能让应用接受关闭事件通知的时候, 保证当前 Tomcat 处理所有已经进入的请求, 我们需要实现 TomcatConnectorCustomizer 接口, 此接口是实现自定义 Tomcat Connector 行为的回调接口.
自定义 Connector
Connector 属于 Tomcat 抽象组件, 功能就是用来接收外部请求, 内部传递, 并返回响应内容, 是 Tomcat 中请求处理和响应的重要组. Connector 具体实现有 HTTP Connector 和 AJP Connector.
通过定制 Connector 的行为, 我们就可以允许在请求处理完毕后进行 Tomcat 线程池的关闭, 具体实现代码如下:
- @Slf4j
- public class CustomShutdown implements TomcatConnectorCustomizer,
- ApplicationListener<ContextClosedEvent> {
- private static final int TIME_OUT = 30;
- private volatile Connector connector;
- @Override
- public void customize(Connector connector) {
- this.connector = connector;
- }
- @Override
- public void onApplicationEvent(ContextClosedEvent event) {
- /* Suspend all external requests*/
- this.connector.pause();
- /* Get ThreadPool For current connector */
- Executor executor = this.connector.getProtocolHandler().getExecutor();
- if (executor instanceof ThreadPoolExecutor) {
- log.warn("当前 Web 应用准备关闭");
- try {
- ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
- /* Initializes a shutdown task after the current one has been processed task*/
- threadPoolExecutor.shutdown();
- if (!threadPoolExecutor.awaitTermination(TIME_OUT, TimeUnit.SECONDS)) {
- log.warn("当前应用等待超过最大时长 {} 秒, 将强制关闭", TIME_OUT);
- /* Try shutDown Now*/
- threadPoolExecutor.shutdownNow();
- if (!threadPoolExecutor.awaitTermination(TIME_OUT, TimeUnit.SECONDS)) {
- log.error("强制关闭失败", TIME_OUT);
- }
- }
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- }
- }
上述代码定义的 TIMEOUT 变量为 Tomcat 线程池延时关闭的最大等待时间, 一旦超过这个时间就会强制关闭线程池, 所以我们可以通过控制 Tomcat 线程池的关闭时间,(当然了这个也可以写成可配的) 来实现优雅关闭 Web 应用的功能. 同时 CustomShutdown 实现了 ApplicationListener<ContextClosedEvent > 接口, 意味着我们会监听着 Spring 容器关闭的事件, 即当前的 ApplicationContext 执行 close 方法.
添加 Connector 回调
在启动过程中将定制的 Connetor 回调添加到内嵌的 Tomcat 容器中, 然后等待执行.
- @Configuration
- public class ShutdownConfig {
- @Bean
- public CustomShutdown customShutdown() {
- return new CustomShutdown();
- }
- @Bean
- public ConfigurableServletWebServerFactory webServerFactory(final CustomShutdown customShutdown) {
- TomcatServletWebServerFactory tomcatServletWebServerFactory = new TomcatServletWebServerFactory();
- tomcatServletWebServerFactory.addConnectorCustomizers(customShutdown);
- return tomcatServletWebServerFactory;
- }
- }
这里的 TomcatServletWebServerFactory 是 Spring Boot 实现内嵌 Tomcat 的工厂类. 其他的 Web 容器, 也有对应的工厂类如 JettyServletWebServerFactory,UndertowServletWebServerFactory. 他们共都是继承抽象类 AbstractServletWebServerFactory.AbstractServletWebServerFactory 提供了 Web 容器默认的公共实现, 如应用上下文设置, 会话管理等. 到这里我们的 Tomcat 平滑关闭就 ok 了
添加启动脚本
实际生产中我都会制作 jar 然后发布. 通常应用的启动和关闭操作流程是固定且重复的, 以避免出现人为的差错, 并且方便使用, 提高操作效率, 一般会配上对应的程序启动脚本来控制程序的启动和关闭.
对应关闭操作的 shell 脚本部分如下所示.
- SEVER_PORT=8893
- export START_JAR_NAME="test-*.jar"
- START_JAR=$(ls $PRG_HOME | grep $START_JAR_NAME)
- stop() {
- echo $"Stoping :"
- boot_id=$(pgrep -f "$START_JAR")
- count=$(pgrep -f "$START_JAR" | wc -l)
- if [ $count != 0 ];then
- curl -X POST "http://localhost:$SEVER_PORT/actuator/shutdown"
- sleep 3
- while(($count != 0))
- do
- kill $boot_id
- sleep 1
- count=$(pgrep -f "$START_JAR" | wc -l)
- done
- echo "服务已停止:"
- else
- echo "服务未在运行"
- fi
- }
总结
本文主要探究了如何对优雅关闭基于 Spring Boot 内嵌 Tomcat 的 Web 应用的实现, 如果采用其他 Web 容器也类似方式, 希望这边文章有所帮助, 若有错误或者不当之处, 还请大家批评指正, 一起学习交流.
参考链接
https://www.cnblogs.com/one12138/p/11241274.html
来源: https://www.cnblogs.com/NathanYang/p/12685952.html