微信公众号:运维开发故事,作者:夏老师
按进程在执行过程中的不同情况至少要定义三种状态:
![三种状态的进程调度.png](https://www.www.zyiz.net/i/ll/?i=img_convert/4581568b54346c5a5760225593c4367b.png#clientId=ua9d9dd15-43f7-4&from=ui&id=uae554c6a&margin=[object Object]&name=三种状态的进程调度.png&originHeight=280&originWidth=720&originalType=binary&ratio=1&size=29985&status=done&style=none&taskId=u50620ef3-15ef-4305-bda9-cd1979fb4f1)
引起进程状态转换的具体原因如下:
五态模型在三态模型的基础上增加了新建态(new)和终止态(exit)。
![经常的状态转换.png](https://www.www.zyiz.net/i/ll/?i=img_convert/209c9cdb5818145a612699da552fd57b.png#clientId=ua9d9dd15-43f7-4&from=ui&id=ub48bbdf3&margin=[object Object]&name=经常的状态转换.png&originHeight=280&originWidth=1360&originalType=binary&ratio=1&size=43400&status=done&style=none&taskId=u59000ad7-3f53-4336-9aa9-d04ca10463a)
引起进程状态转换的具体原因如下:
linux的进程状态
![image.png](https://www.www.zyiz.net/i/ll/?i=img_convert/69b2f667130f111c8210d240376b0954.png#clientId=ua9d9dd15-43f7-4&from=paste&height=233&id=uf7569a6c&margin=[object Object]&name=image.png&originHeight=466&originWidth=1116&originalType=binary&ratio=1&size=47372&status=done&style=none&taskId=uc4648a25-2c7d-4aed-9403-99815216473&width=558)
无论进程还是线程,在 Linux 内核里其实都是用 task_struct{}这个结构来表示的。它其实就是任务(task),也就是 Linux 里基本的调度单位。
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1,也有可能是容器中的init)所收养,并由init进程对它们完成状态收集工作。孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
在类UNIX系统中,僵尸进程是指完成执行(通过 exit 系统调用,或运行时发生致命错误或收到终止信号所致),但在操作系统的进程表中仍然存在其进程控制块,处于"终止状态"的进程。
我们从上面的概念中得知,僵尸进程仍然存在在进程表中。进程会占用系统系统资源,僵尸进程过多会导致资源泄露,最主要的资源就是PID。我们来看一下linux系统中的PID。
这个最大值可以我们在 /proc/sys/kernel/pid_max 这个参数中看到。
[root@k8s-dev]# cat /proc/sys/kernel/pid_max 32768
Linux 内核在进行初始化时,会根据CPU 的数目对 pid_max 进行设置。
所以如果超过这个最大值,那么系统就无法创建出新的进程了,比如你想 SSH 登录到这台机器上就不行了。
清理僵尸进程:
了解了僵尸进程的危害,我们来看看怎么清理僵尸进程:
收割僵尸进程的方法是通过kill命令手工向其父进程发送SIGCHLD信号。如果其父进程仍然拒绝收割僵尸进程,则终止父进程,使得init进程收养僵尸进程。init进程周期执行wait系统调用收割其收养的所有僵尸进程。
为避免产生僵尸进程,实际应用中一般采取的方式是:
在Docker中,进程管理的基础就是Linux内核中的PID名空间技术。在不同PID名空间中,进程ID是独立的;即在两个不同名空间下的进程可以有相同的PID。
Linux内核为所有的PID名空间维护了一个树状结构:最顶层的是系统初始化时创建的root namespace(根名空间),再创建的新PID namespace就称之为child namespace(子名空间),而原先的PID名空间就是新创建的PID名空间的parent namespace(父名空间)。通过这种方式,系统中的PID名空间会形成一个层级体系。父节点可以看到子节点中的进程,并可以通过信号等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点名空间中的任何内容,也不可能通过kill或ptrace影响父节点或其他名空间中的进程。
在Docker中,每个Container都是Docker Daemon的子进程,每个Container进程缺省都具有不同的PID名空间。通过名空间技术,Docker实现容器间的进程隔离。另外Docker Daemon也会利用PID名空间的树状结构,实现了对容器中的进程交互、监控和回收。注:Docker还利用了其他名空间(UTS,IPC,USER)等实现了各种系统资源的隔离,由于这些内容和进程管理关联不多,本文不会涉及。
当创建一个Docker容器的时候,就会新建一个PID名空间。容器启动进程在该名空间内PID为1。当PID1进程结束之后,Docker会销毁对应的PID名空间,并向容器内所有其它的子进程发送SIGKILL。
当执行docker stop命令时,docker会首先向容器的PID1进程发送一个SIGTERM信号,用于容器内程序的退出。如果容器在收到SIGTERM后没有结束, 那么Docker Daemon会在等待一段时间(默认是10s)后,再向容器发送SIGKILL信号,将容器杀死变为退出状态。这种方式给Docker应用提供了一个优雅的退出(graceful stop)机制,允许应用在收到stop命令时清理和释放使用中的资源。
docker kill可以向容器内PID1进程发送任何信号,缺省是发送SIGKILL信号来强制退出应用。
容器化后,由于单容器单进程,已经没有传统意义上的 init 进程了。应用进程直接占用了 pid 1 的进程号。从而导致以下两个问题。
解决这个的办法就是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
k8s 可以将多个容器编排到一个 pod 里面,共享同一个 Linux Namespace。这项技术的本质是使用 k8s 提供一个 pause 镜像,也就是说先启动一个 pause 容器,相当于实例化出 Namespace,然后其他容器加入这个 Namespace 从而实现 Namespace 的共享。
我们来介绍一下 pause。pause 是 k8s 在 1.16 版本引入的技术,要使用 pause,我们只需要在 pod 创建的 yaml 中指定 shareProcessNamespace 参数为 true,如下:
apiVersion: v1 kind: Pod metadata: name: nginx spec: shareProcessNamespace: true containers: - name: nginx image: nginx - name: shell image: busybox securityContext: capabilities: add: - SYS_PTRACE stdin: true tty: true
attach到pod中,ps查看进程列表:
/ # kubectl attach POD -c CONTAINER / # ps ax PID USER TIME COMMAND 1 root 0:00 /pause 8 root 0:00 nginx: master process nginx -g daemon off; 14 101 0:00 nginx: worker process 15 root 0:00 sh 21 root 0:00 ps ax
我们可以看到 pod 中的 1 号进程变成了 /pause,其他容器的 entrypoint 进程都变成了 1 号进程的子进程。这个时候开始逐渐逼近事情的本质了:/pause 进程是如何处理 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 的呢?
pause 镜像的源码如下:pause.c
#include <signal.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> static void sigdown(int signo) { psignal(signo, "Shutting down, got signal"); exit(0); } // 关注1 static void sigreap(int signo) { while (waitpid(-1, NULL, WNOHANG) > 0) ; } int main(int argc, char **argv) { int i; for (i = 1; i < argc; ++i) { if (!strcasecmp(argv[i], "-v")) { printf("pause.c %s\n", VERSION_STRING(VERSION)); return 0; } } if (getpid() != 1) /* Not an error because pause sees use outside of infra containers. */ fprintf(stderr, "Warning: pause should be the first process\n"); if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0) return 1; if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0) return 2; // 关注2 if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap, .sa_flags = SA_NOCLDSTOP}, NULL) < 0) return 3; for (;;) pause(); // 编者注:该系统调用的作用是wait for signal fprintf(stderr, "Error: infinite loop terminated\n"); return 42; }
重点关注一下void sigreap(int signo){…}和if (sigaction(SIGCHLD,…) ,这个不就是我们上面说的
除了这种方式外,还可以通过异步的方式来进行回收,这种方式的基础是子进程结束之后会向父进程发送 SIGCHLD 信号,基于此父进程注册一个 SIGCHLD 信号的处理函数来进行子进程的资源回收就可以了。
SIGCHLD 信号的处理函数核心就是这一行 while (waitpid(-1, NULL, WNOHANG) > 0) ,其中各参数示意如下:
-1:meaning wait for any child process.
NULL:?
WNOHANG :return immediately if no child has exited.
得出pause 容器的两个最重要的特性:
在 pod 中作为容器共享namespace的基础
作为 pod 内的所有容器的父容器,扮演 init 进程(即systemd)的作用。
通过这篇文章的学习,我希望大家能解决容器中出现僵尸进程的问题。