关于docker的一些深入了解

本文将深入介绍一下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容器和运维相关的知识,请前往博客主页。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

margu_168

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值