This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,471 @@
<audio id="audio" title="02 | 理解进程1为什么我在容器中不能kill 1号进程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/22/0c/223f772712a2d99a9106479bb318f10c.mp3"></audio>
你好,我是程远。
今天我们正式进入理解进程的模块。我会通过3讲内容带你了解容器init进程的特殊之处还有它需要具备哪些功能才能保证容器在运行过程中不会出现类似僵尸进程或者应用程序无法graceful shutdown的问题。
那么通过这一讲我会带你掌握init进程和Linux信号的核心概念。
## 问题再现
接下来,我们一起再现用 `kill 1` 命令重启容器的问题。
我猜你肯定想问,为什么要在容器中执行 `kill 1` 或者 `kill -9 1` 的命令呢?其实这是我们团队里的一位同学提出的问题。
这位同学当时遇到的情况是这样的他想修改容器镜像里的一个bug但因为网路配置的问题这个同学又不想为了重建pod去改变pod IP。
如果你用过Kubernetes的话你也肯定知道Kubernetes上是没有 `restart pod` 这个命令的。这样看来他似乎只能让pod做个原地重启了。**当时我首先想到的就是在容器中使用kill pid 1的方式重启容器。**
为了模拟这个过程,我们可以进行下面的这段操作。
如果你没有在容器中做过 `kill 1` 你可以下载我在GitHub上的这个[例子](https://github.com/chengyli/training/tree/master/init_proc/handle_sig),运行 `make image` 来做一个容器镜像。
然后我们用Docker构建一个容器用例子中的 **init.sh脚本**作为这个容器的init进程。
最后,我们在容器中运行 `kill 1``kill -9 1` ,看看会发生什么。
```
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /init.sh
# docker exec -it sig-proc bash
[root@5cc69036b7b2 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
root 8 1 0 07:25 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
root 9 0 6 07:27 pts/0 00:00:00 bash
root 22 9 0 07:27 pts/0 00:00:00 ps -ef
[root@5cc69036b7b2 /]# kill 1
[root@5cc69036b7b2 /]# kill -9 1
[root@5cc69036b7b2 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
root 9 0 0 07:27 pts/0 00:00:00 bash
root 23 1 0 07:27 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
root 24 9 0 07:27 pts/0 00:00:00 ps -ef
```
当我们完成前面的操作,就会发现无论运行 `kill 1` 对应Linux中的SIGTERM信号还是 `kill -9 1`对应Linux中的SIGKILL信号都无法让进程终止。
那么问题来了这两个常常用来终止进程的信号都对容器中的init进程不起作用这是怎么回事呢
要解释这个问题我们就要回到容器的两个最基本概念——init进程和Linux信号中寻找答案。
## 知识详解
### 如何理解init进程
init进程的意思并不难理解你只要认真听我讲完这块内容基本就不会有问题了。我们下面来看一看。
使用容器的理想境界是**一个容器只启动一个进程**,但这在现实应用中有时是做不到的。
比如说在一个容器中除了主进程之外我们可能还会启动辅助进程做监控或者rotate logs再比如说我们需要把原来运行在虚拟机VM的程序移到容器里这些原来跑在虚拟机上的程序本身就是多进程的。
一旦我们启动了多个进程那么容器里就会出现一个pid 1也就是我们常说的1号进程或者init进程然后**由这个进程创建出其他的子进程。**
接下来我带你梳理一下init进程是怎么来的。
一个Linux操作系统在系统打开电源执行BIOS/boot-loader之后就会由boot-loader负责加载Linux内核。
Linux内核执行文件一般会放在/boot目录下文件名类似vmlinuz*。在内核完成了操作系统的各种初始化之后,**这个程序需要执行的第一个用户态程就是init进程。**
内核代码启动1号进程的时候在没有外面参数指定程序路径的情况下一般会从几个缺省路径尝试执行1号进程的代码。这几个路径都是Unix常用的可执行代码路径。
系统启动的时候先是执行内核态的代码然后在内核中调用1号进程的代码从内核态切换到用户态。
目前主流的Linux发行版无论是RedHat系的还是Debian系的都会把/sbin/init作为符号链接指向Systemd。Systemd是目前最流行的Linux init进程在它之前还有SysVinit、UpStart等Linux init进程。
**但无论是哪种Linux init进程它最基本的功能都是创建出Linux系统中其他所有的进程并且管理这些进程。**具体在kernel里的代码实现如下
```
init/main.c
/*
* We try each of these until one succeeds.
*
* The Bourne shell can be used instead of init if we are
* trying to recover a really broken machine.
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
```
```
$ ls -l /sbin/init
lrwxrwxrwx 1 root root 20 Feb 5 01:07 /sbin/init -&gt; /lib/systemd/systemd
```
在Linux上有了容器的概念之后一旦容器建立了自己的Pid Namespace进程命名空间这个Namespace里的进程号也是从1开始标记的。所以容器的init进程也被称为1号进程。
怎么样1号进程是不是不难理解关于这个知识点你只需要记住 **1号进程是第一个用户态的进程由它直接或者间接创建了Namespace中的其他进程。**
### 如何理解Linux信号
刚才我给你讲了什么是1号进程要想解决“为什么我在容器中不能kill 1号进程”这个问题我们还得看看kill命令起到的作用。
我们运行kill命令其实在Linux里就是发送一个信号那么信号到底是什么呢这就涉及到Linux信号的概念了。
其实信号这个概念在很早期的Unix系统上就有了。它一般会从1开始编号通常来说信号编号是1到31这个编号在所有的Unix系统上都是一样的。
在Linux上我们可以用 `kill -l` 来看这些信号的编号和名字,具体的编号和名字我给你列在了下面,你可以看一看。
```
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS
```
用一句话来概括,**信号Signal其实就是Linux进程收到的一个通知。**这些通知产生的源头有很多种,通知的类型也有很多种。
比如下面这几个典型的场景,你可以看一下:
- 如果我们按下键盘“Ctrl+C”当前运行的进程就会收到一个信号SIGINT而退出
- 如果我们的代码写得有问题导致内存访问出错了当前的进程就会收到另一个信号SIGSEGV
- 我们也可以通过命令kill &lt;pid&gt;直接向一个进程发送一个信号缺省情况下不指定信号的类型那么这个信号就是SIGTERM。也可以指定信号类型比如命令 "kill -9 &lt;pid&gt;", 这里的9就是编号为9的信号SIGKILL信号。
在这一讲中,我们主要用到** SIGTERM15和SIGKILL9这两个信号**,所以这里你主要了解这两个信号就可以了,其他信号以后用到时再做介绍。
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
第一个选择是**忽略Ignore**就是对这个信号不做任何处理但是有两个信号例外对于SIGKILL和SIGSTOP这个两个信号进程是不能忽略的。这是因为它们的主要作用是为Linux kernel和超级用户提供删除任意进程的特权。
第二个选择,就是**捕获Catch**这个是指让用户进程可以注册自己针对这个信号的handler。具体怎么做我们目前暂时涉及不到你先知道就行我们在后面课程会进行详细介绍。
**对于捕获SIGKILL和SIGSTOP这两个信号也同样例外这两个信号不能有用户自己的处理代码只能执行系统的缺省行为。**
还有一个选择是**缺省行为Default**Linux为每个信号都定义了一个缺省的行为你可以在Linux系统中运行 `man 7 signal`来查看每个信号的缺省行为。
对于大部分的信号而言应用程序不需要注册自己的handler使用系统缺省定义行为就可以了。
<img src="https://static001.geekbang.org/resource/image/da/0d/dae0e2bdfb4bae2d900e58cb3490dc0d.jpeg" alt="">
我刚才说了SIGTERM15和SIGKILL9这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式我就拿这两个信号为例再带你具体分析一下。
首先我们来看SIGTERM15这个信号是Linux命令kill缺省发出的。前面例子里的命令 `kill 1` 就是通过kill向1号进程发送一个信号在没有别的参数时这个信号类型就默认为SIGTERM。
SIGTERM这个信号是可以被捕获的这里的“捕获”指的就是用户进程可以为这个信号注册自己的handler而这个handler我们后面会看到它可以处理进程的graceful-shutdown问题。
我们再来了解一下SIGKILL (9)这个信号是Linux里两个**特权信号**之一。什么是特权信号呢?
前面我们已经提到过了,**特权信号就是Linux为kernel和超级用户去删除任意进程所保留的不能被忽略也不能被捕获。**那么进程一旦收到SIGKILL就要退出。
在前面的例子里,我们运行的命令 `kill -9 1` 里的参数“-9”其实就是指发送编号为9的这个SIGKILL信号给1号进程。
## 现象解释
现在你应该理解init进程和Linux信号这两个概念了让我们回到开头的问题上来“为什么我在容器中不能kill 1号进程甚至SIGKILL信号也不行
你还记得么在课程的最开始我们已经尝试过用bash作为容器1号进程这样是无法把1号进程杀掉的。那么我们再一起来看一看用别的编程语言写的1号进程是否也杀不掉。
我们现在**用C程序作为init进程**尝试一下杀掉1号进程。和bash init进程一样无论SIGTERM信号还是SIGKILL信号在容器里都不能杀死这个1号进程。
```
# cat c-init-nosig.c
#include &lt;stdio.h&gt;
#include &lt;unistd.h&gt;
int main(int argc, char *argv[])
{
printf("Process is sleeping\n");
while (1) {
sleep(100);
}
return 0;
}
```
```
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-nosig
# docker exec -it sig-proc bash
[root@5d3d42a031b1 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
root 6 0 5 07:48 pts/0 00:00:00 bash
root 19 6 0 07:48 pts/0 00:00:00 ps -ef
[root@5d3d42a031b1 /]# kill 1
[root@5d3d42a031b1 /]# kill -9 1
[root@5d3d42a031b1 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
root 6 0 0 07:48 pts/0 00:00:00 bash
root 20 6 0 07:49 pts/0 00:00:00 ps -ef
```
我们是不是这样就可以得出结论——“容器里的1号进程完全忽略了SIGTERM和SIGKILL信号了”呢你先别着急我们再拿其他语言试试。
接下来,我们用** Golang程序作为1号进程**,我们再在容器中执行 `kill -9 1``kill 1`
这次,我们发现 `kill -9 1` 这个命令仍然不能杀死1号进程也就是说SIGKILL信号和之前的两个测试一样不起作用。
**但是,我们执行 `kill 1` 以后SIGTERM这个信号把init进程给杀了容器退出了。**
```
# cat go-init.go
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("Start app\n")
time.Sleep(time.Duration(100000) * time.Millisecond)
}
```
```
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /go-init
# docker exec -it sig-proc bash
[root@234a23aa597b /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 1 08:04 ? 00:00:00 /go-init
root 10 0 9 08:04 pts/0 00:00:00 bash
root 23 10 0 08:04 pts/0 00:00:00 ps -ef
[root@234a23aa597b /]# kill -9 1
[root@234a23aa597b /]# kill 1
[root@234a23aa597b /]# [~]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
```
对于这个测试结果,你是不是反而觉得更加困惑了?
为什么使用不同程序结果就不一样呢接下来我们就看看kill命令下达之后Linux里究竟发生了什么事我给你系统地梳理一下整个过程。
在我们运行 `kill 1` 这个命令的时候希望把SIGTERM这个信号发送给1号进程就像下面图里的**带箭头虚线**。
在Linux实现里kill命令调用了 **kill()的这个系统调用**所谓系统调用就是内核的调用接口而进入到了内核函数sys_kill() 也就是下图里的**实线箭头**。
而内核在决定把信号发送给1号进程的时候会调用sig_task_ignored()这个函数来做个判断,这个判断有什么用呢?
它会决定内核在哪些情况下会把发送的这个信号给忽略掉。如果信号被忽略了那么init进程就不能收到指令了。
所以我们想要知道init进程为什么收到或者收不到信号都要去看看 **sig_task_ignored()的这个内核函数的实现。**
<img src="https://static001.geekbang.org/resource/image/ce/7f/cec445b6af1c0f678cc1b538bb03d67f.jpeg" alt="" title="sig_task_ignored()内核函数实现示意图">
在sig_task_ignored()这个函数中有三个if{}判断第一个和第三个if{}判断和我们的问题没有关系,并且代码有注释,我们就不讨论了。
我们重点来看第二个if{}。我来给你分析一下,在容器中执行 `kill 1` 或者 `kill -9 1` 的时候这第二个if{}里的三个子条件是否可以被满足呢?
我们来看下面这串代码,这里表示**一旦这三个子条件都被满足,那么这个信号就不会发送给进程。**
```
kernel/signal.c
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
{
void __user *handler;
handler = sig_handler(t, sig);
/* SIGKILL and SIGSTOP may not be sent to the global init */
if (unlikely(is_global_init(t) &amp;&amp; sig_kernel_only(sig)))
return true;
if (unlikely(t-&gt;signal-&gt;flags &amp; SIGNAL_UNKILLABLE) &amp;&amp;
handler == SIG_DFL &amp;&amp; !(force &amp;&amp; sig_kernel_only(sig)))
return true;
/* Only allow kernel generated signals to this kthread */
if (unlikely((t-&gt;flags &amp; PF_KTHREAD) &amp;&amp;
(handler == SIG_KTHREAD_KERNEL) &amp;&amp; !force))
return true;
return sig_handler_ignored(handler, sig);
}
```
接下来,我们就逐一分析一下这三个子条件,我们来说说这个"!(force &amp;&amp; sig_kernel_only(sig))" 。
第一个条件里force的值对于同一个Namespace里发出的信号来说调用值是0所以这个条件总是满足的。
我们再来看一下第二个条件 “handler == SIG_DFL”第二个条件判断信号的handler是否是SIG_DFL。
那么什么是SIG_DFL呢**对于每个信号用户进程如果不注册一个自己的handler就会有一个系统缺省的handler这个缺省的handler就叫作SIG_DFL。**
对于SIGKILL我们前面介绍过它是特权信号是不允许被捕获的所以它的handler就一直是SIG_DFL。这第二个条件对SIGKILL来说总是满足的。
对于SIGTERM它是可以被捕获的。也就是说如果用户不注册handler那么这个条件对SIGTERM也是满足的。
最后再来看一下第三个条件,"t-&gt;signal-&gt;flags &amp; SIGNAL_UNKILLABLE"这里的条件判断是这样的进程必须是SIGNAL_UNKILLABLE的。
这个SIGNAL_UNKILLABLE flag是在哪里置位的呢
可以参考我们下面的这段代码在每个Namespace的init进程建立的时候就会打上 **SIGNAL_UNKILLABLE** 这个标签也就是说只要是1号进程就会有这个flag这个条件也是满足的。
```
kernel/fork.c
if (is_child_reaper(pid)) {
ns_of_pid(pid)-&gt;child_reaper = p;
p-&gt;signal-&gt;flags |= SIGNAL_UNKILLABLE;
}
/*
* is_child_reaper returns true if the pid is the init process
* of the current namespace. As this one could be checked before
* pid_ns-&gt;child_reaper is assigned in copy_process, we check
* with the pid number.
*/
static inline bool is_child_reaper(struct pid *pid)
{
return pid-&gt;numbers[pid-&gt;level].nr == 1;
}
```
我们可以看出来,其实**最关键的一点就是 `handler == SIG_DFL` 。Linux内核针对每个Nnamespace里的init进程把只有default handler的信号都给忽略了。**
如果我们自己注册了信号的handler应用程序注册信号handler被称作"Catch the Signal"那么这个信号handler就不再是SIG_DFL 。即使是init进程在接收到SIGTERM之后也是可以退出的。
不过由于SIGKILL是一个特例因为SIGKILL是不允许被注册用户handler的还有一个不允许注册用户handler的信号是SIGSTOP那么它只有SIG_DFL handler。
所以init进程是永远不能被SIGKILL所杀但是可以被SIGTERM杀死。
说到这里,我们该怎么证实这一点呢?我们可以做下面两件事来验证。
**第一件事你可以查看1号进程状态中SigCgt Bitmap。**
我们可以看到在Golang程序里很多信号都注册了自己的handler当然也包括了SIGTERM(15)也就是bit 15。
而C程序里缺省状态下一个信号handler都没有注册bash程序里注册了两个handlerbit 2和bit 17也就是SIGINT和SIGCHLD但是没有注册SIGTERM。
所以C程序和bash程序里SIGTERM的handler是SIG_DFL系统缺省行为那么它们就不能被SIGTERM所杀。
具体我们可以看一下这段/proc系统的进程状态
```
### golang init
# cat /proc/1/status | grep -i SigCgt
SigCgt: fffffffe7fc1feff
### C init
# cat /proc/1/status | grep -i SigCgt
SigCgt: 0000000000000000
### bash init
# cat /proc/1/status | grep -i SigCgt
SigCgt: 0000000000010002
```
**第二件事给C程序注册一下SIGTERM handler捕获SIGTERM。**
我们调用signal()系统调用注册SIGTERM的handler在handler里主动退出再看看容器中 `kill 1` 的结果。
这次我们就可以看到,**在进程状态的SigCgt bitmap里bit 15 (SIGTERM)已经置位了。同时,运行 `kill 1` 也可以把这个C程序的init进程给杀死了。**
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/wait.h&gt;
#include &lt;unistd.h&gt;
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf("received SIGTERM\n");
exit(0);
}
}
int main(int argc, char *argv[])
{
signal(SIGTERM, sig_handler);
printf("Process is sleeping\n");
while (1) {
sleep(100);
}
return 0;
}
```
```
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-sig
# docker exec -it sig-proc bash
[root@043f4f717cb5 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:05 ? 00:00:00 /c-init-sig
root 6 0 18 09:06 pts/0 00:00:00 bash
root 19 6 0 09:06 pts/0 00:00:00 ps -ef
[root@043f4f717cb5 /]# cat /proc/1/status | grep SigCgt
SigCgt: 0000000000004000
[root@043f4f717cb5 /]# kill 1
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
```
好了,到这里我们可以确定这两点:
1. `kill -9 1` 在容器中是不工作的内核阻止了1号进程对SIGKILL特权信号的响应。
1. `kill 1` 分两种情况如果1号进程没有注册SIGTERM的handler那么对SIGTERM信号也不响应如果注册了handler那么就可以响应SIGTERM信号。
## 重点总结
好了,今天的内容讲完了。我们来总结一下。
这一讲我们主要讲了init进程。围绕这个知识点我提出了一个真实发生的问题“为什么我在容器中不能kill 1号进程?”。
想要解决这个问题,我们需要掌握两个基本概念。
第一个概念是Linux 1号进程。**它是第一个用户态的进程。它直接或者间接创建了Namespace中的其他进程。**
第二个概念是Linux信号。Linux有31个基本信号进程在处理大部分信号时有三个选择**忽略、捕获和缺省行为。其中两个特权信号SIGKILL和SIGSTOP不能被忽略或者捕获。**
只知道基本概念还不行我们还要去解决问题。我带你尝试了用bash, C语言还有Golang程序作为容器init进程发现它们对 kill 1的反应是不同的。
因为信号的最终处理都是在Linux内核中进行的因此我们需要对Linux内核代码进行分析。
容器里1号进程对信号处理的两个要点这也是这一讲里我想让你记住的两句话
1. **在容器中1号进程永远不会响应SIGKILL和SIGSTOP这两个特权信号**
1. **对于其他的信号如果用户自己注册了handler1号进程可以响应。**
## 思考题
这一讲的最开始有这样一个C语言的init进程它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM会发生什么情况呢
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑欢迎你把这篇文章分享给他说不定就帮他解决了一个难题。

View File

@@ -0,0 +1,315 @@
<audio id="audio" title="03理解进程2为什么我的容器里有这么多僵尸进程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/52/371eca0yyd8ece02a2501a78e26dc752.mp3"></audio>
你好,我是程远。今天我们来聊一聊容器里僵尸进程这个问题。
说起僵尸进程相信你并不陌生。很多面试官经常会问到这个知识点用来考察候选人的操作系统背景。通过这个问题可以了解候选人对Linux进程管理和信号处理这些基础知识的理解程度他的基本功扎不扎实。
所以,今天我们就一起来看看容器里为什么会产生僵尸进程,然后去分析如何怎么解决。
通过这一讲你就会对僵尸进程的产生原理有一个清晰的认识也会更深入地理解容器init进程的特性。
## 问题再现
我们平时用容器的时候有的同学会发现自己的容器运行久了之后运行ps命令会看到一些进程进程名后面加了&lt;defunct&gt;标识。那么你自然会有这样的疑问,这些是什么进程呢?
你可以自己做个容器镜像来模拟一下,我们先下载这个[例子](https://github.com/chengyli/training/tree/master/init_proc/zombie_proc),运行 `make image` 之后,再启动容器。
在容器里我们可以看到1号进程fork出1000个子进程。当这些子进程运行结束后它们的进程名字后面都加了<defunct>标识。</defunct>
从它们的Z stat进程状态中我们可以知道这些都是僵尸进程Zombie Process。运行top命令我们也可以看到输出的内容显示有 `1000 zombie` 进程。
```
# docker run --name zombie-proc -d registry/zombie-proc:v1
02dec161a9e8b18922bd3599b922dbd087a2ad60c9b34afccde7c91a463bde8a
# docker exec -it zombie-proc bash
# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 4324 1436 ? Ss 01:23 0:00 /app-test 1000
root 6 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 7 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 8 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 9 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 10 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 999 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1000 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1001 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1002 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1003 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1004 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1005 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] &lt;defunct&gt;
root 1023 0.0 0.0 12020 3392 pts/0 Ss 01:39 0:00 bash
# top
top - 02:18:57 up 31 days, 15:17, 0 users, load average: 0.00, 0.01, 0.00
Tasks: 1003 total, 1 running, 2 sleeping, 0 stopped, 1000 zombie
```
那么问题来了,什么是僵尸进程?它们是怎么产生的?僵尸进程太多会导致什么问题?想要回答这些问题,我们就要从进程状态的源头学习,看看僵尸进程到底处于进程整个生命周期里的哪一环。
## 知识详解
### Linux的进程状态
无论进程还是线程在Linux内核里其实都是用 **task_struct{}这个结构**来表示的。它其实就是任务task也就是Linux里基本的调度单位。为了方便讲解我们在这里暂且称它为进程。
那一个进程从创建fork到退出exit这个过程中的状态转化还是很简单的。
下面这个图是 《Linux Kernel Development》这本书里的Linux进程状态转化图。
我们从这张图中可以看出来在进程“活着”的时候就只有两个状态运行态TASK_RUNNING和睡眠态TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE
<img src="https://static001.geekbang.org/resource/image/dd/70/ddbd530325e12ec8b4ec1ab7e3fc8170.jpeg" alt="">
那运行态和睡眠态这两种状态分别是什么意思呢?
运行态的意思是无论进程是正在运行中也就是获得了CPU资源还是进程在run queue队列里随时可以运行都处于这个状态。
我们想要查看进程是不是处于运行态其实也很简单比如使用ps命令可以看到处于这个状态的进程显示的是R stat。
睡眠态是指进程需要等待某个资源而进入的状态要等待的资源可以是一个信号量Semaphore, 或者是磁盘I/O这个状态的进程会被放入到wait queue队列里。
这个睡眠态具体还包括两个子状态一个是可以被打断的TASK_INTERRUPTIBLE我们用ps查看到的进程显示为S stat。还有一个是不可被打断的TASK_UNINTERRUPTIBLE用ps查看进程就显示为D stat。
这两个子状态,我们在后面的课程里碰到新的问题时,会再做详细介绍,这里你只要知道这些就行了。
除了上面进程在活的时候的两个状态进程在调用do_exit()退出的时候,还有两个状态。
一个是 EXIT_DEAD也就是进程在真正结束退出的那一瞬间的状态第二个是 **EXIT_ZOMBIE状态这是进程在EXIT_DEAD前的一个状态而我们今天讨论的僵尸进程也就是处于这个状态中。**
### 限制容器中进程数目
理解了Linux进程状态之后我们还需要知道在Linux系统中怎么限制进程数目。因为弄清楚这个问题我们才能更深入地去理解僵尸进程的危害。
一台Linux机器上的进程总数目是有限制的。如果超过这个最大值那么系统就无法创建出新的进程了比如你想SSH登录到这台机器上就不行了。
这个最大值可以我们在 /proc/sys/kernel/pid_max这个参数中看到。
Linux内核在初始化系统的时候会根据机器CPU的数目来设置pid_max的值。
比如说如果机器中CPU数目小于等于32那么pid_max就会被设置为3276832K如果机器中的CPU数目大于32那么pid_max就被设置为 N*1024 N就是CPU数目
对于Linux系统而言容器就是一组进程的集合。如果容器中的应用创建过多的进程或者出现bug就会产生类似fork bomb的行为。
这个fork bomb就是指在计算机中通过不断建立新进程来消耗系统中的进程资源它是一种黑客攻击方式。这样容器中的进程数就会把整个节点的可用进程总数给消耗完。
这样不但会使同一个节点上的其他容器无法工作还会让宿主机本身也无法工作。所以对于每个容器来说我们都需要限制它的最大进程数目而这个功能由pids Cgroup这个子系统来完成。
而这个功能的实现方法是这样的pids Cgroup通过Cgroup文件系统的方式向用户提供操作接口一般它的Cgroup文件系统挂载点在 /sys/fs/cgroup/pids。
在一个容器建立之后,创建容器的服务会在/sys/fs/cgroup/pids下建立一个子目录就是一个控制组控制组里**最关键的一个文件就是pids.max**。我们可以向这个文件写入数值,而这个值就是这个容器中允许的最大进程数目。
我们对这个值做好限制,容器就不会因为创建出过多进程而影响到其他容器和宿主机了。思路讲完了,接下来我们就实际上手试一试。
下面是对一个Docker容器的pids Cgroup的操作你可以跟着操作一下。
```
# pwd
/sys/fs/cgroup/pids
# df ./
Filesystem 1K-blocks Used Available Use% Mounted on
cgroup 0 0 0 - /sys/fs/cgroup/pids
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7ecd3aa7fdc1 registry/zombie-proc:v1 "/app-test 1000" 37 hours ago Up 37 hours frosty_yalow
# pwd
/sys/fs/cgroup/pids/system.slice/docker-7ecd3aa7fdc15a1e183813b1899d5d939beafb11833ad6c8b0432536e5b9871c.scope
# ls
cgroup.clone_children cgroup.procs notify_on_release pids.current pids.events pids.max tasks
# echo 1002 &gt; pids.max
# cat pids.max
1002
```
## 解决问题
刚才我给你解释了两个基本概念,进程状态和进程数目限制,那我们现在就可以解决容器中的僵尸进程问题了。
在前面Linux进程状态的介绍里我们知道了僵尸进程是Linux进程退出状态的一种。
从内核进程的do_exit()函数我们也可以看到这时候进程task_struct里的mm/shm/sem/files等文件资源都已经释放了只留下了一个stask_struct instance空壳。
就像下面这段代码显示的一样,从进程对应的/proc/&lt;pid&gt; 文件目录下,我们也可以看出来,对应的资源都已经没有了。
```
# cat /proc/6/cmdline
# cat /proc/6/smaps
# cat /proc/6/maps
# ls /proc/6/fd
```
并且这个进程也已经不响应任何的信号了无论SIGTERM(15)还是SIGKILL(9)。例如上面pid 6的僵尸进程这两个信号都已经被响应了。
```
# kill -15 6
# kill -9 6
# ps -ef | grep 6
root 6 1 0 13:59 ? 00:00:00 [app-test] &lt;defunct&gt;
```
当多个容器运行在同一个宿主机上的时候为了避免一个容器消耗完我们整个宿主机进程号资源我们会配置pids Cgroup来限制每个容器的最大进程数目。也就是说进程数目在每个容器中也是有限的是一种很宝贵的资源。
既然进程号资源在宿主机上是有限的,显然残留的僵尸进程多了以后,给系统带来最大问题就是它占用了进程号。**这就意味着,残留的僵尸进程,在容器里仍然占据着进程号资源,很有可能会导致新的进程不能运转。**
这里我再次借用开头的那个例子也就是一个产生了1000个僵尸进程的容器带你理解一下这个例子中进程数的上限。我们可以看一下1个init进程+1000个僵尸进程+1个bash进程 总共就是1002个进程。
如果pids Cgroup也限制了这个容器的最大进程号的数量限制为1002的话我们在pids Cgroup里可以看到pids.current == pids.max也就是已经达到了容器进程号数的上限。
这时候如果我们在容器里想再启动一个进程例如运行一下ls命令就会看到 `Resource temporarily unavailable` 的错误消息。已经退出的无用进程,却阻碍了有用进程的启动,显然这样是不合理的。
具体代码如下:
```
### On host
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
09e6e8e16346 registry/zombie-proc:v1 "/app-test 1000" 29 minutes ago Up 29 minutes peaceful_ritchie
# pwd
/sys/fs/cgroup/pids/system.slice/docker-09e6e8e1634612580a03dd3496d2efed2cf2a510b9688160b414ce1d1ea3e4ae.scope
# cat pids.max
1002
# cat pids.current
1002
### On Container
[root@09e6e8e16346 /]# ls
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
```
所以,接下来我们还要看看这些僵尸进程到底是怎么产生的。因为只有理解它的产生机制,我们才能想明白怎么避免僵尸进程的出现。
我们先看一下刚才模拟僵尸进程的那段小程序。这段程序里,**父进程在创建完子进程之后就不管了,这就是造成子进程变成僵尸进程的原因。**
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/wait.h&gt;
#include &lt;unistd.h&gt;
int main(int argc, char *argv[])
{
int i;
int total;
if (argc &lt; 2) {
total = 1;
} else {
total = atoi(argv[1]);
}
printf("To create %d processes\n", total);
for (i = 0; i &lt; total; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("Child =&gt; PPID: %d PID: %d\n", getppid(),
getpid());
sleep(60);
printf("Child process exits\n");
exit(EXIT_SUCCESS);
} else if (pid &gt; 0) {
printf("Parent created child %d\n", i);
} else {
printf("Unable to create child process. %d\n", i);
break;
}
}
printf("Paraent is sleeping\n");
while (1) {
sleep(100);
}
return EXIT_SUCCESS;
}
```
前面我们通过分析,发现子进程变成僵尸进程的原因在于父进程“不负责”,那找到原因后,我们再想想,如何来解决。
其实解决思路很好理解,就好像熊孩子犯了事儿,你要去找他家长来管教,那子进程在容器里“赖着不走”,我们就需要让父进程出面处理了。
所以在Linux中的进程退出之后如果进入僵尸状态我们就需要父进程调用wait()这个系统调用,去回收僵尸进程的最后的那些系统资源,比如进程号资源。
那么我们在刚才那段代码里主进程进入sleep(100)之前加上一段wait()函数调用,就不会出现僵尸进程的残留了。
```
for (i = 0; i &lt; total; i++) {
int status;
wait(&amp;status);
}
```
而容器中所有进程的最终父进程就是我们所说的init进程由它负责生成容器中的所有其他进程。因此容器的init进程有责任回收容器中的所有僵尸进程。
前面我们知道了wait()系统调用可以回收僵尸进程但是wait()系统调用有一个问题,需要你注意。
wait()系统调用是一个阻塞的调用,也就是说,如果没有子进程是僵尸进程的话,这个调用就一直不会返回,那么整个进程就会被阻塞住,而不能去做别的事了。
不过这也没有关系我们还有另一个方法处理。Linux还提供了一个类似的系统调用waitpid(),这个调用的参数更多。
其中就有一个参数WNOHANG它的含义就是如果在调用的时候没有僵尸进程那么函数就马上返回了而不会像wait()调用那样一直等待在那里。
比如社区的一个[容器init项目tini](https://github.com/krallin/tini)。在这个例子中它的主进程里就是不断在调用带WNOHANG参数的waitpid(),通过这个方式清理容器中所有的僵尸进程。
```
int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {
pid_t current_pid;
int current_status;
while (1) {
current_pid = waitpid(-1, &amp;current_status, WNOHANG);
switch (current_pid) {
case -1:
if (errno == ECHILD) {
PRINT_TRACE("No child to wait");
break;
}
```
## 重点总结
今天我们讨论的问题是容器中的僵尸进程。
首先,我们先用代码来模拟了这个情况,还原了在一个容器中大量的僵尸进程是如何产生的。为了理解它的产生原理和危害,我们先要掌握两个知识点:
- Linux进程状态中僵尸进程处于EXIT_ZOMBIE这个状态
- 容器需要对最大进程数做限制。具体方法是这样的我们可以向Cgroup中 **pids.max**这个文件写入数值(这个值就是这个容器中允许的最大进程数目)。
掌握了基本概念之后,我们找到了僵尸进程的产生原因。父进程在创建完子进程之后就不管了。
所以我们需要父进程调用wait()或者waitpid()系统调用来避免僵尸进程产生。
关于本节内容,你只要记住下面三个主要的知识点就可以了:
1. 每一个Linux进程在退出的时候都会进入一个僵尸状态EXIT_ZOMBIE
1. 僵尸进程如果不清理,就会消耗系统中的进程数资源,最坏的情况是导致新的进程无法启动;
1. 僵尸进程一定需要父进程调用wait()或者waitpid()系统调用来清理这也是容器中init进程必须具备的一个功能。
## 思考题
如果容器的init进程创建了子进程BB又创建了自己的子进程C。如果C运行完之后退出成了僵尸进程B进程还在运行而容器的init进程还在不断地调用waitpid()那C这个僵尸进程可以被回收吗
欢迎留言和我分享你的想法。如果你的朋友也被僵尸进程占用资源而困扰,欢迎你把这篇文章分享给他,也许就能帮他解决一个问题。

View File

@@ -0,0 +1,411 @@
<audio id="audio" title="04 | 理解进程3为什么我在容器中的进程被强制杀死了" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/44/35/44eef8956326ee36ff1305a2ae560135.mp3"></audio>
你好,我是程远。
今天我们来讲容器中init进程的最后一讲为什么容器中的进程被强制杀死了。理解了这个问题能够帮助你更好地管理进程让容器中的进程可以graceful shutdown。
我先给你说说,为什么进程管理中做到这点很重要。在实际生产环境中,我们有不少应用在退出的时候需要做一些清理工作,比如清理一些远端的链接,或者是清除一些本地的临时数据。
这样的清理工作可以尽可能避免远端或者本地的错误发生比如减少丢包等问题的出现。而这些退出清理的工作通常是在SIGTERM这个信号用户注册的handler里进行的。
但是如果我们的进程收到了SIGKILL那应用程序就没机会执行这些清理工作了。这就意味着一旦进程不能graceful shutdown就会增加应用的出错率。
所以接下来,我们来重现一下,进程在容器退出时都发生了什么。
## 场景再现
在容器平台上你想要停止一个容器无论是在Kubernetes中去删除一个pod或者用Docker停止一个容器最后都会用到Containerd这个服务。
而Containerd在停止容器的时候就会向容器的init进程发送一个SIGTERM信号。
我们会发现在init进程退出之后容器内的其他进程也都立刻退出了。不过不同的是init进程收到的是SIGTERM信号而其他进程收到的是SIGKILL信号。
在理解进程的[第一讲](https://time.geekbang.org/column/article/309423)中我们提到过SIGKILL信号是不能被捕获的catch也就是用户不能注册自己的handler而SIGTERM信号却允许用户注册自己的handler这样的话差别就很大了。
那么我们就一起来看看当容器退出的时候如何才能让容器中的进程都收到SIGTERM信号而不是SIGKILL信号。
延续前面课程中处理问题的思路,我们同样可以运行一个简单的容器,来重现这个问题,用这里的[代码](https://github.com/chengyli/training/tree/master/init_proc/fwd_sig)执行一下 `make image` 然后用Docker启动这个容器镜像。
```
docker run -d --name fwd_sig registry/fwd_sig:v1 /c-init-sig
```
你会发现,在我们用 `docker stop` 停止这个容器的时候如果用strace工具来监控就能看到容器里的init进程和另外一个进程收到的信号情况。
在下面的例子里进程号为15909的就是容器里的init进程而进程号为15959的是容器里另外一个进程。
在命令输出中我们可以看到,**init进程15909收到的是SIGTERM信号而另外一个进程15959收到的果然是SIGKILL信号。**
```
# ps -ef | grep c-init-sig
root 15857 14391 0 06:23 pts/0 00:00:00 docker run -it registry/fwd_sig:v1 /c-init-sig
root 15909 15879 0 06:23 pts/0 00:00:00 /c-init-sig
root 15959 15909 0 06:23 pts/0 00:00:00 /c-init-sig
root 16046 14607 0 06:23 pts/3 00:00:00 grep --color=auto c-init-sig
# strace -p 15909
strace: Process 15909 attached
restart_syscall(&lt;... resuming interrupted read ...&gt;) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---
write(1, "received SIGTERM\n", 17) = 17
exit_group(0) = ?
+++ exited with 0 +++
# strace -p 15959
strace: Process 15959 attached
restart_syscall(&lt;... resuming interrupted read ...&gt;) = ?
+++ killed by SIGKILL +++
```
## 知识详解:信号的两个系统调用
我们想要理解刚才的例子就需要搞懂信号背后的两个系统调用它们分别是kill()系统调用和signal()系统调用。
这里呢我们可以结合前面讲过的信号来理解这两个系统调用。在容器init进程的第一讲里我们介绍过信号的基本概念了**信号就是Linux进程收到的一个通知。**
等你学完如何使用这两个系统调用之后就会更清楚Linux信号是怎么一回事遇到容器里信号相关的问题你就能更好地理清思路了。
我还会再给你举个使用函数的例子帮助你进一步理解进程是如何实现graceful shutdown的。
进程对信号的处理其实就包括两个问题,**一个是进程如何发送信号,另一个是进程收到信号后如何处理。**
我们在Linux中发送信号的系统调用是kill(),之前很多例子里面我们用的命令 `kill` 它内部的实现就是调用了kill()这个函数。
下面是Linux Programmers Manual里对kill()函数的定义。
这个函数有两个参数,一个是 `sig`代表需要发送哪个信号比如sig的值是15的话就是指发送SIGTERM另一个参数是 `pid`也就是指信号需要发送给哪个进程比如值是1的话就是指发送给进程号是1的进程。
```
NAME
kill - send signal to a process
SYNOPSIS
#include &lt;sys/types.h&gt;
#include &lt;signal.h&gt;
int kill(pid_t pid, int sig);
```
我们知道了发送信号的系统调用之后再来看另一个系统调用也就是signal()系统调用这个函数它可以给信号注册handler。
下面是signal()在Linux Programmers Manual里的定义参数 `signum` 也就是信号的编号例如数值15就是信号SIGTERM参数 `handler` 是一个函数指针参数用来注册用户的信号handler。
```
NAME
signal - ANSI C signal handling
SYNOPSIS
#include &lt;signal.h&gt;
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
```
在容器init进程的第一讲里**我们学过进程对每种信号的处理,包括三个选择:调用系统缺省行为、捕获、忽略。**而这里的选择其实就是程序中如何去调用signal()这个系统调用。
第一个选择就是缺省如果我们在代码中对某个信号比如SIGTERM信号不做任何signal()相关的系统调用那么在进程运行的时候如果接收到信号SIGTERM进程就会执行内核中SIGTERM信号的缺省代码。
对于SIGTERM这个信号来说它的缺省行为就是进程退出terminate
内核中对不同的信号有不同的缺省行为一般会采用退出terminate暂停stop忽略ignore这三种行为中的一种。
那第二个选择捕获又是什么意思呢?
捕获指的就是我们在代码中为某个信号调用signal()注册自己的handler。这样进程在运行的时候一旦接收到信号就不会再去执行内核中的缺省代码而是会执行通过signal()注册的handler。
比如下面这段代码我们为SIGTERM这个信号注册了一个handler在handler里只是做了一个打印操作。
那么这个程序在运行的时候如果收到SIGTERM信号它就不会退出了而是只在屏幕上显示出"received SIGTERM"。
```
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf("received SIGTERM\n");
}
}
int main(int argc, char *argv[])
{
...
signal(SIGTERM, sig_handler);
...
}
```
我们再来看看第三个选择如果要让进程“忽略”一个信号我们就要通过signal()这个系统调用为这个信号注册一个特殊的handler也就是 `SIG_IGN`
比如下面的这段代码就是为SIGTERM这个信号注册`SIG_IGN`
这样操作的效果就是在程序运行的时候如果收到SIGTERM信号程序既不会退出也不会在屏幕上输出log而是什么反应也没有就像完全没有收到这个信号一样。
```
int main(int argc, char *argv[])
{
...
signal(SIGTERM, SIG_IGN);
...
}
```
好了我们通过讲解signal()这个系统调用,帮助你回顾了信号处理的三个选择:缺省行为、捕获和忽略。
这里我还想要提醒你一点,** SIGKILL和SIGSTOP信号是两个特权信号它们不可以被捕获和忽略这个特点也反映在signal()调用上。**
我们可以运行下面的[这段代码](https://github.com/chengyli/training/blob/main/init_proc/basic_sig/reg-sigkill.c)如果我们用signal()为SIGKILL注册handler那么它就会返回SIG_ERR不允许我们做捕获操作。
```
# cat reg_sigkill.c
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;errno.h&gt;
#include &lt;signal.h&gt;
typedef void (*sighandler_t)(int);
void sig_handler(int signo)
{
if (signo == SIGKILL) {
printf("received SIGKILL\n");
exit(0);
}
}
int main(int argc, char *argv[])
{
sighandler_t h_ret;
h_ret = signal(SIGKILL, sig_handler);
if (h_ret == SIG_ERR) {
perror("SIG_ERR");
}
return 0;
}
# ./reg_sigkill
SIG_ERR: Invalid argument
```
最后,我用下面[这段代码](https://github.com/chengyli/training/blob/main/init_proc/basic_sig/basic-sig.c)来做个小结。
这段代码里我们用signal()对SIGTERM这个信号做了忽略捕获以及恢复它的缺省行为并且每一次都用kill()系统调用向进程自己发送SIGTERM信号这样做可以确认进程对SIGTERM信号的选择。
```
#include &lt;stdio.h&gt;
#include &lt;signal.h&gt;
typedef void (*sighandler_t)(int);
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf(&quot;received SIGTERM\n\n&quot;);
// Set SIGTERM handler to default
signal(SIGTERM, SIG_DFL);
}
}
int main(int argc, char *argv[])
{
//Ignore SIGTERM, and send SIGTERM
// to process itself.
signal(SIGTERM, SIG_IGN);
printf(&quot;Ignore SIGTERM\n\n&quot;);
kill(0, SIGTERM);
//Catch SIGERM, and send SIGTERM
// to process itself.
signal(SIGTERM, sig_handler);
printf(&quot;Catch SIGTERM\n&quot;);
kill(0, SIGTERM);
//Default SIGTERM. In sig_handler, it sets
//SIGTERM handler back to default one.
printf(&quot;Default SIGTERM\n&quot;);
kill(0, SIGTERM);
return 0;
}
```
我们一起来总结一下刚才讲的两个系统调用:
先说说kill()这个系统调用,它其实很简单,输入两个参数:进程号和信号,就把特定的信号发送给指定的进程了。
再说说signal()这个调用它决定了进程收到特定的信号如何来处理SIG_DFL参数把对应信号恢复为缺省handler也可以用自定义的函数作为handler或者用SIG_IGN参数让进程忽略信号。
对于SIGKILL信号如果调用signal()函数为它注册自定义的handler系统就会拒绝。
## 解决问题
我们在学习了kill()和signal()这个两个信号相关的系统调用之后再回到这一讲最初的问题上为什么在停止一个容器的时候容器init进程收到的SIGTERM信号而容器中其他进程却会收到SIGKILL信号呢
当Linux进程收到SIGTERM信号并且使进程退出这时Linux内核对处理进程退出的入口点就是do_exit()函数do_exit()函数中会释放进程的相关资源,比如内存,文件句柄,信号量等等。
Linux内核对处理进程退出的入口点就是do_exit()函数do_exit()函数中会释放进程的相关资源,比如内存,文件句柄,信号量等等。
在做完这些工作之后它会调用一个exit_notify()函数,用来通知和这个进程相关的父子进程等。
对于容器来说还要考虑Pid Namespace里的其他进程。这里调用的就是 zap_pid_ns_processes()这个函数而在这个函数中如果是处于退出状态的init进程它会向Namespace中的其他进程都发送一个SIGKILL信号。
整个流程如下图所示。
<img src="https://static001.geekbang.org/resource/image/c1/a7/c1e81208784d10ef370b9fd753d2c3a7.jpg" alt="">
你还可以看一下,内核代码是这样的。
```
/*
* The last thread in the cgroup-init thread group is terminating.
* Find remaining pid_ts in the namespace, signal and wait for them
* to exit.
*
* Note: This signals each threads in the namespace - even those that
* belong to the same thread group, To avoid this, we would have
* to walk the entire tasklist looking a processes in this
* namespace, but that could be unnecessarily expensive if the
* pid namespace has just a few processes. Or we need to
* maintain a tasklist for each pid namespace.
*
*/
rcu_read_lock();
read_lock(&amp;tasklist_lock);
nr = 2;
idr_for_each_entry_continue(&amp;pid_ns-&gt;idr, pid, nr) {
task = pid_task(pid, PIDTYPE_PID);
if (task &amp;&amp; !__fatal_signal_pending(task))
group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX);
}
```
说到这里我们也就明白为什么容器init进程收到的SIGTERM信号而容器中其他进程却会收到SIGKILL信号了。
前面我讲过SIGKILL是个特权信号特权信号是Linux为kernel和超级用户去删除任意进程所保留的不能被忽略也不能被捕获
所以进程收到这个信号后就立刻退出了没有机会调用一些释放资源的handler之后再做退出动作。
而SIGTERM是可以被捕获的用户是可以注册自己的handler的。因此容器中的程序在stop container的时候我们更希望进程收到SIGTERM信号而不是SIGKILL信号。
那在容器被停止的时候我们该怎么做才能让容器中的进程收到SIGTERM信号呢
你可能已经想到了就是让容器init进程来转发SIGTERM信号。的确是这样比如Docker Container里使用的tini作为init进程tini的代码中就会调用sigtimedwait()这个函数来查看自己收到的信号然后调用kill() 把信号发给子进程。
我给你举个具体的例子说明从下面的这段代码中我们可以看到除了SIGCHLD这个信号外tini会把其他所有的信号都转发给它的子进程。
```
int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) {
siginfo_t sig;
if (sigtimedwait(parent_sigset_ptr, &amp;sig, &amp;ts) == -1) {
switch (errno) {
}
} else {
/* There is a signal to handle here */
switch (sig.si_signo) {
case SIGCHLD:
/* Special-cased, as we don't forward SIGCHLD. Instead, we'll
* fallthrough to reaping processes.
*/
PRINT_DEBUG("Received SIGCHLD");
break;
default:
PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
/* Forward anything else */
if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
if (errno == ESRCH) {
PRINT_WARNING("Child was dead when forwarding signal");
} else {
PRINT_FATAL("Unexpected error when forwarding signal: '%s'", strerror(errno));
return 1;
}
}
break;
}
}
return 0;
}
```
那么我们在这里明确一下,怎么解决停止容器的时候,容器内应用程序被强制杀死的问题呢?
**解决的方法就是在容器的init进程中对收到的信号做个转发发送到容器中的其他子进程这样容器中的所有进程在停止时都会收到SIGTERM而不是SIGKILL信号了。**
## 重点小结
这一讲我们要解决的问题是让容器中的进程在容器停止的时候有机会graceful shutdown而不是收到SIGKILL信号而被强制杀死。
首先我们通过对kill()和signal()这个两个系统调用的学习进一步理解了进程是怎样处理Linux信号的重点是信号在接收处理的三个选择**忽略,捕获和缺省行为**。
通过代码例子我们知道SIGTERM是可以被忽略和捕获的但是SIGKILL是不可以被忽略和捕获的。
了解这一点以后我们就找到了问题的解决方向也就是我们需要在停止容器时让容器中的应用收到SIGTERM而不是SIGKILL。
具体怎么操作呢我们可以在容器的init进程中对收到的信号做个转发发送到容器中的其他子进程。这样一来容器中的所有进程在停止容器时都会收到SIGTERM而不是SIGKILL信号了。
我认为解决init进程信号的这类问题其实并不难。
我们只需要先梳理一下和这个问题相关的几个知识点再写个小程序让它跑在容器里稍微做几个试验。然后我们再看一下内核和Docker的源代码就可以很快得出结论了。
## 思考题
请你回顾一下基本概念中最后的这段代码,你可以想一想,在不做编译运行的情况下,它的输出是什么?
```
#include &lt;stdio.h&gt;
#include &lt;signal.h&gt;
typedef void (*sighandler_t)(int);
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf(&quot;received SIGTERM\n\n&quot;);
// Set SIGTERM handler to default
signal(SIGTERM, SIG_DFL);
}
}
int main(int argc, char *argv[])
{
//Ignore SIGTERM, and send SIGTERM
// to process itself.
signal(SIGTERM, SIG_IGN);
printf(&quot;Ignore SIGTERM\n\n&quot;);
kill(0, SIGTERM);
//Catch SIGERM, and send SIGTERM
// to process itself.
signal(SIGTERM, sig_handler);
printf(&quot;Catch SIGTERM\n&quot;);
kill(0, SIGTERM);
//Default SIGTERM. In sig_handler, it sets
//SIGTERM handler back to default one.
printf(&quot;Default SIGTERM\n&quot;);
kill(0, SIGTERM);
return 0;
}
```
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎你分享给自己的朋友,共同学习和进步。

View File

@@ -0,0 +1,262 @@
<audio id="audio" title="05容器CPU1怎么限制容器的CPU使用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/d3/7b9ab682cf747b0561cb6b3ef87997d3.mp3"></audio>
你好我是程远。从这一讲开始我们进入容器CPU这个模块。
我在第一讲中给你讲过容器在Linux系统中最核心的两个概念是Namespace和Cgroups。我们可以通过Cgroups技术限制资源。这个资源可以分为很多类型比如CPUMemoryStorageNetwork等等。而计算资源是最基本的一种资源所有的容器都需要这种资源。
那么今天我们就先聊一聊怎么限制容器的CPU使用
我们拿Kubernetes平台做例子具体来看下面这个pod/container里的spec定义在CPU资源相关的定义中有两项内容分别是 **Request CPU****Limit CPU**
```
apiVersion: v1
kind: Pod
metadata:
name: frontend
spec:
containers:
- name: app
image: images.my-company.example/app:v4
env:
resources:
requests:
memory: &quot;64Mi&quot;
cpu: &quot;1&quot;
limits:
memory: &quot;128Mi&quot;
cpu: &quot;2&quot;
```
很多刚刚使用Kubernetes的同学可能一开始并不理解这两个参数有什么作用。
这里我先给你说结论在Pod Spec里的"Request CPU"和"Limit CPU"的值最后会通过CPU Cgroup的配置来实现控制容器CPU资源的作用。
那接下来我会先从进程的CPU使用讲起然后带你在CPU Cgroup子系统中建立几个控制组用这个例子为你讲解CPU Cgroup中的三个最重要的参数"cpu.cfs_quota_us""cpu.cfs_period_us""cpu.shares"。
相信理解了这三个参数后你就会明白我们要怎样限制容器CPU的使用了。
## 如何理解CPU使用和CPU Cgroup
既然我们需要理解CPU Cgroup那么就有必要先来看一下Linux里的CPU使用的概念这是因为CPU Cgroup最大的作用就是限制CPU使用。
### CPU使用的分类
如果你想查看Linux系统的CPU使用的话会用什么方法呢最常用的肯定是运行Top了。
我们对照下图的Top运行界面在截图第三行"%Cpu(s)"开头的这一行,你会看到一串数值,也就是"0.0 us, 0.0 sy, 0.0 ni, 99.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st",那么这里的每一项值都是什么含义呢?
<img src="https://static001.geekbang.org/resource/image/a6/c2/a67fae56ce2f4e7078d552c58c9f9dc2.png" alt="">
下面这张图里最长的带箭头横轴我们可以把它看成一个时间轴。同时它的上半部分代表Linux用户态User space下半部分代表内核态Kernel space。这里为了方便你理解我们先假设只有一个CPU吧。
<img src="https://static001.geekbang.org/resource/image/7d/99/7dbd023628f5f4165abc23c1d67aca99.jpeg" alt="">
我们可以用上面这张图,把这些值挨个解释一下。
假设一个用户程序开始运行了,那么就对应着第一个"us"框,"us"是"user"的缩写代表Linux的用户态CPU Usage。普通用户程序代码中只要不是调用系统调用System Call这些代码的指令消耗的CPU就都属于"us"。
当这个用户程序代码中调用了系统调用比如说read()去读取一个文件,这时候这个用户进程就会从用户态切换到内核态。
内核态read()系统调用在读到真正disk上的文件前就会进行一些文件系统层的操作。那么这些代码指令的消耗就属于"sy",这里就对应上面图里的第二个框。"sy"是 "system"的缩写代表内核态CPU使用。
接下来这个read()系统调用会向Linux的Block Layer发出一个I/O Request触发一个真正的磁盘读取操作。
这时候这个进程一般会被置为TASK_UNINTERRUPTIBLE。而Linux会把这段时间标示成"wa",对应图中的第三个框。"wa"是"iowait"的缩写代表等待I/O的时间这里的I/O是指Disk I/O。
紧接着当磁盘返回数据时进程在内核态拿到数据这里仍旧是内核态的CPU使用中的"sy",也就是图中的第四个框。
然后进程再从内核态切换回用户态在用户态得到文件数据这里进程又回到用户态的CPU使用"us",对应图中第五个框。
这里我们假设一下这个用户进程在读取数据之后没事可做就休眠了。并且我们可以进一步假设这时在这个CPU上也没有其他需要运行的进程了那么系统就会进入"id"这个步骤,也就是第六个框。"id"是"idle"的缩写,代表系统处于空闲状态。
如果这时这台机器在网络收到一个网络数据包网卡就会发出一个中断interrupt。相应地CPU会响应中断然后进入中断服务程序。
这时CPU就会进入"hi",也就是第七个框。"hi"是"hardware irq"的缩写代表CPU处理硬中断的开销。由于我们的中断服务处理需要关闭中断所以这个硬中断的时间不能太长。
但是发生中断后的工作是必须要完成的如果这些工作比较耗时那怎么办呢Linux中有一个软中断的概念softirq它可以完成这些耗时比较长的工作。
你可以这样理解这个软中断从网卡收到数据包的大部分工作都是通过软中断来处理的。那么CPU就会进入到第八个框"si"。这里"si"是"softirq"的缩写代表CPU处理软中断的开销。
这里你要注意,无论是"hi"还是"si"它们的CPU时间都不会计入进程的CPU时间。**这是因为本身它们在处理的时候就不属于任何一个进程。**
好了通过这个场景假设我们介绍了大部分的Linux CPU使用。
不过我们还剩两个类型的CPU使用没讲到我想给你做个补充一次性带你做个全面了解。这样以后你解决相关问题时就不会再犹豫这些值到底影不影响CPU Cgroup中的限制了。下面我给你具体讲一下。
一个是"ni",是"nice"的缩写这里表示如果进程的nice值是正值1-19代表优先级比较低的进程运行时所占用的CPU。
另外一个是"st""st"是"steal"的缩写是在虚拟机里用的一个CPU使用类型表示有多少时间是被同一个宿主机上的其他虚拟机抢走的。
综合前面的内容,我再用表格为你总结一下:<br>
<img src="https://static001.geekbang.org/resource/image/a4/a3/a4f537187a16e872ebcc605d972672a3.jpeg" alt="">
### CPU Cgroup
在第一讲中我们提到过Cgroups是对指定进程做计算机资源限制的CPU Cgroup是Cgroups其中的一个Cgroups子系统它是用来限制进程的CPU使用的。
对于进程的CPU使用, 通过前面的Linux CPU使用分类的介绍我们知道它只包含两部分: 一个是用户态这里的用户态包含了us和ni还有一部分是内核态也就是sy。
至于wa、hi、si这些I/O或者中断相关的CPU使用CPU Cgroup不会去做限制那么接下来我们就来看看CPU Cgoup是怎么工作的
每个Cgroups子系统都是通过一个虚拟文件系统挂载点的方式挂到一个缺省的目录下CPU Cgroup 一般在Linux 发行版里会放在 `/sys/fs/cgroup/cpu` 这个目录下。
在这个子系统的目录下每个控制组Control Group 都是一个子目录各个控制组之间的关系就是一个树状的层级关系hierarchy
比如说我们在子系统的最顶层开始建立两个控制组也就是建立两个目录group1 和 group2然后再在group2的下面再建立两个控制组group3和group4。
这样操作以后,我们就建立了一个树状的控制组层级,你可以参考下面的示意图。<br>
<img src="https://static001.geekbang.org/resource/image/8b/54/8b86bc86706b0bbfe8fe157ee21b6454.jpeg" alt="">
那么我们的每个控制组里都有哪些CPU Cgroup相关的控制信息呢这里我们需要看一下每个控制组目录中的内容
```
# pwd
/sys/fs/cgroup/cpu
# mkdir group1 group2
# cd group2
# mkdir group3 group4
# cd group3
# ls cpu.*
cpu.cfs_period_us cpu.cfs_quota_us cpu.rt_period_us cpu.rt_runtime_us cpu.shares cpu.stat
```
考虑到在云平台里呢大部分程序都不是实时调度的进程而是普通调度SCHED_NORMAL类型进程那什么是普通调度类型呢
因为普通调度的算法在Linux中目前是CFS Completely Fair Scheduler即完全公平调度器。为了方便你理解我们就直接来看CPU Cgroup和CFS相关的参数一共有三个。
第一个参数是 **cpu.cfs_period_us**它是CFS算法的一个调度周期一般它的值是100000以microseconds为单位也就100ms。
第二个参数是 **cpu.cfs_quota_us**它“表示CFS算法中在一个调度周期里这个控制组被允许的运行时间比如这个值为50000时就是50ms。
如果用这个值去除以调度周期也就是cpu.cfs_period_us50ms/100ms = 0.5这样这个控制组被允许使用的CPU最大配额就是0.5个CPU。
从这里能够看出cpu.cfs_quota_us是一个绝对值。如果这个值是200000也就是200ms那么它除以period也就是200ms/100ms=2。
你看结果超过了1个CPU这就意味着这时控制组需要2个CPU的资源配额。
我们再来看看第三个参数, **cpu.shares**。这个值是CPU Cgroup对于控制组之间的CPU分配比例它的缺省值是1024。
假设我们前面创建的group3中的cpu.shares是1024而group4中的cpu.shares是3072那么group3:group4=1:3。
这个比例是什么意思呢?我还是举个具体的例子来说明吧。
在一台4个CPU的机器上当group3和group4都需要4个CPU的时候它们实际分配到的CPU分别是这样的group3是1个group4是3个。
我们刚才讲了CPU Cgroup里的三个关键参数接下来我们就通过几个例子来进一步理解一下代码你可以在[这里](https://github.com/chengyli/training/tree/master/cpu/cgroup_cpu)找到。
第一个例子我们启动一个消耗2个CPU200%的程序threads-cpu然后把这个程序的pid加入到group3的控制组里
```
./threads-cpu/threads-cpu 2 &amp;
echo $! &gt; /sys/fs/cgroup/cpu/group2/group3/cgroup.procs
```
在我们没有修改cpu.cfs_quota_us前用top命令可以看到threads-cpu这个进程的CPU 使用是199%近似2个CPU。
<img src="https://static001.geekbang.org/resource/image/1e/b8/1e95db3f15fc4cf1573f8ebe22db38b8.png" alt="">
然后我们更新这个控制组里的cpu.cfs_quota_us把它设置为150000150ms。把这个值除以cpu.cfs_period_us计算过程是150ms/100ms=1.5, 也就是1.5个CPU同时我们也把cpu.shares设置为1024。
```
echo 150000 &gt; /sys/fs/cgroup/cpu/group2/group3/cpu.cfs_quota_us
echo 1024 &gt; /sys/fs/cgroup/cpu/group2/group3/cpu.shares
```
这时候我们再运行top就会发现threads-cpu进程的CPU使用减小到了150%。这是因为我们设置的cpu.cfs_quota_us起了作用限制了进程CPU的绝对值。
但这时候cpu.shares的作用还没有发挥出来因为cpu.shares是几个控制组之间的CPU分配比例而且一定要到整个节点中所有的CPU都跑满的时候它才能发挥作用。
<img src="https://static001.geekbang.org/resource/image/3c/7e/3c153bba9d7668c22048602d730d627e.png" alt="">
下面我们再来运行第二个例子来理解cpu.shares。我们先把第一个例子里的程序启动同时按前面的内容一步步设置好group3里cpu.cfs_quota_us 和cpu.shares。
设置完成后我们再启动第二个程序并且设置好group4里的cpu.cfs_quota_us 和 cpu.shares。
group3
```
./threads-cpu/threads-cpu 2 &amp; # 启动一个消耗2个CPU的程序
echo $! &gt; /sys/fs/cgroup/cpu/group2/group3/cgroup.procs #把程序的pid加入到控制组
echo 150000 &gt; /sys/fs/cgroup/cpu/group2/group3/cpu.cfs_quota_us #限制CPU为1.5CPU
echo 1024 &gt; /sys/fs/cgroup/cpu/group2/group3/cpu.shares
```
group4
```
./threads-cpu/threads-cpu 4 &amp; # 启动一个消耗4个CPU的程序
echo $! &gt; /sys/fs/cgroup/cpu/group2/group4/cgroup.procs #把程序的pid加入到控制组
echo 350000 &gt; /sys/fs/cgroup/cpu/group2/group4/cpu.cfs_quota_us #限制CPU为3.5CPU
echo 3072 &gt; /sys/fs/cgroup/cpu/group2/group3/cpu.shares # shares 比例 group4: group3 = 3:1
```
好了现在我们的节点上总共有4个CPU而group3的程序需要消耗2个CPUgroup4里的程序要消耗4个CPU。
即使cpu.cfs_quota_us已经限制了进程CPU使用的绝对值group3的限制是1.5CPUgroup4是3.5CPU1.5+3.5=5这个结果还是超过了节点上的4个CPU。
好了说到这里我们发现在这种情况下cpu.shares终于开始起作用了。
在这里shares比例是group4:group3=3:1在总共4个CPU的节点上按照比例group4里的进程应该分配到3个CPU而group3里的进程会分配到1个CPU。
我们用top可以看一下结果和我们预期的一样。
<img src="https://static001.geekbang.org/resource/image/84/a3/8424b7fb4c84679412f75774060fcca3.png" alt="">
好了我们对CPU Cgroup的参数做一个梳理。
第一点cpu.cfs_quota_us和cpu.cfs_period_us这两个值决定了**每个控制组中所有进程的可使用CPU资源的最大值。**
第二点cpu.shares这个值决定了**CPU Cgroup子系统下控制组可用CPU的相对比例**不过只有当系统上CPU完全被占满的时候这个比例才会在各个控制组间起作用。
## 现象解释
在解释了Linux CPU Usage和CPU Cgroup这两个基本概念之后我们再回到我们最初的问题 “怎么限制容器的CPU使用”。有了基础知识的铺垫这个问题就比较好解释了。
首先Kubernetes会为每个容器都在CPUCgroup的子系统中建立一个控制组然后把容器中进程写入到这个控制组里。
这时候"Limit CPU"就需要为容器设置可用CPU的上限。结合前面我们讲的几个参数么我们就能知道容器的CPU上限具体如何计算了。
容器CPU的上限由cpu.cfs_quota_us除以cpu.cfs_period_us得出的值来决定的。而且在操作系统里cpu.cfs_period_us的值一般是个固定值Kubernetes不会去修改它所以我们就是只修改cpu.cfs_quota_us。
而"Request CPU"就是无论其他容器申请多少CPU资源即使运行时整个节点的CPU都被占满的情况下我的这个容器还是可以保证获得需要的CPU数目那么这个设置具体要怎么实现呢
显然我们需要设置cpu.shares这个参数**在CPU Cgroup中cpu.shares == 1024表示1个CPU的比例那么Request CPU的值就是n给cpu.shares的赋值对应就是n*1024。**
## 重点总结
首先我带你了解了Linux下CPU Usage的种类.
这里你要注意的是**每个进程的CPU Usage只包含用户态us或ni和内核态sy两部分其他的系统CPU开销并不包含在进程的CPU使用中而CPU Cgroup只是对进程的CPU使用做了限制。**
其实这一讲我们开篇的问题“怎么限制容器的CPU使用”这个问题背后隐藏了另一个问题也就是容器是如何设置它的CPU Cgroup中参数值的想解决这个问题就要先知道CPU Cgroup都有哪些参数。
所以我详细给你介绍了CPU Cgroup中的主要参数包括这三个**cpu.cfs_quota_uscpu.cfs_period_us 还有cpu.shares。**
其中cpu.cfs_quota_us一个调度周期里这个控制组被允许的运行时间除以cpu.cfs_period_us用于设置调度周期得到的这个值决定了CPU Cgroup每个控制组中CPU使用的上限值。
你还需要掌握一个cpu.shares参数正是这个值决定了CPU Cgroup子系统下控制组可用CPU的相对比例当系统上CPU完全被占满的时候这个比例才会在各个控制组间起效。
最后我们明白了CPU Cgroup关键参数是什么含义后Kubernetes中"Limit CPU"和 "Request CPU"也就很好解释了:
** Limit CPU就是容器所在Cgroup控制组中的CPU上限值Request CPU的值就是控制组中的cpu.shares的值。**
## 思考题
我们还是按照文档中定义的控制组目录层次结构图,然后按序执行这几个脚本:
- [create_groups.sh](https://github.com/chengyli/training/blob/main/cpu/cgroup_cpu/create_groups.sh)
- [update_group1.sh](https://github.com/chengyli/training/blob/main/cpu/cgroup_cpu/update_group1.sh)
- [update_group4.sh](https://github.com/chengyli/training/blob/main/cpu/cgroup_cpu/update_group4.sh)
- [update_group3.sh](https://github.com/chengyli/training/blob/main/cpu/cgroup_cpu/update_group3.sh)
那么在一个4个CPU的节点上group1/group3/group4里的进程分别会被分配到多少CPU呢?
欢迎留言和我分享你的思考和疑问。如果你有所收获,也欢迎分享给朋友,一起学习和交流。

View File

@@ -0,0 +1,176 @@
<audio id="audio" title="06容器CPU2如何正确地拿到容器CPU的开销" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/7e/6a544263ae275a2e6197ef02be61007e.mp3"></audio>
你好我是程远。今天我们聊一聊如何正确地拿到容器CPU的开销。
为啥要解决这个问题呢,还是来源于实际工作中的需要。
无论是容器的所有者还是容器平台的管理者我们想要精准地对运行着众多容器的云平台做监控快速排查例如应用的处理能力下降节点负载过高等问题就绕不开容器CPU开销。**因为CPU开销的异常往往是程序异常最明显的一个指标。**
在一台物理机器或者虚拟机里如果你想得到这个节点的CPU使用率最常用的命令就是top了吧top一下子就能看到整个节点当前的CPU使用情况。
那么在容器里top命令也可以做到这点吗想要知道答案我们还是得实际动手试一试。
## 问题重现
实际上你在使用容器的时候如果运行top命令来查看当前容器总共使用了多少CPU你肯定马上就会失望了。
这是因为我们在容器中运行top命令虽然可以看到容器中每个进程的CPU使用率但是top中"%Cpu(s)"那一行中显示的数值并不是这个容器的CPU整体使用率而是容器宿主机的CPU使用率。
就像下面的这个例子我们在一个12个CPU的宿主机上启动一个容器然后在容器里运行top命令。
这时我们可以看到容器里有两个进程threads-cpu总共消耗了200%的CPU2 CPU Usage而"%Cpu(s)"那一行的"us cpu"是58.5%。对于12CPU的系统来说12 * 58.5%=7.02也就是说这里显示总共消耗了7个CPU远远大于容器中2个CPU的消耗。
<img src="https://static001.geekbang.org/resource/image/by/fb/byy517a2ee2e041c5ce0ae4a30753cfb.png" alt="" title="运行top的示意图">
这个例子说明top这个工具虽然在物理机或者虚拟机上看得到系统CPU开销但是如果是放在容器环境下运行top就无法得到容器中总的CPU使用率。那么我们还有什么其他的办法吗
## 进程CPU使用率和系统CPU使用率
通过问题重现我们发现top工具主要显示了宿主机系统整体的CPU使用率以及单个进程的CPU使用率。既然没有现成的工具可以得到容器CPU开销那我们需要自己开发一个工具来解决问题了。
其实我们自己推导,也没有那么难。我认为,最有效的思路还是从原理上去理解问题。
所以在解决怎样得到单个容器整体的CPU使用率这个问题之前我们先来学习一下在Linux中到底是如何计算单个进程的CPU使用率还有整个系统的CPU使用率的。
### 进程CPU使用率
Linux中每个进程的CPU使用率我们都可以用top命令查看。
对照我们前面的那张示意图我们可以发现每个进程在top命令输出中都有对应的一行然后“%CPU”的那一列就是这个进程的实时CPU使用率了。
比如说100%就表示这个进程在这个瞬时使用了1个CPU200%就是使用了2个CPU。那么这个百分比的数值是怎么得到呢
最直接的方法就是从源头开始寻找答案。因为是top命令的输出我们可以去看一下top命令的[源代码](https://gitlab.com/procps-ng/procps)。在代码中你会看到对于每个进程top都会从proc文件系统中每个进程对应的stat文件中读取2个数值。我们先来看这个文件再来解读文件中具体的两个数值。
这个stat文件就是 `/proc/[pid]/stat` `[pid]` 就是替换成具体一个进程的PID值。比如PID值为1的进程这个文件就是 `/proc/1/stat` ,那么这个 `/proc/[pid]/stat` 文件里有什么信息呢?
其实这个stat文件实时输出了进程的状态信息比如进程的运行态Running还是 Sleeping、父进程PID、进程优先级、进程使用的内存等等总共50多项。
完整的stat文件内容和格式在proc文件系统的 [Linux programmers manual](https://man7.org/linux/man-pages/man5/proc.5.html) 里定义了。在这里我们只需要重点关注这两项数值stat文件中的第14项utime和第15项stime。
<img src="https://static001.geekbang.org/resource/image/53/b1/53c409d607942e8189f73636aafe3cb1.png" alt="">
那么这两项数值utime和stime是什么含义呢utime是表示进程的用户态部分在Linux调度中获得CPU的ticksstime是表示进程的内核态部分在Linux调度中获得CPU的ticks。
看到这个解释你可能又冒出一个新问题疑惑ticks是什么?这个ticks就是Linux操作系统中的一个时间单位你可以理解成类似秒毫秒的概念。
在Linux中有个自己的时钟它会周期性地产生中断。每次中断都会触发Linux内核去做一次进程调度而这一次中断就是一个tick。因为是周期性的中断比如1秒钟100次中断那么一个tick作为一个时间单位看的话也就是1/100秒。
我给你举个例子说明假如进程的utime是130ticks就相当于130 * 1/100=1.3秒也就是进程从启动开始在用户态总共运行了1.3秒钟。
这里需要你注意utime和stime都是一个累计值也就是说从进程启动开始这两个值就是一直在累积增长的。
那么我们怎么计算才能知道某一进程在用户态和内核态中分别获得了多少CPU的ticks呢
首先我们可以假设这个瞬时是1秒钟这1秒是T1时刻到T2时刻之间的那么这样我们就能获得 T1 时刻的utime_1 和stime_1同时获得T2时刻的utime_2 和 stime_2。
在这1秒的瞬时进程用户态获得的CPU ticks就是 (utime_2 utime_1), 进程内核态获得的CPU ticks就是 (stime_2 stime_1)。
那么我们可以推导出进程CPU总的开销就是用户态加上内核态也就是在1秒瞬时进程总的CPU ticks等于 (utime_2 utime_1) + (stime_2 stime_1)。
好了现在我们得到了进程以ticks为单位的CPU开销接下来还要做个转化。我们怎样才能把这个值转化成我们熟悉的百分比值呢其实也不难我们还是可以去top的[源代码](https://gitlab.com/procps-ng/procps)里得到这个百分比的计算公式。
简单总结一下,这个公式是这样的:
**进程的CPU使用率=((utime_2 utime_1) + (stime_2 stime_1)) * 100.0 / (HZ * et * 1 )**
接下来,我再给你讲一下,这个公式里每一个部分的含义。
首先, ((utime_2 utime_1) + (stime_2 stime_1))是瞬时进程总的CPU ticks。这个我们已经在前面解释过了。
其次我们来看100.0这里乘以100.0的目的是产生百分比数值。
最后,我再讲一下 **(HZ * et * 1)**。这是被除数这里的三个参数,我给你详细解释一下。
第一个HZ是什么意思呢前面我们介绍ticks里说了ticks是按照固定频率发生的在我们的Linux系统里1秒钟是100次那么HZ就是1秒钟里ticks的次数这里值是100。
第二个参数et是我们刚才说的那个“瞬时”的时间也就是得到utime_1和utime_2这两个值的时间间隔。
第三个“1”, 就更容易理解了就是1个CPU。那么这三个值相乘你是不是也知道了它的意思呢就是在这“瞬时”的时间et1个CPU所包含的ticks数目。
解释了这些参数,我们可以把这个公式简化一下,就是下面这样:
进程的CPU使用率=进程的ticks/单个CPU总ticks*100.0
知道了这个公式就需要上手来验证一下这个方法对不对怎么验证呢我们可以启动一个消耗CPU的小程序然后读取一下进程对应的/proc/[pid]/stat中的utime和stime然后用这个方法来计算一下进程使用率这个百分比值并且和top的输出对比一下看看是否一致。
先启动一个消耗200%的小程序它的PID是10021CPU使用率是200%。<br>
<img src="https://static001.geekbang.org/resource/image/yy/f9/yy5f601a16cd9c5f8b01157daea0d4f9.png" alt="">
然后我们查看这个进程对应的stat文件/proc/10021/stat间隔1秒钟输出第二次因为stat文件内容很多我们知道utime和stime第14和15项所以我们这里只截取了前15项的输出。这里可以看到utime_1 = 399stime_1=0utime_2=600stime_2=0。
<img src="https://static001.geekbang.org/resource/image/29/b0/29609d255bfd22eab6697f6ccd4923b0.png" alt="">
根据前面的公式我们计算一下进程threads-cpu的CPU使用率。套用前面的公式计算的过程是
((600 399) + (0 0)) * 100.0 / (100 * 1 * 1) =201也就是201%。你会发现这个值和我们运行top里的值是一样的。同时我们也就验证了这个公式是没问题的。
### 系统CPU使用率
前面我们介绍了Linux中如何获取单个进程的CPU使用率下面我们再来看看Linux里是怎么计算系统的整体CPU使用率的。
其实知道了如何计算单个进程的CPU使用率之后要理解系统整体的CPU使用率计算方法就简单多了。
同样我们要计算CPU使用率首先需要拿到数据数据源也同样可以从proc文件系统里得到对于整个系统的CPU使用率这个文件就是/proc/stat。
在/proc/stat 文件的 `cpu` 这行有10列数据同样我们可以在proc文件系统的 [](https://man7.org/linux/man-pages/man5/proc.5.html)[Linux programmers manual](https://man7.org/linux/man-pages/man5/proc.5.html) 里找到每一列数据的定义而前8列数据正好对应top输出中"%Cpu(s)"那一行里的8项数据也就是在上一讲中我们介绍过的user/system/nice/idle/iowait/irq/softirq/steal 这8项。
<img src="https://static001.geekbang.org/resource/image/7d/9d/7da412b1b9e4f4b235e5dae6c997859d.png" alt="">
而在/proc/stat里的每一项的数值就是系统自启动开始的ticks。那么要计算出“瞬时”的CPU使用率首先就要算出这个“瞬时”的ticks比如1秒钟的“瞬时”我们可以记录开始时刻T1的ticks, 然后再记录1秒钟后T2时刻的ticks再把这两者相减就可以得到这1秒钟的ticks了。
<img src="https://static001.geekbang.org/resource/image/fa/60/fa59040d49cb20d808619957ae1e2760.png" alt="">
这里我们可以得到在这1秒钟里每个CPU使用率的ticks
<img src="https://static001.geekbang.org/resource/image/f5/bd/f550d24358e7cc909ff337f4840512bd.jpeg" alt="">
我们想要计算每一种CPU使用率的百分比其实也很简单。我们只需要把所有在这1秒里的ticks相加得到一个总值然后拿某一项的ticks值除以这个总值。比如说计算idle CPU的使用率就是
1203 / 0 + 0 + 0 + 1203 + 0 + 0 + 0 + 0=100%
好了我们现在来整体梳理一下我们通过Linux里的工具要怎样计算进程的CPU使用率和系统的CPU使用率。
对于单个进程的CPU使用率计算我们需要读取对应进程的/proc/[pid]/stat文件将进程瞬时用户态和内核态的ticks数相加就能得到进程的总ticks。
然后我们运用公式“(进程的ticks / 单个CPU总ticks) * 100.0”计算出进程CPU使用率的百分比值。
对于系统的CPU使用率需要读取/proc/stat文件得到瞬时各项CPU使用率的ticks值相加得到一个总值单项值除以总值就是各项CPU的使用率。
## 解决问题
前面我们学习了在Linux中top工具是怎样计算每个进程的CPU使用率以及系统总的CPU使用率。现在我们再来看最初的问题为什么在容器中运行top命令不能得到容器中总的CPU使用率
这就比较好解释了对于系统总的CPU使用率需要读取/proc/stat文件但是这个文件中的各项CPU ticks是反映整个节点的并且这个/proc/stat文件也不包含在任意一个Namespace里。
那么,**对于top命令来说它只能显示整个节点中各项CPU的使用率不能显示单个容器的各项CPU的使用率**。既然top命令不行我们还有没有办法得到整个容器的CPU使用率呢
我们之前已经学习过了CPU Cgroup每个容器都会有一个CPU Cgroup的控制组。在这个控制组目录下面有很多参数文件有的参数可以决定这个控制组里最大的CPU可使用率外除了它们之外目录下面还有一个可读项cpuacct.stat。
这里包含了两个统计值,这两个值分别是**这个控制组里所有进程的内核态ticks和用户态的ticks**那么我们就可以用前面讲过的公式也就是计算进程CPU使用率的公式去计算整个容器的CPU使用率
CPU使用率=((utime_2 utime_1) + (stime_2 stime_1)) * 100.0 / (HZ * et * 1 )
我们还是以问题重现中的例子说明也就是最开始启动容器里的那两个容器threads-cpu进程。
就像下图显示的这样整个容器的CPU使用率的百分比就是 ( (174021 - 173820) + (4 4)) * 100.0 / (100 * 1 * 1) = 201, 也就是201%。**所以我们从每个容器的CPU Cgroup控制组里的cpuacct.stat的统计值中**可以比较快地得到整个容器的CPU使用率。
<img src="https://static001.geekbang.org/resource/image/fd/95/fdcfd5yy6e593cccea1bed7bfe4e5095.png" alt="">
## 重点总结
Linux里获取CPU使用率的工具比如top都是通过读取proc文件系统下的stat文件来得到CPU使用了多少ticks。而这里的ticks是Linux操作系统里的一个时间单位可以理解成类似秒毫秒的概念。
对于每个进程来说它的stat文件是/proc/[pid]/stat里面包含了进程用户态和内核态的ticks数目对于整个节点它的stat文件是 /proc/stat里面包含了user/system/nice/idle/iowait等不同CPU开销类型的ticks。
**由于/proc/stat文件是整个节点全局的状态文件不属于任何一个Namespace因此在容器中无法通过读取/proc/stat文件来获取单个容器的CPU使用率。**
所以要得到单个容器的CPU使用率我们可以从CPU Cgroup每个控制组里的统计文件cpuacct.stat中获取。**单个容器CPU使用率=((utime_2 utime_1) + (stime_2 stime_1)) * 100.0 / (HZ * et * 1 )。**
得到单个容器的CPU的使用率那么当宿主机上负载变高的时候就可以很快知道是哪个容器引起的问题。同时用户在管理自己成百上千的容器的时候也可以很快发现CPU使用率异常的容器这样就能及早地介入去解决问题。
## 思考题
写一个小程序在容器中执行它可以显示当前容器中所有进程总的CPU使用率。
欢迎在留言区和我互动一起探讨容器CPU的相关问题。如果这篇文章让你有所收获也欢迎你分享给更多的朋友一起学习进步。

View File

@@ -0,0 +1,200 @@
<audio id="audio" title="07 | Load Average加了CPU Cgroup限制为什么我的容器还是很慢" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/7e/81a0355018d9842a599f5e2e0dd3577e.mp3"></audio>
你好我是程远。今天我想聊一聊平均负载Load Average的话题。
在上一讲中我们提到过CPU Cgroup可以限制进程的CPU资源使用但是CPU Cgroup对容器的资源限制是存在盲点的。
什么盲点呢就是无法通过CPU Cgroup来控制Load Average的平均负载。而没有这个限制就会影响我们系统资源的合理调度很可能导致我们的系统变得很慢。
那么今天这一讲我们要来讲一下为什么加了CPU Cgroup的配置后即使保证了容器的CPU资源容器中的进程还是会运行得很慢
## 问题再现
在Linux的系统维护中我们需要经常查看CPU使用情况再根据这个情况分析系统整体的运行状态。有时候你可能会发现明明容器里所有进程的CPU使用率都很低甚至整个宿主机的CPU使用率都很低而机器的Load Average里的值却很高容器里进程运行得也很慢。
这么说有些抽象,我们一起动手再现一下这个情况,这样你就能更好地理解这个问题了。
比如说下面的top输出第三行可以显示当前的CPU使用情况我们可以看到整个机器的CPU Usage几乎为0因为"id"显示99.9%这说明CPU是处于空闲状态的。
但是请你注意这里1分钟的"load average"的值却高达9.09这里的数值9几乎就意味着使用了9个CPU了这样CPU Usage和Load Average的数值看上去就很矛盾了。
<img src="https://static001.geekbang.org/resource/image/50/be/507d6732efd350d47161174bc0a8e9be.png" alt="">
那问题来了我们在看一个系统里CPU使用情况时到底是看CPU Usage还是Load Average呢
这里就涉及到今天要解决的两大问题:
1. Load Average到底是什么CPU Usage和Load Average有什么差别
1. 如果Load Average值升高应用的性能下降了这背后的原因是什么呢
好了,这一讲我们就带着这两个问题,一起去揭开谜底。
## 什么是Load Average?
要回答前面的问题很显然我们要搞明白这个Linux里的"load average"这个值是什么意思,又是怎样计算的。
Load Average这个概念你可能在使用Linux的时候就已经注意到了无论你是运行uptime, 还是top都可以看到类似这个输出"load average2.02, 1.83, 1.20"。那么这一串输出到底是什么意思呢?
最直接的办法当然是看手册了,如果我们用"Linux manual page"搜索uptime或者top就会看到对这个"load average"和后面三个数字的解释是"the system load averages for the past 1, 5, and 15 minutes"。
这个解释就是说后面的三个数值分别代表过去1分钟5分钟15分钟在这个节点上的Load Average但是看了手册上的解释我们还是不能理解什么是Load Average。
这个时候你如果再去网上找资料就会发现Load Average是一个很古老的概念了。上个世纪70年代早期的Unix系统上就已经有了这个Load AverageIETF还有一个[RFC546](https://tools.ietf.org/html/rfc546)定义了Load Average这里定义的Load Average是**一种CPU资源需求的度量。**
举个例子对于一个单个CPU的系统如果在1分钟的时间里处理器上始终有一个进程在运行同时操作系统的进程可运行队列中始终都有9个进程在等待获取CPU资源。那么对于这1分钟的时间来说系统的"load average"就是1+9=10这个定义对绝大部分的Unix系统都适用。
对于Linux来说如果只考虑CPU的资源Load Averag等于单位时间内正在运行的进程加上可运行队列的进程这个定义也是成立的。通过这个定义和我自己的观察我给你归纳了下面三点对Load Average的理解。
第一不论计算机CPU是空闲还是满负载Load Average都是Linux进程调度器中**可运行队列Running Queue里的一段时间的平均进程数目。**
第二计算机上的CPU还有空闲的情况下CPU Usage可以直接反映到"load average"上什么是CPU还有空闲呢具体来说就是可运行队列中的进程数目小于CPU个数这种情况下单位时间进程CPU Usage相加的平均值应该就是"load average"的值。
第三计算机上的CPU满负载的情况下计算机上的CPU已经是满负载了同时还有更多的进程在排队需要CPU资源。这时"load average"就不能和CPU Usage等同了。
比如对于单个CPU的系统CPU Usage最大只是有100%也就1个CPU而"load average"的值可以远远大于1因为"load average"看的是操作系统中可运行队列中进程的个数。
这样的解释可能太抽象了,为了方便你理解,我们一起动手验证一下。
怎么验证呢?我们可以执行个程序来模拟一下,先准备好一个可以消耗任意CPU Usage的[程序](https://github.com/chengyli/training/tree/master/cpu/cgroup_cpu/threads-cpu),在执行这个程序的时候,后面加个数字作为参数,
比如下面的设置参数是2就是说这个进程会创建出两个线程并且每个线程都跑满100%的CPU2个线程就是2 * 100% = 200%的CPU Usage也就是消耗了整整两个CPU的资源。
```
# ./threads-cpu 2
```
准备好了这个CPU Usage的模拟程序我们就可以用它来查看CPU Usage和Load Average之间的关系了。
接下来我们一起跑两个例子第一个例子是执行2个满负载的线程第二个例子执行6个满负载的线程同样都是在一台4个CPU的节点上。
先来看第一个例子我们在一台4个CPU的计算机节点上运行刚才这个模拟程序还是设置参数为2也就是使用2个CPU Usage。在这个程序运行了几分钟之后我们运行top来查看一下CPU Usage和Load Average。
我们可以看到两个threads-cpu各自都占了将近100%的CPU两个就是200%2个CPU对于4个CPU的计算机来说CPU Usage占了50%,空闲了一半,这个我们也可以从 idle id49.9%得到印证。
这时候Load Average里第一项也就是前1分钟的数值为1.98近似于2。这个值和我们一直运行的200%CPU Usage相对应也验证了我们之前归纳的第二点——**CPU Usage可以反映到Load Average上。**
因为运行的时间不够前5分钟前15分钟的Load Average还没有到2而且后面我们的例子程序一般都只会运行几分钟所以这里我们只看前1分钟的Load Average值就行。
另外Linux内核中不使用浮点计算这导致Load Average里的1分钟5分钟15分钟的时间值并不精确但这不影响我们查看Load Average的数值所以先不用管这个时间的准确性。
<img src="https://static001.geekbang.org/resource/image/11/52/110f67d3b31a62d3d7f27bb4a28aa552.png" alt=""><br>
那我们再来跑第二个例子同样在这个4个CPU的计算机节点上如果我们执行CPU Usage模拟程序threads-cpu设置参数为6让这个进程建出6个线程这样每个线程都会尽量去抢占CPU但是计算机总共只有4个CPU所以这6个线程的CPU Usage加起来只是400%。
显然这时候4个CPU都被占满了我们可以看到整个节点的idleid也已经是0.0%了。
但这个时候我们看看前1分钟的Load Average数值不是4而是5.93接近6我们正好模拟了6个高CPU需求的线程。这也告诉我们Load Average表示的是一段时间里运行队列中需要被调度的进程/线程平均数目。
<img src="https://static001.geekbang.org/resource/image/3c/26/3caf637cb3bc20yy5610b3d0bf59bd26.png" alt="">
讲到这里我们是不是就可以认定Load Average就代表一段时间里运行队列中需要被调度的进程或者线程平均数目了呢? 或许对其他的Unix系统来说这个理解已经够了但是对于Linux系统还不能这么认定。
为什么这么说呢故事还要从Linux早期的历史说起那时开发者Matthias有这么一个发现比如把快速的磁盘换成了慢速的磁盘运行同样的负载系统的性能是下降的但是Load Average却没有反映出来。
他发现这是因为Load Average只考虑运行态的进程数目而没有考虑等待I/O的进程。所以他认为Load Average如果只是考虑进程运行队列中需要被调度的进程或线程平均数目是不够的因为对于处于I/O资源等待的进程都是处于TASK_UNINTERRUPTIBLE状态的。
那他是怎么处理这件事的呢估计你也猜到了他给内核加一个patch补丁把处于TASK_UNINTERRUPTIBLE状态的进程数目也计入了Load Average中。
在这里我们又提到了TASK_UNINTERRUPTIBLE状态的进程在前面的章节中我们介绍过我再给你强调一下**TASK_UNINTERRUPTIBLE是Linux进程状态的一种是进程为等待某个系统资源而进入了睡眠的状态并且这种睡眠的状态是不能被信号打断的。**
下面就是1993年Matthias的kernel patch你有兴趣的话可以读一下。
```
From: Matthias Urlichs &lt;urlichs@smurf.sub.org&gt;
Subject: Load average broken ?
Date: Fri, 29 Oct 1993 11:37:23 +0200
The kernel only counts "runnable" processes when computing the load average.
I don't like that; the problem is that processes which are swapping or
waiting on "fast", i.e. noninterruptible, I/O, also consume resources.
It seems somewhat nonintuitive that the load average goes down when you
replace your fast swap disk with a slow swap disk...
Anyway, the following patch seems to make the load average much more
consistent WRT the subjective speed of the system. And, most important, the
load is still zero when nobody is doing anything. ;-)
--- kernel/sched.c.orig Fri Oct 29 10:31:11 1993
+++ kernel/sched.c Fri Oct 29 10:32:51 1993
@@ -414,7 +414,9 @@
unsigned long nr = 0;
for(p = &amp;LAST_TASK; p &gt; &amp;FIRST_TASK; --p)
- if (*p &amp;&amp; (*p)-&gt;state == TASK_RUNNING)
+ if (*p &amp;&amp; ((*p)-&gt;state == TASK_RUNNING) ||
+ (*p)-&gt;state == TASK_UNINTERRUPTIBLE) ||
+ (*p)-&gt;state == TASK_SWAPPING))
nr += FIXED_1;
return nr;
}
```
那么对于Linux的Load Average来说除了可运行队列中的进程数目等待队列中的UNINTERRUPTIBLE进程数目也会增加Load Average。
为了验证这一点我们可以模拟一下UNINTERRUPTIBLE的进程来看看Load Average的变化。
这里我们做一个[kernel module](https://github.com/chengyli/training/tree/master/cpu/load_average/uninterruptable/kmod),通过一个/proc文件系统给用户程序提供一个读取的接口只要用户进程读取了这个接口就会进入UNINTERRUPTIBLE。这样我们就可以模拟两个处于UNINTERRUPTIBLE状态的进程然后查看一下Load Average有没有增加。
我们发现程序跑了几分钟之后前1分钟的Load Average差不多从0增加到了2.16节点上CPU Usage几乎为0idle为99.8%。
可以看到可运行队列Running Queue中的进程数目是0只有休眠队列Sleeping Queue中有两个进程并且这两个进程显示为D state进程这个D state进程也就是我们模拟出来的TASK_UNINTERRUPTIBLE状态的进程。
这个例子证明了Linux将TASK_UNINTERRUPTIBLE状态的进程数目计入了Load Average中所以即使CPU上不做任何的计算Load Average仍然会升高。如果TASK_UNINTERRUPTIBLE状态的进程数目有几百几千个那么Load Average的数值也可以达到几百几千。
<img src="https://static001.geekbang.org/resource/image/e0/a9/e031338191bcec89b7fb02c19af843a9.png" alt="">
好了到这里我们就可以准确定义Linux系统里的Load Average了其实也很简单你只需要记住平均负载统计了这两种情况的进程
第一种是Linux进程调度器中可运行队列Running Queue一段时间1分钟5分钟15分钟的进程平均数。
第二种是Linux进程调度器中休眠队列Sleeping Queue里的一段时间的TASK_UNINTERRUPTIBLE状态下的进程平均数。
所以,最后的公式就是:**Load Average=可运行队列进程平均数+休眠队列中不可打断的进程平均数**
如果打个比方来说明Load Average的统计原理。你可以想象每个CPU就是一条道路每个进程都是一辆车怎么科学统计道路的平均负载呢就是看单位时间通过的车辆一条道上的车越多那么这条道路的负载也就越高。
此外Linux计算系统负载的时候还额外做了个补丁把TASK_UNINTERRUPTIBLE状态的进程也考虑了这个就像道路中要把红绿灯情况也考虑进去。一旦有了红灯汽车就要停下来排队那么即使道路很空但是红灯多了汽车也要排队等待也开不快。
## 现象解释为什么Load Average会升高
解释了Load Average这个概念我们再回到这一讲最开始的问题为什么对容器已经用CPU Cgroup限制了它的CPU Usage容器里的进程还是可以造成整个系统很高的Load Average。
我们理解了Load Average这个概念之后就能区分出Load Averge和CPU使用率的区别了。那么这个看似矛盾的问题也就很好回答了因为**Linux下的Load Averge不仅仅计算了CPU Usage的部分它还计算了系统中TASK_UNINTERRUPTIBLE状态的进程数目。**
讲到这里为止我们找到了第一个问题的答案那么现在我们再看第二个问题如果Load Average值升高应用的性能已经下降了真正的原因是什么问题就出在TASK_UNINTERRUPTIBLE状态的进程上了。
怎么验证这个判断呢?这时候我们只要运行 `ps aux | grep “ D ”` 就可以看到容器中有多少TASK_UNINTERRUPTIBLE状态在ps命令中这个状态的进程标示为"D"状态的进程为了方便理解后面我们简称为D状态进程。而正是这些D状态进程引起了Load Average的升高。
找到了Load Average升高的问题出在D状态进程了我们想要真正解决问题还有必要了解D状态进程产生的本质是什么
在Linux内核中有数百处调用点它们会把进程设置为D状态主要集中在disk I/O 的访问和信号量Semaphore锁的访问上因此D状态的进程在Linux里是很常见的。
**无论是对disk I/O的访问还是对信号量的访问都是对Linux系统里的资源的一种竞争。**当进程处于D状态时就说明进程还没获得资源这会在应用程序的最终性能上体现出来也就是说用户会发觉应用的性能下降了。
那么D状态进程导致了性能下降我们肯定是想方设法去做调试的。但目前D状态进程引起的容器中进程性能下降问题Cgroups还不能解决这也就是为什么我们用Cgroups做了配置即使保证了容器的CPU资源 容器中的进程还是运行很慢的根本原因。
这里我们进一步做分析为什么CPU Cgroups不能解决这个问题呢就是因为Cgroups更多的是以进程为单位进行隔离而D状态进程是内核中系统全局资源引入的所以Cgroups影响不了它。
所以我们可以做的是在生产环境中监控容器的宿主机节点里D状态的进程数量然后对D状态进程数目异常的节点进行分析比如磁盘硬件出现问题引起D状态进程数目增加这时就需要更换硬盘。
## 重点总结
这一讲我们从CPU Usage和Load Average差异这个现象讲起最主要的目的是讲清楚Linux下的Load Average这个概念。
在其他Unix操作系统里Load Average只考虑CPU部分Load Average计算的是进程调度器中可运行队列Running Queue里的一段时间1分钟5分钟15分钟的平均进程数目而Linux在这个基础上又加上了进程调度器中休眠队列Sleeping Queue里的一段时间的TASK_UNINTERRUPTIBLE状态的平均进程数目。
这里你需要重点掌握Load Average的计算公式如下图。
<img src="https://static001.geekbang.org/resource/image/e6/4e/e672a6a35248420d623e55d7f7ddf34e.jpeg" alt="">
因为TASK_UNINTERRUPTIBLE状态的进程同样也会竞争系统资源所以它会影响到应用程序的性能。我们可以在容器宿主机的节点对D状态进程做监控定向分析解决。
最后我还想强调一下这一讲中提到的对D状态进程进行监控也很重要因为这是通用系统性能的监控方法。
## 思考题
结合今天的学习你可以自己动手感受一下Load Average是怎么产生的请你创建一个容器在容器中运行一个消耗100%CPU的进程运行10分钟后然后查看Load Average的值。
欢迎在留言区晒出你的经历和疑问。如果有收获,也欢迎你把这篇文章分享给你的朋友,一起学习和讨论。