Go Cmd 服务无法退出的小坑

上家公司的案例。先说下使用背景,服务在每台服务器上启动 agent, 用户会在指定机器上执行任务,并将结果返回到网页上。执行任务由用户自定义脚本,一般也都是 shell 或是 python,会不断的产生子进程,孙进程,直到执行完毕或是超时被 kill。 2021-07-07 12:02:25 Go 服务进程 这 14 个短代码,蕴含着丰富的 Python 编程思维 今天给大家带来一些30秒就能学会的代码片段,这些代码潜力无限,蕴含了丰富的python编程思维,应用领域非常广泛,而且学起来非常简单。 2021-07-07 11:42:00 代码Python数组 聊聊Swift 中 key paths 的能力 Swift 不断获取越来越多的更具动态性的功能,同时还一直把它的关注点放在代码的类型安全上。其中的一个特性就是 KeyPath。 2021-07-07 11:41:38 Swift key paths 这个文件下载问题难住了我至少三位同事 今天给大家分享两个比较有用的浏览器行为与预期不一致的现象,这两个问题其实并不是什么难题,但在工作中发现不少人被难住了,,在我的印象中至少有三位同事在群里问这样的问题,上周又有同事被此现象困住了。 2021-07-07 11:15:05 文件前端浏览器 用「最好的语言」PHP,做一个机器学习数据集 如果想构建一个类似人类的 AI 象棋游戏,首要问题就是创建一个数据库,并且该数据库需要尽可能多的包含象棋大师玩游戏的数据。 2021-07-07 11:08:21 机器学习数据集PHP 鲜为人知的 Python 五种高级特征 下面是 Python 的 5 种高级特征,以及它们的用法。一起来看看吧。 2021-07-07 10:59:48 python代码编程语言 为什么把 Dig 迁移到 Wire dig和wire都是Go依赖注入的工具,那么,本质上功能相似的工具,为什么要从dig切换成 wire? 2021-07-07 10:48:00 DigGoWire 详解对象池模式 & 解释器模式 设计模式系列文章之前已经跟大家聊了一大半了,但是都是聊一些比较常见的设计模式,接下来我们主要是聊一些不常见的设计模式。 2021-07-07 10:31:19 对象池模式解释器模式设计模式 分布式事务如何解决?一次讲清楚! 事务指的就是一个操作单元,在这个操作单元中的所有操作最终要保持一致的行为,要么所有操作都成功,要么所有的操作都被撤销。简单地说,事务提供一种“要么什么都不做,要么做全套”机制。 2021-07-07 10:28:09 分布式架构系统 基于微服务的CICD实战 book-web 前端,采用 Vue MVVM,服务端 Thymeleaf SSR 渲染,友好 SEO MPA。服务端 路由,Spring MVC。 2021-07-07 10:21:26 技术 盘点Python字符串常见的16种操作方法 本文详细的讲解了Python基础 ( 字符串 )。介绍了有关字符串,切片的操作。下标索引。以及在实际操作中会遇到的问题,提供了解决方案。希望可以帮助你更好的学习Python。 2021-07-07 10:01:55 PythonPython字符串Python基础 学生的第一门编程语言应该是什么? “学生第一次开始学习计算机科学(computer science,CS)时,应该从哪种编程语言开始学习?”这一问题一直让教育工作者备受困扰。

上家公司的案例。先说下使用背景,服务在每台服务器上启动 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#catwait.go
  2. packagemain
  3. import(
  4. "bytes"
  5. "context"
  6. "fmt"
  7. "os/exec"
  8. "time"
  9. )
  10. funcmain(){
  11. ctx,cancelFn:=context.WithTimeout(context.Background(),time.Second*30)
  12. defercancelFn()
  13. cmd:=exec.CommandContext(ctx,"./sleep.sh")
  14. varbbytes.Buffer
  15. cmd.Stdout=&b//剧透,坑在这里
  16. cmd.Stderr=&b
  17. cmd.Start()
  18. cmd.Wait()
  19. fmt.Println("recive:",b.String())
  20. }

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

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

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

  1. #!/bin/sh
  2. sleep1000

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

  1. ppidpidpgid
  2. 2468326903269032690?-1Ss00:00\_sshd:root@pts/6
  3. 32690328183281832818pts/628746Ss00:00|\_-bash
  4. 32818285312853132818pts/628746S00:00|\_strace./wait
  5. 28531285432853132818pts/628746Sl00:00||\_./wait
  6. 28543285592853132818pts/628746S00:00||\_/bin/sh/root/dongzerun/sleep.sh
  7. 28559285602853132818pts/628746S00:00||\_sh/root/dongzerun/sleep1.sh
  8. 28560285632853132818pts/628746S00:00||\_sleep1000

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

  1. 2468326903269032690?-1Ss00:00\_sshd:root@pts/6
  2. 32690328183281832818pts/636192Ss00:00|\_-bash
  3. 32818285312853132818pts/636192S00:00|\_strace./wait
  4. 28531285432853132818pts/636192Sl00:00||\_./wait
  5. 1285602853132818pts/636192S00:00sh/root/dongzerun/sleep1.sh
  6. 28560285632853132818pts/636192S00:00\_sleep1000

通过上面的 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. gofunc(){
  2. err:=http.ListenAndServe(":6060",nil)
  3. iferr!=nil{
  4. fmt.Printf("failedtostartpprofmonitor:%s",err)
  5. }
  6. }()
  1. curlhttp://127.0.0.1:6060/debug/pprof/goroutine?debug=2
  2. goroutine1[chanreceive]:
  3. os/exec.(*Cmd).Wait(0xc42017a000,0x7c3d40,0x0)
  4. /usr/local/go/src/os/exec/exec.go:454+0x135
  5. main.main()
  6. /root/dongzerun/wait.go:32+0x167

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

  1. //WaitreleasesanyresourcesassociatedwiththeCmd.
  2. func(c*Cmd)Wait()error{
  3. ......
  4. state,err:=c.Process.Wait()
  5. ifc.waitDone!=nil{
  6. close(c.waitDone)
  7. }
  8. c.ProcessState=state
  9. varcopyErrorerror
  10. forrangec.goroutine{
  11. //卡在了这里
  12. iferr:=<-c.errch;err!=nil&&copyError==nil{
  13. copyError=err
  14. }
  15. }
  16. c.closeDescriptors(c.closeAfterWait)
  17. ......
  18. returncopyError
  19. }

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

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

  1. typeFfunc(*Cmd)(*os.File,error)
  2. for_,setupFd:=range[]F{(*Cmd).stdin,(*Cmd).stdout,(*Cmd).stderr}{
  3. fd,err:=setupFd(c)
  4. iferr!=nil{
  5. c.closeDescriptors(c.closeAfterStart)
  6. c.closeDescriptors(c.closeAfterWait)
  7. returnerr
  8. }
  9. c.childFiles=append(c.childFiles,fd)
  10. }
  11. c.childFiles=append(c.childFiles,c.ExtraFiles...)
  12. varerrerror
  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,errerror){
  2. ifc.Stdin==nil{
  3. f,err=os.Open(os.DevNull)
  4. iferr!=nil{
  5. return
  6. }
  7. c.closeAfterStart=append(c.closeAfterStart,f)
  8. return
  9. }
  10. iff,ok:=c.Stdin.(*os.File);ok{
  11. returnf,nil
  12. }
  13. pr,pw,err:=os.Pipe()
  14. iferr!=nil{
  15. return
  16. }
  17. c.closeAfterStart=append(c.closeAfterStart,pr)
  18. c.closeAfterWait=append(c.closeAfterWait,pw)
  19. c.goroutine=append(c.goroutine,func()error{
  20. _,err:=io.Copy(pw,c.Stdin)
  21. ifskip:=skipStdinCopyError;skip!=nil&&skip(err){
  22. err=nil
  23. }
  24. iferr1:=pw.Close();err==nil{
  25. err=err1
  26. }
  27. returnerr
  28. })
  29. returnpr,nil
  30. }

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

  1. root@nb1963:~/dongzerun#psaux|grepwait
  2. root45310.00.01221806520pts/6Sl17:240:00./wait
  3. root47260.00.0104842144pts/6S+17:240:00grep--color=autowait
  4. root@nb1963:~/dongzerun#
  5. root@nb1963:~/dongzerun#psaux|grepsleep
  6. root45430.00.04456688pts/6S17:240:00/bin/sh/root/dongzerun/sleep.sh
  7. root45480.00.04456760pts/6S17:240:00sh/root/dongzerun/sleep1.sh
  8. root45500.00.05928748pts/6S17:240:00sleep1000
  9. root47840.00.0104802188pts/6S+17:240:00grep--color=autosleep
  10. root@nb1963:~/dongzerun#
  11. root@nb1963:~/dongzerun#lsof-p4531
  12. COMMANDPIDUSERFDTYPEDEVICESIZE/OFFNODENAME
  13. wait4531root0wCHR1,30t01029/dev/null
  14. wait4531root1wREG8,1943714991345/root/dongzerun/nohup.out
  15. wait4531root2wREG8,1943714991345/root/dongzerun/nohup.out
  16. wait4531root3uIPv620055682150t0TCP*:6060(LISTEN)
  17. wait4531root4u00000,1009076anon_inode
  18. wait4531root5rFIFO0,90t02005473170pipe
  19. root@nb1963:~/dongzerun#lsof-p4543
  20. COMMANDPIDUSERFDTYPEDEVICESIZE/OFFNODENAME
  21. sleep.sh4543root0rCHR1,30t01029/dev/null
  22. sleep.sh4543root1wFIFO0,90t02005473170pipe
  23. sleep.sh4543root2wFIFO0,90t02005473170pipe
  24. sleep.sh4543root10rREG8,1554993949/root/dongzerun/sleep.sh
  25. root@nb1963:~/dongzerun#lsof-p4550
  26. COMMANDPIDUSERFDTYPEDEVICESIZE/OFFNODENAME
  27. sleep4550rootmemREG8,116076649179617/usr/lib/locale/locale-archive
  28. sleep4550root0rCHR1,30t01029/dev/null
  29. sleep4550root1wFIFO0,90t02005473170pipe
  30. sleep4550root2wFIFO0,90t02005473170pipe

原因总结

孙进程启动后,默认会继承父进程打开的文件描述符,即 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. ifskip:=skipStdinCopyError;skip!=nil&&skip(err){
  4. err=nil
  5. }
  6. iferr1:=pw.Close();err==nil{
  7. err=err1
  8. }
  9. returnerr
  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. funcRun(instancestring,envmap[string]string)bool{
  2. var(
  3. cmd*exec.Cmd
  4. proc*Process
  5. sysProcAttr*syscall.SysProcAttr
  6. )
  7. t:=time.Now()
  8. sysProcAttr=&syscall.SysProcAttr{
  9. Setpgid:true,//使子进程拥有自己的pgid,等同于子进程的pid
  10. Credential:&syscall.Credential{
  11. Uid:uint32(uid),
  12. Gid:uint32(gid),
  13. },
  14. }
  15. //超时控制
  16. ctx,cancel:=context.WithTimeout(context.Background(),time.Duration(j.Timeout)*time.Second)
  17. defercancel()
  18. ifj.ShellMode{
  19. cmd=exec.Command("/bin/bash","-c",j.Command)
  20. }else{
  21. cmd=exec.Command(j.cmd[0],j.cmd[1:]...)
  22. }
  23. cmd.SysProcAttr=sysProcAttr
  24. varbbytes.Buffer
  25. cmd.Stdout=&b
  26. cmd.Stderr=&b
  27. iferr:=cmd.Start();err!=nil{
  28. j.Fail(t,instance,fmt.Sprintf("%s\n%s",b.String(),err.Error()),env)
  29. returnfalse
  30. }
  31. waitChan:=make(chanstruct{},1)
  32. deferclose(waitChan)
  33. //超时杀掉进程组或正常退出
  34. gofunc(){
  35. select{
  36. case<-ctx.Done():
  37. log.Warnf("timeoutkilljob%s-%s%sppid:%d",j.Group,j.ID,j.Name,cmd.Process.Pid)
  38. syscall.Kill(-cmd.Process.Pid,syscall.SIGKILL)
  39. case<-waitChan:
  40. }
  41. }()
  42. iferr:=cmd.Wait();err!=nil{
  43. j.Fail(t,instance,fmt.Sprintf("%s\n%s",b.String(),err.Error()),env)
  44. returnfalse
  45. }
  46. returntrue
  47. }

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

小结

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

©本文为清一色官方代发,观点仅代表作者本人,与清一色无关。清一色对文中陈述、观点判断保持中立,不对所包含内容的准确性、可靠性或完整性提供任何明示或暗示的保证。本文不作为投资理财建议,请读者仅作参考,并请自行承担全部责任。文中部分文字/图片/视频/音频等来源于网络,如侵犯到著作权人的权利,请与我们联系(微信/QQ:1074760229)。转载请注明出处:清一色财经

(0)
打赏 微信扫码打赏 微信扫码打赏 支付宝扫码打赏 支付宝扫码打赏
清一色的头像清一色管理团队
上一篇 2023年5月5日 17:41
下一篇 2023年5月5日 17:41

相关推荐

发表评论

登录后才能评论

联系我们

在线咨询:1643011589-QQbutton

手机:13798586780

QQ/微信:1074760229

QQ群:551893940

工作时间:工作日9:00-18:00,节假日休息

关注微信