docker stop
对于docker来说,一般来说通过docker stop
命令来实现停止容器,而不是docker kill
。
具体命令如下:
docker stop [OPTIONS] CONTAINER [CONTAINER...]
容器内的主进程(PID为1的进程)将收到SIGTERM,并在宽限期之后收到SIGKILL。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉,因为SIGKILL信号是直接发往系统内核的,应用程序没有机会去处理它。
至于这个宽限期默认是10s,当然可以通过参数来制定具体时间。
docker stop --help
Usage: docker stop [OPTIONS] CONTAINER [CONTAINER...]
Stop one or more running containers
Options:
--help Print usage
-t, --time int Seconds to wait for stop before killing it (default 10)
而对于k8s来说,pod的宽限期默认是30s。通过terminationGracePeriodSeconds
参数设置。
为什么需要优雅stop docker ?
你的程序需要一些退出工作,比如保存checkpoint,回收一些资源对象等。如果你的服务是一个http server,那么你需要完成已经处理的请求。如果是长链接,你还需要主动关闭keepalive。
如果你是在k8s中运行容器,那么k8s整个机制是一种基于watch的并行机制,我们不能保证操作的串行执行。比如在删除一个Pod的时候,需要更改iptables规则,LB的upstream 摘除等。
你的应用程序为什么接收不到SIGTERM停机信号?
- 你的业务进程不是1号进程
Dockerfile中支持两种格式定义入口点:shell格式和exec 格式。
exec格式如下:
ENTRYPOINT ["/app/bin/your-app", "arg1", "arg2"]
该格式能保证你的主进程接受到停机信号。
示例:
程序代码如下:
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
c := make(chan os.Signal)
// 监听信号
signal.Notify(c, syscall.SIGTERM)
go func() {
for s := range c {
switch s {
case syscall.SIGTERM:
fmt.Println("退出:", s)
ExitFunc()
default:
fmt.Println("其他信号:", s)
}
}
}()
fmt.Println("启动了程序")
sum := 0
for {
sum++
fmt.Println("休眠了:", sum, "秒")
time.Sleep(1 * time.Second)
}
}
func ExitFunc() {
fmt.Println("开始退出...")
fmt.Println("执行清理...")
fmt.Println("结束退出...")
os.Exit(0)
}
Dockerfiler如下,我们采用多阶段构建:
FROM golang:latest as builder
WORKDIR /go/src
COPY main.go .
RUN CGO_ENABLED=0 go build -o stop ./main.go
From alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/stop .
RUN chmod +x /root/stop
ENTRYPOINT ["/root/stop"]
构建镜像:
docker build -t stop .
Sending build context to Docker daemon 3.584kB
Step 1/9 : FROM golang:latest as builder
latest: Pulling from library/golang
376057ac6fa1: Pull complete
5a63a0a859d8: Pull complete
496548a8c952: Pull complete
2adae3950d4d: Pull complete
039b991354af: Pull complete
0cca3cbecb14: Pull complete
59c34b3f33f3: Pull complete
Digest: sha256:1e36f8e9ac49d5ee6d72e969382a698614551a59f4533d5d61590e3deeb543a7
Status: Downloaded newer image for golang:latest
---> 7e5e8028e8ec
Step 2/9 : WORKDIR /go/src
---> Running in efb1e4b1c200
Removing intermediate container efb1e4b1c200
---> 312e98c07647
Step 3/9 : COPY main.go .
---> 2dc4088e6548
Step 4/9 : RUN CGO_ENABLED=0 go build -o stop ./main.go
---> Running in 6d18a1ef07ff
Removing intermediate container 6d18a1ef07ff
---> a207b2ecdd67
Step 5/9 : From alpine:latest
latest: Pulling from library/alpine
Digest: sha256:9a839e63dad54c3a6d1834e29692c8492d93f90c59c978c1ed79109ea4fb9a54
Status: Downloaded newer image for alpine:latest
---> f70734b6a266
Step 6/9 : WORKDIR /root/
---> Running in a308fc079da2
Removing intermediate container a308fc079da2
---> a14716065730
Step 7/9 : COPY --from=builder /go/src/stop .
---> 3573b92b9ab3
Step 8/9 : RUN chmod +x /root/stop
---> Running in f620b3287636
Removing intermediate container f620b3287636
---> 3cbc57300792
Step 9/9 : ENTRYPOINT ["/root/stop"]
---> Running in 86f23ea9306f
Removing intermediate container 86f23ea9306f
---> 283788e6ad37
Successfully built 283788e6ad37
Successfully tagged stop:latest
在一个终端中运行该镜像:
docker run stop
在另外一个终端stop该容器:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
91eeef705489 stop "/root/stop" 12 seconds ago Up 11 seconds clever_leavitt
docker stop 91eeef705489
91eeef705489
最终有如下输出:
启动了程序
休眠了: 1 秒
休眠了: 2 秒
休眠了: 3 秒
休眠了: 4 秒
休眠了: 5 秒
休眠了: 6 秒
休眠了: 7 秒
休眠了: 8 秒
休眠了: 9 秒
休眠了: 10 秒
休眠了: 11 秒
休眠了: 12 秒
休眠了: 13 秒
休眠了: 14 秒
休眠了: 15 秒
休眠了: 16 秒
休眠了: 17 秒
休眠了: 18 秒
休眠了: 19 秒
休眠了: 20 秒
休眠了: 21 秒
休眠了: 22 秒
退出: terminated
开始退出...
执行清理...
结束退出...
通过标准输出,我们的程序接受到了SIGTERM信号,并执行了一些退出工作。
shell格式如下:
ENTRYPOINT "/app/bin/your-app arg1 arg2"
Shell格式将您的入口点作为/bin/sh -c
的子命令来运行。
示例:
代码不变,Dockerfile更改为:
FROM golang:latest as builder
WORKDIR /go/src
COPY main.go .
RUN CGO_ENABLED=0 go build -o stop ./main.go
From alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/stop .
RUN chmod +x /root/stop
ENTRYPOINT "/root/stop"
构建新的镜像:
$ docker build -t stop-shell -f Dockerfile-shell .
Sending build context to Docker daemon 4.608kB
Step 1/9 : FROM golang:latest as builder
---> 7e5e8028e8ec
Step 2/9 : WORKDIR /go/src
---> Using cache
---> 312e98c07647
Step 3/9 : COPY main.go .
---> Using cache
---> 2dc4088e6548
Step 4/9 : RUN CGO_ENABLED=0 go build -o stop ./main.go
---> Using cache
---> a207b2ecdd67
Step 5/9 : From alpine:latest
---> f70734b6a266
Step 6/9 : WORKDIR /root/
---> Using cache
---> a14716065730
Step 7/9 : COPY --from=builder /go/src/stop .
---> Using cache
---> 3573b92b9ab3
Step 8/9 : RUN chmod +x /root/stop
---> Using cache
---> 3cbc57300792
Step 9/9 : ENTRYPOINT "/root/stop"
---> Running in 199ca0277b08
Removing intermediate container 199ca0277b08
---> e0fe6a86ee1e
Successfully built e0fe6a86ee1e
Successfully tagged stop-shell:latest
重复上面的步骤,最终观察到的结果如下:
动了程序
休眠了: 1 秒
休眠了: 2 秒
休眠了: 3 秒
休眠了: 4 秒
休眠了: 5 秒
休眠了: 6 秒
休眠了: 7 秒
休眠了: 8 秒
休眠了: 9 秒
休眠了: 10 秒
休眠了: 11 秒
休眠了: 12 秒
休眠了: 13 秒
休眠了: 14 秒
休眠了: 15 秒
休眠了: 16 秒
休眠了: 17 秒
休眠了: 18 秒
休眠了: 19 秒
休眠了: 20 秒
休眠了: 21 秒
休眠了: 22 秒
休眠了: 23 秒
休眠了: 24 秒
退出: terminated
开始退出...
执行清理...
结束退出...
shell格式,我们的主程序也接受到了停机信号,并做了退出工作。
为了验证,我们docker exec
到运行的docker-shell容器中,执行ps:
docker exec -it 0299308034e7 sh
~ # ps
PID USER TIME COMMAND
1 root 0:00 /root/stop
12 root 0:00 sh
17 root 0:00 ps
我们的应用进程是1号进程,所以我们依旧可以接收到SIGTERM信号。
当我们的应用程序直接是启动的入口,那么在接受停机信号方面,两种格式并没有什么区别。
如果我们的启动脚本是一个类似于run.sh 的shell脚本,又会怎么样那?
当我们以一个shell脚本启动我们的应用程序,那么我们的应用程序不再是1号进程,此时,shell进程并不会通知我们的应用进程退出,我们需要在shell脚本中做一些特殊的处理,才能实现同样的效果。
需要做的就是告诉你的Shell用你的应用程序替换自身。为此,shell具有exec 命令(与前面讲到的 exec 格式相似)。详情见exec syscall。
在run.sh 中替换
/app/bin/your-app
为:
exec /app/bin/your-app
示例:
我们的run.sh 脚本如下:
#!/bin/sh
exec /root/stop
然后我们的Dockerfile 变更为:
FROM golang:latest as builder
WORKDIR /go/src
COPY main.go .
RUN CGO_ENABLED=0 go build -o stop ./main.go
From alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/stop .
COPY run.sh .
RUN chmod +x /root/stop
ENTRYPOINT ["/root/run.sh"]
构建新的镜像之后,运行该镜像:
docker run stop-shell-runsh
启动了程序
休眠了: 1 秒
休眠了: 2 秒
休眠了: 3 秒
然后进入到容器中执行ps
:
docker exec -it 97adce7dd7e4 sh
~ # ps
PID USER TIME COMMAND
1 root 0:00 /root/stop
14 root 0:00 sh
19 root 0:00 ps
可以看到虽然我们的启动脚本是run.sh,但是经过exec
之后,应用程序成为了1号进程。
停止运行容器查看停机状况:
docker stop 97adce7dd7e4
然后可以看到容器有如下输出:
休眠了: 104 秒
休眠了: 105 秒
休眠了: 106 秒
休眠了: 107 秒
休眠了: 108 秒
休眠了: 109 秒
休眠了: 110 秒
休眠了: 111 秒
休眠了: 112 秒
休眠了: 113 秒
休眠了: 114 秒
休眠了: 115 秒
休眠了: 116 秒
休眠了: 117 秒
退出: terminated
开始退出...
执行清理...
结束退出...
- 监听了错误的信号
并不是所有的代码框架都支持SIGTERM,比如Python的生态中,经常是SIGINT。
例如:
try:
do_work()
except KeyboardInterrupt:
cleanup()
所以默认是发送SIGTERM信号,我们依旧可以设置成其他的信号。
最简单的解决方法是在Dockerfile中添加一行:
STOPSIGNAL SIGINT
虽然我们将应用程序作为1号进程,可以接收到信号,但是也带来其他的问题,比如僵尸进程。该问题在docker使用过程中很普遍存在。大家可以参考我另外一篇文章-- 避免在Docker镜像下将NodeJS作为PID 1运行。
最佳实践
使用init
系统。这里我们推荐使用tini。
Tini是你可能想到的最简单的init
。 Tini所做的全部工作就是span出子进程,并等待它退出,同时收获僵尸进程并执行信号转发。
使用 tini 有以下好处:
- 它可以保护您免受意外创建僵尸进程的软件的侵害,因为僵尸进程可能(随着时间的推移!)使整个系统缺乏PID(并使其无法使用)。
- 它可确保默认信号处理程序适用于您在Docker镜像中运行的软件。例如,对于Tini,即使您没有显式安装信号处理程序,SIGTERM也会正确终止您的进程。
- 它完全透明地执行!没有Tini的Docker镜像将与Tini一起使用,而无需进行任何更改。
示例:
新的Dockerfile如下:
FROM golang:latest as builder
WORKDIR /go/src
COPY main.go .
RUN CGO_ENABLED=0 go build -o stop ./main.go
From alpine:latest
RUN apk add --no-cache tini
WORKDIR /root/
COPY --from=builder /go/src/stop .
RUN chmod +x /root/stop
ENTRYPOINT ["/sbin/tini", "--", "/root/stop"]
构建镜像:
docker build -t stop-tini -f Dockerfile-tini .
运行tini镜像:
$ docker run stop-tini
启动了程序
休眠了: 1 秒
休眠了: 2 秒
休眠了: 3 秒
休眠了: 4 秒
休眠了: 5 秒
休眠了: 6 秒
休眠了: 7 秒
...
此时在另外一个终端执行docker exec
进入到容器中,并执行ps
:
docker exec -it a727bd6617f4 sh
~ # ps
PID USER TIME COMMAND
1 root 0:00 /sbin/tini -- /root/stop
7 root 0:00 /root/stop
14 root 0:00 sh
20 root 0:00 ps
此时可以看到,tini是1号进程,我们的应用程序是1号进程的子进程(7号)。
停止该容器:
docker stop a727bd6617f4
最终我们的运行容器有以下输出:
休眠了: 82 秒
休眠了: 83 秒
休眠了: 84 秒
休眠了: 85 秒
休眠了: 86 秒
退出: terminated
开始退出...
执行清理...
结束退出...
可以看到我们业务进程虽然不是1号进程,但是也接受到了停机信号。
当然这一切都归功于tini,tini将信号转发到了我们的应用程序。