01 容器里的进程管理

一 容器内的两个特殊进程

1.1 介绍

容器正常启动后,使用docker exec contaienrID bash进入容器后,使用ps命令,一般有两个特殊进程:

  • 1号进程 为容器首启动进程,容器的pid namespcae就是由1号进创建的,容内其余进程基本都是首启动进程的子孙进程。只要1号进程挂掉那容器便会关闭,pid namespace会被回收。
  • 0号进程 为1号进程的父进程,也为docker exec....携带指令的父进程(即从外部向running容器内发起的指令)。当然你要干掉了0号进程,容器一样完蛋,

一个案例带你快速看一眼0号进程与1号进程

1、tail -f /dev/null是容器内的1号进程,而1号进程的父进程则是0号进程

2、docker exec执行的命令是sh,该命令是在容器已经running之后运行的,产生的sh进程的父进程为0号进程

3、在sh环境里执行的新命令如下所示sleep 1000 &,当然都是sh的儿子

[root@test01 init5]# docker container run -d --name test centos:7 tail -f /dev/null
[root@test01 init5]# 
[root@test01 init5]# docker exec -ti test sh
sh-4.2# ps -ef 
UID       PID PPID  C STIME TTY          TIME CMD
root        1    0  0 04:27 ?        00:00:00 tail -f /dev/null
root        6    0  0 04:27 pts/0    00:00:00 sh
root       13    6  0 04:27 pts/0    00:00:00 ps -ef
sh-4.2# 
sh-4.2# sleep 1000 &
[1] 14
sh-4.2# ps -ef
UID       PID PPID  C STIME TTY          TIME CMD
root        1    0  0 04:27 ?        00:00:00 tail -f /dev/null
root        6    0  0 04:27 pts/0    00:00:00 sh
root       14    6  0 04:27 pts/0    00:00:00 sleep 1000
root       15    6  0 04:28 pts/0    00:00:00 ps -ef
sh-4.2# 

1.2 容器内的0号进程

=====>对于linux系统来说:

linux启动的第一个进程是0号进程,是静态创建的

在0号进程启动后会接连创建两个进程,分别是1号进程和2和进程。

1号进程最终会去调用可init可执行文件,init进程最终会去创建所有的应用进程。

2号进程会在内核中负责创建所有的内核线程

所以说0号进程是1号和2号进程的父进程;1号进程是所有用户态进程的父进程;2号进程是所有内核线程的父进程。

===========>对于容器来说:

在容器平台上,无论你是用k8s去删除一个pod,或者用docker关闭一个容器,都会用到Containerd这个服务,

  • 在k8s里,创建pod会时,kubelet收到创建pod的请求后,会调用dockerDaemon发起创建容器请求,然后由containerd接收请求来创建containerd-shim进程,然后containerd-shim去调用runc来启动容器

    真正启动容器是通过 containerd-shim 去调用 runc 来启动容器的,runc 启动完容器后本身会直接退出,containerd-shim 则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程。containerd,containerd-shim和容器进程(即容器主进程)三个进程,是有依赖关系的

  • 如果只是用docker创建容器,那就是直接调用dockerDaemon发起创建容器请求,然后由containerd接收请求来创建containerd-shim进程,然后containerd-shim去调用runc来启动容器

containerd-shim就是上面提到的0号进程,关于containerd-shim需要掌握3个关键知识点

  • 1、实际的创建容器、容器内执行指令等都其实都是由containerd-shim进程在做。

  • 2、containerd-shim进程是容器的爹,具备回收僵尸儿子的功能,容器1号进程退出后,内核清理其下子孙进程,这些子孙进程就会被containerd-shim收养并清理。但如果容器内的1号进程不被Kill,那么其下进程如果有僵尸进程,是无法被处理的。所以用户开发的容器首进程要注意回收退出进程

  • 3、Containerd在stop停止容器的时候,会向容器的1号进程发送一个-15信号,如果容器内的1号进程没有信号转发能力,那在回收pid namespce时会向该namespace里的所有其他进程发送SIGKILL信号信号强制杀死。这是有问题的,后续我们将详细介绍

# kill -15与kill -9
当我们使用kill pid时,实际相当于kill -15 pid。也就是说默认信号为15。使用kill -15时,系统会发送一个SIGTERM的信号给对应的程序。当程序接收到该信号后,具体要如何处理自己可以决定。

这时候,应用程序可以选择:
1、立即停止程序
2、释放响应资源后停止程序
3、忽略该信号,继续执行程序
因为kill -15信号只是通知对应的进程要进行"安全、干净的退出",程序接到信号之后,
退出前一般会进行一些"准备工作",如资源释放、临时文件清理等等,如果准备工作做完了,再进行程序的终止。

但是,如果在"准备工作"进行过程中,遇到阻塞或者其他问题导致无法成功,那么应用程序可以选择忽略该终止信号。

这也就是为什么我们有的时候使用kill命令是没办法"杀死"应用的原因,
因为默认的kill信号是SIGTERM(15),而SIGTERM(15)的信号是可以被阻塞和忽略的。

和kill -15相比,kill -9就相对强硬得多,系统会发出SIGKILL信号,
他要求接收到该信号的程序应该立即结束运行,不能被阻塞或者忽略。

所以,kill -9在执行时,应用程序是没有时间进行"准备工作"的,所以这通常会带来一些副作用,
数据丢失或者终端无法恢复到正常状态等。

启动一个容器

docker run -d --name test1 centos:7 sh -c "(sleep 10d &) ; tail -f /dev/null"

如何查看容器内0号进程对应宿主机的pid号

[root@node1 ~]# docker container ls |grep test1 | awk '{print $1}'
09e4114ddd9d

# 注意,下面查看的是容器内部0号进程,对应宿主机上的pid号
[root@node1 ~]# ps aux |grep containerd-shim |grep 09e4114ddd9d
root       5439  0.0  0.6 712056 12836 ?        Sl   12:27   0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 09e4114ddd9d9747244352637949ade8c61082627984b3381d37a589d92c4bc3 -address /run/containerd/containerd.sock

如果你干死了容器的containerd-shim进程,那站在操作系统角度,容器下的所有进程都被操作系统的init收养然后回收了

如何查看容器内1号进程对应物理机的pid号,我们后续会在物理机用strace命令追踪容器内1号进程收到的信号,需要提前知晓下述方式

[root@node1 ~]# docker container ls |grep test1 | awk '{print $1}'
09e4114ddd9d
[root@node1 ~]# docker inspect 09e4114ddd9d |grep -i pid
            "Pid": 5458,
            "PidMode": "",
            "PidsLimit": null,

如何查看容器内其他进程在宿主机中的PID

[root@node1 ~]# docker container ls |grep test1 | awk '{print $1}'
09e4114ddd9d

# 注意,下面查看的是容器内部的进程对应宿主机对应的pid号,具体查看目录可能是system.slice或docker目录
[root@node1 ~]# cat /sys/fs/cgroup/memory/system.slice/docker-09e4114ddd9d9747244352637949ade8c61082627984b3381d37a589d92c4bc3.scope/cgroup.procs 
5458
5484
5485

# 也可以执行命令查看
docker top 容器名/或ID

# 上述结果包含了容器内1号进程在宿主机的映射,要确定其他进程号与容器内进程的对应关系,可以在宿主机上用ps aux |grep 号码,来过滤进行确认
=============》宿主机
[root@node1 ~]# ps aux |grep 5458 |grep -v grep
root       5458  0.0  0.0  11688  1336 ?        Ss   12:27   0:00 sh -c (sleep 10d &) ; tail -f /dev/null
[root@node1 ~]# ps aux |grep 5484 |grep -v grep
root       5484  0.0  0.0   4364   356 ?        S    12:27   0:00 sleep 10d
[root@node1 ~]# ps aux |grep 5485 |grep -v grep
root       5485  0.0  0.0   4400   352 ?        S    12:27   0:00 tail -f /dev/null

=============》在容器内,看一眼与上面的对应关系
可以执行docker exec -ti test1 sh进入容器内执行ps -ef来查看与上面的的结果是一一对应的
补充:
由于容器采用了Linux的namespace机制, 对pid进行了隔离. 因此容器内的pid将会从1开始重新编号, 并且不会看到其他容器或宿主机的进程pid。本质上容器就是宿主机上的一个普通的Linux进程, 因此在宿主机中是可以看到容器内进程的pid, 只不过这个pid是在宿主机上显示的, 而非容器内的(因为隔离了)

[root@node1 ~]# docker exec -ti test1 sh
sh-4.2# ps -elf
F S UID         PID   PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
4 S root          1      0  0  80   0 -  2922 do_wai 04:27 ?        00:00:00 sh -c (sleep 10d &) ; tail -f /dev/null
0 S root          8      1  0  80   0 -  1091 hrtime 04:27 ?        00:00:00 sleep 10d
0 S root          9      1  0  80   0 -  1100 wait_w 04:27 ?        00:00:00 tail -f /dev/null
4 S root         10      0  0  80   0 -  2956 do_wai 04:35 pts/0    00:00:00 sh
4 R root         16     10  0  80   0 - 12933 -      04:35 pts/0    00:00:00 ps -elf

小结:

  1. 每启动一个容器都会在宿主机产生一个docker-shim进程,它就是容器内的0号进程,是容器内1号进程的爹

  2. 当容器已经running之后,我们exec进入容器里执行命令产生的新进程,都是0号进程的儿子,而不是1号进程的儿子。所以说容器中的1号进程并不会像宿主机的1号进程那样直接或间接地领导所有其它进程;

  3. docker top 显示的容器中的进程可能不太全,与是否该进程归属于1号进程没有任何关系;与进程是否最终归属于该容器的管理进程docker-containerd-shim也没有关系,如果是nsenter进入容器,则启动的进程在docker top中是看不到的,虽然该进程在容器中显示的ppid也是0,其实同样是0的ppid却可能不是同一个进程,因为,只要父进程在容器外部,则容器内部显示的ppid就统一为0; 为什么docker top可能看到的不全?docker top是如何实现的呢?参看: https://phpor.net/blog/post/4420

# 终端1
docker container run -d --name test centos:7 tail -f /dev/null
docker top test

# 终端2
docker exec -ti test sh # 进入容器后启动几个进程,可以在终端1用top命令看到

# 终端3
# 进入后启动的新进程用top看不到,但是exec进入容器是可以看到的
nsenter -t 容器的pid --mount --uts --ipc --net --pid 

1.3 容器内的1号进程

1.3.1 完整操作系统的1号进程

先不提容器,就以物理机上运行的那个完整的操作系统为例:

一个 Linux 操作系统的启动流程

1、通电后,执行 BIOS

2、找到启动盘

3、bios根据自己的配置,找到启动盘,读取第一个扇区512bytes,即mbr的内容,这里放的前446是boot-loader程序,后64是分区信息,后2字节是结束的标志位

4、 bootloader 负责把磁盘里的内核读入内存执行

Linux 内核执行文件一般会放在 /boot 目录下,文件名类似 vmlinuz*,如下

[root@yq01-aip-aikefu19 base]# ls /boot/|grep vm
vmlinuz-0-rescue-53574fee080a44d49195c9f831019258
vmlinuz-3.10.0-514.el7.x86_64
vmlinuz-4.17.11-1.el7.elrepo.x86_64

5、在内核完成了系统的各种初始化之后,这个程序需要执行的第一个用户态程就是 init 进程,PID号为1,该进程是系统中所有其他进程的祖宗,在centos6中该祖宗进程称之为init,在centos7之后该祖宗进程名为systemd。

即:操作系统启动时是先执行内核态代码,然后在内核里调用1号进程的代码,从内核态切换到用户态

ps:目前linux的好嗯多发行版,如红帽、debian等,都会把/sbin/init作为软连接指向Systemd,Systemd是目前最流行

的linux init进程,在此之前还有SysVinit、UpStart等linux init进程

但无论是哪种 Linux init 进程,它最基本的功能都是创建出 Linux 系统中其他所有的进 程,并且管理这些进程**

在 Linux 上有了容器的概念之后,一旦容器建立了自己的 Pid Namespace(进程命名空 间),这个 Namespace 里的进程号也是从 1 开始标记的。所以,容器的 init 进程也被称 为 1 号进程。

1 号进程是第一个 用户态的进程,由它直接或者间接创建了 Namespace 中的其他进程。

总结操作系统的1号进程拥有如下特点

  • 1、它是系统的第一个进程,负责产生其他所有用户进程。
  • 2、init 以守护进程方式存在,是所有其他进程的祖先。

操作系统的1号进程拥有如下重要功能

  • 1、启动守护进程

  • 2、收养孤儿

  • 3、会定期发起wait或waitpid的功能去回收成为僵尸的儿子(这不是该进程独有的功能,但它的确有该功能)

  • 4、将操作系统信号转发给子进程

1.3.2 容器内的1号进程

然后我们再来说一下容器,容器内的操作系统来自于镜像,而镜像并非一个完整的操作系统(只拥有rootfs),通常docker的镜像为了节省空间,是没有安装systemd或者sysvint这类初始化系统的进程的,所以当启动容器时,我们CMD执行的命令是啥,容器里的1号进程就是啥,

# 以下dockerfile为例,当docker容器启动时,PID 1即容器启动程序将会是nginx,
FROM nginx

ENTRYPOINT ["nginx", "-c"]
CMD ["/etc/nginx/nginx.conf"]

容器内的1号进程与操作系统的1号进程的相同点与不同点如下

  • 1、相同之处

    只要容器里的1号进程停止,容器就会结束,就好比是操作系统的1号进程挂掉操作系统就挂掉了是一个道理。所以容器里的1号进程应该是一个一直运行不会停止的进程,而且必须在在前台运行,总结一下就是:1号进程需要在前台一直运行。

    示例1

    # 错误示范1:容器的1号进程不是一直运行的
    # 1.1 run.sh内容
    echo "123"
    
    # 1.2 dockerfile内容
    FROM centos:7
    
    ADD run.sh /opt
    CMD sh /opt/run.sh
    
    # 1.3 构建镜像
    docker build -t test:v1 ./
    
    # 1.4 启动测试
    docker container run -d --name test111 test:v1
    
    # 1.5 会发现容器启动之后立刻挂掉
    [root@test01 test]# docker container ls -a |grep test111
    ......  Exited (0) 33 seconds ago  ......

    示例2

    # 错误示范2:容器的1号进程放在了后台运行,那么无论它里面是啥代码,也都是一下就运行过去了,然后运行下一行代码,发现没有,容器也就结束了
    # 2.1 run.sh 内容
    while true;do echo 123 >> /tmp/a.log;sleep 1;done
    
    # 2.2 dockerfile内容
    FROM centos:7
    
    ADD run.sh /opt
    CMD sh /opt/run.sh &
    # 2.3 构建镜像
    docker build -t test:v2 ./
    
    # 2.4 启动测试
    docker container run -d --name test222 test:v2
    
    # 2.5 会发现容器启动之后立刻挂掉
    [root@test01 test]# docker container ls -a |grep test222
    ......  Exited (0) 33 seconds ago  ......

    示范3:

    # 正确示范:CMD的代码放到一个文件里然后运行也是一样,只要是在前台然后一直运行就都可以
    # 3.1 run.sh 内容
    while true;do echo 123 >> /tmp/a.log;sleep 1;done
    
    # 3.2 dockerfile内容
    FROM centos:7
    
    ADD run.sh /opt
    CMD sh /opt/run.sh
    
    # 3.3 构建镜像
    docker build -t test:v3 ./
    
    # 3.4 启动测试
    docker container run -d --name test333 test:v3
    
    # 3.5 会发现容器正常启动,可以切入到容器里查看
    docker exec -ti test333 sh
  • 2 、不同之处

    我们为容器启动的1号进程通常不具备操作系统的init进程一样的功能

    • 比如收养孤儿,定期发起wait或waitpid系统调用来回收僵尸儿子
    • 再比如信号转发的功能,我们接下来会先介绍回收僵尸儿子,然后再介绍信号转发。

    补充说明:

    我们建议容器设计原则是一个容器只运行一个进程,但在现实工作中往往做不到,还是会生出一些子进程,

    操作系统的init进程是操作系统的开发者开发的,它有一个非常重要的功能就是:会充当孤儿院的作用去回收孤儿进程,并且会定期发起wait或者waitpid的系统调用去回收僵尸的儿子,但是你容器里的1号是你开发的,你摸着自己的良心问问自己有没有实现上面的功能,没有吧,所以你的容器里有僵尸进程而无法被回收也就一点也不奇怪了,那容器里一旦产生僵尸进程该如何应对呢?带着这些疑问往后看吧

二 储备知识僵尸进程与孤儿进程

2.1 linux系统中进程的状态

在linux系统中,无论是进程还是线程,内核转给你都是用task_struct{}结构体来表示的,称之为任务task,task是linux里基本的调度单位,我们通过ps aux会查看到一系列进程的状态,其实就是task的状态。

进程的状态分为两大类,活着的与死亡的

  • 一、活着的
    • 1.1 运行着的进程
    • (1)、运行态,正占用着cpu资源在运行着,状态为R
    • (2)、就绪态,没有申请到cpu资源,处于运行队列中,一旦申请到cpu就可以立即投入运行,状态也为R
    • 1.2 睡眠的进程
    • (1)、可中断睡眠(TASK_INTERRUPTIBLE),状态为S,等待某个资源而进入的状态,比如等待本地或网络用户输入,也可以等待一个信号量(Semaphore),执行的IO操作可以得到硬件设备的响应
    • (2)、不可中断睡眠(TASK_UNINTERRUPTIBLE),状态为D,处于睡眠状态,但是此刻进程是不可中断的,意思是不响应异步信号,执行的IO操作得不到硬件设备的响应(可能是因为硬件繁忙,因此导致内存里的一些缓存数据无法及时刷入磁盘,所以肯定不允许你中断该睡眠状态,并且你会发现处于D状态的进程kill -9竟然也杀不死,就是为了保证数据安全)
  • 二、死亡的:即执行do_exit()结束进程
    • (1)EXIT_DEAD:也就是进程真正结束退出那一瞬间的状态,通常我们看不到,因为很快就没了
    • (2)EXIT_ZOMBIE,这个是进程进入EXIT_DEAD状态前的一个状态,该状态称之为僵尸进程,状态显示为Z,也就是说所有进程在死前都会进入僵尸进程的状态。

强调:我们接下来要讨论的,是僵尸进程的残留问题,而不是僵尸进程的产生问题,所有的进程在死前都会进入僵尸进程的状态,它的父进程负责回收该状态,若没有及时回收,就会残留,至于残留后有何影响、如何回收等问题详解如下

2.2 僵尸进程详解

#1、什么是僵尸进程
操作系统负责管理进程
我们的应用程序若想开启子进程,都是在向操作系统发送系统调用
当一个子进程开启起来以后,它的运行与父进程是异步的,彼此互不影响,谁先死都不一定

linux操作系统的设计规定:父进程应该具备随时获取子进程状态的能力
如果子进程先于父进程运行完毕,此时若linux操作系统立刻把该子进程的所有资源全部释放掉,那么父进程来查看子进程状态时,会突然发现自己刚刚生了一个儿子,但是儿子没了!!!
这就违背了linux操作系统的设计规定
所以,linux系统出于好心,若子进程先于父进程运行完毕/死掉,那么linux系统在清理子进程的时候,会将子进程占用的重型资源都释放掉(比如占用的内存空间、cpu资源、打开的文件等),但是会保留一部分子进程的关键状态信息,比如进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等,此时子进程就相当于死了但是没死干净,因而得名"僵尸进程",其实僵尸进程是linux操作系统出于好心,为父进程准备的一些子进程的状态数据,专门供父进程查阅,也就是说"僵尸进程"是linux系统的一种数据结构,所有的子进程结束后都会进入僵尸进程的状态

# 2、那么问题来了,僵尸进程残存的那些数据不需要回收吗???
当然需要回收了,但是僵尸进程毕竟是linux系统出于好心,为父进程准备的数据,至于回收操作,应该是父进程觉得自己无需查看僵尸进程的数据了,父进程觉得留着僵尸进程的数据也没啥用了,然后由父进程发起一个系统调用wait / waitpid来通知linux操作系统说:哥们,谢谢你为我保存着这些僵尸的子进程状态,我现在用不上他了,你可以把他们回收掉了。然后操作系统再清理掉僵尸进程的残余状态,你看,两者配合的非常默契,但是,怕就怕在。。。

# 3、分三种情况讨论
1、linux系统自带的一些优秀的开源软件,这些软件在开启子进程时,父进程内部都会及时调用wait/waitpid来通知操作系统回收僵尸进程,所以,我们通常看不到优秀的开源软件堆积僵尸进程,因为很及时就回收了,与linux系统配合的很默契

2、一些水平良好的程序员开发的应用程序,这些程序员技术功底深厚,知道父进程要对子进程负责,会在父进程内考虑调用wait/waitpid来通知操作系统回收僵尸进程,但是发起系统调用wait/waitpid的时间可能慢了些,于是我们可以在linux系统中通过命令查看到僵尸进程状态
[root@egon ~]# ps aux | grep [Z]+

3、一些垃圾程序员,技术非常垃圾,只知道开子进程,父进程也不结束,就在那傻不拉几地一直开子进程,也压根不知道啥叫僵尸进程,至于wait/waitpid的系统调用更是没听说过,这个时候,就真的垃圾了,操作系统中会堆积很多僵尸进程,此时我们的计算机会进入一个奇怪的现象,就是内存充足、硬盘充足、cpu空闲,但是,启动新的软件就是无法启动起来,为啥,因为操作系统负责管理进程,每启动一个进程就会分配一个pid号,而pid号是有限的,正常情况下pid也用不完,但怕就怕堆积一堆僵尸进程,他吃不了多少内存,但能吃一堆pid

# 4、如果清理僵尸进程
针对情况3,只有一种解决方案,就是杀死父进程,那么僵尸的子进程会被linux系统中pid为1的顶级进程(init或systemd)接管,顶级进程很靠谱、它是一定会定期发起系统调用wait/waitpid来通知操作系统清理僵尸儿子的

针对情况2,可以发送信号给父进程,通知它快点发起系统调用wait/waitpid来清理僵尸的儿子
kill -CHLD 父进程PID

# 5、结语
僵尸进程是linux系统出于好心设计的一种数据结构,一个子进程死掉后,相当于操作系统出于好心帮它的爸爸保存它的遗体,之说以会在某种场景下有害,是因为它的爸爸不靠谱,儿子死了,也不及时收尸(发起系统调用让操作系统收尸)

说白了,僵尸进程本身无害,有害的是那些水平不足的程序员,他们总是喜欢写bug,好吧,如果你想看看垃圾程序员是如何写bug来堆积僵尸进程的,你可以看一下这篇博客https://www.cnblogs.com/linhaifeng/articles/13567273.html

再次强调:父进程发起的wait或waitpid调用只能回收僵尸儿子,无法回收孙子辈,只能是儿子,只能是儿子,只能是儿子

再次强调:父进程发起的wait或waitpid调用只能回收僵尸儿子,无法回收孙子辈,只能是儿子,只能是儿子,只能是儿子

再次强调:父进程发起的wait或waitpid调用只能回收僵尸儿子,无法回收孙子辈,只能是儿子,只能是儿子,只能是儿子

再次强调:父进程发起的wait或waitpid调用只能回收僵尸儿子,无法回收孙子辈,只能是儿子,只能是儿子,只能是儿子

再次强调:父进程发起的wait或waitpid调用只能回收僵尸儿子,无法回收孙子辈,只能是儿子,只能是儿子,只能是儿子

2.3 僵尸进程示例代码

先储备一个知识,看如下代码

#coding:utf-8
from multiprocessing import Process
import os
import time

def task(n):
    print("father--->%s son--->%s" %(os.getppid(),os.getpid()))
    time.sleep(n)

if __name__ == "__main__":
    # 首选必须知道一个知识点:一个python进程,就是一个python解释器进程,你的python代码本质都是字符串最终运行与调用的
    # 其实都是解释器的代码
    # 对于开启的子进程,即便你不去执行p.join()方法,也不用担心,因为只要python解释器还在运行着,它就会定期定期定期,
    # 注意是定期,去执行
    # 回收僵尸进程的功能,
    # 当如果你没有执行p.join(),同时也time.sleep(100000)住了主进程,那肯定就没有人来回收僵尸儿子了

    # 所以,如果你开启了一千个子进程,每个子进程都是运行一行打印功能然后在极短的时间内进入僵尸进程状态
    # 主进程在一个个开启这一千个子进程的过程中就会定期回收一部分僵尸儿子,当运行到最后执行到time.sleep(100000)时,主进程就停住了
    # 剩下的没来得及回收的僵尸儿子也就残留了,此处我想告诉大家的就是这件事,你不要以为你开一千个子进程每个都很快结束,然后最后你就
    # 会看到一千个僵尸儿子

    for i in range(1000):
        Process(target=task,args=(0,)).start()

    time.sleep(100000)

上述代码你去执行一下,只会看到有几个僵尸进程残留。

综上,我们的用来产生僵尸进程的代码示范如下,先让子进程运行一小会不要立刻结束(否则立即产生的僵尸进程,极有可能会被正在跑着的主进程回收了),然后等所有儿子都开启了之后,主进程执行time.sleep(100000)后彻底停住、不会执行任何定期回收僵尸儿子的任务了。此时就可以静静等着撕掉的儿子,每死一个就产生一个僵尸

=====================窗口1中======================
[root@egon ~]# cat test.py 
#coding:utf-8
from multiprocessing import Process
import os
import time

def task(n):
    print("father--->%s son--->%s" %(os.getppid(),os.getpid()))
    time.sleep(n)

if __name__ == "__main__":
    p1=Process(target=task,args=(10,))
    p2=Process(target=task,args=(10,))
    p3=Process(target=task,args=(10,))
    p1.start()
    p2.start()
    p3.start()
    print("main--->%s" %os.getpid())
    time.sleep(10000)
[root@egon ~]# python test.py &
[5] 104481
[root@egon ~]# main--->104481
father--->104481 son--->104482
father--->104481 son--->104483
father--->104481 son--->104484

=====================窗口2中:大概过个十几秒后查看======================
[root@egon ~]# ps aux |grep Z
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root     104482  0.0  0.0      0     0 pts/2    Z    18:24   0:00 [python] <defunct>
root     104483  0.0  0.0      0     0 pts/2    Z    18:24   0:00 [python] <defunct>
root     104484  0.0  0.0      0     0 pts/2    Z    18:24   0:00 [python] <defunct>
root     104488  0.0  0.0 112828   960 pts/4    R+   18:24   0:00 grep --color=auto Z

=====================如下,我们可以看到僵尸对应的资源都已经没有了======================
# cat /proc/104482/cmdline
# cat /proc/104482/smaps
# cat /proc/104482/maps
# ls -l /proc/104482/fd

任何进程的退出都是调用do_exit()系统接口,do_exit()内部会释放进程task_struct里的mm/shm/sem/files等文件资源,只留下一个stask_struct instance空壳,如上所示

并且,这个进程也已经不响应任何的信号了,无论 SIGTERM(15) 还是 SIGKILL(9)
kill -9 104482
kill -15 104482
ps aux |grep Z # 会发现104482这个僵尸进程依然存在

# 补充
可以去查看p1.join()的代码,里面有一个关于wait的调用,在主进程里调用p1.join()的目的就是等子进程挂掉后
而回收它的尸体,所以python代码多进程编程,在主进程里建议在主进程里一个个join主子进程。
而上例中,主进程在进入sleep前,我们并没有调用join方法,于是僵尸进程就产生了,因为主进程一直停在原地,并没有发起wait系统调用的机会

2.4 孤儿进程

说在最前面:孤儿进程指的是一个进程的父进程死掉了,它就成了孤儿,孤儿会被1号进程收养,而不是被它爷爷进程、或者太爷爷进程收养,这一点很关键

父进程先死掉,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被进程号为1的顶级进程(init或systemd)所收养,并由顶级进程对它们完成状态收集工作。
此处需要强调一句:不管子进程时什么状态,哪怕它是僵尸状态也一样,只要它的父进程挂掉了,它就会被1号进程收养。

进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为顶级进程,而顶级进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,顶级进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

我们来测试一下(创建完子进程后,主进程所在的这个脚本就退出了,当父进程先于子进程结束时,子进程会被顶级进程收养,成为孤儿进程,而非僵尸进程),文件内容

import os
import sys
import time

pid = os.getpid()
ppid = os.getppid()
print 'im father', 'pid', pid, 'ppid', ppid
pid = os.fork()
#执行pid=os.fork()则会生成一个子进程
#返回值pid有两种值:
#    如果返回的pid值为0,表示在子进程当中
#    如果返回的pid值>0,表示在父进程当中
if pid > 0:
    print 'father died..'
    sys.exit(0)

# 保证主线程退出完毕
time.sleep(1)
print 'im child', os.getpid(), os.getppid()

执行文件,输出结果:
im father pid 32515 ppid 32015
father died..
im child 32516 1

看,子进程已经被pid为1的顶级进程接收了,所以僵尸进程在这种情况下是不存在的,存在只有孤儿进程而已,孤儿进程声明周期结束自然会被顶级进程来销毁。

2.5 综合练习

最后我们来做一个小练习,把上面关于僵尸进程与孤儿进程的知识点串一下

在做练习前需要再次强调:我们说一个完整操作系统自带的init进程是会定期发起wait或waitpid系统调用来回收僵尸儿子的,这里需要强调的是

  • 1、wait或waitpid只能回收儿子,只能是儿子、只能是儿子,只能是儿子,孙子及孙子。。都不行

  • 2、当kill -9无法杀掉僵尸进程

  • 3、当杀掉一个进程时(假设它以被kill -9杀掉,僵尸进程时不能被kill -9的)

    • 如果它的父进程存在并且没有发起wait或waitpid那么该进程就会停留在僵尸进程的状态,此时我们杀死该父进程,那么该父进程的僵尸儿子就会被pid为1的进程收养,pid为1的进程会负责回收
    • 如果它的父进程会发起wait或waitpid那么该进程就会被回收,你就看不到什么僵尸进程的状态

示例代码

#coding:utf-8
from multiprocessing import Process
import os
import time

def task1(n):
    print("儿子,PID:%s PPID:%s" %(os.getpid(),os.getppid()))
    pp1=Process(target=task2,args=(10,))
    pp2=Process(target=task2,args=(10,))
    pp3=Process(target=task2,args=(10,))
    pp1.start()
    pp2.start()
    pp3.start()
    time.sleep(n)

def task2(n):
    print("孙子,PID:%s PPID:%s" %(os.getpid(),os.getppid()))
    time.sleep(n)

if __name__ == "__main__":
    p=Process(target=task1,args=(10000,))
    p.start()

    print("爸爸,PID: %s" %os.getpid())
    time.sleep(10000)

实验

[root@test01 test2]# echo $$  # 当前bash进程的pid为63047
63047

[root@test01 test2]# python test.py 
爸爸,PID: 42801
儿子,PID:42802 PPID:42801
孙子,PID:42803 PPID:42802
孙子,PID:42804 PPID:42802
孙子,PID:42805 PPID:42802

# 然后在另外一个终端查看
[root@test01 test2]# ps -elf |grep python
0 S root      42801  63047  0  80   0 - 37800 poll_s 15:17 pts/1    00:00:00 python test.py
1 S root      42802  42801  0  80   0 - 37800 poll_s 15:17 pts/1    00:00:00 python test.py
1 Z root      42803  42802  0  80   0 -     0 do_exi 15:17 pts/1    00:00:00 [python] <defunct>
1 Z root      42804  42802  0  80   0 -     0 do_exi 15:17 pts/1    00:00:00 [python] <defunct>
1 Z root      42805  42802  0  80   0 -     0 do_exi 15:17 pts/1    00:00:00 [python] <defunct>
0 S root      44990   6509  0  80   0 - 28182 pipe_w 15:21 pts/0    00:00:00 grep --color=auto python

# 进程的关系:
        父进程                 子进程     孙子进程     曾孙子进程  
进程即bash,pid为63047--------》42801----》42802----》42803、42804、42805

# 注意,bash这个进程也有定期发起wait与waitpid的功能,所以当它的儿子死掉后,是会被回收的

# 实验1:尝试杀掉曾孙子进程:42803、42804、42805,会发现是杀不死的
kill -9 42803
kill -9 42804
kill -9 42805

# 实验2:尝试杀掉孙子进程42802,它的爹进程42801在原地sleep住了,所以不会回收它,它会进入僵尸进程的状态,但是但是但是42802的三个僵尸儿子会成为孤儿被pid为1的收养,然后被回收,所以我们会看到只有42802这个进程称为僵尸
[root@test01 test2]# ps -elf |grep python
0 S root      42801  63047  0  80   0 - 37800 poll_s 15:17 pts/1    00:00:00 python test.py
1 Z root      42802  42801  0  80   0 -     0 do_exi 15:17 pts/1    00:00:00 [python] <defunct>

# 实验3:然后我们杀死子进程42801,在它死后它肯定会成为僵尸,问题是它爹63047会不会回收,会,因为她爹是bash是有这个功能的,所以它的僵尸状态不会残留
[root@test01 test2]# kill -9 42801
[root@test01 test2]# ps -elf |grep python
0 S root      50846   6509  0  80   0 - 28182 pipe_w 15:34 pts/0    00:00:00 grep --color=auto python

linux进程管理更多内容:详见https://egonlin.com/?p=210

三 容器内的僵尸进程

3.1 容器内残留僵尸进程的原因

再次重申一个重点:

操作系统内有1号进程,容器内也有一个1号进程

区别是操作系统内的1号进程里的代码是别人开发的,开发者为其加入了两个个重要的功能

  • 当进程称为孤儿进程后,1号进程会收养该孤儿、称为他的爹
  • 定义发起wait或waitpid的系统调用去1号进程的僵尸儿子。

而容器里的1号进程的代码是你开发的,你并没有考虑发起wait与waitpid这个系统调用的操作。于是

在容器运行一段时间后,如果有子进程先挂掉了,它爹又没有负责回收,那么僵尸进程的状态就残留了下来,pid资源就被白白占住了

有人会问,那如果我关掉容器,或者删掉k8s里的pod,容器里的僵尸进程还会残留吗,答案是肯定不会残留,为什么呢?

在容器平台上,无论你是用k8s去删除一个pod,或者用docker关闭一个容器,都会用到Containerd这个服务

1、创建容器时:kubelet调用`dockerDaemon`发起创建容器请求,然后由`containerd`接收并创建`containerd-shim`,`containerd-shim`即容器内的0号进程。所以实际的创建容器、容器内执行指令等都是此进程在做

2、同时,`containerd-shim`具有回收僵尸进程的功能,容器1号进程退出后,内核清理其下子孙进程,这些子孙进程被`containerd-shim`收养并清理。 注意:如果1号进程不被Kill,那么其下进程如果有僵尸进程,是无法被处理的。所以用户开发的容器首进程要注意回收退出进程。

ps: 在所有容器都清理后,k8s中的pod也就被删除了。 

所以说,你要知道的是,即便容器内的1号进程没有回收僵尸儿子的能力,0号进程是为其兜底的。

而我们接下来要讨论的不是容器内1号进程挂掉的情况,而是要讨论在1号进程活着的情况下,它若没有回收僵尸进程的能力容器内会产生何种现象,应该如何处理。听懂了没有,不是粗鲁地直接干掉容器来回收一切,这操作谁都会。

我们用python解释器作为1号进程来测试(强调,python解释器与bash解释一样都具备定期回收僵尸儿子的功能,但是我们再次用sleep将python父进程停住,它不动弹了也就不会发起回收了,我们也就能看到僵尸儿子了)

# 1、创建工作目录
mkdir /test
cd /test

# 2、创建脚本文件run1.sh
cat >> test.py << EOF
#coding:utf-8
from multiprocessing import Process
import os
import time

def task1(n):
    print("儿子,PID:%s PPID:%s" %(os.getpid(),os.getppid()))
    pp1=Process(target=task2,args=(10,))
    pp2=Process(target=task2,args=(10,))
    pp3=Process(target=task2,args=(10,))
    pp1.start()
    pp2.start()
    pp3.start()
    time.sleep(n)

def task2(n):
    print("孙子,PID:%s PPID:%s" %(os.getpid(),os.getppid()))
    time.sleep(n)

if __name__ == "__main__":
    p=Process(target=task1,args=(10000,))
    p.start()

    print("爸爸,PID: %s" %os.getpid())
    time.sleep(10000)

EOF

# 3、创建dockerfile
cat > dockerfile << EOF
FROM centos:7

ADD test.py /opt
CMD python /opt/test.py

EOF

# 4、构建镜像并运行
docker build -t test_defunct:v1 ./
docker run -d --name test1 test_defunct:v1

# 5、进入容器会发现有僵尸进程产生: 因为1号进程一直sleep在原地,根本没有机会去回收僵尸的儿子,如此,效果就模拟出来了
[root@test01 test]# docker exec -ti test1 sh
sh-4.2# ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 07:50 ?        00:00:00 python /opt/test.py
root          7      1  0 07:50 ?        00:00:00 python /opt/test.py
root          8      7  0 07:50 ?        00:00:00 [python] <defunct>
root          9      7  0 07:50 ?        00:00:00 [python] <defunct>
root         10      7  0 07:50 ?        00:00:00 [python] <defunct>
root         11      0  0 07:50 pts/0    00:00:00 sh
root         18     11  0 07:51 pts/0    00:00:00 ps -ef

此时你是无法杀死那三个僵尸的,那我们杀死他们的爹,即pid为7的进程
sh-4.2# kill -9 7
sh-4.2# ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 07:50 ?        00:00:00 python /opt/test.py
root          7      1  0 07:50 ?        00:00:00 [python] <defunct>
root          8      1  0 07:50 ?        00:00:00 [python] <defunct>
root          9      1  0 07:50 ?        00:00:00 [python] <defunct>
root         10      1  0 07:50 ?        00:00:00 [python] <defunct>
root         11      0  0 07:50 pts/0    00:00:00 sh
root         19     11  0 07:52 pts/0    00:00:00 ps -ef

此时你会发现两个非常有趣的现象
1、又多一个僵尸进程
2、pid为7、8、9、10的进程的ppid都变成了1,也就是说他们都被1号进程收养了

很简单,当我们杀死pid为7的进程的时候,它爹也就是1号进程在原地sleep呢,并不会回收它,所以它停留在僵尸状态
而pid为7的进程一旦撕掉,它的三个僵尸儿子们就成了孤儿,自然会被pid为1的进程收养,再说一句,这里的pid为1的进程被sleep住了,肯定
不会 回收他们,于是它们三也残留着,这就是你看到的4个僵尸进程

最后,不能再杀了,而且你kill -9 1也是无效的,1号进程是无法被强制杀死的,这一点我们后面再说。

3.1 如何看待容器内的僵尸进程及解决方案

联系管理员微信tutu19192010,注册账号

上一篇
下一篇
Copyright © 2022 Egon的技术星球 egonlin.com 版权所有 帮助IT小伙伴学到真正的技术