docker stop
对于docker来说,一般来说通过docker stop
命令来实现停止容器,而不是docker kill
。
具体命令如下:
docker stop [OPTIONS] CONTAINER [CONTAINER...]
容器内的主进程(PID为1的进程)将收到SIGTERM,并在宽限期之后收到SIGKILL。在容器中的应用程序,可以选择忽略和不处理SIGTERM信号,不过一旦达到超时时间,程序就会被系统强行kill掉,因为SIGKILL信号是直接发往系统内核的,应用程序没有机会去处理它。
至于这个宽限期默认是10s,当然可以通过参数来制定具体时间。
docker stop --helpUsage: docker stop [OPTIONS] CONTAINER [CONTAINER...]Stop one or more running containersOptions: --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 mainimport ( "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 builderWORKDIR /go/srcCOPY main.go .RUN CGO_ENABLED=0 go build -o stop ./main.goFrom alpine:latestWORKDIR /root/COPY --from=builder /go/src/stop .RUN chmod +x /root/stopENTRYPOINT ["/root/stop"]
构建镜像:
docker build -t stop .Sending build context to Docker daemon 3.584kBStep 1/9 : FROM golang:latest as builderlatest: Pulling from library/golang376057ac6fa1: Pull complete 5a63a0a859d8: Pull complete 496548a8c952: Pull complete 2adae3950d4d: Pull complete 039b991354af: Pull complete 0cca3cbecb14: Pull complete 59c34b3f33f3: Pull complete Digest: sha256:1e36f8e9ac49d5ee6d72e969382a698614551a59f4533d5d61590e3deeb543a7Status: Downloaded newer image for golang:latest ---> 7e5e8028e8ecStep 2/9 : WORKDIR /go/src ---> Running in efb1e4b1c200Removing intermediate container efb1e4b1c200 ---> 312e98c07647Step 3/9 : COPY main.go . ---> 2dc4088e6548Step 4/9 : RUN CGO_ENABLED=0 go build -o stop ./main.go ---> Running in 6d18a1ef07ffRemoving intermediate container 6d18a1ef07ff ---> a207b2ecdd67Step 5/9 : From alpine:latestlatest: Pulling from library/alpineDigest: sha256:9a839e63dad54c3a6d1834e29692c8492d93f90c59c978c1ed79109ea4fb9a54Status: Downloaded newer image for alpine:latest ---> f70734b6a266Step 6/9 : WORKDIR /root/ ---> Running in a308fc079da2Removing intermediate container a308fc079da2 ---> a14716065730Step 7/9 : COPY --from=builder /go/src/stop . ---> 3573b92b9ab3Step 8/9 : RUN chmod +x /root/stop ---> Running in f620b3287636Removing intermediate container f620b3287636 ---> 3cbc57300792Step 9/9 : ENTRYPOINT ["/root/stop"] ---> Running in 86f23ea9306fRemoving intermediate container 86f23ea9306f ---> 283788e6ad37Successfully built 283788e6ad37Successfully tagged stop:latest
在一个终端中运行该镜像:
docker run stop
在另外一个终端stop该容器:
docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES91eeef705489 stop "/root/stop" 12 seconds ago Up 11 seconds clever_leavittdocker stop 91eeef70548991eeef705489
最终有如下输出:
启动了程序休眠了: 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 builderWORKDIR /go/srcCOPY main.go .RUN CGO_ENABLED=0 go build -o stop ./main.goFrom alpine:latestWORKDIR /root/COPY --from=builder /go/src/stop .RUN chmod +x /root/stopENTRYPOINT "/root/stop"
构建新的镜像:
$ docker build -t stop-shell -f Dockerfile-shell .Sending build context to Docker daemon 4.608kBStep 1/9 : FROM golang:latest as builder ---> 7e5e8028e8ecStep 2/9 : WORKDIR /go/src ---> Using cache ---> 312e98c07647Step 3/9 : COPY main.go . ---> Using cache ---> 2dc4088e6548Step 4/9 : RUN CGO_ENABLED=0 go build -o stop ./main.go ---> Using cache ---> a207b2ecdd67Step 5/9 : From alpine:latest ---> f70734b6a266Step 6/9 : WORKDIR /root/ ---> Using cache ---> a14716065730Step 7/9 : COPY --from=builder /go/src/stop . ---> Using cache ---> 3573b92b9ab3Step 8/9 : RUN chmod +x /root/stop ---> Using cache ---> 3cbc57300792Step 9/9 : ENTRYPOINT "/root/stop" ---> Running in 199ca0277b08Removing intermediate container 199ca0277b08 ---> e0fe6a86ee1eSuccessfully built e0fe6a86ee1eSuccessfully 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~ # psPID 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/shexec /root/stop
然后我们的Dockerfile 变更为:
FROM golang:latest as builderWORKDIR /go/srcCOPY main.go .RUN CGO_ENABLED=0 go build -o stop ./main.goFrom alpine:latestWORKDIR /root/COPY --from=builder /go/src/stop .COPY run.sh .RUN chmod +x /root/stopENTRYPOINT ["/root/run.sh"]
构建新的镜像之后,运行该镜像:
docker run stop-shell-runsh启动了程序休眠了: 1 秒休眠了: 2 秒休眠了: 3 秒
然后进入到容器中执行ps
:
docker exec -it 97adce7dd7e4 sh~ # psPID 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 builderWORKDIR /go/srcCOPY main.go .RUN CGO_ENABLED=0 go build -o stop ./main.goFrom alpine:latestRUN apk add --no-cache tiniWORKDIR /root/COPY --from=builder /go/src/stop .RUN chmod +x /root/stopENTRYPOINT ["/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~ # psPID 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将信号转发到了我们的应用程序。