go cmd 使用小坑一記
先說下使用場景,某服務在每臺伺服器上啟動 agent, 使用者會在指定機器上執行任務,並將結果返回到網頁上。執行任務由使用者自定義指令碼,一般也都是 shell 或是python,會不斷的產生子程式,孫程式,直到執行完畢或是超時被 kill
最近發現經常有任務,一直處於執行中,但實際上己經超時被 kill,並未將輸出寫到系統,看不到任務的執行情況。登入機器,發現執行指令碼程式己經殺掉,但是有子指令碼卡在某個 http 呼叫。我司的網濫到無法直視,內網還有不通的 case,並且還有很多公網機器,再看下這個指令碼,python requests 預設沒有設定超時...
總結一下現象:agent 用 go cmd 啟動子程式,子程式還會啟動孫程式,孫程式因某種原因阻塞。此時,如果子程式因超時被 agent kill 殺掉, agent 卻仍然處於 wait 狀態。
復現 case
agent 使用 exec.CommandContext 啟動任務,設定 ctx 超時 30s,並將結果寫到 bytes.Buffer, 最後列印。簡化例子如下:
func main() {
ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*30)
defer cancelFn()
cmd := exec.CommandContext(ctx, "/root/dongzerun/sleep.sh")
var b bytes.Buffer
cmd.Stdout = &b //劇透,坑在這裡
cmd.Stderr = &b
cmd.Start()
cmd.Wait()
fmt.Println("recive: ", b.String())
}
這個是 sleep.sh,模擬子程式
#!/bin/sh
echo "in sleep"
sh /root/dongzerun/sleep1.sh
這是 sleep1.sh 模擬孫程式,sleep 1000 阻塞在這裡
#!/bin/sh
sleep 1000
現象
啟動測試 go 程式,檢視 ps axjf | less
ppid pid pgid
2468 32690 32690 32690 ? -1 Ss 0 0:00 \_ sshd: root@pts/6
32690 32818 32818 32818 pts/6 28746 Ss 0 0:00 | \_ -bash
32818 28531 28531 32818 pts/6 28746 S 0 0:00 | \_ strace ./wait
28531 28543 28531 32818 pts/6 28746 Sl 0 0:00 | | \_ ./wait
28543 28559 28531 32818 pts/6 28746 S 0 0:00 | | \_ /bin/sh /root/dongzerun/sleep.sh
28559 28560 28531 32818 pts/6 28746 S 0 0:00 | | \_ sh /root/dongzerun/sleep1.sh
28560 28563 28531 32818 pts/6 28746 S 0 0:00 | | \_ sleep 1000
等過了 30s,通過 ps axjf | less 檢視
2468 32690 32690 32690 ? -1 Ss 0 0:00 \_ sshd: root@pts/6
32690 32818 32818 32818 pts/6 36192 Ss 0 0:00 | \_ -bash
32818 28531 28531 32818 pts/6 36192 S 0 0:00 | \_ strace ./wait
28531 28543 28531 32818 pts/6 36192 Sl 0 0:00 | | \_ ./wait
1 28560 28531 32818 pts/6 36192 S 0 0:00 sh /root/dongzerun/sleep1.sh
28560 28563 28531 32818 pts/6 36192 S 0 0:00 \_ sleep 1000
通過上面的 case,可以看到 sleep1.sh 成了孤兒程式,被 init 1 認領,但是 28543 wait 並沒有退出,那他在做什麼???
分析
使用 strace 檢視 wait 程式
epoll_ctl(4, EPOLL_CTL_DEL, 6, {0, {u32=0, u64=0}}) = 0
close(6) = 0
futex(0xc420054938, FUTEX_WAKE, 1) = 1
waitid(P_PID, 28559, {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=28559, si_status=SIGKILL, si_utime=0, si_stime=0}, WEXITED|WNOWAIT, NULL) = 0
卡在這裡約 30s
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=28559, si_status=SIGKILL, si_utime=0, si_stime=0} ---
rt_sigreturn() = 0
futex(0x9a0378, FUTEX_WAKE, 1) = 1
futex(0x9a02b0, FUTEX_WAKE, 1) = 1
wait4(28559, [{WIFSIGNALED(s) && WTERMSIG(s) == SIGKILL}], 0, {ru_utime={0, 0}, ru_stime={0, 0}, ...}) = 28559
futex(0x9a0b78, FUTEX_WAIT, 0, NULL
通過 go 原始碼可以看到 go exec wait 時,會先執行 waitid,阻塞在這裡,然後再來一次 wait4 等待最終退出結果。不太明白為什麼兩次 wait... 但是最後卡在了 futex 這裡,看著像在等待什麼資源???
開啟 golang pprof, 再次執行程式,並 pprof
go func() {
err := http.ListenAndServe(":6060", nil)
if err != nil {
fmt.Printf("failed to start pprof monitor:%s", err)
}
}()
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2
goroutine 1 [chan receive]:
os/exec.(*Cmd).Wait(0xc42017a000, 0x7c3d40, 0x0)
/usr/local/go/src/os/exec/exec.go:454 +0x135
main.main()
/root/dongzerun/wait.go:32 +0x167
程式沒有退出,並不可思義的卡在了 exec.go:454 行程式碼,檢視 go1.9.6 原碼:
// Wait releases any resources associated with the Cmd.
func (c *Cmd) Wait() error {
......
state, err := c.Process.Wait()
if c.waitDone != nil {
close(c.waitDone)
}
c.ProcessState = state
var copyError error
for range c.goroutine {
//卡在了這裡
if err := <-c.errch; err != nil && copyError == nil {
copyError = err
}
}
c.closeDescriptors(c.closeAfterWait)
......
return copyError
}
通過原始碼分析,程式 wait 卡在了 <-c.errch 獲取 chan 資料。那麼 errch 是如何生成的呢?
檢視 cmd.Start 原始碼,go 將 cmd.Stdin, cmd.Stdout, cmd.Stderr 組織成 *os.File,並依次寫到陣列 childFiles 中,這個陣列索引就對應子程式的0,1,2 文描術符,即子程式的標準輸入,輸出,錯誤。
type F func(*Cmd) (*os.File, error)
for _, setupFd := range []F{(*Cmd).stdin, (*Cmd).stdout, (*Cmd).stderr} {
fd, err := setupFd(c)
if err != nil {
c.closeDescriptors(c.closeAfterStart)
c.closeDescriptors(c.closeAfterWait)
return err
}
c.childFiles = append(c.childFiles, fd)
}
c.childFiles = append(c.childFiles, c.ExtraFiles...)
var err error
c.Process, err = os.StartProcess(c.Path, c.argv(), &os.ProcAttr{
Dir: c.Dir,
Files: c.childFiles,
Env: dedupEnv(c.envv()),
Sys: c.SysProcAttr,
})
在執行 setupFd 時,會有一個關鍵的操作,開啟 pipe 管道,封裝一個匿名 func,功能就是將子程式的輸出結果寫到 pipe 或是將 pipe 資料寫到子程式標準輸入,最後關閉 pipe. 這個匿名函式最終在 Start 時執行
func (c *Cmd) stdin() (f *os.File, err error) {
if c.Stdin == nil {
f, err = os.Open(os.DevNull)
if err != nil {
return
}
c.closeAfterStart = append(c.closeAfterStart, f)
return
}
if f, ok := c.Stdin.(*os.File); ok {
return f, nil
}
pr, pw, err := os.Pipe()
if err != nil {
return
}
c.closeAfterStart = append(c.closeAfterStart, pr)
c.closeAfterWait = append(c.closeAfterWait, pw)
c.goroutine = append(c.goroutine, func() error {
_, err := io.Copy(pw, c.Stdin)
if skip := skipStdinCopyError; skip != nil && skip(err) {
err = nil
}
if err1 := pw.Close(); err == nil {
err = err1
}
return err
})
return pr, nil
}
重新執行測試 case,並用 lsof 檢視程式開啟了哪些資源
root@nb1963:~/dongzerun# ps aux |grep wait
root 4531 0.0 0.0 122180 6520 pts/6 Sl 17:24 0:00 ./wait
root 4726 0.0 0.0 10484 2144 pts/6 S+ 17:24 0:00 grep --color=auto wait
root@nb1963:~/dongzerun#
root@nb1963:~/dongzerun# ps aux |grep sleep
root 4543 0.0 0.0 4456 688 pts/6 S 17:24 0:00 /bin/sh /root/dongzerun/sleep.sh
root 4548 0.0 0.0 4456 760 pts/6 S 17:24 0:00 sh /root/dongzerun/sleep1.sh
root 4550 0.0 0.0 5928 748 pts/6 S 17:24 0:00 sleep 1000
root 4784 0.0 0.0 10480 2188 pts/6 S+ 17:24 0:00 grep --color=auto sleep
root@nb1963:~/dongzerun#
root@nb1963:~/dongzerun# lsof -p 4531
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
wait 4531 root 0w CHR 1,3 0t0 1029 /dev/null
wait 4531 root 1w REG 8,1 94371 4991345 /root/dongzerun/nohup.out
wait 4531 root 2w REG 8,1 94371 4991345 /root/dongzerun/nohup.out
wait 4531 root 3u IPv6 2005568215 0t0 TCP *:6060 (LISTEN)
wait 4531 root 4u 0000 0,10 0 9076 anon_inode
wait 4531 root 5r FIFO 0,9 0t0 2005473170 pipe
root@nb1963:~/dongzerun# lsof -p 4543
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
sleep.sh 4543 root 0r CHR 1,3 0t0 1029 /dev/null
sleep.sh 4543 root 1w FIFO 0,9 0t0 2005473170 pipe
sleep.sh 4543 root 2w FIFO 0,9 0t0 2005473170 pipe
sleep.sh 4543 root 10r REG 8,1 55 4993949 /root/dongzerun/sleep.sh
root@nb1963:~/dongzerun# lsof -p 4550
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
sleep 4550 root mem REG 8,1 1607664 9179617 /usr/lib/locale/locale-archive
sleep 4550 root 0r CHR 1,3 0t0 1029 /dev/null
sleep 4550 root 1w FIFO 0,9 0t0 2005473170 pipe
sleep 4550 root 2w FIFO 0,9 0t0 2005473170 pipe
原因總結
孫程式,啟動後,預設會繼承父程式開啟的檔案描述符,即 node 2005473170 的 pipe,那麼當父程式被 kill -9 後會清理資源,關閉開啟的檔案,但是 close 只是引用計數減 1。實際上 孫程式 仍然開啟著 pipe。回頭看 agent 程式碼
c.goroutine = append(c.goroutine, func() error {
_, err := io.Copy(pw, c.Stdin)
if skip := skipStdinCopyError; skip != nil && skip(err) {
err = nil
}
if err1 := pw.Close(); err == nil {
err = err1
}
return err
})
那麼當子程式執行結束後,go cmd 執行這個匿名函式的 io.Copy 來讀取子程式輸出資料,永遠沒有資料可讀,也沒有超時,阻塞在 copy 這裡。
解決方案
原因找到了,解決方法也就有了。
- 子程式啟動孫程式時,增加 CloseOnEec 標記,但不現實,還要看孫程式的輸出日誌
- io.Copy 改寫,增加超時呼叫,理論上可行,但是要改源
- 超時 kill 時,不單殺子程式,而是殺掉程式組,此時 pipe 會被真正的關閉,觸發 io.Copy 返回
最終採用方案 3,簡化程式碼如下,主要改動點有兩處:
- SysProcAttr 配置 Setpgid,讓子程式與孫程式,擁有獨立的程式組id,即子程式的 pid
- syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 殺程式時指定程式組
func Run(instance string, env map[string]string) bool {
var (
cmd *exec.Cmd
proc *Process
sysProcAttr *syscall.SysProcAttr
)
t := time.Now()
sysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // 使子程式擁有自己的 pgid,等同於子程式的 pid
Credential: &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
},
}
// 超時控制
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(j.Timeout)*time.Second)
defer cancel()
if j.ShellMode {
cmd = exec.Command("/bin/bash", "-c", j.Command)
} else {
cmd = exec.Command(j.cmd[0], j.cmd[1:]...)
}
cmd.SysProcAttr = sysProcAttr
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b
if err := cmd.Start(); err != nil {
j.Fail(t, instance, fmt.Sprintf("%s\n%s", b.String(), err.Error()), env)
return false
}
waitChan := make(chan struct{}, 1)
defer close(waitChan)
// 超時殺掉程式組 或正常退出
go func() {
select {
case <-ctx.Done():
log.Warnf("timeout kill job %s-%s %s ppid:%d", j.Group, j.ID, j.Name, cmd.Process.Pid)
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
case <-waitChan:
}
}()
if err := cmd.Wait(); err != nil {
j.Fail(t, instance, fmt.Sprintf("%s\n%s", b.String(), err.Error()), env)
return false
}
return true
}
相關文章
- Go的迴圈遍歷使用小坑Go
- go select case的一個小坑Go
- 小程式踩坑日記 (一)
- 使用Labelimg的一點小坑
- [踩坑] Go Modules 使用Go
- Go踩坑筆記(十九)Go筆記
- Taro開發小程式填坑筆記(一)筆記
- Go json 踩坑記錄GoJSON
- vue 踩坑小記 - 如何正確的使用 debounceVue
- Robot Framework 小坑記錄Framework
- 記一次小坑–關於window.open()
- 記HTML5 <a> 標籤的一個小坑HTML
- 記一次小坑--關於window.open()
- 小程式專案之填坑小記
- [系列] Go - json.Unmarshal 遇到的小坑GoJSON
- 小程式實踐小坑小結(一)
- dubbo-go v3 版本 go module 踩坑記Go
- 記一次 Laravel 使用 Redis 踩得坑LaravelRedis
- Taro 小程式 踩坑記錄
- 微信小程式踩坑記錄微信小程式
- 今天記錄一個小程式使用客服的功能遇到的坑,版本庫問題
- 小冊《從 0 到 1 實現天氣小程式》跳坑記一
- 小程式踩坑系列一
- 微信小程式BLE踩坑記錄微信小程式
- prerender-spa-plugin 踩坑小記Plugin
- 小程式使用Picker遇到的坑
- GO語言變數作用域-坑記錄Go變數
- 老前端出坑小程式(一)前端
- 微信小程式踩坑指南【一】微信小程式
- Golang Recover的一個小坑Golang
- 小程式canvas的一個坑Canvas
- 記錄一次用 ThinkPHP 小程式登陸遇到的坑PHP
- 微信小程式 海報生成踩坑記微信小程式
- async語法升級踩坑小記
- Vue.js 升級踩坑小記Vue.js
- Go語言中defer的一些坑Go
- 使用constexpr時遇到的小坑
- 微信小程式與 PHP 7.1 的一點小坑微信小程式PHP