9d19f7ea028b5a8bff06c6b83917b404.gif

作者简介:田智洲,长年深耕于 Linux 和虚拟化领域,腾讯虚拟化组高级工程师。

版权声明:本文最先发表于 “泰晓科技” 微信公众号,欢迎转载,转载时请在文章的开头保留本声明。

问题现象:父进程等不到子进程

集成某个 Docker 容器化项目的时候发现子进程没有被 wait 到,现象如下:

Error processing tar file(wait: no child processes): : unknown

发生问题的代码在这里:https://fossies.org/linux/skopeo/vendor/github.com/containers/storage/pkg/chrootarchive/archive_unix.go 的第 198 行

github.com/containers/storage/pkg/chrootarchive/archive_unix.go:

func invokeUnpack(decompressedArchive io.Reader, dest string, options *archive.TarOptions, root string) {
 cmd := reexec.Command("storage-untar", dest, root)
 cmd.Stdin = decompressedArchiv
 cmd.ExtraFiles = append(cmd.ExtraFiles, r)
 output := bytes.NewBuffer(nil)
 cmd.Stdout = output
 cmd.Stderr = output

 if err := cmd.Start(); err != nil {
  w.Close()
  return fmt.Errorf("Untar error on re-exec cmd: %v", err)
 }

 //write the options to the pipe for the untar exec to read
 if err := json.NewEncoder(w).Encode(options); err != nil {
  w.Close()
  return fmt.Errorf("Untar json encode to pipe failed: %v", err)
 }
 w.Close()

 if err := cmd.Wait(); err != nil {
  // when `xz -d -c -q | storage-untar ...` failed on storage-untar side,
  // we need to exhaust `xz`'s output, otherwise the `xz` side will be
  // pending on write pipe forever
  io.Copy(ioutil.Discard, decompressedArchive)
  return fmt.Errorf("Error processing tar file(%v): %s", err, output)
 }
}

那么为什么在这里会 wait 不到子进程?什么情况下子进程会消失不见呢?

第一直觉,是不是父进程还没有执行 wait 的时候,子进程就死掉了,所以会发生这个问题?

复现问题,尝试1:等子进程 ls 执行完再 wait

cmd.Wait()
func TestWait(t *testing.T) {
 cmd := exec.Command("/usr/bin/ls")
 cmd.Stdout = os.Stdout

 cmd.Start()

 time.Sleep(time.Second * 1)
 if err := cmd.Wait(); err != nil {
  fmt.Printf("Wait failed with err:%#+v\n", err)
 }
}
go testls
# go test
main  main.go

复现问题,尝试2:kill 子进程 sleep

那这次我们强制把子进程给 kill 掉,让父进程没有子进程可以等待:

func TestWait(t *testing.T) {
 cmd := exec.Command("/usr/bin/sleep", "200")
 cmd.Stdout = os.Stdout

 if err := cmd.Start(); err != nil {
  fmt.Printf("Start cmd:%#+v with err:%v\n", cmd.Args, err)
 }

 time.Sleep(time.Second * 20)
 if err := cmd.Wait(); err != nil {
  fmt.Printf("Wait failed with err:%v\n", err)
 }
}
sleep 200
root      4606  4600  0 20:57 pts/14   00:00:00 [sleep] 

结果 20 秒后,主进程正常回收了子进程:

# go test
Wait failed with err:signal: killed

那会不会是子进程还没有启动就 Wait 了呢?结果一样很绝望:

# go test
Wait failed with err:exec: not started

那这种现象究竟是怎么产生的呢?(如果对进程机制更有自信,上面的试验根本不用做,也知道不会产生这种问题)

去网络上寻求帮助:找到复现步骤

这种问题我应该不是第一个遇到的,搜索看看。看到有一个回答者复现了这个现象:https://github.com/ramr/go-reaper/issues/2

代码:

func TestReaper(t *testing.T) {
 go reaper.Reap()

 cmd := exec.Command("/usr/bin/sleep", "1")
 if err := cmd.Start(); err != nil {
  t.Fatalf("Start cmd:%#+v with err:%v\n", cmd.Args, err)
 }

 time.Sleep(3 * time.Second)

 if err := cmd.Wait(); err != nil {
  t.Errorf("Got err: %s", err)
 }
}

执行:

$ sudo docker run -it -v $(pwd):/app debian:latest /app/reaper.test
--- FAIL: TestReaper (3.00s)
    wait_test.go:23: Got err: waitid: no child processes
FAIL

但是,比如我们这样执行,就不会出错,大家可以一边看文章一边想想,看看能不能想到答案,我把答案放在最后面

$ sudo docker run -it -v $(pwd):/app debian:latest bash
root@bee3e5cd4d2e:/app# ./reaper.test
PASS

探究 reaper

看看这个段代码:

func TestReaper(t *testing.T) {
 go reaper.Reap()
 ...
}

代码中这个 goroutine 非常可疑,它的 source code 在这里:link: https://github.com/ramr/go-reaper/blob/master/reaper.go

Reap() --> Start() --> reapChildren()
func reapChildren(config Config) {
 var notifications = make(chan os.Signal, 1)

 go sigChildHandler(notifications)

 pid := config.Pid
 opts := config.Options

 for {
  var sig =   if config.Debug {
   fmt.Printf(" - Received signal %v\n", sig)
  }
  for {
   var wstatus syscall.WaitStatus

   //  Reap 'em, so that zombies don't accumulate.
   //  Plants vs. Zombies!!
   pid, err := syscall.Wait4(pid, &wstatus, opts, nil)
   for syscall.EINTR == err {
    pid, err = syscall.Wait4(pid, &wstatus, opts, nil)
   }

   if syscall.ECHILD == err {
    break
   }

   if config.Debug {
    fmt.Printf(" - Grim reaper cleanup: pid=%d, wstatus=%+v\n",
     pid, wstatus)
   }
  }
 }
}
sigChildHandler
reaper.test
func Start(config Config) {
 if !config.DisablePid1Check {
  mypid := os.Getpid()
  if 1 != mypid {
   if config.Debug {
    fmt.Printf(" - Grim reaper disabled, pid not 1\n")
   }
   return
  }
 }
}
os.Getpid()

为什么要有 reaper

这里直接说结论了。

这实际上是为了解决容器可能没有 init 进程的问题。如果没有 init,那么就没有人回收孤儿子进程,造成子进程泄露。因此需要引入一个 reaper 来回收这些子进程。

再解释一下孤儿进程。当子进程产生后,父进程退出了,这时子进程就被称为孤儿子进程。在我们常见的发行版系统中,常驻的 init 进程会作为这个孤儿进程的回收者。但是在容器中可能没有这个 init 进程,导致孤儿子进程退出后无人回收,造成僵尸进程泄露的情况

问题的解决

回到文章开头发现的问题,很显然我们的代码中产生的子进程,并不希望被这个 reaper 给 Wait(),那么有没有什么办法能够解决这个问题呢?问题的解决方案也很简单,那就是为这个 reaper 和业务逻辑之间加上同步。伪代码如下:

reaperLock sync.Mutex

go reaper() {
 reaperLock.Lock()
 // reaper 的 wait() 逻辑
 reaperLock.Unlock()
}()

main() {
 reaperLock.Lock()
 // 业务逻辑
 reaperLock.Unlock()
}

这样,业务逻辑在执行的时候就不受到 reaper Wait() 的影响了。

总结

本文通过分析一个父进程没有 wait 子进程的问题,了解到了进程 reaper 机制,并进一步了解到它在 container 这种特殊系统环境下的存在意义。

后记

这个问题在复盘时看起来轻松,但是解决时确实有一定难度,尤其问题现场是偶现现象,且需要把大量混杂的信息进行整理,并联系到自己当前遇到的问题上。总结起来有一些方案论:

narrow down

通过大量的解决问题,最终训练出一种直觉,以及 「复盘并发文章到泰晓社区」 嘿嘿嘿

6799994509bd46ce8564e34914ad456f.gif