作者简介:田智洲,长年深耕于 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
通过大量的解决问题,最终训练出一种直觉,以及 「复盘并发文章到泰晓社区」 嘿嘿嘿