博客进程泄露及僵尸进程解决

问题背景

博客一直存在一个已知的问题没有修复 —— 长时间运行可能会把服务器搞崩,之前定位到问题是 爬虫部分(优秀博客订阅)) 导致的。

由于部分博客是通过客户端渲染(CSR),实际的博客内容需要通过 js 生成,因此单纯的爬虫很难拿到正文部分,为了解决问题,在运行时环境中安装了 Chromium,通过 chromedp 来控制 Chromium 启动一个无头浏览器渲染页面。

众所周知,Chrome(Chromium)是吃资源黑洞,这里在 Chrome 主进程启动后,会一并拉起大量子进程进行处理(如每个 tab 一个单独的进程)。在大部分情况下,由于用户往往会打开大量的 tab,并且安装大量插件,单独进程运行可以提升性能与稳定性。但在优秀博客订阅的场景下则可能会导致问题。

在目前的逻辑下,爬虫通过协程池控制(最多允许 32 个 goroutine 同时运行),每个爬取任务会拉起自己的 Chromium 进程进行爬取。在 v0.7.4 的版本,并没有实现无头浏览器的优雅退出,因此爬取任务结束后可能会遗留很多 Chromium 实例存活。

在运行 7 周的情况下,容器中已经有 7670 个 chrome 进程在运行

由于目前博客代码更新节奏放缓,经常性几个月不会重启 docker 容器,因此甚至出现过 pid 被用完导致宿主机系统卡住的情况。

默认这里 PID 数量为 32768,可以修改 /proc/sys/kernel/pid_max 来控制 pid 上限

当 PID 累加达到上限后,会尝试从 0 开始继续找可用 PID,如果没有可用 PID,则无法创建进程(体现是很多命令无法执行)

本质上,Docker 内的进程还是运行在宿主机的,只不过会映射容器内 PID 到宿主机 PID

解决该问题最无脑的办法就是定时重启容器(重启大法好),某种意义上这也是 K8S 或者说 Serverless 的优势,通过轮转实例来降低出现长时间运行导致的 bug。

当然,这只是临时措施,本质上还是应该关掉 Chromium 进程

关闭进程

关掉一个进程最简单的办法就是 kill 掉进程,这么常见的问题自然早就有先驱者给出了代码,在 #276 中实现了通过发送 SIGTERM 优雅结束进程的方案。

但更新依赖对于目前的博客系统是一个大工程,需要升级依赖镜像的版本。既然只有几行代码,也可以直接在对应逻辑的地方进行修改。

chromedp 对外并不是面对对象的形式提供接口,而是有使用者声明 Context 后,传递 Context 到不同的函数内。因此第一步是拿到 Context 内存储的 Chromium 进程 ID。借助于 chromedp.FromContext() 可以拿到 Browser 对象,并进一步得到 process。而关掉进程则只需要发送 SIGTERM 即可。

defer func() {
    if process := chromedp.FromContext(ctx3).Browser.Process(); process != nil {
        process.Signal(syscall.SIGTERM)
    }
}()
  • SIGINT(2) CTRL+C 触发,给进程组全部发送结束命令,并给他们清理资源的时间(如果清理资源卡住进程不会被 kill)
  • SIGKILL(9) 强制结束进程,程序瞬间结束,资源不会清理
  • SIGTERM(15) 优雅结束进程,不会通知子进程,给程序清理资源的时间(如果清理资源卡住进程不会被 kill)

僵尸进程解决

到这里问题解决了吗?其实并没有,测试可以发现,与原本的版本相比,进程确实变少了,但是仍然存在一些 Chromeium 进程,而且他们全是 僵尸进程

当子进程运行结束时,需要父进程对其进行回收。如果父进程未对其进行操作,则会导致子进程无法完全关闭(部分资源被清理,PID 保留),这种状态被叫做僵尸进程。

一般僵尸进程导致的原因是父进程未正确实现子进程处理逻辑。

如果父进程结束,子进程会被转交给 PID 1 作为父进程,由系统进行资源回收。

单纯从上面的描述看,似乎不会存在问题,Chromium 父进程被 kill 了,子进程没人回收,移交给 PID 1 进行回收。

但是这个 PID 1 又涉及了另一个问题 —— 容器内的 PID 1 就是启动程序,也就是说这些 Chromium 孤儿进程被转交给了博客后端程序管理,在 Go 有 goroutine 的情况下,一般都不会处理子进程问题,自然这些子进程资源就永远无法释放了。

到这一步,问题解决方案有了几种

  1. 不要由博客处理,而是交给一个其他会回收资源的进程
  2. 让博客也拥有回收资源的能力

前者就是不要直接运行 blotter 二进制文件,而是找一个引导程序(如 bash 启动),让引导程序来担任 PID 1 的工作

后者则要在收到 SIGCHLD 信号后,进行 wait 操作(wait 本身需要持续等待重试,直到成功),在 Go 中,使用 syscall.Wait4(pid, &wstatus, 0, nil) 实现 wait

参考资料