【優雅停機】——kill -9 pid、kill -15 pid、Ctrl+C

原文鏈接:https://cloud.tencent.com/developer/article/1110765

最近瞥了一眼項目的重啓腳本,發現運維一直在使用 kill-9<pid> 的方式重啓 springboot embedded tomcat,其實大家幾乎一致認爲: kill-9<pid> 的方式比較暴力,但究竟會帶來什麼問題卻很少有人能分析出個頭緒。這篇文章主要記錄下自己的思考過程。

kill -9 和 kill -15 有什麼區別?

在以前,我們發佈 WEB 應用通常的步驟是將代碼打成 war 包,然後丟到一個配置好了應用容器(如 Tomcat,Weblogic)的 Linux 機器上,這時候我們想要啓動/關閉應用,方式很簡單,運行其中的啓動/關閉腳本即可。而 springboot 提供了另一種方式,將整個應用連同內置的 tomcat 服務器一起打包,這無疑給發佈應用帶來了很大的便捷性,與之而來也產生了一個問題:如何關閉 springboot 應用呢?一個顯而易見的做法便是,根據應用名找到進程 id,殺死進程 id 即可達到關閉應用的效果。

上述的場景描述引出了我的疑問:怎麼優雅地殺死一個 springboot 應用進程呢?這裏僅僅以最常用的 Linux 操作系統爲例,在 Linux 中 kill 指令負責殺死進程,其後可以緊跟一個數字,代表信號編號(Signal),執行 kill-l 指令,可以一覽所有的信號編號。

xu@ntzyz-qcloud ~ % kill -l                                                                     
HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

本文主要介紹下第 9 個信號編碼 KILL,以及第 15 個信號編號 TERM

先簡單理解下這兩者的區別: kill-9pid 可以理解爲操作系統從內核級別強行殺死某個進程, kill-15pid 則可以理解爲發送一個通知,告知應用主動關閉。這麼對比還是有點抽象,那我們就從應用的表現來看看,這兩個命令殺死應用到底有啥區別。

代碼準備

由於筆者 springboot 接觸較多,所以以一個簡易的 springboot 應用爲例展開討論,添加如下代碼。

1 增加一個實現了 DisposableBean 接口的類

@Component
public class TestDisposableBean implements DisposableBean{
    @Override
    public void destroy() throws Exception {
        System.out.println("測試 Bean 已銷燬 ...");
    }
}

2 增加 JVM 關閉時的鉤子

@SpringBootApplication
@RestController
public class TestShutdownApplication implements DisposableBean {

    public static void main(String[] args) {
        SpringApplication.run(TestShutdownApplication.class, args);
        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("執行 ShutdownHook ...");
            }
        }));
    }
}

測試步驟

  1. 執行 java-jar test-shutdown-1.0.jar 將應用運行起來
  2. 測試 kill-9pidkill-15pidctrl+c 後輸出日誌內容

測試結果

kill-15pid & ctrl+c,效果一樣,輸出結果如下

2018-01-14 16:55:32.424  INFO 8762 --- [       Thread-3] ationConfigEmbeddedWebApplicationContext : Closing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2cdf8d8a: startup date [Sun Jan 14 16:55:24 UTC 2018]; root of context hierarchy
2018-01-14 16:55:32.432  INFO 8762 --- [       Thread-3] o.s.j.e.a.AnnotationMBeanExporter        : Unregistering JMX-exposed beans on shutdown
執行 ShutdownHook ...
測試 Bean 已銷燬 ...
java -jar test-shutdown-1.0.jar  7.46s user 0.30s system 80% cpu 9.674 total

kill-9pid,沒有輸出任何應用日誌

[1]    8802 killed     java -jar test-shutdown-1.0.jar
java -jar test-shutdown-1.0.jar  7.74s user 0.25s system 41% cpu 19.272 total

可以發現,kill -9 pid 是給應用殺了個措手不及,沒有留給應用任何反應的機會。而反觀 kill -15 pid,則比較優雅,先是由 AnnotationConfigEmbeddedWebApplicationContext (一個 ApplicationContext 的實現類)收到了通知,緊接着執行了測試代碼中的 Shutdown Hook,最後執行了 DisposableBean#destory() 方法。孰優孰劣,立判高下。

一般我們會在應用關閉時處理一下“善後”的邏輯,比如

  1. 關閉 socket 鏈接
  2. 清理臨時文件
  3. 發送消息通知給訂閱方,告知自己下線
  4. 將自己將要被銷燬的消息通知給子進程
  5. 各種資源的釋放

等等

而 kill -9 pid 則是直接模擬了一次系統宕機,系統斷電,這對於應用來說太不友好了,不要用收割機來修剪花盆裏的花。取而代之,便是使用 kill -15 pid 來代替。如果在某次實際操作中發現:kill -15 pid 無法關閉應用,則可以考慮使用內核級別的 kill -9 pid ,但請事後務必排查出是什麼原因導致 kill -15 pid 無法關閉。

springboot 如何處理 -15 TERM Signal?

上面解釋過了,使用 kill -15 pid 的方式可以比較優雅的關閉 springboot 應用,我們可能有以下的疑惑: springboot/spring 是如何響應這一關閉行爲的呢?是先關閉了 tomcat,緊接着退出 JVM,還是相反的次序?它們又是如何互相關聯的?

嘗試從日誌開始着手分析, AnnotationConfigEmbeddedWebApplicationContext 打印出了 Closing 的行爲,直接去源碼中一探究竟,最終在其父類 AbstractApplicationContext 中找到了關鍵的代碼:

@Override
public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    this.shutdownHook = new Thread() {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          doClose();
        }
      }
    };
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

@Override
public void close() {
   synchronized (this.startupShutdownMonitor) {
      doClose();
      if (this.shutdownHook != null) {
         Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
      }
   }
}

protected void doClose() {
   if (this.active.get() && this.closed.compareAndSet(false, true)) {
      LiveBeansView.unregisterApplicationContext(this);
      // 發佈應用內的關閉事件
      publishEvent(new ContextClosedEvent(this));
      // Stop all Lifecycle beans, to avoid delays during individual destruction.
      if (this.lifecycleProcessor != null) {
         this.lifecycleProcessor.onClose();
      }
      // spring 的 BeanFactory 可能會緩存單例的 Bean 
      destroyBeans();
      // 關閉應用上下文&BeanFactory
      closeBeanFactory();
      // 執行子類的關閉邏輯
      onClose();
      this.active.set(false);
   }
}

爲了方便排版以及便於理解,我去除了源碼中的部分異常處理代碼,並添加了相關的註釋。在容器初始化時,ApplicationContext 便已經註冊了一個 Shutdown Hook,這個鉤子調用了 Close() 方法,於是當我們執行 kill -15 pid 時,JVM 接收到關閉指令,觸發了這個 Shutdown Hook,進而由 Close() 方法去處理一些善後手段。具體的善後手段有哪些,則完全依賴於 ApplicationContext 的 doClose() 邏輯,包括了註釋中提及的銷燬緩存單例對象,發佈 close 事件,關閉應用上下文等等,特別的,當 ApplicationContext 的實現類是 AnnotationConfigEmbeddedWebApplicationContext 時,還會處理一些 tomcat/jetty 一類內置應用服務器關閉的邏輯。

窺見了 springboot 內部的這些細節,更加應該瞭解到優雅關閉應用的必要性。JAVA 和 C 都提供了對 Signal 的封裝,我們也可以手動捕獲操作系統的這些 Signal,在此不做過多介紹,有興趣的朋友可以自己嘗試捕獲下。

還有其他優雅關閉應用的方式嗎?

spring-boot-starter-actuator 模塊提供了一個 restful 接口,用於優雅停機。

添加依賴

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

添加配置

#啓用shutdown
endpoints.shutdown.enabled=true
#禁用密碼驗證
endpoints.shutdown.sensitive=false

生產中請注意該端口需要設置權限,如配合 spring-security 使用。

執行 curl-X POST host:port/shutdown 指令,關閉成功便可以獲得如下的返回:

{"message":"Shutting down, bye..."}

雖然 springboot 提供了這樣的方式,但按我目前的瞭解,沒見到有人用這種方式停機,kill -15 pid 的方式達到的效果與此相同,將其列於此處只是爲了方案的完整性。

如何銷燬作爲成員變量的線程池?

儘管 JVM 關閉時會幫我們回收一定的資源,但一些服務如果大量使用異步回調,定時任務,處理不當很有可能會導致業務出現問題,在這其中,線程池如何關閉是一個比較典型的問題。

@Service
public class SomeService {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }
}

我們需要想辦法在應用關閉時(JVM 關閉,容器停止運行),關閉線程池。

初始方案:什麼都不做。在一般情況下,這不會有什麼大問題,因爲 JVM 關閉,會釋放之,但顯然沒有做到本文一直在強調的兩個字,沒錯----優雅。

方法一的弊端在於線程池中提交的任務以及阻塞隊列中未執行的任務變得極其不可控,接收到停機指令後是立刻退出?還是等待任務執行完成?抑或是等待一定時間任務還沒執行完成則關閉?

方案改進:

發現初始方案的劣勢後,我立刻想到了使用 DisposableBean 接口,像這樣:

@Service
public class SomeService implements DisposableBean{

    ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void concurrentExecute() {
        executorService.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("executed...");
            }
        });
    }

    @Override
    public void destroy() throws Exception {
        executorService.shutdownNow();
        //executorService.shutdown();
    }
}

緊接着問題又來了,是 shutdown 還是 shutdownNow 呢?這兩個方法還是經常被誤用的,簡單對比這兩個方法。

ThreadPoolExecutor 在 shutdown 之後會變成 SHUTDOWN 狀態,無法接受新的任務,隨後等待正在執行的任務執行完成。意味着,shutdown 只是發出一個命令,至於有沒有關閉還是得看線程自己。

ThreadPoolExecutor 對於 shutdownNow 的處理則不太一樣,方法執行之後變成 STOP 狀態,並對執行中的線程調用 Thread.interrupt() 方法(但如果線程未處理中斷,則不會有任何事發生),所以並不代表“立刻關閉”。

查看 shutdown 和 shutdownNow 的 java doc,會發現如下的提示:

shutdown() :Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted.Invocation has no additional effect if already shut down.This method does not wait for previously submitted tasks to complete execution.Use {@link #awaitTermination awaitTermination} to do that. shutdownNow():Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution. These tasks are drained (removed) from the task queue upon return from this method.This method does not wait for actively executing tasks to terminate. Use {@link #awaitTermination awaitTermination} to do that.There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. This implementation cancels tasks via {@link Thread#interrupt}, so any task that fails to respond to interrupts may never terminate.

兩者都提示我們需要額外執行 awaitTermination 方法,僅僅執行 shutdown/shutdownNow 是不夠的。

最終方案:參考 spring 中線程池的回收策略,我們得到了最終的解決方案。

public abstract class ExecutorConfigurationSupport extends CustomizableThreadFactory
      implements DisposableBean{
    @Override
    public void destroy() {
        shutdown();
    }

    /**
     * Perform a shutdown on the underlying ExecutorService.
     * @see java.util.concurrent.ExecutorService#shutdown()
     * @see java.util.concurrent.ExecutorService#shutdownNow()
     * @see #awaitTerminationIfNecessary()
     */
    public void shutdown() {
        if (this.waitForTasksToCompleteOnShutdown) {
            this.executor.shutdown();
        }
        else {
            this.executor.shutdownNow();
        }
        awaitTerminationIfNecessary();
    }

    /**
     * Wait for the executor to terminate, according to the value of the
     * {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
     */
    private void awaitTerminationIfNecessary() {
        if (this.awaitTerminationSeconds > 0) {
            try {
                this.executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS));
            }
            catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

保留了註釋,去除了一些日誌代碼,一個優雅關閉線程池的方案呈現在我們的眼前。

1 通過 waitForTasksToCompleteOnShutdown 標誌來控制是想立刻終止所有任務,還是等待任務執行完成後退出。

2 executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS)); 控制等待的時間,防止任務無限期的運行(前面已經強調過了,即使是 shutdownNow 也不能保證線程一定停止運行)。

更多需要思考的優雅停機策略

在我們分析 RPC 原理的系列文章裏面曾經提到,服務治理框架一般會考慮到優雅停機的問題。通常的做法是事先隔斷流量,接着關閉應用。常見的做法是將服務節點從註冊中心摘除,訂閱者接收通知,移除節點,從而優雅停機;涉及到數據庫操作,則可以使用事務的 ACID 特性來保證即使 crash 停機也能保證不出現異常數據,正常下線則更不用說了;又比如消息隊列可以依靠 ACK 機制+消息持久化,或者是事務消息保障;定時任務較多的服務,處理下線則特別需要注意優雅停機的問題,因爲這是一個長時間運行的服務,比其他情況更容易受停機問題的影響,可以使用冪等和標誌位的方式來設計定時任務...

事務和 ACK 這類特性的支持,即使是宕機,停電,kill -9 pid 等情況,也可以使服務儘量可靠;而同樣需要我們思考的還有 kill -15 pid,正常下線等情況下的停機策略。最後再補充下整理這個問題時,自己對 jvm shutdown hook 的一些理解。

When the virtual machine begins its shutdown sequence it will start all registered shutdown hooks in some unspecified order and let them run concurrently. When all the hooks have finished it will then run all uninvoked finalizers if finalization-on-exit has been enabled. Finally, the virtual machine will halt.

shutdown hook 會保證 JVM 一直運行,知道 hook 終止 (terminated)。這也啓示我們,如果接收到 kill -15 pid 命令時,執行阻塞操作,可以做到等待任務執行完成之後再關閉 JVM。同時,也解釋了一些應用執行 kill -15 pid 無法退出的問題,沒錯,中斷被阻塞了。

參考資料

[1] https://stackoverflow.com/questions/2921945/useful-example-of-a-shutdown-hook-in-java

[2] spring 源碼

[3] jdk 文檔

[4]linux oom kill 的代碼

[5]代碼中的SIGKILL

[6]Spring鉤子方法和鉤子接口的使用詳解

[7]Spring優雅關閉之:ShutDownHook

[8]理解和配置 Linux 下的 OOM Killer

原文地址:https://cloud.tencent.com/developer/article/1110765

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