课程原文: 李程远. 理解进程(2):为什么我的容器里有这么多僵尸进程?
本文实践环境:
Operating System: Ubuntu 20.04.2 LTS
Kernel: Linux 5.8.0-55-generic
Architecture: x86-64
Docker Client/Server Version: 20.10.5
在《Linux 僵尸进程实战》这篇文章中,笔者对 Linux 下的僵尸进程进行了初步分析。本文将接着僵尸进程的话题,学习如何在容器中限制进程数。
pid_max
一个 Linux 系统上的进程总数是有限的,我们可以在 /proc/sys/kernel/pid_max
查看到这个数值,如下:
demonlee@demonlee-ubuntu:~$ cat /proc/sys/kernel/pid_max
4194304
demonlee@demonlee-ubuntu:~$
在 Ubuntu 20.04.2 LTS 上的值为 4194304,而在另一台 CentOS 7.9 机器上这个值是 32768,如下:
➜ ~ cat /proc/sys/kernel/pid_max
32768
➜ ~ cat /etc/redhat-release
CentOS Linux release 7.9.2009 (Core)
➜ ~
不同机器上的值不同,通过 man 5 proc
,然后搜索 pid_max
,可以看到如下说明:
/proc/sys/kernel/pid_max (since Linux 2.5.34)
This file specifies the value at which PIDs wrap around (i.e., the value in this file is one greater than the maximum PID). PIDs greater than this value are not allocated; thus, the value in this file also acts as a system-wide limit on the total number of processes and threads. The default value for this file, 32768, results in the same range of PIDs as on earlier kernels. On 32-bit plat‐forms, 32768 is the maximum value for pid_max. On 64-bit systems, pid_max can be set to any value up to 2^22 (PID_MAX_LIMIT, approxi‐mately 4 million).
简单来说,如果系统允许创建的进程和线程总数不得超过 pid_max
,如果超过,创建新进程会报错,即使通过 ssh
登录到这台机器上也不行。在 32 位操作系统上,这个默认值是 32768,而在 64 位操作系统上,这个值 可以 设置为 2^22 (即 4,194,304 )。笔者的两台 Linux 机器都是 64 位操作系统,4 核CPU,但两个值却不同。
为进一步了解这个配置,笔者翻阅内核代码,通过搜索 pid_max
发现了相关宏定义在 include/linux/threads.h
文件中,如下所示:
/* SPDX-License-Identifier: GPL-2.0 */
#ifndef _LINUX_THREADS_H
#define _LINUX_THREADS_H
//...
/*
* This controls the default maximum pid allocated to a process
*/
#define PID_MAX_DEFAULT (CONFIG_BASE_SMALL ? 0x1000 : 0x8000)
/*
* A maximum of 4 million PIDs should be enough for a while.
* [NOTE: PID/TIDs are limited to 2^30 ~= 1 billion, see FUTEX_TID_MASK.]
*/
#define PID_MAX_LIMIT (CONFIG_BASE_SMALL ? PAGE_SIZE * 8 : \
(sizeof(long) > 4 ? 4 * 1024 * 1024 : PID_MAX_DEFAULT))
/*
* Define a minimum number of pids per cpu. Heuristically based
* on original pid max of 32k for 32 cpus. Also, increase the
* minimum settable value for pid_max on the running system based
* on similar defaults. See kernel/pid.c:pidmap_init() for details.
*/
#define PIDS_PER_CPU_DEFAULT 1024
#define PIDS_PER_CPU_MIN 8
#endif
另外,通过搜索引擎找到了 kernel/pid.c
中相关代码:
// ...
int pid_max = PID_MAX_DEFAULT;
#define RESERVED_PIDS 300
int pid_max_min = RESERVED_PIDS + 1;
int pid_max_max = PID_MAX_LIMIT;
// ...
void __init pid_idr_init(void)
{
/* Verify no one has done anything silly: */
BUILD_BUG_ON(PID_MAX_LIMIT >= PIDNS_ADDING);
/* bump default and minimum pid_max based on number of cpus */
pid_max = min(pid_max_max, max_t(int, pid_max,
PIDS_PER_CPU_DEFAULT * num_possible_cpus()));
pid_max_min = max_t(int, pid_max_min,
PIDS_PER_CPU_MIN * num_possible_cpus());
pr_info("pid_max: default: %u minimum: %u\n", pid_max, pid_max_min);
idr_init(&init_pid_ns.idr);
init_pid_ns.pid_cachep = KMEM_CACHE(pid,
SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT);
}
// ...
代码中 PID_MAX_LIMIT
依赖于环境变量 CONFIG_BASE_SMALL
,而这个变量定义在主机上的 /boot/config-5.8.0-55-generic
文件中,如下:
demonlee@demonlee-ubuntu:boot$ pwd
/boot
demonlee@demonlee-ubuntu:boot$ grep CONFIG_BASE_SMALL conf*
config-5.8.0-53-generic:CONFIG_BASE_SMALL=0
config-5.8.0-55-generic:CONFIG_BASE_SMALL=0
demonlee@demonlee-ubuntu:boot$
结合上面的相关代码,基本可以理解这个 pid_max
在内核初始化时是如何赋值的:
Linux 内核在进行初始化时,会根据 CPU 的数目对
pid_max
进行设置。如果 CPU 的数目小于等于 32,那么pid_max
会被设置为 32768;如果 CPU 的数目大于 32,那么pid_max
= 1024 * (CPU 数目)。
但为啥 Ubuntu 20.04.2 LTS 上 pid_max
值要被置为上限 2^22,笔者仍没有找到原因。
另外,由于笔者对内核也不熟悉,还在学习中,上面的分析可能有误,特此说明。
容器内僵尸进程模拟
下面我们仍以《Linux 僵尸进程实战》这篇文章中的代码为例,进行演示:
/**
* Created by DemonLee on 2021/06/05.
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <string.h>
#include <signal.h>
void create_process(int total);
char *get_current_time();
void reap_child_process(int total);
void sig_handler(int sig_no) {
printf("[%s]:receive sig_no: %d\n", get_current_time(), sig_no);
if (sig_no == SIGCHLD) {
int wait_status;
int pid;
// ④
// 1) 这里不能使用if判断,因为Linux信号不排队,如果多个子进程在同一时刻发送SIGCHLD信号,父进程可能来不及响应,
// 就会出现并发问题,父进程执行一次信号处理函数,只会回收了一个子进程,其他进程依然会沦为僵尸进程
// 2) 这里while循环中不用wait,而用waitpid(同时指定options为WNOHANG),是因为wait会阻塞主进程,直到所有子进程都被回收
if ((pid = wait(&wait_status)) > 0) {
// while ((pid = wait(&wait_status)) > 0) {
// while ((pid = waitpid(-1, &wait_status, WNOHANG)) > 0) {
printf("[%s]: child process [%d] is terminated, wait_status=[%d], WIFEXITED=[%d], WEXITSTATUS=[%d]...\n",
get_current_time(), pid, wait_status, WIFEXITED(wait_status), WEXITSTATUS(wait_status));
}
}
}
int main(int argc, char *args[]) {
int total = 1;
if (argc > 1) {
total = atoi(args[1]);
}
// ①:通过注册信号函数回收子进程
// signal(SIGCHLD, sig_handler);
create_process(total);
// ②:通过循环中多个wait()函数回收子进程
// reap_child_process(total);
int k = 0;
// 主进程收到信号后会被唤醒,所以这里要用死循环保证主进程不会退出
while (1) {
printf("[%s]:Parent process is going to sleep now[%d]...\n", get_current_time(), k++);
sleep(60);
}
printf("[%s]:Parent process exit now...\n", get_current_time());
return EXIT_SUCCESS;
}
void reap_child_process(int total) {
for (int i = 0; i < total; ++i) {
int waitStatus;
printf("[%s]: idx[%d] pre to wait child process, waitStatus: [%d]\n", get_current_time(), i, waitStatus);
pid_t exit_pid = wait(&waitStatus);
//pid_t exit_pid = waitpid(-1, &waitStatus, 0);
printf("[%s]:idx[%d] child process[%d] is terminated, waitStatus: [%d]\n",
get_current_time(), i, exit_pid, waitStatus);
}
}
void create_process(int total) {
for (int i = 0; i < total; ++i) {
pid_t pid = fork();
if (pid == 0) {
printf("[%s]: child[%d] process is running, ppid: [%d], pid: [%d]\n",
get_current_time(), i, getppid(), getpid());
//sleep(10 + i * 5);
sleep(10);
// ③ 模拟 wait和waitpid的区别
// if (i == 1) {
// while (1) { sleep(30); }
// }
exit(EXIT_FAILURE);
//exit(EXIT_SUCCESS);
} else if (pid > 0) {
printf("[%s]: parent: [%d] create a child[%d] process success...\n", get_current_time(), getpid(), i);
} else {
printf("[%s]: Cannot create child process, errno: [%d]\n", get_current_time(), errno);
break;
}
}
}
char *get_current_time() {
time_t t;
time(&t);
char *time_str = ctime(&t);
time_str[strlen(time_str) - 1] = '\0';
return time_str;
}
将代码编译后制作成镜像:
demonlee@demonlee-ubuntu:zombie-proc$ ll
total 36
drwxrwxr-x 2 demonlee demonlee 4096 6月 16 06:43 ./
drwxrwxr-x 3 demonlee demonlee 4096 6月 5 23:15 ../
-rw-rw-r-- 1 demonlee demonlee 100 6月 16 06:43 Dockerfile
-rwxrwxr-x 1 demonlee demonlee 17384 6月 16 06:38 zombie-test*
-rw-rw-r-- 1 demonlee demonlee 3502 6月 16 06:38 zombie-test.c
demonlee@demonlee-ubuntu:zombie-proc$ cat Dockerfile
FROM centos:8.1.1911
WORKDIR /home/proc
COPY ./zombie-test /home/proc
CMD ["./zombie-test", "100"]
demonlee@demonlee-ubuntu:zombie-proc$ docker build -t zombie-test:v1 -f Dockerfile .
Sending build context to Docker daemon 24.06kB
Step 1/4 : FROM centos:8.1.1911
---> 470671670cac
Step 2/4 : WORKDIR /home/proc
---> Using cache
---> f7f393c8d161
Step 3/4 : COPY ./zombie-test /home/proc
---> 49f40bee4861
Step 4/4 : CMD ["./zombie-test", "100"]
---> Running in 3f6600834d9e
Removing intermediate container 3f6600834d9e
---> a1d67cf57929
Successfully built a1d67cf57929
Successfully tagged zombie-test:v1
demonlee@demonlee-ubuntu:zombie-proc$ docker images|grep zombie-test
zombie-test v1 a1d67cf57929 15 seconds ago 237MB
demonlee@demonlee-ubuntu:zombie-proc$
这里我们给程序输入的参数是 100,即启动 100 个子进程,运行并观察:
demonlee@demonlee-ubuntu:zombie-proc$ docker run -it --name zombie-test-it zombie-test:v1
[Tue Jun 15 22:54:02 2021]: parent: [1] create a child[0] process success...
[Tue Jun 15 22:54:02 2021]: child[0] process is running, ppid: [1], pid: [7]
[Tue Jun 15 22:54:02 2021]: parent: [1] create a child[1] process success...
[Tue Jun 15 22:54:02 2021]: child[1] process is running, ppid: [1], pid: [8]
...
[Tue Jun 15 22:54:02 2021]: child[97] process is running, ppid: [1], pid: [104]
[Tue Jun 15 22:54:02 2021]: child[98] process is running, ppid: [1], pid: [105]
[Tue Jun 15 22:54:02 2021]: parent: [1] create a child[99] process success...
[Tue Jun 15 22:54:02 2021]:Parent process is going to sleep now[0]...
[Tue Jun 15 22:54:02 2021]: child[99] process is running, ppid: [1], pid: [106]
[Tue Jun 15 22:54:02 2021]: child[81] process is running, ppid: [1], pid: [88]
[Tue Jun 15 22:54:02 2021]: child[79] process is running, ppid: [1], pid: [86]
[Tue Jun 15 22:55:02 2021]:Parent process is going to sleep now[1]...
[Tue Jun 15 22:56:02 2021]:Parent process is going to sleep now[2]...
[Tue Jun 15 22:57:02 2021]:Parent process is going to sleep now[3]...
...
再启动一个终端,进入容器内查看进程状态:
demonlee@demonlee-ubuntu:process$ docker exec -it zombie-test-it /bin/bash
[root@0f16b2f58ad5 proc]# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4340 1444 pts/0 Ss+ 22:54 0:00 ./zombie-test 100
root 7 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
root 8 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
root 9 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
...
root 104 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
root 105 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
root 106 0.0 0.0 0 0 pts/0 Z+ 22:54 0:00 [zombie-test] <defunct>
root 107 1.0 0.0 12028 3240 pts/1 Ss 22:55 0:00 /bin/bash
root 122 0.0 0.0 43964 3240 pts/1 R+ 22:55 0:00 ps aux
[root@0f16b2f58ad5 proc]# kill 106
[root@0f16b2f58ad5 proc]# kill -9 106
[root@0f16b2f58ad5 proc]# ps aux | grep -v grep|grep 106
root 106 0.0 0.0 0 0 pts/0 Z+ 14:17 0:00 [zombie-test] <defunct>
[root@0f16b2f58ad5 proc]# cat /proc/106/cmdline
[root@0f16b2f58ad5 proc]# cat /proc/106/smaps
[root@0f16b2f58ad5 proc]# cat /proc/106/maps
[root@0f16b2f58ad5 proc]# ls /proc/106/fd
[root@0f16b2f58ad5 proc]#
okay,到这里,我们成功的在容器中模拟出了僵尸进程,同时还发现:
- 僵尸进程不响应任何信号,哪怕是
SIGTERM
或SIGKILL
; - 查看进程对应的
/proc/<pid>
目录下相关资源文件,进程申请的资源都已释放。
还记得我们为什么要使用容器吗?隔离与限制。如果一个容器内的可以创建任意多的进程,最后导致宿主机上都无法创建进程了,那肯定是不允许的。所以,我们可以利用 pids Cgroup
子系统对容器内的最大进程数进行控制。
限制容器内进程数
pids Cgroup
通过Cgroup
文件系统的方式向用户提供操作接口,一般它的Cgroup
文件系统挂载点在/sys/fs/cgroup/pids
。当一个容器建立之后,会在
/sys/fs/cgroup/pids
目录下新建一个子目录(目录的位置可能因不同的Linux
发行版本而有所差异),即一个控制组。该子目录下有一个名为pids.max
的文件,改变这个文件中的值,其实就是控制当前这个容器允许创建的最大进程数。
继续以上面的容器为例,我们先来看一下 Cgroup
控制组相关内容:
demonlee@demonlee-ubuntu:pids$ pwd
/sys/fs/cgroup/pids
demonlee@demonlee-ubuntu:pids$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
0f16b2f58ad5 zombie-test:v1 "./zombie-test 100" 17 hours ago Up 17 hours zombie-test-it
demonlee@demonlee-ubuntu:pids$ find ./ -name "0f16b*"
./docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073
demonlee@demonlee-ubuntu:pids$ cd docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073/
demonlee@demonlee-ubuntu:0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073$ ls -lrt
total 0
-rw-r--r-- 1 root root 0 6月 16 06:54 cgroup.procs
-rw-r--r-- 1 root root 0 6月 16 23:29 tasks
-rw-r--r-- 1 root root 0 6月 16 23:29 pids.max
-r--r--r-- 1 root root 0 6月 16 23:29 pids.events
-r--r--r-- 1 root root 0 6月 16 23:29 pids.current
-rw-r--r-- 1 root root 0 6月 16 23:29 notify_on_release
-rw-r--r-- 1 root root 0 6月 16 23:29 cgroup.clone_children
demonlee@demonlee-ubuntu:0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073$ cat pids.max
max
demonlee@demonlee-ubuntu:0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073$ cat pids.current
102
demonlee@demonlee-ubuntu:
可以看到,当前容器内拥有的进程数 pids.current
= 102(1个 init
进程,100 个僵尸进程,1 个 bash
进程),而 pids.max
= max。
此时在宿主机上,将 pids.max
调整为102,然后在容器中运行 ls
等命令,发现报错了:“Resource temporarily unavailable(资源暂不可用)”,这说明我们修改的配置生效了。
root@demonlee-ubuntu:/sys/fs/cgroup/pids/docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073# cat pids.max
max
root@demonlee-ubuntu:/sys/fs/cgroup/pids/docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073# cat pids.current
102
root@demonlee-ubuntu:/sys/fs/cgroup/pids/docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073# echo 102 > pids.max
root@demonlee-ubuntu:/sys/fs/cgroup/pids/docker/0f16b2f58ad5bff4e28763bf8406c44db06e08c509025074d9158f80de0ac073#
[root@0f16b2f58ad5 proc]# ls
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
^Cbash: fork: Interrupted system call
[root@0f16b2f58ad5 proc]# ps aux
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
^Cbash: fork: Interrupted system call
[root@0f16b2f58ad5 proc]#
可以想象到,如果不对最大进程数进行控制,一旦容器内的僵尸进程把所有的进程号资源都消耗掉,将会影响宿主机上所有的其他程序。
总结
- Linux 系统中有进程数的上限,即
pid_max
。如果因各种原因(比如僵尸进程)导致创建的进程数超过上限,将会耗尽进程数资源,整个系统可能都无法正常工作。 - 通过
pid Cgroups
子系统可以配置容器内最大进程数,从而对容器内的进程数资源进行限制。