容器的單進程模型和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