聊聊容器與pod中的僵尸進(jìn)程
三種狀態(tài)的進(jìn)程模型
按進(jìn)程在執(zhí)行過程中的不同情況至少要定義三種狀態(tài):
- 運行(running)態(tài):進(jìn)程占有處理器正在運行的狀態(tài)。進(jìn)程已獲得CPU,其程序正在執(zhí)行。在單處理機系統(tǒng)中,只有一個進(jìn)程處于執(zhí)行狀態(tài);在多處理機系統(tǒng)中,則有多個進(jìn)程處于執(zhí)行狀態(tài)。
- 就緒(ready)態(tài):進(jìn)程具備運行條件,等待系統(tǒng)分配處理器以便運行的狀態(tài)。當(dāng)進(jìn)程已分配到除CPU以外的所有必要資源后,只要再獲得CPU,便可立即執(zhí)行,進(jìn)程這時的狀態(tài)稱為就緒狀態(tài)。在一個系統(tǒng)中處于就緒狀態(tài)的進(jìn)程可能有多個,通常將它們排成一個隊列,稱為就緒隊列。
- 等待(wait)態(tài):又稱阻塞態(tài)或睡眠態(tài),指進(jìn)程不具備運行條件,正在等待某個時間完成的狀態(tài)。也稱為等待或睡眠狀態(tài),一個進(jìn)程正在等待某一事件發(fā)生(例如請求I/O而等待I/O完成等)而暫時停止運行,這時即使把處理機分配給進(jìn)程也無法運行,故稱該進(jìn)程處于阻塞狀態(tài)。
引起進(jìn)程狀態(tài)轉(zhuǎn)換的具體原因如下:
- 運行態(tài)→等待態(tài):等待使用資源;如等待外設(shè)傳輸;等待人工干預(yù)。
- 等待態(tài)→就緒態(tài):資源得到滿足;如外設(shè)傳輸結(jié)束;人工干預(yù)完成。
- 運行態(tài)→就緒態(tài):運行時間片到;出現(xiàn)有更高優(yōu)先權(quán)進(jìn)程。
- 就緒態(tài)—→運行態(tài):CPU 空閑時選擇一個就緒進(jìn)程。
五種狀態(tài)的進(jìn)程模型
五態(tài)模型在三態(tài)模型的基礎(chǔ)上增加了新建態(tài)(new)和終止態(tài)(exit)。
新建態(tài):對應(yīng)于進(jìn)程被創(chuàng)建時的狀態(tài),尚未進(jìn)入就緒隊列。創(chuàng)建一個進(jìn)程需要通過兩個步驟:1.為新進(jìn)程分配所需要資源和建立必要的管理信息。2.設(shè)置該進(jìn)程為就緒態(tài),并等待被調(diào)度執(zhí)行。
終止態(tài):指進(jìn)程完成任務(wù)到達(dá)正常結(jié)束點,或出現(xiàn)無法克服的錯誤而異常終止,或被操作系統(tǒng)及有終止權(quán)的進(jìn)程所終止時所處的狀態(tài)。處于終止態(tài)的進(jìn)程不再被調(diào)度執(zhí)行,下一步將被系統(tǒng)撤銷,最終從系統(tǒng)中消失。終止一個進(jìn)程需要兩個步驟:1.先等待操作系統(tǒng)或相關(guān)的進(jìn)程進(jìn)行善后處理(如抽取信息)。2.然后回收占用的資源并被系統(tǒng)刪除。
引起進(jìn)程狀態(tài)轉(zhuǎn)換的具體原因如下:
- NULL→新建態(tài):執(zhí)行一個程序,創(chuàng)建一個子進(jìn)程。
- 新建態(tài)→就緒態(tài):當(dāng)操作系統(tǒng)完成了進(jìn)程創(chuàng)建的必要操作,并且當(dāng)前系統(tǒng)的性能和虛擬內(nèi)存的容量均允許。
- 運行態(tài)→終止態(tài):當(dāng)一個進(jìn)程到達(dá)了自然結(jié)束點,或是出現(xiàn)了無法克服的錯誤,或是被操作系統(tǒng)所終結(jié),或是被其他有終止權(quán)的進(jìn)程所終結(jié)。
- 運行態(tài)→就緒態(tài):運行時間片到;出現(xiàn)有更高優(yōu)先權(quán)進(jìn)程。
- 運行態(tài)→等待態(tài):等待使用資源;如等待外設(shè)傳輸;等待人工干預(yù)。
- 就緒態(tài)→終止態(tài):未在狀態(tài)轉(zhuǎn)換圖中顯示,但某些操作系統(tǒng)允許父進(jìn)程終結(jié)子進(jìn)程。
- 等待態(tài)→終止態(tài):未在狀態(tài)轉(zhuǎn)換圖中顯示,但某些操作系統(tǒng)允許父進(jìn)程終結(jié)子進(jìn)程。
- 終止態(tài)→NULL:完成善后操作。
linux的進(jìn)程狀態(tài)
無論進(jìn)程還是線程,在 Linux 內(nèi)核里其實都是用 task_struct{}這個結(jié)構(gòu)來表示的。它其實就是任務(wù)(task),也就是 Linux 里基本的調(diào)度單位。
Linux進(jìn)程狀態(tài)有:
- TASK_RUNNING : 就緒態(tài)或者運行態(tài),進(jìn)程就緒可以運行,但是不一定正在占有CPU,對應(yīng)進(jìn)程狀態(tài)的R。
- TASK_INTERRUPTIBLE:睡眠態(tài),但是進(jìn)程處于淺度睡眠,可以響應(yīng)信號,一般是進(jìn)程主動sleep進(jìn)入的狀態(tài),對應(yīng)進(jìn)程狀態(tài)S。
- TASK_UNINTERRUPTIBLE:睡眠態(tài),深度睡眠,不響應(yīng)信號,典型場景是進(jìn)程獲取信號量阻塞,對應(yīng)進(jìn)程狀態(tài)D。
- TASK_ZOMBIE:僵尸態(tài),進(jìn)程已退出或者結(jié)束,但是父進(jìn)程還不知道,沒有回收時的狀態(tài),結(jié)束之前的一個狀態(tài),對應(yīng)進(jìn)程狀態(tài)Z。
- EXIT_DEAD:結(jié)束態(tài),進(jìn)程已經(jīng)結(jié)束,也就是進(jìn)程結(jié)束退出那一瞬間的狀態(tài),對應(yīng)進(jìn)程狀態(tài)X。
- TASK_STOPED:停止,調(diào)試狀態(tài),對應(yīng)進(jìn)程狀態(tài)T。
孤兒進(jìn)程
一個父進(jìn)程退出,而它的一個或多個子進(jìn)程還在運行,那么那些子進(jìn)程將成為孤兒進(jìn)程。孤兒進(jìn)程將被init進(jìn)程(進(jìn)程號為1,也有可能是容器中的init)所收養(yǎng),并由init進(jìn)程對它們完成狀態(tài)收集工作。孤兒進(jìn)程是沒有父進(jìn)程的進(jìn)程,孤兒進(jìn)程這個重任就落到了init進(jìn)程身上,init進(jìn)程就好像是一個民政局,專門負(fù)責(zé)處理孤兒進(jìn)程的善后工作。每當(dāng)出現(xiàn)一個孤兒進(jìn)程的時候,內(nèi)核就把孤 兒進(jìn)程的父進(jìn)程設(shè)置為init,而init進(jìn)程會循環(huán)地wait()它的已經(jīng)退出的子進(jìn)程。這樣,當(dāng)一個孤兒進(jìn)程凄涼地結(jié)束了其生命周期的時候,init進(jìn)程就會代表黨和政府出面處理它的一切善后工作。因此孤兒進(jìn)程并不會有什么危害。
僵尸進(jìn)程
在類UNIX系統(tǒng)中,僵尸進(jìn)程是指完成執(zhí)行(通過 exit 系統(tǒng)調(diào)用,或運行時發(fā)生致命錯誤或收到終止信號所致),但在操作系統(tǒng)的進(jìn)程表中仍然存在其進(jìn)程控制塊,處于"終止?fàn)顟B(tài)"的進(jìn)程。我們從上面的概念中得知,僵尸進(jìn)程仍然存在在進(jìn)程表中。進(jìn)程會占用系統(tǒng)系統(tǒng)資源,僵尸進(jìn)程過多會導(dǎo)致資源泄露,最主要的資源就是PID。我們來看一下linux系統(tǒng)中的PID。這個最大值可以我們在 /proc/sys/kernel/pid_max 這個參數(shù)中看到。
- [root@k8s-dev]# cat /proc/sys/kernel/pid_max
- 32768
Linux 內(nèi)核在進(jìn)行初始化時,會根據(jù)CPU 的數(shù)目對 pid_max 進(jìn)行設(shè)置。
- 如果CPU 的數(shù)目小于等于32,那么 pid_max 會被設(shè)置為32768;
- 如果CPU 的數(shù)目大于32,那么 pid_max = 1024 * (CPU 數(shù)目)。
所以如果超過這個最大值,那么系統(tǒng)就無法創(chuàng)建出新的進(jìn)程了,比如你想 SSH 登錄到這臺機器上就不行了。清理僵尸進(jìn)程:了解了僵尸進(jìn)程的危害,我們來看看怎么清理僵尸進(jìn)程:收割僵尸進(jìn)程的方法是通過kill命令手工向其父進(jìn)程發(fā)送SIGCHLD信號。如果其父進(jìn)程仍然拒絕收割僵尸進(jìn)程,則終止父進(jìn)程,使得init進(jìn)程收養(yǎng)僵尸進(jìn)程。init進(jìn)程周期執(zhí)行wait系統(tǒng)調(diào)用收割其收養(yǎng)的所有僵尸進(jìn)程。為避免產(chǎn)生僵尸進(jìn)程,實際應(yīng)用中一般采取的方式是:
將父進(jìn)程中對SIGCHLD信號的處理函數(shù)設(shè)為SIG_IGN(忽略信號);
- fork兩次并殺死一級子進(jìn)程,令二級子進(jìn)程成為孤兒進(jìn)程而被init所“收養(yǎng)”、清理。
- docker容器中的進(jìn)程
容器中的PID
在Docker中,進(jìn)程管理的基礎(chǔ)就是Linux內(nèi)核中的PID名空間技術(shù)。在不同PID名空間中,進(jìn)程ID是獨立的;即在兩個不同名空間下的進(jìn)程可以有相同的PID。
Linux內(nèi)核為所有的PID名空間維護(hù)了一個樹狀結(jié)構(gòu):最頂層的是系統(tǒng)初始化時創(chuàng)建的root namespace(根名空間),再創(chuàng)建的新PID namespace就稱之為child namespace(子名空間),而原先的PID名空間就是新創(chuàng)建的PID名空間的parent namespace(父名空間)。通過這種方式,系統(tǒng)中的PID名空間會形成一個層級體系。父節(jié)點可以看到子節(jié)點中的進(jìn)程,并可以通過信號等方式對子節(jié)點中的進(jìn)程產(chǎn)生影響。反過來,子節(jié)點不能看到父節(jié)點名空間中的任何內(nèi)容,也不可能通過kill或ptrace影響父節(jié)點或其他名空間中的進(jìn)程。
在Docker中,每個Container都是Docker Daemon的子進(jìn)程,每個Container進(jìn)程缺省都具有不同的PID名空間。通過名空間技術(shù),Docker實現(xiàn)容器間的進(jìn)程隔離。另外Docker Daemon也會利用PID名空間的樹狀結(jié)構(gòu),實現(xiàn)了對容器中的進(jìn)程交互、監(jiān)控和回收。注:Docker還利用了其他名空間(UTS,IPC,USER)等實現(xiàn)了各種系統(tǒng)資源的隔離,由于這些內(nèi)容和進(jìn)程管理關(guān)聯(lián)不多,本文不會涉及。
容器退出
當(dāng)創(chuàng)建一個Docker容器的時候,就會新建一個PID名空間。容器啟動進(jìn)程在該名空間內(nèi)PID為1。當(dāng)PID1進(jìn)程結(jié)束之后,Docker會銷毀對應(yīng)的PID名空間,并向容器內(nèi)所有其它的子進(jìn)程發(fā)送SIGKILL。
當(dāng)執(zhí)行docker stop命令時,docker會首先向容器的PID1進(jìn)程發(fā)送一個SIGTERM信號,用于容器內(nèi)程序的退出。如果容器在收到SIGTERM后沒有結(jié)束, 那么Docker Daemon會在等待一段時間(默認(rèn)是10s)后,再向容器發(fā)送SIGKILL信號,將容器殺死變?yōu)橥顺鰻顟B(tài)。這種方式給Docker應(yīng)用提供了一個優(yōu)雅的退出(graceful stop)機制,允許應(yīng)用在收到stop命令時清理和釋放使用中的資源。
docker kill可以向容器內(nèi)PID1進(jìn)程發(fā)送任何信號,缺省是發(fā)送SIGKILL信號來強制退出應(yīng)用。
容器中的僵尸進(jìn)程
產(chǎn)生原因
容器化后,由于單容器單進(jìn)程,已經(jīng)沒有傳統(tǒng)意義上的 init 進(jìn)程了。應(yīng)用進(jìn)程直接占用了 pid 1 的進(jìn)程號。從而導(dǎo)致以下兩個問題。
常見的使用是 docker run my-container script 。給 docker run進(jìn)程發(fā)送SIGTERM 信號會殺掉 docker run 進(jìn)程,但是容器還在后臺運行。
2.當(dāng)進(jìn)程退出時,它會變成僵尸進(jìn)程,直到它的父進(jìn)程調(diào)用 wait()( 或其變種 ) 的系統(tǒng)調(diào)用。process table 里面會把它的標(biāo)記為 defunct 狀態(tài)。一般情況下,父進(jìn)程應(yīng)該立即調(diào)用 wait(), 以防僵尸進(jìn)程時間過長。
如果父進(jìn)程在子進(jìn)程之前退出,子進(jìn)程會變成孤兒進(jìn)程, 它的父進(jìn)程會變成 PID 1。因此,init進(jìn)程就要對這些進(jìn)程負(fù)責(zé),并在適當(dāng)?shù)臅r候調(diào)用 wait() 方法。通常情況下,大部分應(yīng)用進(jìn)程不會處理偶然依附在自己進(jìn)程上的隨機子進(jìn)程,所以在容器中,會出現(xiàn)許多僵尸進(jìn)程。
解決方案
解決這個的辦法就是pid為1的跑一個支持信號轉(zhuǎn)發(fā)且支持回收孤兒僵尸進(jìn)程的進(jìn)程就行了,為此有人開發(fā)出了tini項目,感興趣可以github上搜下下,現(xiàn)在tini已經(jīng)內(nèi)置在docker里了。使用tini可以在docker run的時候添加選項–init即可,底層我猜測是復(fù)制docker-init到容器的/dev/init路徑里然后啟動entrypoint cmd,大家可以在run的時候測試下上面的步驟會發(fā)現(xiàn)根本不會有僵尸進(jìn)程遺留。這里不多說,如果是想默認(rèn)使用tini可以把tini構(gòu)建到鏡像里(例如k8s目前不支持docker run 的--init,所以需要把tini做到鏡像里),參照jenkins官方鏡像dockerfile和tini的github地址文檔 https://github.com/krallin/tini
k8s pod中的僵尸進(jìn)程
k8s 可以將多個容器編排到一個 pod 里面,共享同一個 Linux Namespace。這項技術(shù)的本質(zhì)是使用 k8s 提供一個 pause 鏡像,也就是說先啟動一個 pause 容器,相當(dāng)于實例化出 Namespace,然后其他容器加入這個 Namespace 從而實現(xiàn) Namespace 的共享。我們來介紹一下 pause。
pause 是 k8s 在 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
attach到pod中,ps查看進(jìn)程列表:
- / # kubectl attach POD -c CONTAINER
- / # 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 號進(jìn)程變成了 /pause,其他容器的 entrypoint 進(jìn)程都變成了 1 號進(jìn)程的子進(jìn)程。這個時候開始逐漸逼近事情的本質(zhì)了:/pause 進(jìn)程是如何處理 將孤兒進(jìn)程的父進(jìn)程置為 1 號進(jìn)程進(jìn)而避免僵尸進(jìn)程的呢?
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);
- }
- // 關(guān)注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;
- // 關(guān)注2
- if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap,
- .sa_flags = SA_NOCLDSTOP},
- NULL) < 0)
- return 3;
- for (;;)
- pause(); // 編者注:該系統(tǒng)調(diào)用的作用是wait for signal
- fprintf(stderr, "Error: infinite loop terminated\n");
- return 42;
- }
重點關(guān)注一下void sigreap(int signo){...}和if (sigaction(SIGCHLD,...) ,這個不就是我們上面說的 除了這種方式外,還可以通過異步的方式來進(jìn)行回收,這種方式的基礎(chǔ)是子進(jìn)程結(jié)束之后會向父進(jìn)程發(fā)送 SIGCHLD 信號,基于此父進(jìn)程注冊一個 SIGCHLD 信號的處理函數(shù)來進(jìn)行子進(jìn)程的資源回收就可以了。
SIGCHLD 信號的處理函數(shù)核心就是這一行 while (waitpid(-1, NULL, WNOHANG) > 0) ,其中各參數(shù)示意如下:
- -1:meaning wait for any child process.
- NULL:?
- WNOHANG :return immediately if no child has exited.
結(jié)論
得出pause 容器的兩個最重要的特性:
- 在 pod 中作為容器共享namespace的基礎(chǔ)
- 作為 pod 內(nèi)的所有容器的父容器,扮演 init 進(jìn)程(即systemd)的作用。
最后
通過這篇文章的學(xué)習(xí),大家能解決容器中出現(xiàn)僵尸進(jìn)程的問題。
本文轉(zhuǎn)載自微信公眾號「運維開發(fā)故事」