容器應用優雅關閉的終極大招
概述
優雅關閉:在關閉前,執行正常的關閉過程,釋放連接和資源,如我們操作系統執行 shutdown。
目前業務系統組件衆多,互相之間調用關係也比較複雜,一個組件的下線、關閉會涉及到多個組件 對於任何一個線上應用,如何保證服務更新部署過程中從應用停止到重啓恢復服務這個過程中不影響正常的業務請求,這是應用開發運維團隊必須要解決的問題。傳統的解決方式是通過將應用更新流程劃分爲手工摘流量、停應用、更新重啓三個步驟,由人工操作實現客戶端不對更新感知。這種方式簡單而有效,但是限制較多:不僅需要使用藉助網關的支持來摘流量,還需要在停應用前人工判斷來保證在途請求已經處理完畢。
同時,在應用層也有一些保障應用優雅停機的機制,目前 Tomcat、Spring Boot、Dubbo 等框架都有提供相關的內置實現,如 SpringBoot 2.3 內置 graceful shutdown 可以很方便的直接實現優雅停機時的資源處理,同時一個普通的 Java 應用也可以基於 Runtime.getRuntime().addShutdownHook() 來自定義實現,它們的實現原理都基本一致,通過等待操作系統發送的 SIGTERM 信號,然後針對監聽到該信號做一些處理動作。優雅停機是指在停止應用時,執行的一系列保證應用正常關閉的操作。這些操作往往包括等待已有請求執行完成、關閉線程、關閉連接和釋放資源等,優雅停機可以避免非正常關閉程序可能造成數據異常或丟失,應用異常等問題。優雅停機本質上是 JVM 即將關閉前執行的一些額外的處理代碼。
現狀分析
現階段,業務容器化後業務啓動是通過 shell 腳本啓動業務,對應的在容器內 PID 爲 1 的進程爲 shell 進程但 shell 程序不轉發 signals,也不響應退出信號。所以在容器應用中如果應用容器中啓動 shell,佔據了 pid=1 的位置,那麼就無法接收 k8s 發送的 SIGTERM 信號,只能等超時後被強行殺死了。
案例分析
go 開發的一個 Demo
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
c := make(chan os.Signal)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
go func() {
for s := range c {
switch s {
case syscall.SIGINT, syscall.SIGTERM:
fmt.Println("退出", s)
ExitFunc()
default:
fmt.Println("other", s)
}
}
}()
fmt.Println("進程啓動...")
time.Sleep(time.Duration(200000)*time.Second)
}
func ExitFunc() {
fmt.Println("正在退出...")
fmt.Println("執行清理...")
fmt.Println("退出完成...")
os.Exit(0)
}
代碼參考:https://www.jianshu.com/p/ae72ad58ecb6
1、Signal.Notify 會監聽括號內指定的信號,若沒有指定,則監聽所有信號。2、通過 switch 對監聽到信號進行判斷,如果是 SININT 和 SIGTERM 則條用 Exitfunc 函數執行退出。
SHELL 模式和 CMD 模式帶來的差異性
編寫應用 Dockerfile 文件
概述 在 Dockerfile 中 CMD 和 ENTRYPOINT 用來啓動應用,有 shell 模式和 exec 模式,對應的使用 shell 模式,PID 爲 1 的進程爲 shell,使用 exec 模式 PID 爲 1 的進程爲業務本身。SHELL 模式
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM ubuntu
WORKDIR /root/
COPY --from=builder /go/app .
CMD ./app
構建鏡像
$ docker build -t app:v1.0-shell ./
運行查看
$ docker exec -it app-shell ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.7 0.0 2608 548 pts/0 Ss+ 03:22 0:00 /bin/sh -c ./
root 6 0.0 0.0 704368 1684 pts/0 Sl+ 03:22 0:00 ./app
root 24 0.0 0.0 5896 2868 pts/1 Rs+ 03:23 0:00 ps aux
可以看見 PID 爲 1 的進程是 sh 進程
此時執行 docker stop,業務進程是接收不到 SIGTERM 信號的,要等待一個超時時間後被 KILL
日誌沒有輸出 SIGTERM 關閉指令
$ docker stop app-shell
app-shell
$ docker logs app-shell
進程啓動...
EXEC 模式
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM ubuntu
WORKDIR /root/
COPY --from=builder /go/app .
CMD ["./app"]
構建鏡像
$ docker build -t app:v1.0-exec ./
運行查看
$ docker exec -it app-exec ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 2.0 0.0 703472 1772 pts/0 Ssl+ 03:33 0:00 ./app
root 14 0.0 0.0 5896 2908 pts/1 Rs+ 03:34 0:00 ps aux
可以看見 PID 爲 1 的進程是應用進程
此時執行 docker stop,業務進程是可以接收 SIGTERM 信號的,會優雅退出
$ docker stop app-exec
app-exec
$ docker logs app-exec
進程啓動...
退出 terminated
正在退出...
執行清理...
退出完成...
注意:1、以下測試在 ubuntu 做爲應用啓動 base 鏡像測試成功,在 alpine 做爲應用啓動 base 鏡像時 shell 模式和 exec 模式都一樣,都是應用進程爲 PID 1 的進程。
直接啓動應用和通過腳本啓動區別
在實際生產環境中,因爲應用啓動命令後會接很多啓動參數,所以通常我們會使用一個啓動腳本來啓動應用,方便我們啓動應用。對應的在容器內 PID 爲 1 的進程爲 shell 進程但 shell 程序不轉發 signals,也不響應退出信號。所以在容器應用中如果應用容器中啓動 shell,佔據了 pid=1 的位置,那麼就無法接收 k8s 發送的 SIGTERM 信號,只能等超時後被強行殺死了。啓動腳本 start.sh
$ cat > start.sh<< EOF
#!/bin/sh
sh -c /root/app
EOF
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM alpine
WORKDIR /root/
COPY --from=builder /go/app .
ADD start.sh /root/
CMD ["/bin/sh","/root/start.sh"]
構建應用
$ docker build -t app:v1.0-script ./
查看
$ docker exec -it app-script ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh /root/start.sh
6 root 0:00 /root/app
19 root 0:00 ps aux
docker stop 關閉應用
$ docker stop app-script
是登待超時後被強行 KILL
$ docker logs app-script
進程啓動...
容器應用優雅關閉方案介紹
方案介紹
正常的優雅停機可以簡單的認爲包括兩個部分:
-
應用:應用自身需要實現優雅停機的處理邏輯,確保處理中的請求可以繼續完成,資源得到有效的關閉釋放,等等。針對應用層,不管是 Java 應用還是其他語言編寫的應用,其實現原理基本一致,都提供了類似的監聽處理接口,根據規範要求實現即可。
-
平臺:平臺層要能夠將應用從負載均衡中去掉,確保應用不會再接受到新的請求連接,並且能夠通知到應用要進行優雅停機處理。在傳統的部署模式下,這部分工作可能需要人工處理,但是在 K8s 容器平臺中,K8s 的 Pod 刪除默認就會向容器中的主進程發送優雅停機命令,並提供了默認 30s 的等待時長,若優雅停機處理超出 30s 以後就會強制終止。同時,有些應用在容器中部署時,並不是通過容器主進程的形式進行部署,那麼 K8s 也提供了 PreStop 的回調函數來在 Pod 停止前進行指定處理,可以是一段命令,也可以是一個 HTTP 的請求,從而具備了較強的靈活性。通過以上分析,理論上應用容器化部署以後仍然可以很好的支持優雅停機,甚至相比於傳統方式實現了更多的自動化操作,本文檔後面會針對該方案進行詳細的方案驗證。
-
容器應用中第三方 Init:在構建應用中使用第三方 init 如 tini 或 dumb-init
方案一:通過 k8s 的 prestop 參數調用容器內進程關閉腳本,實現優雅關閉。
方案二:通過第三方 init 進程傳遞 SIGTERM 到進程中。
方案驗證
方案一:通過 k8s Prestop 參數調用
在前面腳本啓動的 dockerfile 基礎上,定義一個優雅關閉的腳本,通過 k8s-prestop 在關閉 POD 前調用優雅關閉腳本,實現 pod 優雅關閉。
啓動腳本 start.sh
$ cat > start.sh<< EOF
#!/bin/sh
./app
EOF
stop.sh 優雅關閉腳本
#!/bin/sh
ps -ef|grep app|grep -v grep|awk '{print $1}'|xargs kill -15
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM alpine
WORKDIR /root/
COPY --from=builder /go/app .
ADD start.sh /root/
CMD ["/bin/sh","/root/start.sh"]
構建鏡像
$ docker build -t app:v1.0-prestop ./
通過 yaml 部署到 k8s 中
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-prestop
labels:
app: prestop
spec:
replicas: 1
selector:
matchLabels:
app: prestop
template:
metadata:
labels:
app: prestop
spec:
containers:
- name: prestop
image: 172.16.1.31/library/app:v1.0-prestop
lifecycle:
preStop:
exec:
command:
- sh
- /root/stop.sh
查看 POD 日誌,然後刪除 pod 副本
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
app-prestop-847f5c4db8-mrbqr 1/1 Running 0 73s
查看日誌
$ kubectl logs app-prestop-847f5c4db8-mrbqr -f
進程啓動...
另外窗口刪除 POD
$ kubectl logs app-prestop-847f5c4db8-mrbqr -f
進程啓動...
退出 terminated
正在退出...
執行清理...
退出完成...
可以看見執行了 Prestop 腳本進行優雅關閉。同樣的可以將 yaml 文件中的 Prestop 腳本取消進行對比測試可以發現就會進行強制刪除。
方案二:shell 腳本修改爲 exec 執行
修改start.sh
腳本
#!/bin/sh
exec ./app
shell 中添加一個 exec 即可讓應用進程替代當前 shell 進程, 可將 SIGTERM 信號傳遞到業務層,讓業務實現優雅關閉。
可使用上面例子,進行修改測試。
方案三:通過第三 init 工具啓動
使用 dump-init 或 tini 做爲容器的主進程,在收到退出信號的時候,會將退出信號轉發給進程組所有進程。,主要適用應用本身無關閉信號處理的場景。docker –init 本身也是集成的 tini。
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM alpine
WORKDIR /root/
COPY --from=builder /go/app .
ADD start.sh tini /root/
RUN chmoad a+x start.sh && apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/root/tini", "--", /root/start.sh"]
構建鏡像
$ docker build -t app:v1.0-tini ./
測試運行
$ docker run -itd --name app-tini app:v1.0-tini
查看日誌
$ docker logs app-tini
進程啓動...
發現容器快速停止了,但沒有輸出應用關閉和清理的日誌
後面查閱相關資料發現
使用 tini 或 dump-init 做爲應用啓動的主進程。tini 和 dumb-init 會將關閉信號向子進程傳遞,但不會等待子進程完全退出後自己在退出。而是傳遞完後直接就退出了。
相關 issue:https://github.com/krallin/tini/issues/180
後面又查到另外一個第三方的組件 smell-baron 能實現等待子進程優雅關閉後在關閉本身功能。但這個項目本身熱度不是特別高,並且有很久沒有維護了。
FROM golang as builder
WORKDIR /go/
COPY app.go .
RUN go build app.go
FROM ubuntu
WORKDIR /root/
COPY --from=builder /go/app .
ADD start.sh /root/
ADD smell-baron /bin/smell-baron
RUN chmod a+x /bin/smell-baron && chmod a+x start.sh
ENTRYPOINT ["/bin/smell-baron"]
CMD ["/root/start.sh"]
構建鏡像
$ docker build -t app:v1.0-smell-baron ./
測試
$ docker run -itd --name app-smell-baron app:v1.0-smell-baron
$ docker stop app-smell-baron
進程啓動...
退出 terminated
正在退出...
執行清理...
退出完成...
總結:
1、對於容器化應用啓動命令建議使用 EXEC 模式。2、對於應用本身代碼層面已經實現了優雅關閉的業務,但有 shell 啓動腳本,容器化後部署到 k8s 上建議使方案一和方案二。3、對於應用本身代碼層面沒有實現優雅關閉的業務,建議使用方案三。
項目地址:
-
https://github.com/insidewhy/smell-baron[1]
-
https://github.com/Yelp/dumb-init[2]
-
https://github.com/krallin/tini[3]
腳註
[1]
https://github.com/insidewhy/smell-baron: https://github.com/insidewhy/smell-baron
[2]
https://github.com/Yelp/dumb-init: https://github.com/Yelp/dumb-init
[3]
https://github.com/krallin/tini: https://github.com/krallin/tini
原文鏈接:https://www.bladewan.com/2021/05/26/graceful_close/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bS4Skooi9gs3aBxNu1nTAA