优雅关闭:在关闭前,执行正常的关闭过程,释放连接和资源,如我们操作系统执行 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 函数执行退出。
编写应用 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
进程启动...
正常的优雅停机可以简单的认为包括两个部分:
方案一:通过 k8s 的 prestop 参数调用容器内进程关闭脚本,实现优雅关闭。
方案二:通过第三方 init 进程传递 SIGTERM 到进程中。
在前面脚本启动的 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 脚本取消进行对比测试可以发现就会进行强制删除。
修改start.sh
脚本
#!/bin/sh
exec ./app
shell 中添加一个 exec 即可让应用进程替代当前 shell 进程,可将 SIGTERM 信号传递到业务层,让业务实现优雅关闭。
可使用上面例子,进行修改测试。
使用 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、对于应用本身代码层面没有实现优雅关闭的业务,建议使用方案三。
项目地址:
[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/