[置顶] 泰晓 RISC-V 实验箱,配套 30+ 讲嵌入式 Linux 系统开发公开课
揭密容器环境下 Golang 回收子进程的运行机制
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 这种特殊系统环境下的存在意义。
后记
这个问题在复盘时看起来轻松,但是解决时确实有一定难度,尤其问题现场是偶现现象,且需要把大量混杂的信息进行整理,并联系到自己当前遇到的问题上。总结起来有一些方案论:
- 简化问题、把问题
narrow down
(直接照搬了我老板常说的话;-) ) - 了解流程,然后闭上眼把流程在脑海中梳理一遍
- 开脑洞,找出可疑点,逐个尝试
通过大量的解决问题,最终训练出一种直觉,以及 复盘并发文章到泰晓社区 嘿嘿嘿
猜你喜欢:
- 我要投稿:发表原创技术文章,收获福利、挚友与行业影响力
- 知识星球:独家 Linux 实战经验与技巧,订阅「Linux知识星球」
- 视频频道:泰晓学院,B 站,发布各类 Linux 视频课
- 开源小店:欢迎光临泰晓科技自营店,购物支持泰晓原创
- 技术交流:Linux 用户技术交流微信群,联系微信号:tinylab
支付宝打赏 ¥9.68元 | 微信打赏 ¥9.68元 | |
请作者喝杯咖啡吧 |