mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 22:53:42 +08:00
del
This commit is contained in:
471
极客时间专栏/geek/容器实战高手课/容器进程/02 | 理解进程(1):为什么我在容器中不能kill 1号进程?.md
Normal file
471
极客时间专栏/geek/容器实战高手课/容器进程/02 | 理解进程(1):为什么我在容器中不能kill 1号进程?.md
Normal 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 -> /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 <pid>,直接向一个进程发送一个信号,缺省情况下不指定信号的类型,那么这个信号就是SIGTERM。也可以指定信号类型,比如命令 "kill -9 <pid>", 这里的9,就是编号为9的信号,SIGKILL信号。
|
||||
|
||||
在这一讲中,我们主要用到** SIGTERM(15)和SIGKILL(9)这两个信号**,所以这里你主要了解这两个信号就可以了,其他信号以后用到时再做介绍。
|
||||
|
||||
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
|
||||
|
||||
第一个选择是**忽略(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="">
|
||||
|
||||
我刚才说了,SIGTERM(15)和SIGKILL(9)这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式,我就拿这两个信号为例,再带你具体分析一下。
|
||||
|
||||
首先我们来看SIGTERM(15),这个信号是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 <stdio.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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) && sig_kernel_only(sig)))
|
||||
|
||||
return true;
|
||||
|
||||
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
|
||||
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
|
||||
return true;
|
||||
|
||||
/* Only allow kernel generated signals to this kthread */
|
||||
if (unlikely((t->flags & PF_KTHREAD) &&
|
||||
(handler == SIG_KTHREAD_KERNEL) && !force))
|
||||
return true;
|
||||
|
||||
return sig_handler_ignored(handler, sig);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就逐一分析一下这三个子条件,我们来说说这个"!(force && 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->signal->flags & 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)->child_reaper = p;
|
||||
p->signal->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->child_reaper is assigned in copy_process, we check
|
||||
* with the pid number.
|
||||
*/
|
||||
|
||||
static inline bool is_child_reaper(struct pid *pid)
|
||||
{
|
||||
return pid->numbers[pid->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程序里注册了两个handler,bit 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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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. **对于其他的信号,如果用户自己注册了handler,1号进程可以响应。**
|
||||
|
||||
## 思考题
|
||||
|
||||
这一讲的最开始,有这样一个C语言的init进程,它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM,会发生什么情况呢?
|
||||
|
||||
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑,欢迎你把这篇文章分享给他,说不定就帮他解决了一个难题。
|
||||
315
极客时间专栏/geek/容器实战高手课/容器进程/03|理解进程(2):为什么我的容器里有这么多僵尸进程?.md
Normal file
315
极客时间专栏/geek/容器实战高手课/容器进程/03|理解进程(2):为什么我的容器里有这么多僵尸进程?.md
Normal 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命令会看到一些进程,进程名后面加了<defunct>标识。那么你自然会有这样的疑问,这些是什么进程呢?
|
||||
|
||||
你可以自己做个容器镜像来模拟一下,我们先下载这个[例子](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] <defunct>
|
||||
root 7 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 8 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 9 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 10 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
|
||||
…
|
||||
|
||||
root 999 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1000 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1001 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1002 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1003 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1004 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
root 1005 0.0 0.0 0 0 ? Z 01:23 0:00 [app-test] <defunct>
|
||||
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_INTERRUPTIBLE,TASK_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就会被设置为32768(32K);如果机器中的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 > pids.max
|
||||
# cat pids.max
|
||||
1002
|
||||
|
||||
```
|
||||
|
||||
## 解决问题
|
||||
|
||||
刚才我给你解释了两个基本概念,进程状态和进程数目限制,那我们现在就可以解决容器中的僵尸进程问题了。
|
||||
|
||||
在前面Linux进程状态的介绍里,我们知道了,僵尸进程是Linux进程退出状态的一种。
|
||||
|
||||
从内核进程的do_exit()函数我们也可以看到,这时候进程task_struct里的mm/shm/sem/files等文件资源都已经释放了,只留下了一个stask_struct instance空壳。
|
||||
|
||||
就像下面这段代码显示的一样,从进程对应的/proc/<pid> 文件目录下,我们也可以看出来,对应的资源都已经没有了。
|
||||
|
||||
```
|
||||
# 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] <defunct>
|
||||
|
||||
```
|
||||
|
||||
当多个容器运行在同一个宿主机上的时候,为了避免一个容器消耗完我们整个宿主机进程号资源,我们会配置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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int i;
|
||||
int total;
|
||||
|
||||
if (argc < 2) {
|
||||
total = 1;
|
||||
} else {
|
||||
total = atoi(argv[1]);
|
||||
}
|
||||
|
||||
printf("To create %d processes\n", total);
|
||||
|
||||
for (i = 0; i < total; i++) {
|
||||
pid_t pid = fork();
|
||||
|
||||
if (pid == 0) {
|
||||
printf("Child => PPID: %d PID: %d\n", getppid(),
|
||||
getpid());
|
||||
sleep(60);
|
||||
printf("Child process exits\n");
|
||||
exit(EXIT_SUCCESS);
|
||||
} else if (pid > 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 < total; i++) {
|
||||
int status;
|
||||
wait(&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, &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进程创建了子进程B,B又创建了自己的子进程C。如果C运行完之后,退出成了僵尸进程,B进程还在运行,而容器的init进程还在不断地调用waitpid(),那C这个僵尸进程可以被回收吗?
|
||||
|
||||
欢迎留言和我分享你的想法。如果你的朋友也被僵尸进程占用资源而困扰,欢迎你把这篇文章分享给他,也许就能帮他解决一个问题。
|
||||
411
极客时间专栏/geek/容器实战高手课/容器进程/04 | 理解进程(3):为什么我在容器中的进程被强制杀死了?.md
Normal file
411
极客时间专栏/geek/容器实战高手课/容器进程/04 | 理解进程(3):为什么我在容器中的进程被强制杀死了?.md
Normal 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(<... resuming interrupted read ...>) = ? 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(<... resuming interrupted read ...>) = ?
|
||||
+++ killed by SIGKILL +++
|
||||
|
||||
```
|
||||
|
||||
## 知识详解:信号的两个系统调用
|
||||
|
||||
我们想要理解刚才的例子,就需要搞懂信号背后的两个系统调用,它们分别是kill()系统调用和signal()系统调用。
|
||||
|
||||
这里呢,我们可以结合前面讲过的信号来理解这两个系统调用。在容器init进程的第一讲里,我们介绍过信号的基本概念了,**信号就是Linux进程收到的一个通知。**
|
||||
|
||||
等你学完如何使用这两个系统调用之后,就会更清楚Linux信号是怎么一回事,遇到容器里信号相关的问题,你就能更好地理清思路了。
|
||||
|
||||
我还会再给你举个使用函数的例子,帮助你进一步理解进程是如何实现graceful shutdown的。
|
||||
|
||||
进程对信号的处理其实就包括两个问题,**一个是进程如何发送信号,另一个是进程收到信号后如何处理。**
|
||||
|
||||
我们在Linux中发送信号的系统调用是kill(),之前很多例子里面我们用的命令 `kill` ,它内部的实现就是调用了kill()这个函数。
|
||||
|
||||
下面是Linux Programmer’s Manual里对kill()函数的定义。
|
||||
|
||||
这个函数有两个参数,一个是 `sig`,代表需要发送哪个信号,比如sig的值是15的话,就是指发送SIGTERM;另一个参数是 `pid`,也就是指信号需要发送给哪个进程,比如值是1的话,就是指发送给进程号是1的进程。
|
||||
|
||||
```
|
||||
NAME
|
||||
kill - send signal to a process
|
||||
|
||||
SYNOPSIS
|
||||
#include <sys/types.h>
|
||||
#include <signal.h>
|
||||
|
||||
int kill(pid_t pid, int sig);
|
||||
|
||||
```
|
||||
|
||||
我们知道了发送信号的系统调用之后,再来看另一个系统调用,也就是signal()系统调用这个函数,它可以给信号注册handler。
|
||||
|
||||
下面是signal()在Linux Programmer’s Manual里的定义,参数 `signum` 也就是信号的编号,例如数值15,就是信号SIGTERM;参数 `handler` 是一个函数指针参数,用来注册用户的信号handler。
|
||||
|
||||
```
|
||||
NAME
|
||||
signal - ANSI C signal handling
|
||||
|
||||
SYNOPSIS
|
||||
#include <signal.h>
|
||||
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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
|
||||
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 <stdio.h>
|
||||
#include <signal.h>
|
||||
|
||||
typedef void (*sighandler_t)(int);
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n\n");
|
||||
// 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("Ignore SIGTERM\n\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
//Catch SIGERM, and send SIGTERM
|
||||
// to process itself.
|
||||
signal(SIGTERM, sig_handler);
|
||||
printf("Catch SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
|
||||
//Default SIGTERM. In sig_handler, it sets
|
||||
//SIGTERM handler back to default one.
|
||||
printf("Default SIGTERM\n");
|
||||
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(&tasklist_lock);
|
||||
nr = 2;
|
||||
idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
|
||||
task = pid_task(pid, PIDTYPE_PID);
|
||||
if (task && !__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, &sig, &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 <stdio.h>
|
||||
#include <signal.h>
|
||||
|
||||
typedef void (*sighandler_t)(int);
|
||||
|
||||
void sig_handler(int signo)
|
||||
{
|
||||
if (signo == SIGTERM) {
|
||||
printf("received SIGTERM\n\n");
|
||||
// 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("Ignore SIGTERM\n\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
//Catch SIGERM, and send SIGTERM
|
||||
// to process itself.
|
||||
signal(SIGTERM, sig_handler);
|
||||
printf("Catch SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
|
||||
//Default SIGTERM. In sig_handler, it sets
|
||||
//SIGTERM handler back to default one.
|
||||
printf("Default SIGTERM\n");
|
||||
kill(0, SIGTERM);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎留言和我分享你的想法和疑问。如果读完这篇文章有所收获,也欢迎你分享给自己的朋友,共同学习和进步。
|
||||
262
极客时间专栏/geek/容器实战高手课/容器进程/05|容器CPU(1):怎么限制容器的CPU使用?.md
Normal file
262
极客时间专栏/geek/容器实战高手课/容器进程/05|容器CPU(1):怎么限制容器的CPU使用?.md
Normal file
@@ -0,0 +1,262 @@
|
||||
<audio id="audio" title="05|容器CPU(1):怎么限制容器的CPU使用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7b/d3/7b9ab682cf747b0561cb6b3ef87997d3.mp3"></audio>
|
||||
|
||||
你好,我是程远。从这一讲开始,我们进入容器CPU这个模块。
|
||||
|
||||
我在第一讲中给你讲过,容器在Linux系统中最核心的两个概念是Namespace和Cgroups。我们可以通过Cgroups技术限制资源。这个资源可以分为很多类型,比如CPU,Memory,Storage,Network等等。而计算资源是最基本的一种资源,所有的容器都需要这种资源。
|
||||
|
||||
那么,今天我们就先聊一聊,怎么限制容器的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: "64Mi"
|
||||
cpu: "1"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "2"
|
||||
…
|
||||
|
||||
```
|
||||
|
||||
很多刚刚使用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_us),50ms/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个CPU(200%)的程序threads-cpu,然后把这个程序的pid加入到group3的控制组里:
|
||||
|
||||
```
|
||||
./threads-cpu/threads-cpu 2 &
|
||||
echo $! > /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,把它设置为150000(150ms)。把这个值除以cpu.cfs_period_us,计算过程是150ms/100ms=1.5, 也就是1.5个CPU,同时我们也把cpu.shares设置为1024。
|
||||
|
||||
```
|
||||
echo 150000 > /sys/fs/cgroup/cpu/group2/group3/cpu.cfs_quota_us
|
||||
echo 1024 > /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 & # 启动一个消耗2个CPU的程序
|
||||
echo $! > /sys/fs/cgroup/cpu/group2/group3/cgroup.procs #把程序的pid加入到控制组
|
||||
echo 150000 > /sys/fs/cgroup/cpu/group2/group3/cpu.cfs_quota_us #限制CPU为1.5CPU
|
||||
echo 1024 > /sys/fs/cgroup/cpu/group2/group3/cpu.shares
|
||||
|
||||
|
||||
```
|
||||
|
||||
group4:
|
||||
|
||||
```
|
||||
./threads-cpu/threads-cpu 4 & # 启动一个消耗4个CPU的程序
|
||||
echo $! > /sys/fs/cgroup/cpu/group2/group4/cgroup.procs #把程序的pid加入到控制组
|
||||
echo 350000 > /sys/fs/cgroup/cpu/group2/group4/cpu.cfs_quota_us #限制CPU为3.5CPU
|
||||
echo 3072 > /sys/fs/cgroup/cpu/group2/group3/cpu.shares # shares 比例 group4: group3 = 3:1
|
||||
|
||||
```
|
||||
|
||||
好了,现在我们的节点上总共有4个CPU,而group3的程序需要消耗2个CPU,group4里的程序要消耗4个CPU。
|
||||
|
||||
即使cpu.cfs_quota_us已经限制了进程CPU使用的绝对值,group3的限制是1.5CPU,group4是3.5CPU,1.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_us,cpu.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呢?
|
||||
|
||||
欢迎留言和我分享你的思考和疑问。如果你有所收获,也欢迎分享给朋友,一起学习和交流。
|
||||
176
极客时间专栏/geek/容器实战高手课/容器进程/06|容器CPU(2):如何正确地拿到容器CPU的开销?.md
Normal file
176
极客时间专栏/geek/容器实战高手课/容器进程/06|容器CPU(2):如何正确地拿到容器CPU的开销?.md
Normal file
@@ -0,0 +1,176 @@
|
||||
<audio id="audio" title="06|容器CPU(2):如何正确地拿到容器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%的CPU(2 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个CPU,200%就是使用了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 programmer’s 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的ticks,stime是表示进程的内核态部分在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。那么这三个值相乘,你是不是也知道了它的意思呢?就是在这“瞬时”的时间(et)里,1个CPU所包含的ticks数目。
|
||||
|
||||
解释了这些参数,我们可以把这个公式简化一下,就是下面这样:
|
||||
|
||||
进程的CPU使用率=(进程的ticks/单个CPU总ticks)*100.0
|
||||
|
||||
知道了这个公式,就需要上手来验证一下这个方法对不对,怎么验证呢?我们可以启动一个消耗CPU的小程序,然后读取一下进程对应的/proc/[pid]/stat中的utime和stime,然后用这个方法来计算一下进程使用率这个百分比值,并且和top的输出对比一下,看看是否一致。
|
||||
|
||||
先启动一个消耗200%的小程序,它的PID是10021,CPU使用率是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 = 399,stime_1=0,utime_2=600,stime_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 programmer’s 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的相关问题。如果这篇文章让你有所收获,也欢迎你分享给更多的朋友,一起学习进步。
|
||||
@@ -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 average:2.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 Average,IETF还有一个[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%的CPU,2个线程就是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 (id):49.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都被占满了,我们可以看到整个节点的idle(id)也已经是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 <urlichs@smurf.sub.org>
|
||||
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 = &LAST_TASK; p > &FIRST_TASK; --p)
|
||||
- if (*p && (*p)->state == TASK_RUNNING)
|
||||
+ if (*p && ((*p)->state == TASK_RUNNING) ||
|
||||
+ (*p)->state == TASK_UNINTERRUPTIBLE) ||
|
||||
+ (*p)->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几乎为0,idle为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的值。
|
||||
|
||||
欢迎在留言区晒出你的经历和疑问。如果有收获,也欢迎你把这篇文章分享给你的朋友,一起学习和讨论。
|
||||
Reference in New Issue
Block a user