深入理解容器的单进程模型和k8s中的pause容器
容器的单进程模型和pause容器
0. 概述
在k8s中,pause容器作为pod中其他容器的父容器(parent container),它有两个核心特质:
- 作为每个pod中共享Linux Namespace的基础
- 启用共享PID namespace之后,作为每个pod中PID为1的进程,负责回收僵尸进程🧟♀️
1. 共享namespace
在Docker网络实现中已经提到过容器的4种网络模型,其中「container模式」就应用在k8s的pod中。pause容器作为pod中所有容器的父容器,是第一个启动的容器,当pause容器正常启动后,其他业务负载相关的容器就会加入pause容器的namespace,从而同一pod下的其他容器都共享这个pause容器的命名空间,相互之间以localhost访问,构成统一的整体。
比如,下面的命令启动一个pause容器:
$ docker run -d --name pause -p 8080:80 gcr.io/google_containers/pause-amd64:3.0
然后,启动一个业务容器(比如nginx),通过--net
命令加入pause容器的Network namespace中,通过--pid
命令加入其PID namespace中,通过--ipc
加入其IPC namespace中:
$ docker run -d --name nginx --net=container:pause --ipc=container:pause --pid=container:pause nginx
按同样的方式再启动一个业务容器(比如ghost,这是一个博客应用)
$ docker run -d --name ghost --net=container:pause --ipc=container:pause --pid=container:pause ghost
那么,同一个pod内的不同业务容器间就可以直接通过localhost进行通信了,如下图所示:
2. 回收僵尸进程
2.1 背景
过去两年很多大公司的一个主要技术方向就是将应用上云,在这个过程中的一个典型错误用法就是将容器当成虚拟机来使用,将一堆进程启动在一个容器内。但是容器和虚拟机对进程的管理能力是有着巨大差异的。不管在容器中还是虚拟机中都有一个1号进程,虚拟机中是 systemd 进程,容器中是 entrypoint 启动进程,然后其他进程都是1号进程的子进程,或者子进程的子进程,递归下去。这里的主要差异就体现在 systemd 进程对僵尸进程回收的能力。
这里简单介绍一下 Linux 系统中的进程状态,我们可以通过 ps 或者 top 等命令查看系统中的进程,比如通过 ps aux 在我的虚拟机(CentOS Linux release 7.4.1708 (Core))上得到如下的输出:
[root@master ~]# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 128064 5248 ? Ss 4月29 38:33 /usr/lib/systemd/systemd
root 2 0.0 0.0 0 0 ? S 4月29 0:00 [kthreadd]
root 3 0.0 0.0 0 0 ? S 4月29 0:39 [ksoftirqd/0]
root 5 0.0 0.0 0 0 ? S< 4月29 0:00 [kworker/0:0H]
root 7 0.0 0.0 0 0 ? S 4月29 0:27 [migration/0]
root 8 0.0 0.0 0 0 ? S 4月29 0:00 [rcu_bh]
root 9 0.0 0.0 0 0 ? S 4月29 17:17 [rcu_sched]
root 10 0.0 0.0 0 0 ? S 4月29 0:12 [watchdog/0]
root 11 0.0 0.0 0 0 ? S 4月29 0:09 [watchdog/1]
root 12 0.0 0.0 0 0 ? S 4月29 0:26 [migration/1]
root 13 0.0 0.0 0 0 ? S 4月29 0:38 [ksoftirqd/1]
...
排在第一位的就是前面说到的 1 号进程 systemd。其中的 STAT 那一列就是进程状态,这里的状态都是和 S 有关的,但是正常还有 R、D、Z 等状态。各个状态的含义简单描述如下:
- S: Interruptible Sleep,可以叫做可中断的睡眠状态,表示进程因为等待某个资源或者事件就绪而被系统暂时挂起。当资源或者事件 Ready 的时候,进程轮转到 R 状态。
- R: 也就是 Running,有时候也可以指代 Runnable,表示进程正在运行或者等待运行。
- Z: Zombie,也就是僵尸进程。我们知道每个进程都会占用一定的资源,比如 pid 等,如果进程结束,资源没有被回收就会变成僵尸进程。
- D: Disk Sleep,也就是 Uninterruptible Sleep,不可中断的睡眠状态,一般是进程在等待 IO 等资源,并且不可中断。D 状态一般在 IO 等资源就绪之后就会轮转到 R 状态,如果进程处于 D 状态比较久,这个时候往往是 IO 出现问题,解决办法大部分情况是重启机器。
- I: Idle,也就是空闲状态,不可中断的睡眠的内核线程。和 D 状态进程的主要区别是可能实际上不会造成负载升高。
2.2 僵尸进程
对于正常的使用情况,子进程的创建一般需要父进程通过系统调用 wait() 或者 waitpid() 来等待子进程结束,从而回收子进程的资源。除了这种方式外,还可以通过异步的方式来进行回收,这种方式的基础是子进程结束之后会向父进程发送 SIGCHLD 信号,基于此父进程注册一个 SIGCHLD 信号的处理函数来进行子进程的资源回收就可以了。记住这两种方式,后面还会涉及到。
什么是「僵尸进程」?摘自https://man7.org/linux/man-pages/man2/waitpid.2.html NOTES 部分
A child that terminates, but has not been waited for becomes a "zombie". The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child. As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes.
子进程终止后,其父进程没有对其资源进行回收,于是该子进程就变成了”僵尸进程“。在内核中,维护了一个僵尸进程的信息集合(包括PID, termination status, resource usage information)。只要僵尸进程未被移除(即通过系统调用wait()),那么一个僵尸进程就会占据内核进程表中的一个条目,一旦这张表被填满了,就不能再创建新的进程了。这也就是僵尸进程的危害。
僵尸进程的最大危害是对资源的一种永久性占用,比如进程号,系统会有一个最大的进程数 n 的限制,也就意味一旦 1 到 n 进程号都被占用,系统将不能创建任何进程和线程(进程和线程对于 OS 而言,使用同一种数据结构来表示,task_struct)。这个时候对于用户的一个直观感受就是 shell 无法执行任何命令,这个原因是 shell 执行命令的本质是 fork。
[root@master ~]# ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 63471
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 131070
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 63471 //最大进程数
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
2.3 孤儿进程
前面讲到,如果子进程退出后,父进程没有对子进程残留的资源进行回收,就会产生僵尸进程。那么如果父进程先于子进程退出的话,子进程的资源该由谁来回收呢?
父进程先于子进程退出,我们一般将还在运行的子进程称为孤儿进程,那么孤儿进程的资源谁来回收呢?类 Unix 系统针对这种情况会将这些孤儿进程的父进程置为 1 号进程(也就是 systemd 进程),然后由 systemd 来对孤儿进程的资源进行回收。
2.4 容器单进程模型的本质
通过上面的回顾,基本了解了在操作系统中是如何避免僵尸进程的,但是在容器中,1 号进程一般是 entrypoint 进程,针对上面这种 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 处理方式,容器是处理不了的。进而就会导致容器中在孤儿进程这种异常场景下僵尸进程无法彻底处理的窘境。
所以说,容器的单进程模型的本质其实是容器中的 1 号进程并不具有管理多进程、多线程等复杂场景下的能力。如果一定在容器中处理这些复杂情况,那么需要开发者对 entrypoint 进程赋予这种能力。这无疑是加重了开发者的心智负担,这是任何一项大众技术或者平台框架都不愿看到的尴尬之地。
2.5 如何避免
除了「开发者自己赋予 entrypoint 进程管理多进程的能力」这一思路,目前的做法是,通过 Kubernetes 来管理容器。这也就是回到了本文的主题。
k8s 可以将多个容器编排到一个 pod 里面,共享同一个 Linux Namespace。这项技术的本质是使用 k8s 提供一个 pause 镜像,也就是说先启动一个 pause 容器,相当于实例化出 Namespace,然后其他容器加入这个 Namespace 从而实现 Namespace 的共享。
我们来介绍一下 pause。pause 是 k8s 在 1.16 版本引入的技术,要使用 pause,我们只需要在 pod 创建的 yaml 中指定 shareProcessNamespace 参数为 true,如下:
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
shareProcessNamespace: true
containers:
- name: nginx
image: nginx
- name: shell
image: busybox
securityContext:
capabilities:
add:
- SYS_PTRACE
stdin: true
tty: true
创建 pod。
kubectl apply -f share-process-namespace.yaml
attach 到 pod 中,ps 查看进程列表。
/ # ps ax
PID USER TIME COMMAND
1 root 0:00 /pause
8 root 0:00 nginx: master process nginx -g daemon off;
14 101 0:00 nginx: worker process
15 root 0:00 sh
21 root 0:00 ps ax
我们可以看到 pod 中的 1 号进程变成了 /pause,其他容器的 entrypoint 进程都变成了 1 号进程的子进程。这个时候开始逐渐逼近事情的本质了:/pause 进程是如何处理 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 的呢?
pause 镜像的源码如下:pause.c
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static void sigdown(int signo) {
psignal(signo, "Shutting down, got signal");
exit(0);
}
// 关注1
static void sigreap(int signo) {
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main(int argc, char **argv) {
int i;
for (i = 1; i < argc; ++i) {
if (!strcasecmp(argv[i], "-v")) {
printf("pause.c %s\n", VERSION_STRING(VERSION));
return 0;
}
}
if (getpid() != 1)
/* Not an error because pause sees use outside of infra containers. */
fprintf(stderr, "Warning: pause should be the first process\n");
if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
return 1;
if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
return 2;
// 关注2
if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap,
.sa_flags = SA_NOCLDSTOP},
NULL) < 0)
return 3;
for (;;)
pause(); // 编者注:该系统调用的作用是wait for signal
fprintf(stderr, "Error: infinite loop terminated\n");
return 42;
}
重点关注一下void sigreap(int signo){...}
和if (sigaction(SIGCHLD,...)
,这个不就是我们上面说的
除了这种方式外,还可以通过异步的方式来进行回收,这种方式的基础是子进程结束之后会向父进程发送 SIGCHLD 信号,基于此父进程注册一个 SIGCHLD 信号的处理函数来进行子进程的资源回收就可以了。
SIGCHLD 信号的处理函数核心就是这一行 while (waitpid(-1, NULL, WNOHANG) > 0)
,其中各参数示意如下:
- -1:meaning wait for any child process.
- NULL:?
- WNOHANG :return immediately if no child has exited.
3. 总结
本文主要探讨了 pause 容器的两个最重要的特性,再次回顾:
- 在 pod 中作为容器共享namespace的基础
- 作为 pod 内的所有容器的父容器,扮演 init 进程(即systemd)的作用
在 Unix 系统中,PID 为 1 的进程为 init 进程,即所有进程的父进程。它很特殊,维护一张进程表,不断地检查进程状态。例如,一旦某个子进程由于父进程的错误而变成了“孤儿进程”,其便会被 init 进程进行收养并最终回收资源,从而结束进程。或者,某子进程已经停止但进程表中仍然存在该进程,也就是说其父进程未对其进程资源回收,从而该子进程变成“僵尸进程”。如果不被及时回收,那么僵尸进程会占用系统资源,但是由于操作系统中,会把这类”没人管”的子进程交由 init 进程管理,因此可以较好的解决僵尸进程占用系统资源的问题。
操作系统中有能力 将孤儿进程的父进程置为 1 号进程进而避免僵尸进程 ,为了让容器也有类似的能力,理论上可以将容器的启动进程(即ENTRYPOINT进程)作为 init 进程,不过这需要开发者本身做很多的工作,因此实现起来不太现实。于是,就把容器的管理工作交给了 k8s 来完成,k8s 中的 pod 是对多个容器的抽象,而 pause 容器就是 pod 中所有其他容器的父容器,其他业务相关的容器则通过「container模式」加入pod 中,共享 pause 容器创建的namespace(比如network namespace, PID namespace等),从而同一个pod内的各个容器之间可以通过 localhost 直接进行访问。
(全文完)
参考:
- https://www.ianlewis.org/en/almighty-pause-container
- https://github.com/kubernetes/kubernetes/blob/master/build/pause/pause.c
- https://zhuanlan.zhihu.com/p/83482791
- https://o-my-chenjian.com/2017/10/17/The-Pause-Container-Of-Kubernetes/
- http://dockone.io/article/2785
- https://jimmysong.io/kubernetes-handbook/concepts/pause-container.html
- https://www.tutorialspoint.com/unix_system_calls/waitpid.htm
- https://www.tutorialspoint.com/unix_system_calls/pause.htm