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/

(全文完)


参考:

  1. 极客时间专栏:https://time.geekbang.org/column/article/18119
posted @ 2020-05-24 13:33  kkbill  阅读(3961)  评论(0编辑  收藏  举报