假如我們從 kafka 拉取資料然後生成任務處理資料,在服務退出時,如何保證記憶體中的資料能被正常處理完不丟失呢?假如服務是部署在 Kubernetes
中又該如何處理?
Java 應用優雅停機
我們首先考慮下,一般在什麼場景下資料會丟失呢?
- 升級服務時
- pod重啟時
- 伺服器斷電時
因為伺服器斷電屬於極端情況,我們暫且不考慮。那就只有 Java 退出時我們要保證資料的完整性了。在 Java 中,有一個方法可以實現應用退出時候的優雅停機:shutdown hook
。Spring 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時間),在優雅停機過程中,此 pod
在 API server
中會被更新為dead
狀態。當我們用kubectl
命令檢視此pod
時,它被展示為Terminating
的狀態。當 Kubelet
看到 pod
被標記為了 Terminating
狀態時,它就會開始執行 pod
的 shutdown
程式。如果我們 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
捕捉 TERM
(Kubelet
傳送的訊號) 和 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 許可協議,轉載請註明出處。