如何優雅的關閉基於Spring Boot 內嵌 Tomcat 的 Web 應用

背景

最近在搞雲化項目的啓動腳本,覺得以往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 容器也類似方式,希望這邊文章有所幫助,若有錯誤或者不當之處,還請大家批評指正,一起學習交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章