背景簡介
在unix/linux系統(tǒng)中,正常情況下,子進程是通過父進程fork創(chuàng)建的。子進程的結(jié)束和父進程的運行是一個異步過程,即父進程永遠無法預(yù)測子進程到底什么時候結(jié)束。
當(dāng)一個進程完成它的工作終止之后,它的父進程需要調(diào)用wait()或者waitpid()系統(tǒng)調(diào)用取得子進程的終止?fàn)顟B(tài)。
孤兒進程
父進程先于子進程退出,那么子進程將成為孤兒進程。孤兒進程將被init進程(進程號為1)接管,并由init進程對它完成狀態(tài)收集(wait/waitpid)工作。
#include
#include
#include
#include
int main(){
pid_t pid; //創(chuàng)建一個進程
pid = fork(); //創(chuàng)建失敗
if (pid < 0) {
perror("fork error:");
exit(1);
} //子進程
if (pid == 0) {
printf("I'm child process, pid:%d ppid:%d\n", getpid(), getppid()); //睡眠3s,保證父進程先退出
sleep(3); // 輸出子進程ID和父進程ID
printf("I'm child process, pid:%d ppid:%d\n", getpid(), getppid());
printf("child process is exited.\n"); } //父進程
else {
printf("I'm father process, pid:%d ppid:%d\n", getpid(), getppid());
//父進程睡眠1s,保證子進程輸出進程id
sleep(1);
printf("father process is exited.\n");
}
return 0;
}
注: 運行結(jié)果如圖: 父進程退出后,子進程的父進程(ppid)變?yōu)?,被init進程接管.

僵尸進程
子進程退出,而父進程并沒有調(diào)用wait或waitpid獲取子進程的狀態(tài)信息,那么子進程的進程描述符仍然保存在系統(tǒng)中,這種進程稱之為僵尸進程.
#include
#include
#include
#include
int main(){
pid_t pid;
pid = fork();
if (pid < 0) {
perror("fork error:");
exit(1); }
else if (pid == 0) {
printf("I am child process - %d.I am exiting.\n", getpid());
exit(0);}
printf("I am father process- %d.I will sleep two seconds\n", getpid());
//等待子進程先退出
sleep(3);
//輸出進程信息
system("ps -o pid,ppid,state,command");
printf("father process - %d is exiting.\n", getpid());
return 0;
}
運行結(jié)果如圖:子進程(pid=2158)成為了僵尸進程

僵尸進程的危害
在每個進程退出的時候,內(nèi)核釋放該進程所有的資源,包括打開的文件,占用的內(nèi)存等。 但是仍然為其保留一定的信息(包括進程號、退出狀態(tài)、運行時間等)。直到父進程通過wait / waitpid來取時才釋放。 如果父進程不調(diào)用wait / waitpid的話, 那么保留的那段信息就不會釋放,其進程號就會一直被占用,系統(tǒng)所能使用的進程號是有限的,如果大量的產(chǎn)生僵尸進程,可能導(dǎo)致系統(tǒng)不能產(chǎn)生新的進程.
Docker中的孤兒進程
在docker容器中運行的進程,一般是沒有init進程的??梢赃M入容器使用 ps 查看,會發(fā)現(xiàn) pid 為 1 的進程并不是 init,而是容器的主進程。如果容器中產(chǎn)生了孤兒進程,誰來接管這個進程?
看下linux內(nèi)核代碼關(guān)于接收孤兒進程的代碼
/*
* When we die, we re-parent all our children, and try to:
* 1. give them to another thread in our thread group, if such a member exists
* 2. give it to the first ancestor process which prctl'd itself as a
* child_subreaper for its children (like a service manager)
* 3. give it to the init process (PID 1) in our pid namespace
*/
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
struct task_struct *thread, *reaper;
thread = find_alive_thread(father);
if (thread)
return thread;
if (father->signal->has_child_subreaper) {
/*
* Find the first ->is_child_subreaper ancestor in our pid_ns.
* We start from father to ensure we can not look into another
* namespace, this is safe because all its threads are dead.
*/
for (reaper = father;
!same_thread_group(reaper, child_reaper);
reaper = reaper->real_parent) {
/* call_usermodehelper() descendants need this check */
if (reaper == &init_task)
break;
if (!reaper->signal->is_child_subreaper)
continue;
thread = find_alive_thread(reaper);
if (thread)
return thread;
}
}
return child_reaper;
}
- 找到相同線程組里其它可用線程
- 沿著它的進程樹向祖先進程找一個最近的child_subreaper并且運行著的進程
- 該namespace下進程號為1的進程
關(guān)于child_subreaper可以參考PRCTL的PR_SET_CHILD_SUBREAPER參數(shù)的描述。被標(biāo)記為CHILD SUBREAPER的進程,它的所有子進程以及后續(xù)進程都會被標(biāo)記為擁有subrepear,該進程充當(dāng)init(1)的功能收養(yǎng)該進程樹的孤兒進程
PR_SET_CHILD_SUBREAPER (since Linux 3.4)
If arg2 is nonzero, set the "child subreaper" attribute of the
calling process; if arg2 is zero, unset the attribute.
When a process is marked as a child subreaper, all of the
children that it creates, and their descendants, will be
marked as having a subreaper. In effect, a subreaper fulfills
the role of init(1) for its descendant processes. Upon
termination of a process that is orphaned (i.e., its immediate
parent has already terminated) and marked as having a
subreaper, the nearest still living ancestor subreaper will
receive a SIGCHLD signal and will be able to wait(2) on the
process to discover its termination status.
Docker進程樹
Docker Daemon從1.11版后從架構(gòu)上發(fā)生了比較大的變化,由原來的一個模塊拆分為4個獨立的模塊:engine、containerd、runC、containerd-shim,將容器的生命周期管理交給containerd, containerd再使用runC運行容器。
架構(gòu)上的變化也改變了docker容器運行時的進程樹的結(jié)構(gòu),這里運行一個簡單的docker鏡像,并通過ps xf -o pid,ppid,stat,args查看進程樹,從進程樹中也可以看出docker daemon架構(gòu)的變化。

$docker run -d --name ubuntu ubuntu:14.04 sleep 1000
docker 1.11之后

docker 1.11之前

docker產(chǎn)生孤兒進程
-
準(zhǔn)備兩個文件parent.sh、child.sh
#parent.shbash ./child.sh#child.shwhile truedo sleep 10done運行docker,此時sleep進程的為容器首進程,pid為1
docker run -d -v `pwd`/parent.sh:/root/test/parent.sh -v `pwd`/child.sh:/root/test/child.sh --name test ubuntu:14.04 sleep 10000進入容器,并運行parent.sh
# 進入容器docker exec -it test /bin/bash# 進入腳本目錄cd /root/test# 運行parent.sh腳本bash ./parent.sh在容器中通過
ps xf -o pid,ppid,stat,args查看進程樹可以看到進程結(jié)構(gòu)如下, sleep作為容器啟動命令,它的進程號為1,根據(jù)上一節(jié)關(guān)于linux接收孤兒進程的描述,當(dāng)沒有其他符合條件的進程接收時,該進程就會成為孤兒進程的接收者
image
接下來通過`kill -9`殺死運行parent.sh的進程,此時運行child.sh的進程就成為了孤兒進程,這個時候docker容器是如何處理孤兒進程的接收的呢?Docker 1.11之前和之后版本的處理是有所區(qū)別的
先來看下docker 1.11版之前容器內(nèi)的進程樹(如下圖),可以看到運行child.sh的進程的父進程變?yōu)榱?(sleep進程)

再來看下Docker 1.11版之后版本容器內(nèi)的進程樹(如下圖),可以看到child.sh進程的父進程變成了0,與sleep處于同一個層級,那么是誰接收了這個孤兒進程呢?

此時需要查看主機的進程樹才能確定孤兒進程到底是被誰接收了,在主機上運行` ps xf -o pid,ppid,stat,args`,結(jié)果如下圖
可以看到child.sh進程被docker-containerd-shim的進程接收,根據(jù)上面關(guān)于linux孤兒進程接收的描述,docker-containerd-shim應(yīng)該是被標(biāo)記為child_subreaper的,這樣它就能接收以他為父節(jié)點的進程樹下所有的孤兒進程。查找[docker/containerd](https://github.com/docker/containerd)的代碼,在container-shim的啟動函數(shù)start中通過[osutils.SetSubreaper](https://github.com/docker/containerd/blob/master/containerd-shim/main.go#L66)設(shè)置了child_subreaper
```bash
func start(log *os.File) error {
// start handling signals as soon as possible so that things are properly reaped
// or if runtime exits before we hit the handler
signals := make(chan os.Signal, 2048)
signal.Notify(signals)
// set the shim as the subreaper for all orphaned processes created by the container
if err := osutils.SetSubreaper(1); err != nil {
return err
}
...
}
```
#### 結(jié)論
- Docker1.11版本之前孤兒進程是由容器內(nèi)pid為1的進程接收,而1.11版本后是由docker-containerd-shim進程接收
Docker中的僵尸進程
關(guān)于僵尸進程的概念以及產(chǎn)生的原因上面已經(jīng)闡述過了,僵尸進程是指子進程退出,而父進程并沒有調(diào)用wait或waitpid獲取子進程的狀態(tài)信息,那么子進程的進程描述符仍然保存在系統(tǒng)中。我們這里只討論docker中的孤兒進程機制是否會導(dǎo)致僵尸進程的產(chǎn)生,這個也是docker早期版本被詬病的問題。
1.11版本前
1.11版本前,孤兒進程是被容器內(nèi)pid為1的進程所接收。上面關(guān)于孤兒進程的實驗中,容器中pid為1的進程為sleep進程,而sleep進程是不會對子進程退出進行wait/waitpid操作的,所以我們kill掉child.sh進程就會產(chǎn)生僵尸進程(如下圖)
上圖可以看到運行child.sh的進程和sleep進程都成為了僵尸進程,這里sleep進程成為僵尸進程是由于sleep進程是child.sh的子進程,當(dāng)child.sh退出時,sleep進程成為了孤兒進程并被pid為1的sleep進程所接收,當(dāng)sleep運行結(jié)束時(這里運行的是sleep 10)退出,pid為1的sleep進程不進行wait/waitpid操作,就使得sleep進程成為僵尸進程
1.11版本后
1.11版本后,孤兒進程是被docker-containerd-shim進程接收,如果docker-containerd-shim在子進程退出時調(diào)用wait/waitpid就不會產(chǎn)生僵尸進程,反之就會產(chǎn)生僵尸進程。這里也進行相同的操作,kill掉運行child.sh的進程,結(jié)果如下圖
從結(jié)果上看child.sh和sleep(child.sh的子進程)進程都正常退出(進程樹上看不到),并沒有產(chǎn)生僵尸進程。所以docker-containerd-shim會在子進程退出時調(diào)用wait/waitpid。從源碼中看下docker-containerd-shim的處理

func start(log *os.File) error {
...
switch s {
case syscall.SIGCHLD:
exits, _ := osutils.Reap(false)
...
}
...
}
在其start函數(shù)中可以看到接收子進程退出的信號量(SIGCHLD), 調(diào)用osutils.Reap(false)進行處理,并且在osutils.Reap函數(shù)中調(diào)用了wait方法
func Reap(wait bool) (exits []Exit, err error) {
...
for {
pid, err := syscall.Wait4(-1, &ws, flag, &rus)
if err != nil {
if err == syscall.ECHILD {
return exits, nil
}
return exits, err
}
...
}
}
結(jié)論
- Docker1.11之前的版本,孤兒進程是否有可能成為僵尸進程取決于容器內(nèi)pid為1的進程是否在子進程退出時調(diào)用wait/waitpid, Docker1.11版本之后孤兒進程不會成為僵尸進程
