本文将深入介绍一下docker方面的知识,不尽完全,慢慢完善。
进程
进程的概念
在介绍docker的相关知识前,先了解一下相关概念。进程就是系统中正在运行的程序,进程是操作系统的概念,每当我们执行一个程序时,对于操作系统来讲就是创建了一个进程,在这个过程中操作系统对进程资源的分配和释放,可以认为进程就是一个程序的一次执行过程。
Linux下的三个特殊进程
Linux下有三个特殊的进程idle进程(PID=0),init进程(PID=1),和kthreadd(PID=2)
- idle进程由系统自动创建,运行在内核态。idle进程的pid=0,其前身是系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程。完成加载系统后,演变为进程调度、交换,是其他所有进程的祖先进程。
- kthreadd进程由idle通过kernel_thread创建,并始终运行在内核空间,负责所有内核进程的调度和管理。
它的任务就是管理和调度其他内核线程kernel_thread,会循环执行一个kthread的函数,该函数的作用就是运行kthread_create_list全局链表中维护的kthread, 当我们调用kernel_thread创建的内核线程会被加入到此链表中,因此所有的内核线程都是直接或者间接的以kthreadd为父进程 。 - init进程由idle通过kernel_thread创建,在内核空间完成初始化后,加载init程序。
init进程
在这里我们就主要讲解下init进程,init进程由idle进程创建,完成系统的初始化。在Linux操作系统启动时,首先从 BIOS 开始,接下来进入 boot loader,由 bootloader 载入内核,进行内核初始化。内核初始化的最后一步就是启动 pid 为 1 的 init 进程,这个进程是系统的第一个进程,它负责产生其他所有用户进程。由此我们可以看出,整个系统的用户进程,是一棵由init进程作为根的进程树。(注意是用户进程)
init 的一些特点
-
init 以守护进程方式存在,是所有其他进程的祖先。init 进程非常独特,能够完成其他进程无法完成的任务。
init系统能够定义、管理和控制 init 进程的行为。它负责组织和运行许多独立的或相关的始化工作(因此被称为 init 系统),从而让计算机系统进入某种用户预订的运行模式。仅仅将内核运行起来是毫无实际用途的,必须由 init 系统将系统代入可操作状态。比如启动外壳 shell 后,便有了人机交互,这样就可以让计算机执行一些预订程序完成有实际意义的任务。 -
init进程有一个非常厉害的地方,就是SIGKILL信号对它无效。很显然,如果我们将一棵树的树根砍了,那么这棵树就会分解成很多棵子树,这样的最终结果是导致整个操作系统进程杂乱无章,无法管理。所以为了防止用户误操作init进程是无法kill掉的。
init(PID 1)进程的发展也是一段非常有趣的过程,从最早的sysvinit,到upstart,再到systemd。我们可以用pstree -p
查看PID 1的 进程是谁。
init(PID 1)的作用是负责清理那些被抛弃的进程(孤儿和僵尸进程)所留下来的痕迹,有效的回收的系统资源,保证系统长时间稳定的运行,可谓是功不可没。在理解了它的重要性之后,我们今天主要探讨一下在容器中的PID 1是怎么回事。
僵尸进程
僵尸进程指的是:进程退出后,到其父进程还未对其调用wait/waitpid之间的这段时间所处的状态。一般来说,这种状态持续的时间很短,我们一般很难在系统中捕捉到。但是,一些粗心的程序员可能会忘记调用wait/waitpid,或者由于某种原因未执行该调用等等,那么这个时候就会出现长期驻留的僵尸进程了。如果大量的产生僵尸进程,其进程号就会一直被占用,可能导致系统不能产生新的进程。(子进程挂了,如果父进程不给子进程“收尸”(调用 wait/waitpid),那这个子进程小可怜就变成了僵尸进程。)
孤儿进程
父进程先于子进程退出,那么子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)接管,并由init进程对它完成状态收集(wait/waitpid)工作。
容器中的PID 1
对Docker有一定使用经验的童鞋应该知道,容器并不是一个完整的操作系统,它也没有什么内核初始化过程,更没有像init(1)这样的初始化过程。在容器中被标志为PID 1的进程实际上就是一个普普通通的用户进程,也就是我们制作镜像时在Dockerfile中指定的ENTRYPOINT(CMD)的那个进程。而这个进程在宿主机上有一个普普通通的进程ID,而在容器中之所以变成PID 1,是因为linux内核提供的PID namespaces功能,如果宿主机的所有用户进程构成了一个完整的树型结构,那么PID namespaces实际上就是将这个ENTRYPOINT进程(包括它的后代进程)从这棵大树剪下来,很显然,剪下来的这部分东西本身也是一个树型结构,它完全可以自己长成一棵苍天大树(不断地fork),当然子namespaces里面是看不到整棵树的原貌的,但是父级的namespaces确可以看到完整的子树。
pid1 的测试
创建一个测试镜像
[root@k8s-m1 k8s-total]# cat Dockerfile
From centos:7
CMD ["/bin/sh","-c","sleep 3600"]
[root@k8s-m1 k8s-total]# docker build -t lifecycle:v1 .
Sending build context to Docker daemon 3.223MB
Step 1/2 : From centos:7
---> eeb6ee3f44bd
Step 2/2 : CMD ["/bin/sh","-c","sleep 3600"]
---> Running in 5c9b2704c6cd
Removing intermediate container 5c9b2704c6cd
---> 8d208d2b880b
Successfully built 8d208d2b880b
Successfully tagged lifecycle:v1
[root@k8s-m1 k8s-total]# docker run -idt lifecycle:v1
3971502e8f0410f779da7ed4a79aab8260754d9b2211b248b1153cd4ef6dd45e
[root@k8s-m1 k8s-total]# docker ps -l
3971502e8f04 lifecycle:v1 "/bin/sh -c 'sleep 3…" 9 seconds ago Up 8 seconds friendly_hodgkin
[root@k8s-m1 k8s-total]# docker exec -it 39 /bin/bash
[root@3971502e8f04 /]# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4364 356 pts/0 Ss+ 02:15 0:00 sleep 3600
root 6 1.6 0.0 11828 1892 pts/1 Ss 02:16 0:00 /bin/bash
root 19 0.0 0.0 51732 1704 pts/1 R+ 02:16 0:00 ps aux
[root@3971502e8f04 /]#
从上面我们可以看到pid为1的是一个/bin/sh的进程,容器是单独一个pid namespaces的。通过下图可以更方便理解。由于子namespaces无法看到父级的namespaces,所以容器里第一个进程(也就是cmd)认为自己是pid为1,容器里其余进程都是它的子进程。
#在容器内部使用kill -9杀pid为1的进程发现是杀不掉的
[root@3971502e8f04 /]# kill -9 1
[root@3971502e8f04 /]# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4364 356 pts/0 Ss+ 02:15 0:00 sleep 3600
root 6 0.0 0.0 11828 1892 pts/1 Ss 02:16 0:00 /bin/bash
root 20 0.0 0.0 51732 1704 pts/1 R+ 02:19 0:00 ps aux
[root@3971502e8f04 /]# exit
宿主机上测试是否能杀掉容器内部pid为1 的进程
#查看容器内部pid为1的进程在宿主机上的pid号,通过docker top查看
[root@k8s-m1 k8s-total]# docker top 3971502e8f04
UID PID PPID C STIME TTY TIME CMD
root 29298 29279 0 10:34 pts/0 00:00:00 sleep 3600
#或者ps也可以查看
[root@k8s-m1 k8s-total]# ps aux|grep sleep
root 29298 0.4 0.0 4364 356 pts/0 Ss+ 10:34 0:00 sleep 3600
root 29676 0.0 0.0 112812 976 pts/0 S+ 10:35 0:00 grep --color=auto sleep
[root@k8s-m1 k8s-total]# kill -9 29298
[root@k8s-m1 k8s-total]# docker ps -a|grep 3971502e8f04
3971502e8f04 lifecycle:v1 "/bin/sh -c 'sleep 3…" 20 minutes ago Exited (137) 7 seconds ago friendly_hodgkin
#可以看到在宿主机上已经将容器内部pid为1的容器杀掉
容器存活时间
[root@k8s-m1 k8s-total]# docker run -d centos:7 ls
b80e296e4667f106cc5c71a39050841cc8a87917de16c3d582118b9818a8fd7b
[root@k8s-m1 k8s-total]# docker run -d centos:7 sleep 3600
5665a9169f9a1bfbdb0e38f5fb9bdd828fb7c79cd2edbba56be14abcbd4251c8
[root@k8s-m1 k8s-total]# docker ps -a|grep centos
5665a9169f9a centos:7 "sleep 3600" 10 seconds ago Up 9 seconds wonderful_germain
b80e296e4667 centos:7 "ls" 21 seconds ago Exited (0) 20 seconds ago peaceful_ishizaka
[root@k8s-m1 k8s-total]#
docker run 后面镜像后面的command和arg会覆盖掉镜像的CMD(注意entrypoint一般不能被覆盖,注意二者区别)。上面例子通过命令行添加了cmd覆盖掉centos镜像默认的CMD bash。我们可以看到ls的容器直接退出了,但是sleep 3600的容器会运行3600s后才会退出。这也说明了容器不是虚拟机,容器是个隔离的进程。
而且容器的存活是容器里pid为1的进程运行时长决定的。所以nginx的官方镜像里就是用的exec格式让nginx充当pid为1的角色
CMD [“nginx”, “-g”, “daemon off;”]
JDK无法识别cgroup限制
首先Docker容器本质是宿主机上的一个进程,它与宿主机共享一个/proc目录,也就是说我们在容器内看到的/proc/meminfo,/proc/cpuinfo与直接在宿主机上看到的一致。
如下:
[root@8baf80228c25 /]# head -n3 /proc/meminfo
MemTotal: 8007180 kB
MemFree: 2976140 kB
MemAvailable: 5661936 kB
[root@8baf80228c25 /]# exit
[root@k8s-m1 k8s-total]# head -n3 /proc/meminfo
MemTotal: 8007180 kB
MemFree: 2991332 kB
MemAvailable: 5677132 kB
[root@k8s-m1 k8s-total]#
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的宿主机内存。这样会远远大于容器规格限制的内存,导致oom之后容器自动重启。这里我用我们生产用的openjdk8做演示,jdk8也是一个长期维护版本。测试机器为8G内存,给容器限制内存为4G,看JDK默认参数下的最大堆为多少。
jdk 212版本之前
[root@k8s-m1 k8s-total]# docker run -m 4GB --rm openjdk:8u181 java -XshowSettings:vm -version
VM settings:
Max. Heap Size (Estimated): 1.70G
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)
未能正确识别CGroup限制,使用的是/proc/meminfo
里面的值
jdk 212版本之后
[root@k8s-m1 k8s-total]# 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_312"
OpenJDK Runtime Environment (build 1.8.0_312-b07)
OpenJDK 64-Bit Server VM (build 25.312-b07, mixed mode)
[root@k8s-m1 k8s-total]#
能正确识别CGroup限制
总结,OpenJDK8老版本无法识别容器限制,我们在选jdk的时候可以选取高版本的OpenJDK8或adoptopenjdk。如果不想指定-Xmx,而让Java进程自动的发现容器限制,那么请选择JDK8u212之后的版本。如果你想要指定-Xmx,那么你选什么版本都可以。
Docker容器优雅终止方案
作为一名系统可靠工程师(SRE),你可能经常需要重启容器,毕竟 Kubernetes 的优势就是快速弹性伸缩和故障恢复,遇到问题先重启容器再说,几秒钟即可恢复,实在不行再重启系统,这就是系统重启工程师的杀手锏。然而现实并没有理论上那么美好,某些容器需要花费 10s 左右才能停止,这是为啥?有以下两种可能性:
- 容器中的进程收到了信号,但不能进行处理。
- 容器中应用的关闭时间确实就是这么长。
对于第二种可能性这个还是主要需要开发对代码进行优化,本文主要解决第一种
如果要构建一个新的 Docker 镜像,肯定希望镜像越小越好,这样它的下载和启动速度都很快,一般我们都会选择一个瘦了身的操作系统(例如 Alpine,Busybox 等)作为基础镜像。
问题就在这里,这些基础镜像的 init 系统 也被抹掉了,这就是问题的根源!
init 系统有以下几个特点:
- 它是系统的第一个进程,负责产生其他所有用户进程。
- init 以守护进程方式存在,是所有其他用户进程的先祖。
它主要负责:- 启动守护进程
- 回收孤儿进程
- 将操作系统信号转发给子进程
Docker 容器停止过程
对于容器来说,init 系统不是必须的,当你通过命令 docker stop mycontainer 来停止容器时,docker CLI 会将 TERM 信号发送给 mycontainer 的 PID 为 1 的进程。
- 如果 PID 1 是 init 进程 ,那么 PID 1 会将 TERM 信号直接转发给子进程,然后子进程开始关闭,最后容器终止。
- 如果没有 init 进程 - 那么容器中的应用进程(Dockerfile 中的 ENTRYPOINT 或 CMD 指定的应用,新版的docker应该做了优化,不管是使用ENTRYPOINT 或 CMD,shell模式在制作镜像时都会转为exec模式)就是 PID 1,不需要转发。应用进程直接负责响应 TERM 信号。这时又分为两种情况:
- 应用收到不处理 SIGTERM - 如果应用没有监听 SIGTERM 信号,或者应用中没有实现处理 SIGTERM 信号的逻辑,应用就不会停止,容器也不会终止。
- 应用收到 SIGTERM 信号并处理信号
第一种会导致容器停止时间很长 运行命令 docker stop mycontainer 之后,Docker 会等待 10s,如果 10s 后容器还没有终止,Docker 就会绕过容器应用直接向内核发送 SIGKILL,内核会强行杀死应用,从而终止容器。
容器进程收不到 SIGTERM 信号?
如果容器中的进程没有收到 SIGTERM 信号,很有可能是因为应用进程不是 PID 1,PID 1 是 shell,而应用进程只是 shell 的子进程。而 shell 不具备 init 系统的功能,也就不会将操作系统的信号转发到子进程上,这也是容器中的应用没有收到 SIGTERM 信号的常见原因。
解决方案 :使用 init 系统
如果容器中的应用默认无法处理 SIGTERM 信号,又不能修改代码,可以在容器中添加一个 init 系统。init 系统有很多种,这里使用tini测试,它是专用于容器的轻量级 init 系统,使用方法也很简单:
- 安装 tini
- 将 tini 设为容器的默认应用
- 将 test.sh 作为 tini 的参数
具体的 Dockerfile 如下:
[root@k8s-m1 tmp]# cat Dockerfile
FROM alpine:3.18
COPY test.sh .
RUN chmod +x test.sh
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--", "./test.sh"]
[root@k8s-m1 tmp]# docker build -t test:v1 .
Sending build context to Docker daemon 24.06kB
Step 1/5 : FROM alpine:3.18
---> c1aabb73d233
Step 2/5 : COPY test.sh .
---> 7fd1ff5ff917
Step 3/5 : RUN chmod +x test.sh
---> Running in 772972a29e18
Removing intermediate container 772972a29e18
---> 8336d2d63696
Step 4/5 : RUN apk add --no-cache tini
---> Running in 2461ef80c3f4
fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.18/community/x86_64/APKINDEX.tar.gz
(1/1) Installing tini (0.19.0-r1)
Executing busybox-1.36.1-r0.trigger
OK: 7 MiB in 16 packages
Removing intermediate container 2461ef80c3f4
---> b54a49cb55aa
Step 5/5 : ENTRYPOINT ["/sbin/tini", "--", "./test.sh"]
---> Running in fcd4c55b5ebd
Removing intermediate container fcd4c55b5ebd
---> 69039ca9c5a1
Successfully built 69039ca9c5a1
Successfully tagged test:v1
[root@k8s-m2 tmp]# docker run -d test:v1
9c6fbeae5422b62ee0cac50cc240d1ef83280045c302c730a373566ca6c13b8b
[root@k8s-m2 tmp]# docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9c6fbeae5422 test:v1 "/sbin/tini -- ./tes…" 3 seconds ago Up 2 seconds vigorous_tereshkova
[root@k8s-m2 tmp]# docker exec -it 9c /bin/sh
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 /sbin/tini -- ./test.sh
6 root 0:00 {test.sh} /bin/sh ./test.sh
21 root 0:00 /bin/sh
28 root 0:00 ps aux
/ #
从上面结果可以看到现在 tini 就是 PID 1,它会将收到的系统信号转发给子进程 test.sh。
如果你想直接通过 docker 命令来运行容器,可以直接通过参数 --init 来使用 tini,其实不需要在镜像中安装 tini。如果是 Kubernetes 就不行了,还得老老实实安装 tini。
其实在k8s中的ingress-nginx就使用了dumb-init,一样的效果
"Cmd": [
"/nginx-ingress-controller"
],
"ArgsEscaped": true,
"Image": "sha256:f3745c5705f1617a2b44c28ee7f5637257b9ca281b9df0a2069c6c8d30ebbba8",
"Volumes": null,
"WorkingDir": "/etc/nginx",
"Entrypoint": [
"/usr/bin/dumb-init",
"--"
],
而如果安装了tini,应用test.sh 中是否还需要类似如下脚本中对 SIGTERM 信号的处理逻辑。也就是trap "exit" TERM
cat test.sh
#!/bin/sh
# catch the TERM signal and then exit
trap "exit" TERM
while true
do
date
sleep 1
done
大家可以自行测试,是不影响的。也就是可以不需要在对 SIGTERM 信号再进行处理。
更多关于docker容器和运维相关的知识,请前往博客主页。