背景
“大栋老师”的一个应用,经常会有僵尸进程产生。程序的调用逻辑大概如下:
主进程A产生多个B类进程B1,B2,B3等,每一个B类进程又产生了若干个C类进程,C1,C2,C3,现象就是容器中会出现部分C进程的僵尸进程。
经过简单的分析发现是一些B类进程先结束,导致一些C类进程成为僵尸进程。但是这个不符合常规的逻辑,因为正常情况下父进程如果结束,子进程会成为孤儿进程,从而被内核的1号进程接管,结束之后自然被清理了,理论上不会成为僵尸进程。“大鹏老师”提出,上面的逻辑是在传统的操作系统上,容器会不会有一些特殊呢。我们带着这个猜想,进入验证阶段。
模拟以及验证
简单模拟单条链路A--->B--->C的情况,看看是否能复现。
callmain作为上面描述的A进程,代码如下
package main
import (
"context"
"os/exec"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", " ./main")
err := cmd.Run()
if err != nil {
fmt.Println(err)
}
time.Sleep(5 * time.Hour)
}
main作为上面描述的B进程,代码如下
package main
import (
"context"
"os/exec"
)
func main() {
cmd := exec.CommandContext(context.Background(), "bash", "-c", "sleep 100")
err := cmd.Run()
if err != nil {
panic(err)
}
}
简单描述一下上面的逻辑,callmain里面调用编译好的main执行文件,main执行文件里面则是调用shell命令阻塞100秒,这样main(B)结束的时候,
sleep进程(C)仍在继续。把两个可执行文件放入容器的同一目录,如下如
然后执行 ./callmain
ps -ef 结果如下,进程父子关系符合预期
30s之后,这里我们发现sleep进程已经最为孤儿进程被1号进程作为子进程接管,如图
100s之后,如下图,sleep进程已经正常结束释放资源,并没有成为所谓的僵尸进程
质疑与新的猜测
上面基于容器模拟生产类似的场景,但是却没有复现。是因为只是单条链路,进程不够多?还是因为模拟的时候逻辑没梳理清楚相似度不够?
这时候“大鹏老师”又站出来了,他提出,我们生产环境的entrypoint就是直接启动进程A,模拟的场景则是在bash里面手动启动的。有道理哦,似乎接近真相了。
再次验证
为了方便我们需要在callmain里面使用绝对路径调用main执行文件
所以略微修改一下callmain的代码
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-c", " /home/rain/shell/main")
err := cmd.Run()
if err != nil {
fmt.Println(err)
}
time.Sleep(5 * time.Hour)
执行下面的命令
docker run -v C:\work\go\code\first\test:/home/rain --entrypoint /home/rain/shell/callmain centos:7
进入容器如图
main结束后sleep被1号进程接管
但是当100秒过去,sleep应该结束的时候,如图,却没有释放资源,成为了僵尸进程
结论
在容器里面,自定义的进程作为entrypoint启动时,它是1号进程,它不具备waitpid回收由它接管的孤儿进程资源的能力
解决方案
既然有这个问题,肯定有相应的处理办法。大栋老师心生一计:既然go进程作为1号进程没有这个能力,那我们套一层bash呢?我们感觉应该可行,话不多说,直接进入验证。
验证解决方案
我们引入一个简单的shell脚本,内容如下:
#! /usr/bin/bash
/home/rain/shell/callmain
执行
docker run -v C:\work\go\code\first\test:/home/rain --entrypoint /home/rain/shell/shell.sh centos:7
未到30s,进程父子关系符合预期:
30s之后,发现sleep成功被1号进程接管
100s之后,发现sleep正常退出
结果符合预期,所以这个方案是可行的
其他思考
在上面的例子中,我们进程的父子关系最多产生了四级,拿最后一个例子来说,从父到子依次是
为了方便描述,我们简单编了个号
1./usr/bin/bash /home/rain/shell/shell.sh
2./home/rain/shell/callmain
3./home/rain/shell/main
4.sleep 100
当进程3运行超过30s收到结束信号结束后,进程4仍然在运行,4作为孤儿进程托管给了进程1直到正常结束回收资源。
其实在有一些场景下,我们更多的是期望进程3结束了,4也不需要再运行了,因为继续运行在一些场景下会有进程泄漏,其实这些进程并没有其他进程在等待它的结果,它在运行会造成没必要的资源占用和浪费。因此,我们会有一个需要是,当一个进程退出了,它的产生的子进程、孙子进程乃至后面的子子孙孙都需要退出。
基于上面的需求,golang有一个通用的解决方案,我们还是修改callmain代码
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
cmd.Cancel = func() error {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
return nil
}
err := cmd.Run()
if err != nil {
fmt.Println(err)
}
time.Sleep(5 * time.Hour)
上面的代码含义是,在进程2启动进程3的时候,设置进程3的进程组id为其进程id,这样进程3产生的子孙进程都会使用这个id作为及进程组id,这样在进程3结束的时候,只需要执行系统调用kill -9 -pid就能杀死整个进程组。下面会进行验证。
结束子孙进程方法验证
还是执行命令
docker run -v C:\work\go\code\first\test:/home/rain --entrypoint /home/rain/shell/shell.sh centos:7
开始的情况符合预期
30s之后再查看,发现sleep进程也被结束了,方案可行
总结
- 经过分析、猜想以及实验验证,我们发现,如果entrypoint是一个golang的可执行文件(entrypoint执行的命令就是容器里面的1号进程),那这个进程启动之后是不具备传统操作系统中的1号进程的对孤儿进程waitpid、等孤儿进程结束回收其资源的能力的。后面我们会继续研究,为什么golang执行文件不具备这个能力,bash就可以。
- 另外我们顺便也验证了在golang中结束整个进程树的方案。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。