golang并发ssh执行远程命令
需求
在kubernetes/docker容器化应用中,业务应用由大量容器组成,由于生产环境中出于安全考虑,一般不会允许用户直接登入集群机器,然后登入机器上的容器。况且数量之多,也没有效率。因此设计了一个命令行工具,以权限受控的账号ssh远程连接到容器所在宿主机,然后docker exec到容器内执行命令。而且该过程必须能够批量化的进行。
实现
下面是并发执行远程ssh命令的核心实现
jobs := make(chan *model.Command, len(instanceList))
results := make(chan *model.CommandResult, len(instanceList))
// 开启多个goroutine去远程登入容器,执行命令
for e := 1; e <= parallelism; e++ {
go service.Executor(e, jobs, results)
}
for _, ins := range instanceList {
jobs <- &model.Command{
Host: ins.Host,
ContainerId: ins.ContainerId,
Command: cmd,
}
}
close(jobs)
failCount := 0
size := len(instanceList)
for j := 1; j <= size; j++ {
rst := <-results
success := "Success"
if rst.CmdError != nil {
success = "Fail"
failCount++
}
fmt.Printf("[%d/%d] - [%s]\t", j, size, success)
fmt.Printf("Host = %s, ContainerId = %s, rst.Host, rst.ContainerId)
fmt.Println(rst.Output)
if rst.CmdError != nil {
if ee, ok := rst.CmdError.(*exec.ExitError); ok {
waitStatus := ee.Sys().(syscall.WaitStatus)
fmt.Printf("%d\n", waitStatus.ExitStatus())
}
fmt.Printf("%s\n", rst.CmdError.Error())
}
}
//结果汇总
fmt.Printf("[INFO] Total = %d, Success = %d, Fail = %d", size, size-failCount, failCount)
下面是service.Executor的关键代码
func Executor(jobs <-chan *model.Command, jobResults chan<- *model.CommandResult) {
for job := range jobs {
out, err := ExecuteCommandInContainer(job.Host, job.ContainerId, job.Command)
jobResults <- &model.CommandResult{
CmdError: err,
ContainerId: job.ContainerId,
Host: job.Host,
Output: out,
}
}
}
// 登录容器,执行一个具体的命令
func ExecuteCommandInContainer(host string, containerId string, command string) (out string, err error) {
err = AddRsafile()
if err != nil {
return
}
homeDir := os.Getenv("HOME")
dockerHost := fmt.Sprintf(`rd@%s`, host)
containerLoginCmd := fmt.Sprintf("sudo docker exec -it -u rd %s bash -c \"%s\"", containerId, command)
cmd := exec.Command("ssh", "-i", homeDir+"/.ssh/.id_rsa",
"-oUserKnownHostsFile=/dev/null", "-oStrictHostKeyChecking=no",
"-t", "-t", dockerHost, containerLoginCmd)
cmd.Stdin = os.Stdin
b, err := cmd.Output()
if err != nil {
return
}
out = string(b)
return
}
问题
上述代码,编译成二进制可执行文件后,在shell终端里执行,当并发度大于1时,终端会被打乱,同时执行完了之后,终端已经假死,必须reset才能继续使用。但是,放到crontab里执行时,并无该问题,这是为什么?
追踪
初步怀疑是ssh并发写终端stdout问题,但是代码中明明是串行写的。于是去查ssh相关参数的用法。
注意到,上面ssh命令,带有2个-t参数,这是做什么的?参见ssh的帮助
-T Disable pseudo-tty allocation.
-t Force pseudo-tty allocation. This can be used to execute arbitrary screen-based programs on a remote machine, which can be very useful, e.g., when implementing menu services. Multiple -t options force tty allocation,
even if ssh has no local tty.
两个-t是强制ssh分配tty,尝试去掉一个,我们发现,在命令行里执行并没有什么问题,但是在crontab里就有问题了,会提示
Pseudo-terminal will not be allocated because stdin is not a terminal.
首先crontab是非登录式shell的环境,分配伪终端时,无法将stdin分配为一个terminal,也就是上面提示的含义。使用2个-t,强制分配。完美解决了crontab里无法正确执行的问题。但是并发执行是什么问题呢?
受到这个启发,由初期怀疑是并发写到控制台导致的,转入怀疑是多个ssh的线程公用了同一个stdin导致的,因为上述代码中,设定了cmd.Stdin = os.Stdin,于是将cmd.Stdin = nil, 本来这个工具也无需输入,调整之后,并发执行问题完美解决。
参考
有关如何在golang中执行shell命令,可参考这篇文章 Shelled-out Commands In Golang