Go语言中Kill子进程的正确姿势
场景
我们在编写部署系统的时候,通常需要在机器上部署一个agent,用来执行部署脚本,为了防止部署脚本写的有问题,长时间hang住,我们通常会为脚本的执行设置一个超时时间,到了时间之后就kill掉该脚本的进程。如果是Go语言实现,脑袋里应该立马浮现出
os/exec
包、cmd.Process.Kill()
这样的手段。但是,如果部署脚本中又调用了其他脚本,即子进程又fork出更多子进程的时候,这招就不好使了。
简单来说,就是
cmd.Process.Kill()
无法杀死子进程。
知识储备
pstree -g #查看进程树和每个进程的PGID
问题验证
下面我们写段代码来简单验证一下
一般情况下:
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
cmd := exec.Command("sleep", "5") //睡眠5s
start := time.Now() //记录启动时间
time.AfterFunc(3*time.Second, func() { cmd.Process.Kill() }) //3s后将此进程杀死
err := cmd.Run() //运行该命令
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err) //输出进程ID、运行时间、错误
}
打开终端查看:发现进程能被kill掉
root@flight:~$ ps au
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
root 1804 0.0 0.0 4278124 996 s000 R+ 4:10下午 0:00.00 ps au
didi 1158 0.0 0.0 4296892 1284 s000 S 4:08下午 0:00.03 -bash
didi 1798 0.0 0.0 4270348 564 s001 S+ 4:10下午 0:00.00 sleep 5
root@flight:~$ ps au
USER PID %CPU %MEM VSZ RSS TT STAT STARTED TIME COMMAND
root 1819 0.0 0.0 4268908 972 s000 R+ 4:10下午 0:00.00 ps au
didi 1158 0.0 0.0 4296892 1300 s000 S 4:08下午 0:00.03 -bash
root@flight:~$
当进程fork出子进程:
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
cmd := exec.Command("/bin/sh", "-c", "watch date > date.txt") //watch进程fork出了其他子进程
start := time.Now() //记录启动时间
time.AfterFunc(3*time.Second, func() { cmd.Process.Kill() }) //3s后将此进程杀死
err := cmd.Run() //运行该命令
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err) //输出进程ID、运行时间、错误
}
输出结果:发现同样运行了3s被kill
[root@localhost ~]# go run test.go
pid=16860 duration=3.001284491s err=signal: killed #同样运行了3s被kill
[root@localhost ~]#
但是查看用户进程会发现不一样:
[root@localhost ~]# ps -af
UID PID PPID C STIME TTY TIME CMD
root 2409 2277 0 17:07 pts/1 00:00:01 top
root 5118 4953 0 17:09 pts/3 00:00:00 top
root 16804 2269 14 17:14 pts/0 00:00:00 go run test.go
root 16855 16804 0 17:14 pts/0 00:00:00 /tmp/go-build276131739/b001/exe/test
root 16860 16855 0 17:14 pts/0 00:00:00 /bin/sh -c watch date > date.txt
root 16861 16860 0 17:14 pts/0 00:00:00 watch date
root 16954 4919 0 17:14 pts/2 00:00:00 ps -af
[root@localhost ~]#
[root@localhost ~]#
[root@localhost ~]# ps -af
UID PID PPID C STIME TTY TIME CMD
root 2409 2277 0 17:07 pts/1 00:00:01 top
root 5118 4953 0 17:09 pts/3 00:00:00 top
root 16861 1 0 17:14 pts/0 00:00:00 watch date
root 17169 4919 0 17:14 pts/2 00:00:00 ps -af
现象:
- 程序运行3s后
/bin/sh -c watch date > date.txt
被kill,但是watch date
依然存在。 watch date
的父进程是1号进程。
问题原因
Go是使用kill(2)向sh进程的PID发了一个KILL信号,但没有发给watch进程,sh进程被kill之后,导致watch进程变成孤儿进程。
解决方案
kill(2)不但支持向单个PID发送信号,还可以向进程组发信号,我们只要把sh进程及其所有子进程放到一个进程组里,就可以批量Kill了。关键是PGID的设置,默认情况下,子进程会把自己的PGID设置成与父进程相同,所以,我们只要设置了sh进程的PGID,所有子进程也就相应的有了PGID。
注意:传递进程组PGID的时候要使用负数的形式。
注意:下面这种方式适合非su - <user> command
执行命令的方式,否则杀死父进程后,子进程将被托管成为孤儿进程。因为他们的PGID不一样,即使设置了cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
也没有用。正确的方式是采用exec包自带的方式来指定执行用户
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("/bin/sh", "-c", "watch date > date.txt")
// Go会将PGID设置成与PID相同的值
cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.SysProcAttr.Setpgid=true
start := time.Now()
time.AfterFunc(3*time.Second, func() { syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) //想要杀死整个进程组,而不是单个进程,需要传递负数形式。
err := cmd.Run()
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err)
}
如果想要指定用户:
sysuser, err := user.Lookup("user1") // 通过用户名来获取用户信息
if err != nil {
fmt.Println(err)
}
uid, err := strconv.Atoi(sysuser.Uid) // 将UID的类型转换成 uint32
if err != nil {
fmt.Println(err)
}
gid, err := strconv.Atoi(sysuser.Gid) // 将GID的类型转换成 uint32
if err != nil {
fmt.Println(err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{}
cmd.SysProcAttr.Credential = &syscall.Credential{
Uid: uint32(uid),
Gid: uint32(gid),
}