golang并发ssh执行远程命令

2018-02-01  本文已影响142人  yiduyangyi

需求

在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

上一篇下一篇

猜你喜欢

热点阅读