為什么說容器是單進(jìn)程模型
Go 語言現(xiàn)在的一個(gè)主要應(yīng)用領(lǐng)域就是云原生技術(shù),包括容器(以 Docker 為代表)、Kubernetes、Prometheus 等。后面將寫一系列文章來介紹一下云原生技術(shù)棧中的關(guān)鍵技術(shù)。
過去兩年很多大公司的一個(gè)主要技術(shù)方向就是將應(yīng)用上云,在這個(gè)過程中的一個(gè)典型錯(cuò)誤用法就是將容器當(dāng)成虛擬機(jī)來使用,將一堆進(jìn)程啟動(dòng)在一個(gè)容器內(nèi)。但是容器和虛擬機(jī)對(duì)進(jìn)程的管理能力是有著巨大差異的。不管在容器中還是虛擬機(jī)中都有一個(gè)一號(hào)進(jìn)程,虛擬機(jī)中是 systemd 進(jìn)程,容器中是 entrypoint 啟動(dòng)進(jìn)程,然后所有的其他線程都是一號(hào)進(jìn)程的子進(jìn)程,或者子進(jìn)程的子進(jìn)程,遞歸下去。這里的主要差異就體現(xiàn)在 systemd 進(jìn)程對(duì)僵尸進(jìn)程回收的能力。如果你想和更多容器技術(shù)專家交流,可以加我微信liyingjiese,備注『加群』。群里每周都有全球各大公司的最佳實(shí)踐以及行業(yè)最新動(dòng)態(tài)。
僵尸進(jìn)程
說到僵尸進(jìn)程,這里簡(jiǎn)單介紹一下 Linux 系統(tǒng)中的進(jìn)程狀態(tài),我們可以通過 ps 或者 top 等命令查看系統(tǒng)中的進(jìn)程,比如通過 ps aux 在我的 ecs 虛擬機(jī)上面得到如下的輸出。
- [root@emr-header-1 ~]# ps aux
 - USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
 - root 1 0.1 0.0 190992 3568 ? Ss Mar16 289:04 /usr/lib/systemd/systemd --switched-root --system --de
 - root 2 0.0 0.0 0 0 ? S Mar16 0:05 [kthreadd]
 - root 3 0.0 0.0 0 0 ? S Mar16 13:01 [ksoftirqd/0]
 - root 5 0.0 0.0 0 0 ? S< Mar16 0:00 [kworker/0:0H]
 - root 7 0.0 0.0 0 0 ? S Mar16 14:41 [migration/0]
 - root 8 0.0 0.0 0 0 ? S Mar16 0:00 [rcu_bh]
 - root 9 0.0 0.0 0 0 ? S Mar16 243:19 [rcu_sched]
 - root 10 0.0 0.0 0 0 ? S Mar16 0:50 [watchdog/0]
 - root 11 0.0 0.0 0 0 ? S Mar16 0:39 [watchdog/1]
 - root 12 0.0 0.0 0 0 ? S Mar16 23:51 [migration/1]
 - root 13 0.0 0.0 0 0 ? S Mar16 15:44 [ksoftirqd/1]
 - root 15 0.0 0.0 0 0 ? S< Mar16 0:00 [kworker/1:0H]
 
我們可以看到排在第一位的就是前面說到的 1 號(hào)進(jìn)程 systemd。其中的 STAT 那一列就是進(jìn)程狀態(tài),這里的狀態(tài)都是和 S 有關(guān)的,但是正常還有 R、D、Z 等狀態(tài)。各個(gè)狀態(tài)的含義簡(jiǎn)單描述如下:
- S:Interruptible Sleep,中文可以叫做可中斷的睡眠狀態(tài),表示進(jìn)程因?yàn)榈却硞€(gè)資源或者事件就緒而被系統(tǒng)暫時(shí)掛起。當(dāng)資源或者事件 Ready 的時(shí)候,進(jìn)程輪轉(zhuǎn)到 R 狀態(tài)。
 - R:也就是 Running,有時(shí)候也可以指代 Runnable,表示進(jìn)程正在運(yùn)行或者等待運(yùn)行。
 - Z:Zombie,也就是僵尸進(jìn)程。我們知道每個(gè)進(jìn)程都是會(huì)占用一定的資源的,比如 pid 等,如果進(jìn)程結(jié)束,資源沒有被回收就會(huì)變成僵尸進(jìn)程。
 - D:Disk Sleep,也就是 Uninterruptible Sleep,不可中斷的睡眠狀態(tài),一般是進(jìn)程在等待 IO 等資源,并且不可中斷。D 狀態(tài)相信很多人在實(shí)踐中第一次接觸就是 ps 卡住。D 狀態(tài)一般在 IO 等資源就緒之后就會(huì)輪轉(zhuǎn)到 R 狀態(tài),如果進(jìn)程處于 D 狀態(tài)比較久,這個(gè)時(shí)候往往是 IO 出現(xiàn)問題,解決辦法大部分情況是重啟機(jī)器。
 - I:Idle,也就是空閑狀態(tài),不可中斷的睡眠的內(nèi)核線程。和 D 狀態(tài)進(jìn)程的主要區(qū)別是可能實(shí)際上不會(huì)造成負(fù)載升高。
 
關(guān)于僵尸進(jìn)程,這里繼續(xù)討論一下。對(duì)于正常的使用情況,子進(jìn)程的創(chuàng)建一般需要父進(jìn)程通過系統(tǒng)調(diào)用 wait() 或者 waitpid() 來等待子進(jìn)程結(jié)束,從而回收子進(jìn)程的資源。除了這種方式外,還可以通過異步的方式來進(jìn)行回收,這種方式的基礎(chǔ)是子進(jìn)程結(jié)束之后會(huì)向父進(jìn)程發(fā)送 SIGCHLD 信號(hào),基于此父進(jìn)程注冊(cè)一個(gè) SIGCHLD 信號(hào)的處理函數(shù)來進(jìn)行子進(jìn)程的資源回收就可以了。記住這兩種方式,后面還會(huì)涉及到。
僵尸進(jìn)程的最大危害是對(duì)資源的一種永久性占用,比如進(jìn)程號(hào),系統(tǒng)會(huì)有一個(gè)最大的進(jìn)程數(shù) n 的限制,也就意味一旦 1 到 n 進(jìn)程號(hào)都被占用,系統(tǒng)將不能創(chuàng)建任何進(jìn)程和線程(進(jìn)程和線程對(duì)于 OS 而言,使用同一種數(shù)據(jù)結(jié)構(gòu)來表示,task_struct)。這個(gè)時(shí)候?qū)τ谟脩舻囊粋€(gè)直觀感受就是 shell 無法執(zhí)行任何命令,這個(gè)原因是 shell 執(zhí)行命令的本質(zhì)是 fork。
- [root@emr-header-1 ~]# 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
 
孤兒進(jìn)程
前面說到如果子進(jìn)程先于父進(jìn)程退出,并且父進(jìn)程沒有對(duì)子進(jìn)程殘留的資源進(jìn)行回收的話將會(huì)產(chǎn)生僵尸進(jìn)程。這里引申另外一種情況,父進(jìn)程先于子進(jìn)程退出的話,那么子進(jìn)程的資源誰來回收呢?
父進(jìn)程先于子進(jìn)程退出,這個(gè)時(shí)候我們一般將還在運(yùn)行的子進(jìn)程稱為孤兒進(jìn)程,但是實(shí)際上孤兒進(jìn)程并沒有一個(gè)明確的定義,他的狀態(tài)還是處于上面討論的幾種進(jìn)程狀態(tài)中。那么孤兒進(jìn)程的資源誰來回收呢?類 Unix 系統(tǒng)針對(duì)這種情況會(huì)將這些孤兒進(jìn)程的父進(jìn)程置為 1 號(hào)進(jìn)程也就是 systemd 進(jìn)程,然后由 systemd 來對(duì)孤兒進(jìn)程的資源進(jìn)行回收。
單進(jìn)程模型的本質(zhì)
看完上面兩節(jié)大家應(yīng)該知道了虛擬機(jī)或者一個(gè)完整的 OS 是如何避免僵尸進(jìn)程的。但是,在容器中,1 號(hào)進(jìn)程一般是 entry point 進(jìn)程,針對(duì)上面這種 將孤兒進(jìn)程的父進(jìn)程置為 1 號(hào)進(jìn)程進(jìn)而避免僵尸進(jìn)程 處理方式,容器是處理不了的。進(jìn)而就會(huì)導(dǎo)致容器中在孤兒進(jìn)程這種異常場(chǎng)景下僵尸進(jìn)程無法徹底處理的窘境。
所以說,容器的單進(jìn)程模型的本質(zhì)其實(shí)是容器中的 1 號(hào)進(jìn)程并不具有管理多進(jìn)程、多線程等復(fù)雜場(chǎng)景下的能力。如果一定在容器中處理這些復(fù)雜情況的,那么需要開發(fā)者對(duì) entry point 進(jìn)程賦予這種能力。這無疑是加重了開發(fā)者的心智負(fù)擔(dān),這是任何一項(xiàng)大眾技術(shù)或者平臺(tái)框架都不愿看到的尷尬之地。
如何避免
除了第二節(jié)討論的開發(fā)者自己賦予 entrypoint 進(jìn)程管理多進(jìn)程的能力,這里我更推薦借助 Kubernetes 來做這件事情。我想現(xiàn)在應(yīng)該也沒有人對(duì)容器進(jìn)行人工管理了,大部分人應(yīng)該都轉(zhuǎn)向了容器編排和調(diào)度工具 Kubernetes 陣營(yíng)了(對(duì)于那些還在使用 Swarm 的一小波人,我勸你們?cè)缛諚壈低睹?:))。
Kubernetes 中可以將多個(gè)容器編排到一個(gè) Pod 里面,共享同一個(gè) Linux NameSpace。這項(xiàng)技術(shù)的本質(zhì)是使用 Kubernetes 提供一個(gè) pause 鏡像,展開來說就是先用 pause 鏡像實(shí)例化出 NameSpace,然后其他容器加入這個(gè) NameSpace 從而實(shí)現(xiàn) NameSpace 共享。突然意識(shí)到這塊需要有容器和 NameSpace 的技術(shù)背景,限于篇幅,希望你可以自行搜索這種技術(shù)背景。或者我下一篇文章討論一下容器技術(shù)的本質(zhì)。
言歸正傳,我們來介紹一下 pause。pause 是 Kubernetes 在 1.16 版本引入的技術(shù),要使用 pause,我們只需要在 Pod 創(chuàng)建的 yaml 中指定 shareProcessNamespace 參數(shù)為 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
 
創(chuàng)建 Pod:
- kubectl apply -fshare-process-namespace.yaml
 
attach 到 Pod 中,ps 查看進(jìn)程列表:
- / # 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 號(hào)進(jìn)程變成了 /pause,其他容器的 entrypoint 進(jìn)程都變成了 1 號(hào)進(jìn)程的子進(jìn)程。這個(gè)時(shí)候開始逐漸逼近事情的本質(zhì)了:/pause 進(jìn)程是如何處理將孤兒進(jìn)程的父進(jìn)程置為 1 號(hào)進(jìn)程進(jìn)而避免僵尸進(jìn)程的呢?我們看一下源碼,git repo: pause.c:
- #define STRINGIFY(x) #x
 - #define VERSION_STRING(x) STRINGIFY(x)
 - #ifndef VERSION
 - #define VERSION HEAD
 - #endif
 - static void sigdown(int signo) {
 - psignal(signo, "Shutting down, got signal");
 - exit(0);
 - }
 - 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;
 - if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap,
 - .sa_flags = SA_NOCLDSTOP},
 - NULL) < 0)
 - return 3;
 - for (;;)
 - pause();
 - fprintf(stderr, "Error: infinite loop terminated\n");
 - return 42;
 - }
 
重點(diǎn)關(guān)注一下 35 行和 13 行,這個(gè)不就是我們上面說的。
- 除了這種方式外,還可以通過異步的方式來進(jìn)行回收,這種方式的基礎(chǔ)是子進(jìn)程結(jié)束之后會(huì)向父進(jìn)程發(fā)送 SIGCHLD 信號(hào),基于此父進(jìn)程注冊(cè)一個(gè) SIGCHLD 信號(hào)的處理函數(shù)來進(jìn)行子進(jìn)程的資源回收就可以了。
 
SIGCHLD 信號(hào)的處理函數(shù)核心就是這一行 while (waitpid(-1, NULL, WNOHANG) > 0) ,其中 WNOHANG 參數(shù)是為了讓父進(jìn)程直接返回不阻塞。
總結(jié)
容器化改造的路非常漫長(zhǎng),對(duì)于很多業(yè)務(wù)同學(xué)在改造的過程中由于一些思維的慣性就想把容器當(dāng)成一個(gè)虛擬機(jī)來使用,這個(gè)可能會(huì)導(dǎo)致非常多的問題?;蛟S我們可以探究一些容器的設(shè)計(jì)模式,以便進(jìn)行更好的實(shí)踐。















 
 
 










 
 
 
 