容器中的孤兒進程&僵尸進程簡介

背景簡介

      在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進程接管.


image

僵尸進程

子進程退出,而父進程并沒有調(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)成為了僵尸進程


image

僵尸進程的危害

在每個進程退出的時候,內(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;
}
  1. 找到相同線程組里其它可用線程
  2. 沿著它的進程樹向祖先進程找一個最近的child_subreaper并且運行著的進程
  3. 該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)的變化。

image

$docker run -d --name ubuntu ubuntu:14.04 sleep 1000

docker 1.11之后


image

docker 1.11之前


image

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進程)
image
再來看下Docker 1.11版之后版本容器內(nèi)的進程樹(如下圖),可以看到child.sh進程的父進程變成了0,與sleep處于同一個層級,那么是誰接收了這個孤兒進程呢?
image
此時需要查看主機的進程樹才能確定孤兒進程到底是被誰接收了,在主機上運行` 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的處理


image
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版本之后孤兒進程不會成為僵尸進程
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容