docker exec实现原理
在使用Docker部署应用以及容器数据卷Volume中,已经了解了Docker的基本操作。其中有一个很神奇的操作,即docker exec
,这个命令允许我们从外部进入一个容器中。本文主要剖析这个命令背后的原理,借此回顾Linux Namespace的一些实现原理。
(1)通过如下命令启动一个容器
root@ubuntu:~# docker run -d kkbill/helloworld:v1.0
664562841f30f29c04577763e09ac6db393afde9bf435c5d30c11ce654e8eb8b
可以看到,该容器正在正常运行
root@ubuntu:~# docker ps
CONTAINER ID IMAGE COMMAND ... PORTS NAMES
664562841f30 kkbill/helloworld:v1.0 "python app.py" ... 80/tcp zen_mahavira
(2)可以通过如下指令得到当前容器进程对应的 PID
root@ubuntu:~# docker inspect 664562841f30
[
{
...
"State": {
...
"Pid": 10444,
...
},
...
或者,加上--format
参数:
root@ubuntu:~# docker inspect --format '{{ .State.Pid }}' 664562841f30
10444
(3)这时,可以通过查看宿主机的 proc 文件,看到这个 10444 进程的所有 Namespace 对应的文件:
root@ubuntu:~# ls -l /proc/10444/ns
total 0
lrwxrwxrwx 1 root root 0 May 17 16:05 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 May 17 16:05 ipc -> 'ipc:[4026532219]'
lrwxrwxrwx 1 root root 0 May 17 16:05 mnt -> 'mnt:[4026532217]'
lrwxrwxrwx 1 root root 0 May 17 15:52 net -> 'net:[4026532222]'
lrwxrwxrwx 1 root root 0 May 17 16:05 pid -> 'pid:[4026532220]'
lrwxrwxrwx 1 root root 0 May 17 16:05 pid_for_children -> 'pid:[4026532220]'
lrwxrwxrwx 1 root root 0 May 17 16:05 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 May 17 16:05 uts -> 'uts:[4026532218]'
可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[PID]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。
现在看来,Namespace 不再是虚无缥缈的概念,而是一个个实实在在存在的文件。前面所说的“进入”一个容器,应该就是对这些文件做一些操作。在 Linux 的系统调用中,有一个setns()
函数,可以实现这样的功能。
该系统调用的说明如下:
int setns(int fd, int nstype);
DESCRIPTION:
Given a file descriptor referring to a namespace, reassociate the calling thread with that namespace.
The <fd> argument is a file descriptor referring to one of the namespace entries in a /proc/[pid]/ns/ directory; The calling thread will be reassociated with the corresponding namespace, subject to any constraints imposed by the nstype argument.
The <nstype> argument specifies which type of namespace the calling thread may be reassociated with. nstype = 0 means allow any type of namespace to be joined.
setns()
系统调用的作用就是,把调用该函数的进程关联到指定的namespace中,确切的说就是关联到指定的 /proc/[pid]/ns/
目录中。
下面以一小段代码进行说明:
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)
int main(int argc, char *argv[]) {
int fd;
fd = open(argv[1], O_RDONLY);
if (setns(fd, 0) == -1) {
errExit("setns");
}
execvp(argv[2], &argv[2]);
errExit("execvp");
}
这段代码功能非常简单:它一共接收两个参数,第一个参数是 argv[1],即当前进程(calling thread)要加入的 Namespace 文件的路径,比如 /proc/10444/ns/net;而第二个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。
这段代码的核心操作,则是通过 open()
系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns()
使用。在setns()
执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。
现在,你可以编译执行一下这个程序,加入到容器进程(PID=10444)的 Network Namespace 中:
root@ubuntu:~/work/container# gcc -o set_ns set_ns.c
root@ubuntu:~/work/container# ./set_ns /proc/10444/ns/net /bin/bash
root@ubuntu:~/work/container# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.18.0.2 netmask 255.255.0.0 broadcast 172.18.255.255
ether 02:42:ac:12:00:02 txqueuelen 0 (Ethernet)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
loop txqueuelen 1000 (Local Loopback)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
如上所示,当执行 ifconfig 命令查看网络设备时,发现只有 2 个,而我的宿主机上其实有4个网络设备。
实际上,在 setns() 之后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=10444)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视图,也被修改了。
一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现。
在宿主机上,通过 ps 命令找到这个 set_ns 程序对应的 PID,其值为 10667:
root@ubuntu:~# ps aux | grep /bin/bash
root 10667 0.0 0.1 21604 3960 pts/0 S+ 16:24 0:00 /bin/bash
按照前面的方法,查看一下 PID = 10667 这个进程对应的Network Namespace,会发现:
// 外部程序,即执行 set_ns 的进程
root@ubuntu:~# ls -l /proc/10667/ns/net
lrwxrwxrwx 1 root root 0 May 17 16:37 /proc/10667/ns/net -> 'net:[4026532222]'
// 容器进程
root@ubuntu:~# ls -l /proc/10444/ns/net
lrwxrwxrwx 1 root root 0 May 17 15:52 /proc/10444/ns/net -> 'net:[4026532222]'
可以看到,这两者指向的Network Namespace文件是一致的,也就是说,这两个进程,共享了这个名叫 net:[4026532222]的 Network Namespace。
此外,Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:
root@ubuntu:~# docker run -ti --net container:664562841f30 busybox ifconfig
新启动的这个容器,就会直接加入到 ID=664562841f30的容器,也就是我们前面的创建的 Python 应用容器(PID=10444)的 Network Namespace 中。所以,这里 ifconfig 返回的网卡信息,跟我前面那个程序返回的结果一模一样。
而如果我指定–net=host
,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。
-net 参数实际讲的就是容器网络模型相关的,容器的网络模型有4种,分别是none模式,container模式,host模式和bridge模式,默认使用的是bridge模式,关于这一主题已经在容器网络实现中讨论过了。
另外,docker exec 更详细的使用可参考:https://docs.docker.com/engine/reference/commandline/exec/
(全文完)
参考: