Go Cmd 服务无法退出的小坑

开发 后端
上家公司的案例。先说下使用背景,服务在每台服务器上启动 agent, 用户会在指定机器上执行任务,并将结果返回到网页上。执行任务由用户自定义脚本,一般也都是 shell 或是 python,会不断的产生子进程,孙进程,直到执行完毕或是超时被 kill。

[[409900]]

本文转载自微信公众号「董泽润的技术笔记」,作者董泽润。转载本文请联系董泽润的技术笔记公众号。

上家公司的案例。先说下使用背景,服务在每台服务器上启动 agent, 用户会在指定机器上执行任务,并将结果返回到网页上。执行任务由用户自定义脚本,一般也都是 shell 或是 python,会不断的产生子进程,孙进程,直到执行完毕或是超时被 kill。

问题

最近发现经常有任务,一直处于运行中,但实际上己经超时被 kill,并未将输出写到系统,看不到任务的执行情况

登录机器,发现执行脚本进程己经杀掉,但是有子脚本卡在某个 http 调用。再看下这个脚本,python requests 默认没有设置超时...

总结一下现象:agent 用 go cmd 启动子进程,子进程还会启动孙进程,孙进程因某种原因阻塞。此时,如果子进程因超时被 agent kill 杀掉, agent 却仍然处于 wait 状态

复现

环境是 go version go1.16.5 linux/amd64, agent 使用 exec.CommandContext 启动任务,设置 ctx 超时 30s,并将结果写到 bytes.Buffer, 最后打印。简化例子如下:

  1. ~/zerun.dong/code/gotest# cat wait.go 
  2. package main 
  3.  
  4. import ( 
  5.     "bytes" 
  6.     "context" 
  7.     "fmt" 
  8.     "os/exec" 
  9.     "time" 
  10.  
  11. func main() { 
  12.     ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*30) 
  13.     defer cancelFn() 
  14.     cmd := exec.CommandContext(ctx, "./sleep.sh"
  15.     var b bytes.Buffer 
  16.     cmd.Stdout = &b //剧透,坑在这里 
  17.     cmd.Stderr = &b 
  18.     cmd.Start() 
  19.     cmd.Wait() 
  20.     fmt.Println("recive: ", b.String()) 

这个是 sleep.sh,模拟子进程

  1. #!/bin/sh 
  2. echo "in sleep" 
  3. sh ./sleep1.sh 

这是 sleep1.sh 模拟孙进程,sleep 1000 阻塞在这里

  1. #!/bin/sh 
  2. sleep 1000 

###现象 启动测试 wait 程序,查看 ps axjf | less查看

  1. ppid  pid   pgid 
  2.  2468 32690 32690 32690 ?           -1 Ss       0   0:00  \_ sshd: root@pts/6 
  3. 32690 32818 32818 32818 pts/6    28746 Ss       0   0:00  |   \_ -bash 
  4. 32818 28531 28531 32818 pts/6    28746 S        0   0:00  |       \_ strace ./wait 
  5. 28531 28543 28531 32818 pts/6    28746 Sl       0   0:00  |       |   \_ ./wait 
  6. 28543 28559 28531 32818 pts/6    28746 S        0   0:00  |       |       \_ /bin/sh /root/dongzerun/sleep.sh 
  7. 28559 28560 28531 32818 pts/6    28746 S        0   0:00  |       |           \_ sh /root/dongzerun/sleep1.sh 
  8. 28560 28563 28531 32818 pts/6    28746 S        0   0:00  |       |               \_ sleep 1000 

等过了 30s,通过 ps axjf | less 查看

  1.  2468 32690 32690 32690 ?           -1 Ss       0   0:00  \_ sshd: root@pts/6 
  2. 32690 32818 32818 32818 pts/6    36192 Ss       0   0:00  |   \_ -bash 
  3. 32818 28531 28531 32818 pts/6    36192 S        0   0:00  |       \_ strace ./wait 
  4. 28531 28543 28531 32818 pts/6    36192 Sl       0   0:00  |       |   \_ ./wait 
  5.     1 28560 28531 32818 pts/6    36192 S        0   0:00 sh /root/dongzerun/sleep1.sh 
  6. 28560 28563 28531 32818 pts/6    36192 S        0   0:00  \_ sleep 1000 

通过上面的 case,可以看到 sleep1.sh 成了孤儿进程,被 init 1 认领,但是 28543 wait 并没有退出,那他在做什么???

分析

这个时候僵住了,祭出我们的 strace 大法,查看 wait 程序

  1. epoll_ctl(4, EPOLL_CTL_DEL, 6, {0, {u32=0, u64=0}}) = 0 
  2. close(6)                                = 0 
  3. futex(0xc420054938, FUTEX_WAKE, 1)      = 1 
  4. 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 
  5. 卡在这里约 30s 
  6. --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_KILLED, si_pid=28559, si_status=SIGKILL, si_utime=0, si_stime=0} --- 
  7. rt_sigreturn()                          = 0 
  8. futex(0x9a0378, FUTEX_WAKE, 1)          = 1 
  9. futex(0x9a02b0, FUTEX_WAKE, 1)          = 1 
  10. wait4(28559, [{WIFSIGNALED(s) && WTERMSIG(s) == SIGKILL}], 0, {ru_utime={0, 0}, ru_stime={0, 0}, ...}) = 28559 
  11. futex(0x9a0b78, FUTEX_WAIT, 0, NULL 

通过 go 源码可以看到 go exec wait 时,会先执行 waitid, 阻塞在这里,然后再来一次 wait4 等待最终退出结果

不太明白为什么两次 wait... 但是最后卡在了 futex 这里,看着像在等待什么资源???

打开 golang pprof, 再次运行程序,并 pprof

  1. go func() { 
  2.  err := http.ListenAndServe(":6060", nil) 
  3.  if err != nil { 
  4.   fmt.Printf("failed to start pprof monitor:%s", err) 
  5.  } 
  6. }() 
  1. curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 
  2.  
  3. goroutine 1 [chan receive]: 
  4. os/exec.(*Cmd).Wait(0xc42017a000, 0x7c3d40, 0x0) 
  5.  /usr/local/go/src/os/exec/exec.go:454 +0x135 
  6. main.main() 
  7.  /root/dongzerun/wait.go:32 +0x167 

程序没有退出,并不可思议的卡在了 exec.go:454 行代码,查看源码:

  1. // Wait releases any resources associated with the Cmd. 
  2. func (c *Cmd) Wait() error { 
  3.       ...... 
  4.  state, err := c.Process.Wait() 
  5.  if c.waitDone != nil { 
  6.   close(c.waitDone) 
  7.  } 
  8.  c.ProcessState = state 
  9.  
  10.  var copyError error 
  11.  for range c.goroutine { 
  12.         //卡在了这里 
  13.   if err := <-c.errch; err != nil && copyError == nil { 
  14.    copyError = err 
  15.   } 
  16.  } 
  17.  
  18.  c.closeDescriptors(c.closeAfterWait) 
  19.     ...... 
  20.  return copyError 

通过源代码分析,程序 wait 卡在了 <-c.errch 获取 chan 数据。那么 errch 是如何生成的呢?

查看 cmd.Start 源码,go 将 cmd.Stdin, cmd.Stdout, cmd.Stderr 组织成 *os.File,并依次写到数组childFiles 中,这个数组索引就对应子进程的 0,1,2 文描术符,即子进程的标准输入,输出,错误

  1. type F func(*Cmd) (*os.File, error) 
  2. for _, setupFd := range []F{(*Cmd).stdin, (*Cmd).stdout, (*Cmd).stderr} { 
  3.  fd, err := setupFd(c) 
  4.  if err != nil { 
  5.   c.closeDescriptors(c.closeAfterStart) 
  6.   c.closeDescriptors(c.closeAfterWait) 
  7.   return err 
  8.  } 
  9.  c.childFiles = append(c.childFiles, fd) 
  10. c.childFiles = append(c.childFiles, c.ExtraFiles...) 
  11.  
  12. var err error 
  13. c.Process, err = os.StartProcess(c.Path, c.argv(), &os.ProcAttr{ 
  14.  Dir:   c.Dir, 
  15.  Files: c.childFiles, 
  16.  Env:   dedupEnv(c.envv()), 
  17.  Sys:   c.SysProcAttr, 
  18. }) 

在执行 setupFd 时,会有一个关键的操作,打开 pipe 管道,封装一个匿名 func,功能就是将子进程的输出结果写到 pipe 或是将 pipe 数据写到子进程标准输入,最后关闭 pipe

这个匿名函数最终在 Start 时执行

  1. func (c *Cmd) stdin() (f *os.File, err error) { 
  2.  if c.Stdin == nil { 
  3.   f, err = os.Open(os.DevNull) 
  4.   if err != nil { 
  5.    return 
  6.   } 
  7.   c.closeAfterStart = append(c.closeAfterStart, f) 
  8.   return 
  9.  } 
  10.  
  11.  if f, ok := c.Stdin.(*os.File); ok { 
  12.   return f, nil 
  13.  } 
  14.  
  15.  pr, pw, err := os.Pipe() 
  16.  if err != nil { 
  17.   return 
  18.  } 
  19.  
  20.  c.closeAfterStart = append(c.closeAfterStart, pr) 
  21.  c.closeAfterWait = append(c.closeAfterWait, pw) 
  22.  c.goroutine = append(c.goroutine, func() error { 
  23.   _, err := io.Copy(pw, c.Stdin) 
  24.   if skip := skipStdinCopyError; skip != nil && skip(err) { 
  25.    err = nil 
  26.   } 
  27.   if err1 := pw.Close(); err == nil { 
  28.    err = err1 
  29.   } 
  30.   return err 
  31.  }) 
  32.  return pr, nil 

重新运行测试 case,并用 lsof 查看进程打开了哪些资源

  1. root@nb1963:~/dongzerun# ps aux |grep wait 
  2. root      4531  0.0  0.0 122180  6520 pts/6    Sl   17:24   0:00 ./wait 
  3. root      4726  0.0  0.0  10484  2144 pts/6    S+   17:24   0:00 grep --color=auto wait 
  4. root@nb1963:~/dongzerun# 
  5. root@nb1963:~/dongzerun# ps aux |grep sleep 
  6. root      4543  0.0  0.0   4456   688 pts/6    S    17:24   0:00 /bin/sh /root/dongzerun/sleep.sh 
  7. root      4548  0.0  0.0   4456   760 pts/6    S    17:24   0:00 sh /root/dongzerun/sleep1.sh 
  8. root      4550  0.0  0.0   5928   748 pts/6    S    17:24   0:00 sleep 1000 
  9. root      4784  0.0  0.0  10480  2188 pts/6    S+   17:24   0:00 grep --color=auto sleep 
  10. root@nb1963:~/dongzerun# 
  11. root@nb1963:~/dongzerun# lsof -p 4531 
  12. COMMAND  PID USER   FD   TYPE     DEVICE SIZE/OFF       NODE NAME 
  13. wait    4531 root    0w   CHR        1,3      0t0       1029 /dev/null 
  14. wait    4531 root    1w   REG        8,1    94371    4991345 /root/dongzerun/nohup.out 
  15. wait    4531 root    2w   REG        8,1    94371    4991345 /root/dongzerun/nohup.out 
  16. wait    4531 root    3u  IPv6 2005568215      0t0        TCP *:6060 (LISTEN) 
  17. wait    4531 root    4u  0000       0,10        0       9076 anon_inode 
  18. wait    4531 root    5r  FIFO        0,9      0t0 2005473170 pipe 
  19. root@nb1963:~/dongzerun# lsof -p 4543 
  20. COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF       NODE NAME 
  21. sleep.sh 4543 root    0r   CHR    1,3      0t0       1029 /dev/null 
  22. sleep.sh 4543 root    1w  FIFO    0,9      0t0 2005473170 pipe 
  23. sleep.sh 4543 root    2w  FIFO    0,9      0t0 2005473170 pipe 
  24. sleep.sh 4543 root   10r   REG    8,1       55    4993949 /root/dongzerun/sleep.sh 
  25. root@nb1963:~/dongzerun# lsof -p 4550 
  26. COMMAND  PID USER   FD   TYPE DEVICE SIZE/OFF       NODE NAME 
  27. sleep   4550 root  mem    REG    8,1  1607664    9179617 /usr/lib/locale/locale-archive 
  28. sleep   4550 root    0r   CHR    1,3      0t0       1029 /dev/null 
  29. sleep   4550 root    1w  FIFO    0,9      0t0 2005473170 pipe 
  30. sleep   4550 root    2w  FIFO    0,9      0t0 2005473170 pipe 

原因总结

孙进程启动后,默认会继承父进程打开的文件描述符,即 node 2005473170 的 pipe

那么当父进程被 kill -9 后会清理资源,关闭打开的文件,但是 close 只是引用计数减 1。实际上 孙进程 仍然打开着 pipe。回头看 agent 代码

  1. c.goroutine = append(c.goroutine, func() error { 
  2.  _, err := io.Copy(pw, c.Stdin) 
  3.  if skip := skipStdinCopyError; skip != nil && skip(err) { 
  4.   err = nil 
  5.  } 
  6.  if err1 := pw.Close(); err == nil { 
  7.   err = err1 
  8.  } 
  9.  return err 
  10. }) 

那么当子进程执行结束后,go cmd 执行这个匿名函数的 io.Copy 来读取子进程输出数据,永远没有数据可读,也没有超时,阻塞在 copy 这里

解决方案

原因找到了,解决方法也就有了。

  1. 子进程启动孙进程时,增加 CloseOnEec 标记,但不现实,还要看孙进程的输出日志
  2. io.Copy 改写,增加超时调用,理论上可行,但是要改源码
  3. 超时 kill, 不单杀子进程,而是杀掉进程组,此时 pipe 会被真正的关闭,触发 io.Copy 返回

最终采用方案 3,简化代码如下,主要改动点有两处:

SysProcAttr 配置 Setpgid,让子进程与孙进程,拥有独立的进程组id,即子进程的 pid

Syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 杀进程时指定进程组

  1. func Run(instance string, env map[string]string) bool { 
  2.  var ( 
  3.   cmd         *exec.Cmd 
  4.   proc        *Process 
  5.   sysProcAttr *syscall.SysProcAttr 
  6.  ) 
  7.  
  8.  t := time.Now() 
  9.  sysProcAttr = &syscall.SysProcAttr{ 
  10.   Setpgid: true, // 使子进程拥有自己的 pgid,等同于子进程的 pid 
  11.   Credential: &syscall.Credential{ 
  12.    Uid: uint32(uid), 
  13.    Gid: uint32(gid), 
  14.   }, 
  15.  } 
  16.  
  17.  // 超时控制 
  18.  ctx, cancel := context.WithTimeout(context.Background(), time.Duration(j.Timeout)*time.Second
  19.  defer cancel() 
  20.  
  21.  if j.ShellMode { 
  22.   cmd = exec.Command("/bin/bash""-c", j.Command) 
  23.  } else { 
  24.   cmd = exec.Command(j.cmd[0], j.cmd[1:]...) 
  25.  } 
  26.  
  27.  cmd.SysProcAttr = sysProcAttr 
  28.  var b bytes.Buffer 
  29.  cmd.Stdout = &b 
  30.  cmd.Stderr = &b 
  31.  
  32.  if err := cmd.Start(); err != nil { 
  33.   j.Fail(t, instance, fmt.Sprintf("%s\n%s", b.String(), err.Error()), env) 
  34.   return false 
  35.  } 
  36.  
  37.  waitChan := make(chan struct{}, 1) 
  38.  defer close(waitChan) 
  39.  
  40.  // 超时杀掉进程组 或正常退出 
  41.  go func() { 
  42.   select { 
  43.   case <-ctx.Done(): 
  44.    log.Warnf("timeout kill job %s-%s %s ppid:%d", j.Group, j.ID, j.Name, cmd.Process.Pid) 
  45.    syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 
  46.   case <-waitChan: 
  47.   } 
  48.  }() 
  49.  
  50.  if err := cmd.Wait(); err != nil { 
  51.   j.Fail(t, instance, fmt.Sprintf("%s\n%s", b.String(), err.Error()), env) 
  52.   return false 
  53.  } 
  54.  return true 

但这种方式,也有个局限,目前只适用于类 linux 平台

小结 

大家也可以看到,只要权限足够,问题稳定复现,没有查不出来的问题。套路也都差不多,回归问题开始,python request 库不写 timeout 的比比皆是 ...

 

责任编辑:武晓燕 来源: 董泽润的技术笔记
相关推荐

2021-06-07 23:51:16

MacGo服务

2022-07-31 23:05:55

Go语言短变量

2021-10-28 19:10:02

Go语言编码

2023-04-12 08:18:40

ChatGLM避坑微调模型

2022-01-03 20:13:08

Gointerface 面试

2022-08-08 08:31:55

Go 语言闭包匿名函数

2016-12-28 13:19:08

Android开发坑和小技巧

2022-08-08 06:50:06

Go语言闭包

2012-02-09 09:52:39

服务器节能

2021-03-16 08:56:35

Go interface面试

2023-03-13 13:36:00

Go扩容切片

2021-01-26 00:46:40

微服务架构微服务应用

2017-03-31 10:27:08

推送服务移动

2021-10-18 21:41:10

Go程序员 Defer

2024-04-01 08:05:27

Go开发Java

2022-05-19 08:56:13

Go提案赋值

2022-11-02 08:55:43

Gofor 循环存储

2023-03-06 07:50:19

内存回收Go

2024-12-03 15:59:56

2024-09-20 06:00:32

点赞
收藏

51CTO技术栈公众号