你的Kubernetes Java應用優雅停機了嗎?
- 2022 年 1 月 16 日
- 筆記
- JAVA, Kubernetes
假如我們從 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 許可協議,轉載請註明出處。