1. 程式人生 > >SpringBoot系列: 如何優雅停止服務

SpringBoot系列: 如何優雅停止服務

============================
背景
============================
在系統生命週期中, 免不了要做升級部署, 對於關鍵服務, 我們應該能做到不停服務完成升級 (perform a zero downtime upgrade), 對於一般系統, 應該做到優雅地停服務.

如何做到不停服務的升級? 需要做到下面兩點:
1. 服務本身應該部署多份, 前面應該有 LVS/Haproxy 層或者服務註冊元件.
2. 每一份服務能被優雅停機, 即: 在 kill pid 命令發出後, 程式應該能拒絕新的請求, 但應該繼續完成已有請求的處理.

本文重點關注如何支援優雅停機.


============================
Linux kill 命令
============================
kill 命令常用的訊號選項:
(1) kill -2 pid 向指定 pid 傳送 SIGINT 中斷訊號, 等同於 ctrl+c.
(2) kill -9 pid, 向指定 pid 傳送 SIGKILL 立即終止訊號.
(3) kill -15 pid, 向指定 pid 傳送 SIGTERM 終止訊號.
(4) kill pid 等同於 kill 15 pid

SIGINT/SIGKILL/SIGTERM 訊號的區別:
(1) SIGINT (ctrl+c) 訊號 (訊號編號為 2), 訊號會被當前程序樹接收到, 也就說, 不僅當前程序會收到該訊號, 而且它的子程序也會收到.
(2) SIGKILL 訊號 (訊號編號為 9), 程式不能捕獲該訊號, 最粗暴最快速結束程式的方法.
(3) SIGTERM 訊號 (訊號編號為 15), 訊號會被當前程序接收到, 但它的子程序不會收到, 如果當前程序被 kill 掉, 它的的子程序的父程序將變成 init 程序 (init 程序是那個 pid 為 1 的程序)

一般要結束某個程序, 我們應該優先使用 kill pid , 而不是 kill -9 pid. 如果對應程式提供優雅關閉機制的話, 在完全退出之前, 先可以做一些善後處理.


============================
Java 對於優雅停機的底層支援
============================
Java 語言底層有機制能捕獲到 OS 的 SIGINT/ SIGTERM 停止指令的, 具體是通過 Runtime.getRuntime().addShutdownHook() 向 JVM 中註冊一個 Shutdown hook 執行緒, 當 JVM 收到停止訊號後, 該執行緒將被啟用執行, 這時候我們就可以向其他執行緒發出中斷指令, 進而快速而優雅地關閉整個程式.

複製程式碼
public class Test {
    public static void main(String[] args){
        System.out.println("1: Main start");

        Thread mainThread = Thread.currentThread();

        //註冊一個 ShutdownHook
        ShutdownSampleHook thread=new ShutdownSampleHook(mainThread);
        Runtime.getRuntime().addShutdownHook(thread);

        try {
            Thread.sleep(30*1000);
        } catch (InterruptedException e) {
            System.out.println("3: mainThread get interrupt signal.");
        }

        System.out.println("4: Main end");  
    }
}

class ShutdownSampleHook extends Thread {
    private Thread mainThread;
    @Override
    public void run() {
        System.out.println("2: Shut down signal received.");
        mainThread.interrupt();//給主執行緒傳送一箇中斷訊號
        try {
            mainThread.join(); //等待 mainThread 正常執行完畢
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("5: Shut down complete.");
    }

    public ShutdownSampleHook(Thread mainThread) {
        this.mainThread=mainThread;

    }
}
複製程式碼

 

關於 mainThread.interrupt() 方法的說明, 該方法將給主執行緒傳送一箇中斷訊號. 如果主執行緒沒有進入阻塞狀態, interrupt() 其實沒有什麼作用; 如果主執行緒處於阻塞狀態, 該執行緒將得到一個 InterruptedException 異常. 在呼叫 mainThread.join() 或 mainThread.wait() 之前, 仍可以通過呼叫 mainThread.interrupted() 來清除中斷訊號.
一個執行緒有三種進入阻塞狀態的方法, 分別是呼叫 Thread.wait() 或 Thread.join() 或 Thread.sleep().

 

正常情況下, 程式需要執行 30 秒, 程式的輸出是:

 

如果在程式啟動後, 按下 Ctrl+C, 程式很快就結束了, 最終的輸出是:

============================
SpringBoot Web 專案的優雅停機
============================
Java web 伺服器通常也支援優雅退出, 比如 tomcat, 提供如下命令:
catalina.sh stop n         , 先等 n 秒後, 然後停止 tomcat.
catalina.sh stop n -force  , 先等 n 秒後, 然後 kill -9 tomcat.

SpringBoot Web 專案, 如果使用的是外接 tomcat, 可以直接使用上面 tomcat 命令完成優雅停機. 但通常使用的是內建 tomcat 伺服器, 這時就需要編寫程式碼來支援優雅停止.
網上很多文章都提及 Actuator 的 shutdown 提供優雅停機功能, 官方文件也是這麼宣傳的, 其實並沒有實現優雅停機功能, 至少在 SpringBoot 2.1.0, 在 github issues/4657 也有提及, 也許將來會實現, https://github.com/spring-projects/spring-boot/issues/4657

本節所有的程式碼摘自 https://dzone.com/articles/graceful-shutdown-spring-boot-applications
下面是一個簡單的測試程式碼:

複製程式碼
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LongProcessController {
    @RequestMapping("/long-process")
    public String pause() throws InterruptedException {
        Thread.sleep(20*1000);
        System.out.println("Process finished");
        return "Process finished";
    }
}
複製程式碼

appication.properties 檔案內容:

management.endpoint.shutdown.enabled=true
management.endpoints.web.exposure.include=*

瀏覽器訪問 GET http://localhost:8080/long-process , 緊接訪問actuator shutdown 端點: POST http://localhost:8080/actuator/shutdown , 當應用程式停止時, GET請求並沒有得到返回值, 可見 Actuator 並沒有提供優雅停機功能.

------------------------------------
增加 GracefulShutdown Connector 監聽類
------------------------------------
當 tomcat 收到 kill 訊號後, web程式先關閉新的請求, 然後等待 30 秒, 最後結束整個程式.

複製程式碼
import org.apache.catalina.connector.Connector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextClosedEvent;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {
    private static final Logger log = LoggerFactory.getLogger(GracefulShutdown.class);
    private volatile Connector connector;
    @Override
    public void customize(Connector connector) {
        this.connector = connector;
    }
    
    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        this.connector.pause();
        Executor executor = this.connector.getProtocolHandler().getExecutor();
        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();
                if (!threadPoolExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
                    log.warn("Tomcat thread pool did not shut down gracefully within "
                            + "30 seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}
複製程式碼

------------------------------------
註冊自定義的 Connector 監聽器
------------------------------------
在 @SpringBootApplication 入口類中, 增加下面的程式碼, 註冊之前定義的 Connector 監聽器.  

複製程式碼
@Bean
public GracefulShutdown gracefulShutdown() {
    return new GracefulShutdown();
}

@Bean
public ConfigurableServletWebServerFactory webServerFactory(final GracefulShutdown gracefulShutdown) {
    TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
    factory.addConnectorCustomizers(gracefulShutdown);
    return factory;
}
複製程式碼



============================
graceful shutdown-down spring-boot starters
============================
上面的示例程式碼基本能滿足我們的需要, github上甚至有幾個專門處理 graceful shutdown-down 的 starter 庫, 使用這些 starter 包就不需要上編寫 Tomcat Connector 監聽類.
https://github.com/jihor/hiatus-spring-boot    , 支援SpringBoot 2
https://github.com/Askerlve/grace-shutdown     , 支援SpringBoot 2
https://github.com/gesellix/graceful-shutdown-spring-boot  , 有一些有關 docker 的資訊.

hiatus-spring-boot 庫是一個很有意思的庫, 它並沒有實現一個 Tomcat Connector 監聽類, 所以直接 kill pid, 將不會有善後處理過程, 它而僅僅是修改 actuator/health 狀態為 OUT_OF_SERVICE, 所以要想截流量功能必須配合 discovery server.  專案取名為 hiatus , 該單詞和 pause 意思相近, 專案取名是很準確的, 僅僅是暫停服務, 後續可以重新開啟服務.


hiatus-spring-boot 的特點是:
1. actuator metrics 端點只能提供OS/JVM/Tomcat類的指標, hiatus 引入一個 @UnitOfWork 計數器註解, 加在檢視方法上, 可以作為一個業務方面的 metrics.
2. 實現了三個 actuator 端點
/actuator/hiatus-on 端點(POST), 停歇 springBoot 程式,
/actuator/hiatus-off 端點(POST), 恢復 springBoot 程式.
/actuator/hiatus 端點(GET), 查詢暫停狀態和正在處理的request數量.
進入 hiatus 狀態後,  actuator/health 端點查詢的結果是 "status":"OUT_OF_SERVICE".

專案中的推薦的做法是, 引入 hiatus-spring-boot, 並參考上面示例編寫一個Tomcat Connector 監聽類, 這樣既能優雅應對 kill pid, 又能做到主動截留.

 

============================
docker 微服務的優雅關閉
============================
詳見 https://www.cnblogs.com/harrychinese/p/springboot_Dockerize_SpringBoot_App.html"docker 微服務的優雅關閉"章節.

 

============================
參考
============================
https://www.jianshu.com/p/0c49eb23c627
https://www.jianshu.com/p/073a0da36d48
https://dzone.com/articles/graceful-shutdown-spring-boot