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

末日沒有進行曲發表於2022-01-16

假如我們從 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 許可協議,轉載請註明出處。

相關文章