谈谈stop容器

49次阅读

共计 6998 个字符,预计需要花费 18 分钟才能阅读完成。

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

正文完
 0