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


iyacontrol
1.4k 声望2.7k 粉丝

专注kubernetes,devops,aiops,service mesh。