Docker容器你需要知道的

Docker容器的存活周期

 今天和大家聊聊容器的存活周期,但在聊存活周期以前我们得先了解一下init进程,在Linux操作系统中,当内核初始化完毕之后,会启动一个init进程,这个进程是整个操作系统的第一个用户进程,所以它的进程ID为1,也就是我们常说的PID1进程。在这之后,所有的用户态进程都是该进程的后代进程,由此我们可以看出,整个系统的用户进程,是一棵由init进程作为根的进程树。
 init进程有一个非常厉害的地方,就是SIGKILL信号对它无效。很显然,如果我们将一棵树的树根砍了,那么这棵树就会分解成很多棵子树,这样的最终结果是导致整个操作系统进程杂乱无章,无法管理。所以为了防止用户误操作init进程是无法kill掉的。
 PID 1进程的发展也是一段非常有趣的过程,从最早的sysvinit,到upstart,再到systemd。我们可以用 pstree -p查看PID 1的 进程是谁。
 PID 1的作用是负责清理那些被抛弃的进程(孤儿和僵尸进程)所留下来的痕迹,有效的回收的系统资源,保证系统长时间稳定的运行,可谓是功不可没。在理解了它的重要性之后,我们今天主要探讨一下在容器中的PID 1是怎么回事。

僵尸进程

僵尸进程指的是:进程退出后,到其父进程还未对其调用wait/waitpid之间的这段时间所处的状态。一般来说,这种状态持续的时间很短,所以我们一般很难在系统中捕捉到。但是,一些粗心的程序员可能会忘记调用wait/waitpid,或者由于某种原因未执行该调用等等,那么这个时候就会出现长期驻留的僵尸进程了。如果大量的产生僵尸进程,其进程号就会一直被占用,可能导致系统不能产生新的进程。(子进程挂了,如果父进程不给子进程“收尸”(调用 wait/waitpid),那这个子进程小可怜就变成了僵尸进程。)

孤儿进程

父进程先于子进程退出,那么子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)接管,并由init进程对它完成状态收集(wait/waitpid)工作。

容器中的PID 1

熟悉Docker同学可能知道,容器并不是一个完整的操作系统,它也没有什么内核初始化过程,更没有像init(1)这样的初始化过程。在容器中被标志为PID 1的进程实际上就是一个普普通通的用户进程,也就是我们制作镜像时在Dockerfile中指定的ENTRYPOINT的那个进程。而这个进程在宿主机上有一个普普通通的进程ID,而在容器中之所以变成PID 1,是因为linux内核提供的PID namespaces功能,如果宿主机的所有用户进程构成了一个完整的树型结构,那么PID namespaces实际上就是将这个ENTRYPOINT进程(包括它的后代进程)从这棵大树剪下来,很显然,剪下来的这部分东西本身也是一个树型结构,它完全可以自己长成一棵苍天大树(不断地fork),当然子namespaces里面是看不到整棵树的原貌的,但是父级的namespaces确可以看到完整的子树。

创建一个docker image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[root@k8s-m1 shallwe]# cat dockerfile 
FROM ubuntu
CMD sleep 3600


[root@k8s-m1 shallwe]# docker build -t test1 .
Sending build context to Docker daemon 117.2kB
Step 1/2 : FROM ubuntu
---> bb0eaf4eee00
Step 2/2 : CMD sleep 3600
---> Running in 1d8cc7425f0a
Removing intermediate container 1d8cc7425f0a
---> 54d1c4970147
Successfully built 54d1c4970147
Successfully tagged test1:latest


[root@k8s-m1 shallwe]# docker run -itd test1
c1559a36232de647ccce2d760dc1ebdc0541632d8626dfc785d352f10d052900


[root@k8s-m1 shallwe]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c1559a36232d test1 "/bin/sh -c 'sleep 3…" 5 seconds ago Up 3 seconds affectionate_shtern


[root@k8s-m1 shallwe]# docker exec -it c1559a36232d /bin/sh
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.7 0.0 2612 604 pts/0 Ss+ 06:40 0:00 /bin/sh -c sleep 3600
root 6 0.0 0.0 2512 580 pts/0 S+ 06:40 0:00 sleep 3600
root 7 4.0 0.0 2612 604 pts/1 Ss 06:40 0:00 /bin/sh
root 12 0.0 0.0 5892 2856 pts/1 R+ 06:40 0:00 ps aux

我们发现pid为1的是一个/bin/sh的进程,容器是单独一个pid namespaces的。通过下图可以更方便理解。由于子namespaces无法看到父级的namespaces,所以容器里第一个进程(也就是cmd)认为自己是pid为1,容器里其余进程都是它的子进程。

init-1

在Linux中init进程是不处理SIGKILL信号的,这可以防止init进程被误杀掉,即使是superuser。所以 kill -9 init 不会kill掉init进程。但是容器的进程是在容器的ns里是init级别,我们可以在宿主机上杀掉它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[root@k8s-n2 shallwe]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
366672a12386 6f715d38cfe0 "/docker-entrypoint.…" 2 minutes ago Up 2 minutes k8s_nginx_nginx-97499b967-rfcvf_default_077ea5b9-746d-4687-9652-42af09bdc129_0



[root@k8s-n2 shallwe]# docker exec -it 366672a12386 ps aux
PID USER TIME COMMAND
1 root 0:00 nginx: master process nginx -g daemon off;
29 nginx 0:00 nginx: worker process
30 nginx 0:00 nginx: worker process
31 nginx 0:00 nginx: worker process
32 nginx 0:00 nginx: worker process
39 root 0:00 ps aux


[root@k8s-n2 shallwe]# docker exec -it 366672a12386 kill -9 1


[root@k8s-n2 shallwe]# docker exec -it 366672a12386 ps aux
PID USER TIME COMMAND
1 root 0:00 nginx: master process nginx -g daemon off;
29 nginx 0:00 nginx: worker process
30 nginx 0:00 nginx: worker process
31 nginx 0:00 nginx: worker process
32 nginx 0:00 nginx: worker process
49 root 0:00 ps aux

[root@k8s-n2 shallwe]# docker top 366672a12386
UID PID PPID C STIME TTY TIME CMD
root 46176 46157 0 15:14 ? 00:00:00 nginx: master process nginx -g daemon off;
101 46223 46176 0 15:14 ? 00:00:00 nginx: worker process
101 46224 46176 0 15:14 ? 00:00:00 nginx: worker process
101 46225 46176 0 15:14 ? 00:00:00 nginx: worker process
101 46226 46176 0 15:14 ? 00:00:00 nginx: worker process

[root@k8s-n2 shallwe]# ps aux | grep 46157
root 46157 0.0 0.1 108760 8460 ? Sl 15:14 0:00 containerd-shim -namespace moby -workdir /var/lib/containerd/io.containerd.runtime.v1.linux/moby/366672a1238655735e4dfd180bdd459109115d45caf904b16df792ad547c461b -address /run/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc -systemd-cgroup
root 47549 0.0 0.0 12108 1048 pts/0 S+ 15:16 0:00 grep --color=auto 46157


[root@k8s-n2 shallwe]# kill -9 46176

[root@k8s-n2 shallwe]# docker top 366672a12386
Error response from daemon: Container 366672a1238655735e4dfd180bdd459109115d45caf904b16df792ad547c461b is not running

1
2
3
4
5
6
7
8
9
10
11
12
[root@guan ~]# docker run -d centos ls
24b2195731fef5b3e52898bcb7e2c6cebdb9afb8cfc929c1e69ed7126e967699
[root@guan ~]# docker run -d centos sleep 10
8c0a7cba4af9a847e0092e1855426149cf093ef90fd4b91b1cbf452001176a38
[root@guan ~]# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8c0a7cba4af9 centos "sleep 10" 4 seconds ago Up 3 seconds cocky_visvesvaraya
24b2195731fe centos "ls" 10 seconds ago Exited (0) 9 seconds ago friendly_mirzakhani
[root@guan ~]# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8c0a7cba4af9 centos "sleep 10" About a minute ago Exited (0) 15 seconds ago cocky_visvesvaraya
24b2195731fe centos "ls" About a minute ago Exited (0) 29 seconds ago friendly_mirzakhani

docker run 后面镜像后面的command和arg会覆盖掉镜像的CMD。上面我那个例子覆盖掉centos镜像默认的CMD bash。我们可以看到ls的容器直接退出了,但是sleep 10的容器运行了10秒后就退出了。以上也说明了容器不是虚拟机,容器是个隔离的进程

这说明了容器的存活是容器里pid为1的进程运行时长决定的。所以nginx的官方镜像里就是用的exec格式让nginx充当pid为1的角色

1
CMD ["nginx", "-g", "daemon off;"]

pid 为1 真的好吗

第一种情况实际上php和java的容器在长期运行后经常会频繁oom,主要是代码里涉及到fork等原因(fork程序子进程)。传统Linux上,pid为1的角色承担了孤儿和僵尸进程的回收,但是目前我们的业务进程都是pid为1的角色,没有处理掉孤儿进程,这里我们主进程用bash模仿个僵尸进程看看会不会被回收。

起一个容器exec运行sleep

1
2
3
docker run -d --name test centos:7 bash -c 'sleep 1000'
docker exec -ti test bash # 运行一个bash操作容器
[root@134b96f29c73 /]# bash -c 'sleep 2000'

再开一个终端操作按照如下图操作

init-2

得到一个僵尸进程。解决这个的办法就是pid为1的跑一个支持信号转发且支持回收孤儿僵尸进程的进程就行了,为此有人开发出了tini项目,感兴趣可以github上搜下下,现在tini已经内置在docker里了。

使用tini可以在docker run的时候添加选项–init即可,底层我猜测是复制docker-init到容器的/dev/init路径里然后启动entrypoint cmd,大家可以在run的时候测试下上面的步骤会发现根本不会有僵尸进程遗留。

这里不多说,如果是想默认使用tini可以把tini构建到镜像里(例如k8s目前不支持docker run 的--init,所以需要把tini做到镜像里),参照jenkins官方镜像dockerfile和tini的github地址文档 https://github.com/krallin/tini

如果是基于alpine,tini是依赖glibc的可能还会command not found,可以尝试它的静态版本

1
2
3
4
5
6
7
ENV TINI_VERSION=v0.18.0 \
TINI_DOWNLOAD_URL https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static
RUN curl -sSL ${TINI_DOWNLOAD_URL} > /usr/bin/tini \
&& chmod +x /usr/bin/tini
...
ENTRYPOINT ["tini","--"]
CMD ["/your/program","-and","-its","args"]

类似tini的还有dumb-init,例如后续的k8s的ingress nginx镜像就是
init-3

JDK无法识别cgroup限制

首先Docker容器本质是宿主机上的一个进程,它与宿主机共享一个/proc目录,也就是说我们在容器内看到的/proc/meminfo/proc/cpuinfo与直接在宿主机上看到的一致。

如下:

1
2
3
4
5
6
7
8
[root@k8s-n1 shallwe]# head -n3 /proc/meminfo 
MemTotal: 7976536 kB
MemFree: 2325284 kB
MemAvailable: 6672952 kB
[root@k8s-n1 shallwe]# docker run -m 500m --rm alpine head -n3 /proc/meminfo # 带上内存限制选项无法识别
MemTotal: 7976536 kB
MemFree: 2277308 kB
MemAvailable: 6626188 kB

jvm也是读取/proc目录,会导致无法识别cgroup限制。默认情况下,JVM的Max Heap Size是系统内存的1/4,假如我们系统是8G,那么JVM将的默认Heap≈2G。

Docker通过CGroups完成的是对内存的限制,而/proc目录是已只读形式挂载到容器中的,由于默认情况下Java压根就看不见CGroups的限制的内存大小,而默认使用/proc/meminfo中的信息作为内存信息进行启动,这种不兼容情况会导致,如果容器分配的内存小于JVM的内存,JVM进程申请超过限制的内存会被docker认为oom杀掉。

测试用例(OPENJDK)

在JDK8u212版本之前,JVM在容器里面识别到的是宿主机的内存。如果没有手动调整堆大小的话JVM默认会使用1/4的宿主机内存。这样会远远大于容器规格限制的内存,导致内存爆了之后容器自动重启。这里我用我们生产用的openjdk8做演示,jdk8也是一个长期维护版本。测试机器为8G内存,给容器限制内存为4G,看JDK默认参数下的最大堆为多少。

测试命令为如下: (jdk包含jre)

1
2
docker run -m 4GB --rm  openjdk:8-jdk java -XshowSettings:vm  -version
docker run -m 4GB --rm openjdk:8u181 java -XshowSettings:vm -version
  • OpenJDK8新版本(正确的识别容器限制,910.50M)安全

    1
    2
    3
    4
    5
    6
    7
    8
    9
    [root@k8s-n1 shallwe]# docker run -m 4GB --rm  openjdk:8-jdk java -XshowSettings:vm  -version
    VM settings:
    Max. Heap Size (Estimated): 910.50M
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

    openjdk version "1.8.0_265"
    OpenJDK Runtime Environment (build 1.8.0_265-b01)
    OpenJDK 64-Bit Server VM (build 25.265-b01, mixed mode)
  • OpenJDK8老版本(并没有识别容器限制,1.69G) 危险

    1
    2
    3
    4
    5
    6
    7
    8
    9
    [root@k8s-n1 shallwe]# docker run -m 4GB --rm  openjdk:8u181 java -XshowSettings:vm  -version
    VM settings:
    Max. Heap Size (Estimated): 1.69G
    Ergonomics Machine Class: server
    Using VM: OpenJDK 64-Bit Server VM

    openjdk version "1.8.0_181"
    OpenJDK Runtime Environment (build 1.8.0_181-8u181-b13-2~deb9u1-b13)
    OpenJDK 64-Bit Server VM (build 25.181-b13, mixed mode)

结论

OpenJDK8老版本无法识别容器限制,至于其他版本或者其他发行版的jdk我就不测了,我们在选jdk的时候可以选OpenJDK8adoptopenjdk。如果你想要的是,不显示的指定-Xmx,让Java进程自动的发现容器限制,那么请选择JDK8u212之后的版本。如果你想要的是手动挡的体验,更加进一步的利用内存资源,那么你可能需要回到手动配置-Xmx时代,那么你选什么版本都无所谓了。

CMD和ENTRYPOINT指令的作用和区别

首先先来说说他们两的相同点,他们都有两种写法EXEC和SHELL写法。前者是exec格式也是推荐格式,后者是SHELL格式。当一个dockerfile文件中有多个CMD和ENTRYPOINT时只有最后一个生效。

1
2
CMD ["executable", "param1", "param2"] 
CMD command param1 param2
1
2
ENTRYPOINT ["executable", "param1", "param2"]
ENTRYPOINT command param1 param2

以SHELL风格示例

init-4
init-5
init-6

此时的可执行体/bin/sleep是由/bin/sh启动的,我们发现pid为1的是一个/bin/sh的进程。当我们用docker stop命令来停掉容器的时候会先向容器中PID为1的进程发送系统信号SIGTERM,docker默认会允许容器中的应用程序有10秒的时间用以终止运行。如果等待时间超过10秒会继续发送SIGKILL的系统信号强行kill掉进程。一般业务进程都是pid为1,所有官方的进程都会处理收到的SIGTERM信号进行优雅收尾退出。如果是/bin/sh格式的话,主进程是一个sh -c的进程,shell不用trap做信号捕捉、信号处理的话是无法转发信号的。最终只能强制Kill掉。

以EXEC风格示例

init-7
init-8
init-9

这样不会独立启动一个shell进程,应用的可执行程序(/bin/sleep)成为容器的PID 1进程,可以接收Unix信号。

ENTRYPOINT与CMD指令

  • CMD: 指定容器启动时默认执行的命令。
  • ENTRYPOINT: 指定容器启动时所运行的可执行程序与参数。

上面提到CMD是设置容器启动时的默认命令,既然是默认命令那么就是可以被替换的。当我们执行 docker run [OPTIONS] IMAGE [COMMAND] [ARG...] 镜像后面的commandarg会覆盖掉镜像的CMD。如果ENTRYPOINT和CMD指令同时存在一个dockerfile中,则ENTRYPOINT优先级高于CMD,CMD指令将被作为参数传递给ENTRYPOINT。最终运行的是 <ENTRYPOINT> <CMD>

init-10

1、首先docker run的时候我们替换掉了CMD里面的'-l'指令。变成了 ls /root 但是alpine的root目录是没有文件,所以ls /root没有输出。
2、我们用选项去覆盖住entrypoint可以看到输出了date。注意一点是覆盖entrypoint的时候镜像的CMD会被忽略。

Docker容器优雅终止方案

作为一名系统重启工程师(SRE),你可能经常需要重启容器,毕竟 Kubernetes 的优势就是快速弹性伸缩和故障恢复,遇到问题先重启容器再说,几秒钟即可恢复,实在不行再重启系统,这就是系统重启工程师的杀手锏。然而现实并没有理论上那么美好,某些容器需要花费 10s 左右才能停止,这是为啥?有以下几种可能性:

  1. 容器中的进程没有收到 SIGTERM 信号。
  2. 容器中的进程收到了信号,但忽略了。
  3. 容器中应用的关闭时间确实就是这么长。

对于第 3 种可能性我们无能为力,本文主要解决 1 和 2。

如果要构建一个新的 Docker 镜像,肯定希望镜像越小越好,这样它的下载和启动速度都很快,一般我们都会选择一个瘦了身的操作系统(例如 AlpineBusybox 等)作为基础镜像。

init-11

问题就在这里,这些基础镜像的 init 系统 也被抹掉了,这就是问题的根源!

init 系统有以下几个特点:

  • 它是系统的第一个进程,负责产生其他所有用户进程。
  • init 以守护进程方式存在,是所有其他进程的祖先。
  • 它主要负责:
    • 启动守护进程
    • 回收孤儿进程
    • 将操作系统信号转发给子进程

Docker 容器停止过程

对于容器来说,init 系统不是必须的,当你通过命令 docker stop mycontainer 来停止容器时,docker CLI 会将 TERM 信号发送给 mycontainerPID 为 1 的进程。

  • 如果 PID 1 是 init 进程 - 那么 PID 1 会将 TERM 信号转发给子进程,然后子进程开始关闭,最后容器终止。
  • 如果没有 init 进程 - 那么容器中的应用进程(Dockerfile 中的 ENTRYPOINT 或 CMD 指定的应用)就是 PID 1,应用进程直接负责响应 TERM 信号。这时又分为三种情况:
    1. 应用不处理 SIGTERM - 如果应用没有监听 SIGTERM 信号,或者应用中没有实现处理 SIGTERM 信号的逻辑,应用就不会停止,容器也不会终止。
    2. 应用收不到 SIGTERM 信号 - 在写dockerfile时ENTRYPOINT 或 CMD使用 shell模式 会导致应用无法收到SIGTERM信号,因为shell不会转发信号到子进程
    3. 应用收到 SIGTERM 信号并处理信号

第一种和第二种会导致容器停止时间很长 运行命令 docker stop mycontainer 之后,Docker 会等待 10s,如果 10s 后容器还没有终止,Docker 就会绕过容器应用直接向内核发送 SIGKILL,内核会强行杀死应用,从而终止容器。

容器进程收不到 SIGTERM 信号?

如果容器中的进程没有收到 SIGTERM 信号,很有可能是因为应用进程不是 PID 1,PID 1 是 shell,而应用进程只是 shell 的子进程。而 shell 不具备 init 系统的功能,也就不会将操作系统的信号转发到子进程上,这也是容器中的应用没有收到 SIGTERM 信号的常见原因。

问题的根源就来自 Dockerfile,例如:

1
2
3
4
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT ./popcorn.sh

ENTRYPOINT 指令使用的是 shell 模式,这样 Docker 就会把应用放到 shell 中运行,因此 shell 是 PID 1。

解决方案 1:使用 exec 模式的 ENTRYPOINT 指令

1
2
3
4
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT ["./popcorn.sh"]

这样 PID 1 就是 ./popcorn.sh,它将负责响应所有发送到容器的信号,至于 ./popcorn.sh 是否真的能捕捉到系统信号,那是另一回事。

举个例子,假设使用上面的 Dockerfile 来构建镜像,popcorn.sh 脚本每过一秒打印一次日期:

1
2
3
4
5
6
#!/bin/sh
while true
do
date
sleep 1
done

构建镜像并创建容器:

1
2
docker build -t truek8s/popcorn .
docker run -it --name corny --rm truek8s/popcorn

打开另外一个终端执行停止容器的命令,并计时:

1
time docker stop corny

因为 popcorn.sh 并没有实现捕获和处理 SIGTERM 信号的逻辑,所以需要 10s 左右才能停止容器。要想解决这个问题,就要往脚本中添加信号处理代码,让它捕获到 SIGTERM 信号时就终止进程:

1
2
3
4
5
6
7
8
9
#!/bin/sh
# catch the TERM signal and then exit
trap "exit" TERM

while true
do
date
sleep 1
done

解决方案 2:使用 init 系统

如果容器中的应用默认无法处理 SIGTERM 信号,又不能修改代码,这时候方案 1 行不通了,只能在容器中添加一个 init 系统。init 系统有很多种,这里推荐使用 tini,它是专用于容器的轻量级 init 系统,使用方法也很简单:

  1. 安装 tini
  2. 将 tini 设为容器的默认应用
  3. popcorn.sh 作为 tini 的参数

具体的 Dockerfile 如下:

1
2
3
4
5
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--", "./popcorn.sh"]

现在 tini 就是 PID 1,它会将收到的系统信号转发给子进程 popcorn.sh

如果你想直接通过 docker 命令来运行容器,可以直接通过参数 --init 来使用 tini,不需要在镜像中安装 tini。如果是 Kubernetes 就不行了,还得老老实实安装 tini。

使用 tini 后应用还需要处理 SIGTERM 吗?

最后一个问题:如果移除 popcorn.sh 中对 SIGTERM 信号的处理逻辑,容器会在我们执行停止命令后立即终止吗?

首先启动两个容器一个容器使用 init 一个容器不使用 init
init-12

停止两个容器查看耗时
init-13

结果:可以优雅关闭容器


Docker容器你需要知道的
https://system51.github.io/2020/09/17/docker-need-to-know/
作者
Mr.Ye
发布于
2020年9月17日
许可协议