你的Kubernetes Java應用優雅停機了嗎?

假如我們從 kafka 拉取數據然後生成任務處理數據,在服務退出時,如何保證記憶體中的數據能被正常處理完不丟失呢?假如服務是部署在 Kubernetes 中又該如何處理?

Java 應用優雅停機

我們首先考慮下,一般在什麼場景下數據會丟失呢?

  • 升級服務時
  • pod重啟時
  • 伺服器斷電時

因為伺服器斷電屬於極端情況,我們暫且不考慮。那就只有 Java 退出時我們要保證數據的完整性了。在 Java 中,有一個方法可以實現應用退出時候的優雅停機:shutdown hookSpring boot把這個東西封裝了一下,可以通過 @PreDestroy 註解實現。當 JVM 收到退出的訊號時,會調用 shutdown hook 中的方法,完成清理操作。示例程式碼如下:

Runtime.getRuntime().addShutdownHook(new Thread() {
	@Override
	public void run() {
		System.out.println("Start to run shutdown hook.");
	}
})

Shutdown hook 可以保證在我們程式碼主動調用 System.exit()OOM, 在終端執行 Ctrl+C,以及應用主動關閉等情況下時被調用。在實際的場景中,我們可以在上述的執行緒中執行清理操作。比如,停止 kafka 的數據消費,以及任務的及時處理等。

當我們使用 java -jar *.jar 運行 Java程式後,通過執行 kill $pid,可以發現程式確實可以優雅退出。但是當我把服務部署到 Kubernetes 時,發現這個邏輯並沒有被執行,到底哪裡出了問題?

在 Kubernetes 中優雅停機

當我們發送 delete 命令給 pod 時,Kubernetes 會使用優雅停機(默認30s時間),在優雅停機過程中,此 podAPI server 中會被更新為dead狀態。當我們用kubectl 命令查看此pod時,它被展示為Terminating 的狀態。當 Kubelet 看到 pod被標記為了 Terminating 狀態時,它就會開始執行 podshutdown 程式。如果我們 pod 的容器定義了 preStop hook,那麼這個 hook 會在容器中執行;與此同時,Kubelet 會向容器內發送一個TERM訊號。Service也會將此 pod 從 endpoint 列表移除。當優雅停機時間過後,在 pod 里仍然存活的進程則會被SIGKILL命令殺掉。Kubelet會在 API server 里通過設置 grace period=0(立即刪除)來完成 Pod 的刪除操作。刪除後此 Pod 會在API中消失,並且在客戶端也不可見了。

以上,可以看出,我們的容器是會收到 TERM 訊號的,按照常理,如果我們的 Java 進程收到了 TERM 訊號是可以正常執行我們寫的 shutdown hook 優雅退出的,但是這裡卻沒有執行,很有可能是我們的 Java 進程根本就沒有收到訊號。

查看我們的 Dockerfile,發現我們定義的啟動命令是執行一個 run.sh 的腳本,在 run.sh 腳本中,進一步執行了啟動 Java 進程的命令。

# run.sh
...
sh start.sh start
...
while [1]
do 
	sleep 30
done

可以看到,我們在 run.sh 中進一步執行了 start.sh,Java 進程的啟動邏輯在start.sh腳本中。我們可以執行 ps -ef 查看下當前容器中的進程

UID		PID		PPID		C 	STIME 		TTY 	TIME 		CMD
root		1		0		0	11:01		?	00:00:00	bash ~/run.sh	
root		4084		1		8	11:01		?	00:15:00	java -Dname=test
root		14913		1		0	13:49		?	00:00:00	sleep 30
root		14914		0		0	13:50		pts/0	00:00:00	bash
root		14955		14914		0	13:50		pts/0	00:00:00	ps -ef

可以看到,我們運行的 run.sh 的 PID 是 1,Java 進程的 PID 是 4084,Java 進程是 run.sh 進程的一個子進程。問題就出在這裡,在 pod 被刪除時,TERM 訊號只會發送給 1號進程,而 run.sh 接收到此訊號後並不會將其轉發給 Java 進程,因此 Java 便無法觸發 shutdown hook,無法實現優雅退出。最終,Java 是被 SIGKILL 訊號殺掉的(強制退出)。所以,我們只需要讓 Java 進程作為 1號進程就行了。改寫下腳本,我們把啟動 Java 進程的命令放到 run.sh

# run.sh
...
exec java $JAVA_OPTS -jar ./*.jar --server.port=8080
...
while [1]
do 
	sleep 30
done

exec 的作用是被執行的命令行替換掉當前的 shell 進程。測試發現 OK,此時我們實現了優雅停機。但是,這足夠優雅嗎?

更優雅地停機

在上一步,我們實現了優雅停機,但是其實這並不是最優方案。我在看 start.sh 腳本中,發現此腳本定義了 start, restart, stop, status 4個方法,而且這個腳本中定義了很多額外的變數,如果我們要把之前的功能都實現的話,就需要把邏輯都搬到 run.sh 中。這無疑會增大工作量,這是不優雅的原因之一。

其次,一般是不推薦把 Java 進程作為1號進程的。因為在 Linux中,1號進程有特殊作用:1號進程會作為孤兒進程的父進程,它需要對自己的子進程進行清理回收,避免系統產生殭屍進程。bash可以很好地處理這種清理工作,我們一般自己寫的 Java 程式是不會考慮這種東西的。

那麼,就需要我們在 shell 中接收到 TERM 訊號後把訊號傳遞給 Java 進程了。這需要怎麼做呢?我們需要使用trap命令。trap 命令的作用是捕捉訊號和其他事件並執行命令。

# run.sh
...
sh start.sh start

grace_exit() {
	echo 'grace exit started'
	sh start.sh stop &
	wait $!
	echo 'grace exit finished'
}
trap 'grace_exit' TERM INT
...
while [1]
do 
	sleep 30
done

在腳本中,我們使用 trap 捕捉 TERMKubelet 發送的訊號) 和 INT(快速關閉,當用戶輸入 Control-C時由終端程式發送) 訊號,捕捉到了以後,我們執行了 grace_exit 方法,在此方法中,調用了 start.sh 腳本的 stop 方法,其實這個 stop 方法就是找到了 Java 進程,然後給其發送了 kill 命令,我們直接在 grace_exit 中執行相同邏輯也是可以的,這裡是為了復用邏輯。我們還使用了 & 保證 stop 方法在後台運行,這樣方便我們獲取其進程號($!會返回shell最後運行的後台進程的 PID),等待其執行結束。 這樣,當我們 delete``pod 時,Kubelet 發送 TERM 訊號後,我們就能傳達給 Java 進程,進而讓 Java 進程進行優雅停機了。


標題你的Kubernetes Java應用優雅停機了嗎?
作者末日沒有進行曲
鏈接你的Kubernetes Java應用優雅停機了嗎?
時間:2021-01-15
聲明:本部落格所有文章均採用 CC BY-NC-SA 4.0 許可協議,轉載請註明出處。