背景
以往的單機應用會采用kill方式關閉應用服務,但是這種關閉應用的方式在springboot中會讓當前應用將所有處理中的請求丟棄,返回失敗響應。我們在處理重要業務邏輯要極力避免的這種響應失敗在,所以我們需要一種更加好的的方式關閉springBoot應用。本文講述了一種基於SpringBoot Actuator和tomcat回調的方式平滑關閉應用
基本思路
我們關閉一個微服務應用基本可以分為兩大步驟
- 關閉web應用服務器
- 關閉spring容器
由於web應用服務器有多種所以下文描述的是如何平滑的關閉springboot中的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.yml中添加如下配置。
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.yml中添加如下配置
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) { String displayName = "Web"; if (event.getSource() instanceof AnnotationConfigApplicationContext ){ displayName = ((AnnotationConfigApplicationContext) event.getSource()).getDisplayName(); }/* Suspend all external requests*/ this.connector.pause(); /* Get ThreadPool For current connector */ Executor executor = this.connector.getProtocolHandler().getExecutor(); if (executor instanceof ThreadPoolExecutor) { log.warn("當前{}應用准備關閉",displayName); 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("當前{}應用等待超過最大時長{}秒,將強制關閉", displayName,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 容器也類似方式,希望這邊文章有所幫助,若有錯誤或者不當之處,還請大家批評指正,一起學習交流。
參考鏈接