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将信号转发到了我们的应用程序。