背景
“大棟老師”的一個應用,經常會有殭屍進程產生。程序的調用邏輯大概如下:
主進程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中結束整個進程樹的方案。