动态

详情 返回 返回

一次容器裏的殭屍進程排查2 - 动态 详情

前序

上次的排查,我們發現在容器裏golang進程作為1號進程的時候不具備等待孤兒進程退出狀態的能力,但是bash就可以,帶着這個問題,我們進一步研究。

尋找思路

我們再次看下維基百科對於殭屍進程的定義。

殭屍進程定義

對於裏面的內容,我們不逐字逐句分析,其中有一句話

子進程死後,系統會發送SIGCHLD信號給父進程,父進程對其默認處理是忽略。如果想響應這個消息,父進程通常在信號事件處理程序中,使用wait系統調用來響應子進程的終止。

我們找到兩個關鍵點,SIGCHLD信號和wait系統調用。基於這兩個關鍵點我們展開下面的討論。

結合前面的文章,我們可以初步判斷,對於託管的孤兒進程,執行golang二進制文件的進程並沒有調用wait等待子進程的退出狀態。至於是這種子進程結束的時候父進程沒有收到SIGCHLD信號,我們可以先保持疑問。

go cmd源碼

我們直接找到golang exec.go中的 Run方法

func (c *Cmd) Run() error {
    if err := c.Start(); err != nil {
        return err
    }
    return c.Wait()
}

func (p *Process) wait() (ps *ProcessState, err error) {
    if p.Pid == -1 {
        return nil, syscall.EINVAL
    }

    // If we can block until Wait4 will succeed immediately, do so.
    ready, err := p.blockUntilWaitable()
    if err != nil {
        return nil, err
    }
    if ready {
        // Mark the process done now, before the call to Wait4,
        // so that Process.signal will not send a signal.
        p.setDone()
        // Acquire a write lock on sigMu to wait for any
        // active call to the signal method to complete.
        p.sigMu.Lock()
        p.sigMu.Unlock()
    }

    var (
        status syscall.WaitStatus
        rusage syscall.Rusage
        pid1   int
        e      error
    )
    for {
        pid1, e = syscall.Wait4(p.Pid, &status, 0, &rusage)
        if e != syscall.EINTR {
            break
        }
    }
    if e != nil {
        return nil, NewSyscallError("wait", e)
    }
    if pid1 != 0 {
        p.setDone()
    }
    ps = &ProcessState{
        pid:    pid1,
        status: status,
        rusage: &rusage,
    }
    return ps, nil
}

start是以非阻塞的方式直接啓動子進程,然後wait裏面其實就包含了系統調用wait4。我們不難得出結論,以這種方式創建並啓動的子進程,其實無需專門處理SIGCHLD信號。

我們閲讀golang signal的官方文檔

golang signal

我們可以看到下面這段話

Notify disables the default behavior for a given set of asynchronous signals and instead delivers them over one or more registered channels. Specifically, it applies to the signals SIGHUP, SIGINT, SIGQUIT, SIGABRT, and SIGTERM. It also applies to the job control signals SIGTSTP, SIGTTIN, and SIGTTOU, in which case the system default behavior does not occur. It also applies to some signals that otherwise cause no action: SIGUSR1, SIGUSR2, SIGPIPE, SIGALRM, SIGCHLD, SIGCONT, SIGURG, SIGXCPU, SIGXFSZ, SIGVTALRM, SIGWINCH, SIGIO, SIGPWR, SIGSYS, SIGINFO, SIGTHR, SIGWAITING, SIGLWP, SIGFREEZE, SIGTHAW, SIGLOST, SIGXRES, SIGJVM1, SIGJVM2, and any real time signals used on the system. Note that not all of these signals are available on all systems.

有一些系統信號golang進程是默認不處理的,SIGCHLD包含其中。
也就是説,如果我們想讓golang進程對SIGCHLD信號做出響應,需要自己實現。

驗證信號接收

如果我們想基於處理SIGCHLD信號解決殭屍進程的問題,我們首先要驗證一下前面提到的問題,對於這種託管過來的孤兒進程的退出,父進程是否能收到SIGCHLD信號,話不多説,上代碼。

我們在callmain中加入信號的處理

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal)
        signal.Notify(c)
        fmt.Println(time.Now(), " ready to get singnal")
        for {
            s := <-c
            fmt.Println(time.Now()," get a signal:", s.String())
        }
    }()
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)
}

老樣子執行

docker run -v C:\work\go\code\first\patest:/home/rain --entrypoint /home/rain/shell/callmain centos:7

通過終端內容我們可以看到收到了兩個SIGCHLD信號,根據時間不難判斷,一個是子進程main函數的結束信號,另一個是託管的sleep的結束信號。

image.png

對於sleep的結束信號,golang沒有處理,所以可以看到,sleep進程成為了殭屍進程

image.png

好了,通過上面的實驗,證明即便是後面託管過來的孤兒進程,它退出的時候,父進程也是有SIGCHLD信號收到的。

bash的處理方式

下載了bash4.2的源碼

源碼地址

這一塊我沒有特別深入研究詳細的邏輯,所以暫時不做詳細的論述,看幾段示例代碼。

#define UNQUEUE_SIGCHLD(os) \
    do { \
      queue_sigchld--; \
      if (queue_sigchld == 0 && os != sigchld) \
        waitchld (-1, 0); \
    } while (0)
sigchld_handler (sig)
     int sig;
{
  int n, oerrno;

  oerrno = errno;
  REINSTALL_SIGCHLD_HANDLER;
  sigchld++;
  n = 0;
  if (queue_sigchld == 0)
    n = waitchld (-1, 0);
  errno = oerrno;
  SIGRETURN (n);
}

job.c裏面有很多關於這個sigchld的處理,我並沒有一一分析,不過我們至少可以知道,bash對sigchld有處理邏輯而不是像golang默認忽略。

在golang中處理sigchld信號

我們嘗試在golang中加入對sigchld的處理邏輯
前面我們通過對cmd源碼查看,發現golang最終是調用的wait4這個系統調用,我們找到了相關文檔

wait4文檔
image.png

因為我們接收到sigchld信號,但是並不知道具體是哪個子進程,所以我們pid參數設置為-1(等待所有子進程),其他選項我們就選擇阻塞的方式(阻塞情況下如果當前有子進程存活,就一直處於阻塞中,但是如果沒有子進程存在,就會直接返回錯誤和-1)
我們繼續修改callmain的代碼

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGCHLD)
        fmt.Println(time.Now(), " ready to get sigchld singnal")
        for {
            s := <-c
            fmt.Println(time.Now(), " get a signal:", s.String())

            var (
                status syscall.WaitStatus
                rusage syscall.Rusage
                pid1   int
                e      error
            )
            pid1, e = syscall.Wait4(-1, &status, 0, &rusage)
            fmt.Println(time.Now(), " child pid", pid1)
            fmt.Println(time.Now(), " err", e)

        }
    }()

    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)

編譯好重新執行

docker run -v C:\work\go\code\first\patest:/home/rain --entrypoint /home/rain/shell/callmain centos:7

根據時間發現其實是main函數進程結束的時候收到了第一個SIGCHLD信號,但是因為去wait的時候,main因為是屬於callmain自己啓動了已經默認wait了,這時候第一時間沒有獲取main的退出狀態。但是因為還有sleep進程存在,所以wait一直阻塞,等待sleep結束,wait執行獲取到了sleep的退出狀態。
等再次獲取到的信號,其實才是sleep的信號。雖然這個信號和wait的結果沒有一一對應,但是其實結果符合我們預期。

image.png

image.png

其中有一個小插曲,一開始執行的時候,打印出err <nil>的時候就沒有日誌了打印了,我看了一下signal.Notify的參數介紹,其中對第一個參數chan的説明

Package signal will not block sending to c: the caller must ensure
that c has sufficient buffer space to keep up with the expected
signal rate. For a channel used for notification of just one signal value,
a buffer of size 1 is sufficient.

也就是説,信號不會阻塞等待可以發送到chan,當我們設置為無緩衝chan的時候,當執行後面邏輯,沒有嘗試從chan裏面獲取一個元素,這時候來的信號就無法發送到chan,又不會阻塞等待,所以應該是被丟棄了。因此,可以注意到,我將chan緩衝大小設置為1。

好了,説明基於SIGCHLD信號去wait子進程的方案是有可行性的,我們可以稍微優化一下邏輯。收到SIGCHLD就循環wait一直到收到沒有子進程的報錯。
如下:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGCHLD)
        fmt.Println(time.Now(), " ready to get sigchld singnal")
        for {
            s := <-c
            fmt.Println(time.Now(), " get a signal:", s.String())

            var (
                status syscall.WaitStatus
                rusage syscall.Rusage
                pid1   int
                e      error
            )
            for {
                pid1, e = syscall.Wait4(-1, &status, 0, &rusage)
                fmt.Println(time.Now(), " child pid", pid1)
                fmt.Println(time.Now(), " err", e)
                if pid1 == -1 && strings.Contains(e.Error(), "no child processes") {
                    break
                }
            }

        }
    }()
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)
}

我們模擬的場景A、B、C類進程都有一個,後面會讓A多產生一些B、C進程進行測試。

user avatar ji_jason 头像 u_17470194 头像 huaihuaidehongdou 头像 u_17021563 头像 tongbo 头像 yuzhoustayhungry 头像 damonxiaozhi 头像 mangrandechangjinglu 头像 gouguoyin 头像 ansurfen 头像 zyuxuaner 头像 uyangxiansen 头像
点赞 44 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.