泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:http://tinylab.org
请稍侯

揭密容器环境下 Golang 回收子进程的运行机制

Zhizhou Tian 创作于 2020/10/28

By Zhizhou Tian of TinyLab.org Oct 04, 2020

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

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

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

发生问题的代码在这里:link 的第 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 test , 发现正常打印了 ls 的结果后,什么也没发生:

# 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,把它 kill 掉。这样我们获得了一个没有回收的僵尸进程:

root      4606  4600  0 20:57 pts/14   00:00:00 [sleep] <defunct>

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

# go test
Wait failed with err:signal: killed

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

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

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

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

这种问题我应该不是第一个遇到的,搜索看看。看到有一个回答者复现了这个现象:link

代码:

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

这个 reaper 的实际作用就是收割子进程。其主要工作路径为: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 = <-notifications
		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 这个 goroutine,不停的监听 SIGCHLD 信号,并在捕获信号后进行 Wait 。这样,父进程就 wait 不到了。

至于刚刚提到的问题,为什么 bash 到容器中再执行 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(),可以得到当前进程的进程号,如果当前进程的进程号不是 1,那么就不执行 reaper 逻辑。 在之前的例子中,我们留下了一个疑问,直接执行命令,和以 bash 进入 container 后再执行命令,会得到不同的试验结果,这里也可以得到解释了: 我们以 bash 进入 container 之后,container 里的 1 号进程是 bash,而非测试程序,因此 reaper 逻辑不会生效。而直接执行命令,那么 reaper 逻辑生效,他会在我们的测试程序的父进程执行 Wait() 之前,把执行结束的子进程给回收掉,导致父进程 Wait() 失败。

为什么要有 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 这种特殊系统环境下的存在意义。

后记

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

  1. 简化问题、把问题 narrow down (直接照搬了我老板常说的话;-) )
  2. 了解流程,然后闭上眼把流程在脑海中梳理一遍
  3. 开脑洞,找出可疑点,逐个尝试

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



Read Related:

Read Latest: