背景
最近在搞雲化項目的啓動腳本,覺得以往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.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 方式請求對應路徑來實現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 接口,意味着我們會監聽着 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 容器也類似方式,希望這邊文章有所幫助,若有錯誤或者不當之處,還請大家批評指正,一起學習交流。