容器應(yīng)用優(yōu)雅關(guān)閉的終極大招

概述
優(yōu)雅關(guān)閉:在關(guān)閉前,執(zhí)行正常的關(guān)閉過程,釋放連接和資源,如我們操作系統(tǒng)執(zhí)行 shutdown。
目前業(yè)務(wù)系統(tǒng)組件眾多,互相之間調(diào)用關(guān)系也比較復(fù)雜,一個(gè)組件的下線、關(guān)閉會(huì)涉及到多個(gè)組件 對(duì)于任何一個(gè)線上應(yīng)用,如何保證服務(wù)更新部署過程中從應(yīng)用停止到重啟恢復(fù)服務(wù)這個(gè)過程中不影響正常的業(yè)務(wù)請(qǐng)求,這是應(yīng)用開發(fā)運(yùn)維團(tuán)隊(duì)必須要解決的問題。傳統(tǒng)的解決方式是通過將應(yīng)用更新流程劃分為手工摘流量、停應(yīng)用、更新重啟三個(gè)步驟,由人工操作實(shí)現(xiàn)客戶端不對(duì)更新感知。這種方式簡單而有效,但是限制較多:不僅需要使用借助網(wǎng)關(guān)的支持來摘流量,還需要在停應(yīng)用前人工判斷來保證在途請(qǐng)求已經(jīng)處理完畢。
同時(shí),在應(yīng)用層也有一些保障應(yīng)用優(yōu)雅停機(jī)的機(jī)制,目前 Tomcat、Spring Boot、Dubbo 等框架都有提供相關(guān)的內(nèi)置實(shí)現(xiàn),如 SpringBoot 2.3 內(nèi)置 graceful shutdown 可以很方便的直接實(shí)現(xiàn)優(yōu)雅停機(jī)時(shí)的資源處理,同時(shí)一個(gè)普通的 Java 應(yīng)用也可以基于 Runtime.getRuntime().addShutdownHook()來自定義實(shí)現(xiàn),它們的實(shí)現(xiàn)原理都基本一致,通過等待操作系統(tǒng)發(fā)送的 SIGTERM 信號(hào),然后針對(duì)監(jiān)聽到該信號(hào)做一些處理動(dòng)作。優(yōu)雅停機(jī)是指在停止應(yīng)用時(shí),執(zhí)行的一系列保證應(yīng)用正常關(guān)閉的操作。這些操作往往包括等待已有請(qǐng)求執(zhí)行完成、關(guān)閉線程、關(guān)閉連接和釋放資源等,優(yōu)雅停機(jī)可以避免非正常關(guān)閉程序可能造成數(shù)據(jù)異?;騺G失,應(yīng)用異常等問題。優(yōu)雅停機(jī)本質(zhì)上是 JVM 即將關(guān)閉前執(zhí)行的一些額外的處理代碼。
現(xiàn)狀分析
現(xiàn)階段,業(yè)務(wù)容器化后業(yè)務(wù)啟動(dòng)是通過 shell 腳本啟動(dòng)業(yè)務(wù),對(duì)應(yīng)的在容器內(nèi) PID 為 1 的進(jìn)程為 shell 進(jìn)程但 shell 程序不轉(zhuǎn)發(fā) signals,也不響應(yīng)退出信號(hào)。所以在容器應(yīng)用中如果應(yīng)用容器中啟動(dòng) shell,占據(jù)了 pid=1 的位置,那么就無法接收 k8s 發(fā)送的 SIGTERM 信號(hào),只能等超時(shí)后被強(qiáng)行殺死了。
案例分析
go 開發(fā)的一個(gè) 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("進(jìn)程啟動(dòng)...")
time.Sleep(time.Duration(200000)*time.Second)
}
func ExitFunc() {
fmt.Println("正在退出...")
fmt.Println("執(zhí)行清理...")
fmt.Println("退出完成...")
os.Exit(0)
}
代碼參考:https://www.jianshu.com/p/ae72ad58ecb6
1、Signal.Notify 會(huì)監(jiān)聽括號(hào)內(nèi)指定的信號(hào),若沒有指定,則監(jiān)聽所有信號(hào)。2、通過 switch 對(duì)監(jiān)聽到信號(hào)進(jìn)行判斷,如果是 SININT 和 SIGTERM 則條用 Exitfunc 函數(shù)執(zhí)行退出。
SHELL 模式和 CMD 模式帶來的差異性
編寫應(yīng)用 Dockerfile 文件
概述 在 Dockerfile 中 CMD 和 ENTRYPOINT 用來啟動(dòng)應(yīng)用,有 shell 模式和 exec 模式,對(duì)應(yīng)的使用 shell 模式,PID 為 1 的進(jìn)程為 shell,使用 exec 模式 PID 為 1 的進(jìn)程為業(yè)務(wù)本身。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
構(gòu)建鏡像
$ docker build -t app:v1.0-shell ./
運(yùn)行查看
$ 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 的進(jìn)程是 sh 進(jìn)程
此時(shí)執(zhí)行 docker stop,業(yè)務(wù)進(jìn)程是接收不到 SIGTERM 信號(hào)的,要等待一個(gè)超時(shí)時(shí)間后被 KILL
日志沒有輸出 SIGTERM 關(guān)閉指令
$ docker stop app-shell
app-shell
$ docker logs app-shell
進(jìn)程啟動(dòng)...
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"]
構(gòu)建鏡像
$ docker build -t app:v1.0-exec ./
運(yùn)行查看
$ 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 的進(jìn)程是應(yīng)用進(jìn)程
此時(shí)執(zhí)行 docker stop,業(yè)務(wù)進(jìn)程是可以接收 SIGTERM 信號(hào)的,會(huì)優(yōu)雅退出
$ docker stop app-exec
app-exec
$ docker logs app-exec
進(jìn)程啟動(dòng)...
退出 terminated
正在退出...
執(zhí)行清理...
退出完成...
注意:1、以下測試在 ubuntu 做為應(yīng)用啟動(dòng) base 鏡像測試成功,在 alpine 做為應(yīng)用啟動(dòng) base 鏡像時(shí) shell 模式和 exec 模式都一樣,都是應(yīng)用進(jìn)程為 PID 1 的進(jìn)程。
直接啟動(dòng)應(yīng)用和通過腳本啟動(dòng)區(qū)別
在實(shí)際生產(chǎn)環(huán)境中,因?yàn)閼?yīng)用啟動(dòng)命令后會(huì)接很多啟動(dòng)參數(shù),所以通常我們會(huì)使用一個(gè)啟動(dòng)腳本來啟動(dòng)應(yīng)用,方便我們啟動(dòng)應(yīng)用。對(duì)應(yīng)的在容器內(nèi) PID 為 1 的進(jìn)程為 shell 進(jìn)程但 shell 程序不轉(zhuǎn)發(fā) signals,也不響應(yīng)退出信號(hào)。所以在容器應(yīng)用中如果應(yīng)用容器中啟動(dòng) shell,占據(jù)了 pid=1 的位置,那么就無法接收 k8s 發(fā)送的 SIGTERM 信號(hào),只能等超時(shí)后被強(qiáng)行殺死了。啟動(dòng)腳本 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"]
構(gòu)建應(yīng)用
$ 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 關(guān)閉應(yīng)用
$ docker stop app-script
是登待超時(shí)后被強(qiáng)行 KILL
$ docker logs app-script
進(jìn)程啟動(dòng)...
容器應(yīng)用優(yōu)雅關(guān)閉方案介紹
方案介紹
正常的優(yōu)雅停機(jī)可以簡單的認(rèn)為包括兩個(gè)部分:
應(yīng)用:應(yīng)用自身需要實(shí)現(xiàn)優(yōu)雅停機(jī)的處理邏輯,確保處理中的請(qǐng)求可以繼續(xù)完成,資源得到有效的關(guān)閉釋放,等等。針對(duì)應(yīng)用層,不管是 Java 應(yīng)用還是其他語言編寫的應(yīng)用,其實(shí)現(xiàn)原理基本一致,都提供了類似的監(jiān)聽處理接口,根據(jù)規(guī)范要求實(shí)現(xiàn)即可。 平臺(tái):平臺(tái)層要能夠?qū)?yīng)用從負(fù)載均衡中去掉,確保應(yīng)用不會(huì)再接受到新的請(qǐng)求連接,并且能夠通知到應(yīng)用要進(jìn)行優(yōu)雅停機(jī)處理。在傳統(tǒng)的部署模式下,這部分工作可能需要人工處理,但是在 K8s 容器平臺(tái)中,K8s 的 Pod 刪除默認(rèn)就會(huì)向容器中的主進(jìn)程發(fā)送優(yōu)雅停機(jī)命令,并提供了默認(rèn) 30s 的等待時(shí)長,若優(yōu)雅停機(jī)處理超出 30s 以后就會(huì)強(qiáng)制終止。同時(shí),有些應(yīng)用在容器中部署時(shí),并不是通過容器主進(jìn)程的形式進(jìn)行部署,那么 K8s 也提供了 PreStop 的回調(diào)函數(shù)來在 Pod 停止前進(jìn)行指定處理,可以是一段命令,也可以是一個(gè) HTTP 的請(qǐng)求,從而具備了較強(qiáng)的靈活性。通過以上分析,理論上應(yīng)用容器化部署以后仍然可以很好的支持優(yōu)雅停機(jī),甚至相比于傳統(tǒng)方式實(shí)現(xiàn)了更多的自動(dòng)化操作,本文檔后面會(huì)針對(duì)該方案進(jìn)行詳細(xì)的方案驗(yàn)證。 容器應(yīng)用中第三方 Init:在構(gòu)建應(yīng)用中使用第三方 init 如 tini 或 dumb-init
方案一:通過 k8s 的 prestop 參數(shù)調(diào)用容器內(nèi)進(jìn)程關(guān)閉腳本,實(shí)現(xiàn)優(yōu)雅關(guān)閉。
方案二:通過第三方 init 進(jìn)程傳遞 SIGTERM 到進(jìn)程中。
方案驗(yàn)證
方案一:通過 k8s Prestop 參數(shù)調(diào)用
在前面腳本啟動(dòng)的 dockerfile 基礎(chǔ)上,定義一個(gè)優(yōu)雅關(guān)閉的腳本,通過 k8s-prestop 在關(guān)閉 POD 前調(diào)用優(yōu)雅關(guān)閉腳本,實(shí)現(xiàn) pod 優(yōu)雅關(guān)閉。
啟動(dòng)腳本 start.sh
$ cat > start.sh<< EOF
#!/bin/sh
./app
EOF
stop.sh 優(yōu)雅關(guān)閉腳本
#!/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"]
構(gòu)建鏡像
$ 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
進(jìn)程啟動(dòng)...
另外窗口刪除 POD
$ kubectl logs app-prestop-847f5c4db8-mrbqr -f
進(jìn)程啟動(dòng)...
退出 terminated
正在退出...
執(zhí)行清理...
退出完成...
可以看見執(zhí)行了 Prestop 腳本進(jìn)行優(yōu)雅關(guān)閉。同樣的可以將 yaml 文件中的 Prestop 腳本取消進(jìn)行對(duì)比測試可以發(fā)現(xiàn)就會(huì)進(jìn)行強(qiáng)制刪除。
方案二:shell 腳本修改為 exec 執(zhí)行
修改start.sh腳本
#!/bin/sh
exec ./app
shell 中添加一個(gè) exec 即可讓應(yīng)用進(jìn)程替代當(dāng)前 shell 進(jìn)程,可將 SIGTERM 信號(hào)傳遞到業(yè)務(wù)層,讓業(yè)務(wù)實(shí)現(xiàn)優(yōu)雅關(guān)閉。
可使用上面例子,進(jìn)行修改測試。
方案三:通過第三 init 工具啟動(dòng)
使用 dump-init 或 tini 做為容器的主進(jìn)程,在收到退出信號(hào)的時(shí)候,會(huì)將退出信號(hào)轉(zhuǎn)發(fā)給進(jìn)程組所有進(jìn)程。,主要適用應(yīng)用本身無關(guān)閉信號(hào)處理的場景。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"]
構(gòu)建鏡像
$ docker build -t app:v1.0-tini ./
測試運(yùn)行
$ docker run -itd --name app-tini app:v1.0-tini
查看日志
$ docker logs app-tini
進(jìn)程啟動(dòng)...
發(fā)現(xiàn)容器快速停止了,但沒有輸出應(yīng)用關(guān)閉和清理的日志
后面查閱相關(guān)資料發(fā)現(xiàn)
使用 tini 或 dump-init 做為應(yīng)用啟動(dòng)的主進(jìn)程。tini 和 dumb-init 會(huì)將關(guān)閉信號(hào)向子進(jìn)程傳遞,但不會(huì)等待子進(jìn)程完全退出后自己在退出。而是傳遞完后直接就退出了。
相關(guān) issue:https://github.com/krallin/tini/issues/180
后面又查到另外一個(gè)第三方的組件 smell-baron 能實(shí)現(xiàn)等待子進(jìn)程優(yōu)雅關(guān)閉后在關(guān)閉本身功能。但這個(gè)項(xiàng)目本身熱度不是特別高,并且有很久沒有維護(hù)了。
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"]
構(gòu)建鏡像
$ 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
進(jìn)程啟動(dòng)...
退出 terminated
正在退出...
執(zhí)行清理...
退出完成...
總結(jié):
1、對(duì)于容器化應(yīng)用啟動(dòng)命令建議使用 EXEC 模式。2、對(duì)于應(yīng)用本身代碼層面已經(jīng)實(shí)現(xiàn)了優(yōu)雅關(guān)閉的業(yè)務(wù),但有 shell 啟動(dòng)腳本,容器化后部署到 k8s 上建議使方案一和方案二。3、對(duì)于應(yīng)用本身代碼層面沒有實(shí)現(xiàn)優(yōu)雅關(guān)閉的業(yè)務(wù),建議使用方案三。
項(xiàng)目地址:
https://github.com/insidewhy/smell-baron[1] https://github.com/Yelp/dumb-init[2] https://github.com/krallin/tini[3]
腳注
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/


你可能還喜歡
點(diǎn)擊下方圖片即可閱讀

云原生是一種信仰 ??
關(guān)注公眾號(hào)
后臺(tái)回復(fù)?k8s?獲取史上最方便快捷的 Kubernetes 高可用部署工具,只需一條命令,連 ssh 都不需要!


點(diǎn)擊 "閱讀原文" 獲取更好的閱讀體驗(yàn)!
發(fā)現(xiàn)朋友圈變“安靜”了嗎?


