上一节我们讲了 Docker 的基本原理,今天我们来看一下,“看起来隔离的”技术 namespace 在内核里面是如何工作的。

既然容器是一种类似公司内部创业的技术,我们可以设想一下,如果一个创新项目要独立运营,应该成立哪些看起来独立的组织和部门呢?

首先是用户管理,咱们这个小分队应该有自己独立的用户和组管理体系,公司里面并不是任何人都知道我们在做什么。

其次是项目管理,咱们应该有自己独立的项目管理体系,不能按照大公司的来。

然后是档案管理,咱们这个创新项目的资料一定要保密,要不然创意让人家偷走了可不好。

最后就是合作部,咱们这个小分队还是要和公司其他部门或者其他公司合作的,所以需要一个外向的人来干这件事情。

对应到容器技术,为了隔离不同类型的资源,Linux 内核里面实现了以下几种不同类型的 namespace。

  • UTS,对应的宏为 CLONE_NEWUTS,表示不同的 namespace 可以配置不同的 hostname。
  • User,对应的宏为 CLONE_NEWUSER,表示不同的 namespace 可以配置不同的用户和组。
  • Mount,对应的宏为 CLONE_NEWNS,表示不同的 namespace 的文件系统挂载点是隔离的
  • PID,对应的宏为 CLONE_NEWPID,表示不同的 namespace 有完全独立的 pid,也即一个 namespace 的进程和另一个 namespace 的进程,pid 可以是一样的,但是代表不同的进程。
  • Network,对应的宏为 CLONE_NEWNET,表示不同的 namespace 有独立的网络协议栈。

还记得咱们启动的那个容器吗?

1
2
3
4
5
6

# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES

f604f0e34bc2        testnginx:1         "/bin/sh -c 'nginx -…"   17 hours ago        Up 17 hours         0.0.0.0:8081->80/tcp   youthful_torvalds

我们可以看这个容器对应的 entrypoint 的 pid。通过 docker inspect 命令,可以看到,进程号为 58212。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150

[root@deployer ~]# docker inspect f604f0e34bc2

[

    {

        "Id": "f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3",

        "Created": "2019-07-15T17:43:44.158300531Z",

        "Path": "/bin/sh",

        "Args": [

            "-c",

            "nginx -g \"daemon off;\""

        ],

        "State": {

            "Status": "running",

            "Running": true,

            "Pid": 58212,

            "ExitCode": 0,

            "StartedAt": "2019-07-15T17:43:44.651756682Z",

            "FinishedAt": "0001-01-01T00:00:00Z"

        },

......

        "Name": "/youthful_torvalds",

        "RestartCount": 0,

        "Driver": "overlay2",

        "Platform": "linux",

        "HostConfig": {

            "NetworkMode": "default",

            "PortBindings": {

                "80/tcp": [

                    {

                        "HostIp": "",

                        "HostPort": "8081"

                    }

                ]

            },

......

        },

        "Config": {

            "Hostname": "f604f0e34bc2",

            "ExposedPorts": {

                "80/tcp": {}

            },

            "Image": "testnginx:1",

            "Entrypoint": [

                "/bin/sh",

                "-c",

                "nginx -g \"daemon off;\""

            ],

        },

        "NetworkSettings": {

            "Bridge": "",

            "SandboxID": "7fd3eb469578903b66687090e512958658ae28d17bce1a7cee2da3148d1dfad4",

            "Ports": {

                "80/tcp": [

                    {

                        "HostIp": "0.0.0.0",

                        "HostPort": "8081"

                    }

                ]

            },

            "Gateway": "172.17.0.1",

            "IPAddress": "172.17.0.3",

            "IPPrefixLen": 16,

            "MacAddress": "02:42:ac:11:00:03",

            "Networks": {

                "bridge": {

                    "NetworkID": "c8eef1603afb399bf17af154be202fd1e543d3772cc83ef4a1ca3f97b8bd6eda",

                    "EndpointID": "8d9bb18ca57889112e758ede193d2cfb45cbf794c9d952819763c08f8545da46",

                    "Gateway": "172.17.0.1",

                    "IPAddress": "172.17.0.3",

                    "IPPrefixLen": 16,

                    "MacAddress": "02:42:ac:11:00:03",

                }

            }

        }

    }

]

如果我们用 ps 查看机器上的 nginx 进程,可以看到 master 和 worker,worker 的父进程是 master。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

# ps -ef |grep nginx

root     58212 58195  0 01:43 ?        00:00:00 /bin/sh -c nginx -g "daemon off;"

root     58244 58212  0 01:43 ?        00:00:00 nginx: master process nginx -g daemon off;

33       58250 58244  0 01:43 ?        00:00:00 nginx: worker process

33       58251 58244  0 01:43 ?        00:00:05 nginx: worker process

33       58252 58244  0 01:43 ?        00:00:05 nginx: worker process

33       58253 58244  0 01:43 ?        00:00:05 nginx: worker process

在 /proc/pid/ns 里面,我们能够看到这个进程所属于的 6 种 namespace。我们拿出两个进程来,应该可以看出来,它们属于同一个 namespace。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# ls -l /proc/58212/ns 

lrwxrwxrwx 1 root root 0 Jul 16 19:19 ipc -> ipc:[4026532278]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 mnt -> mnt:[4026532276]

lrwxrwxrwx 1 root root 0 Jul 16 01:43 net -> net:[4026532281]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 pid -> pid:[4026532279]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 user -> user:[4026531837]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 uts -> uts:[4026532277]

 # ls -l /proc/58253/ns 

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 ipc -> ipc:[4026532278]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 mnt -> mnt:[4026532276]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 net -> net:[4026532281]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 pid -> pid:[4026532279]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 user -> user:[4026531837]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 uts -> uts:[4026532277]

接下来,我们来看,如何操作 namespace。这里我们重点关注 pid 和 network。

操作 namespace 的常用指令nsenter,可以用来运行一个进程,进入指定的 namespace。例如,通过下面的命令,我们可以运行 /bin/bash,并且进入 nginx 所在容器的 namespace。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

# nsenter --target 58212 --mount --uts --ipc --net --pid -- env --ignore-environment -- /bin/bash

 root@f604f0e34bc2:/# ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

    inet 127.0.0.1/8 scope host lo

       valid_lft forever preferred_lft forever

23: eth0@if24: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 

    link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff

    inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0

       valid_lft forever preferred_lft forever

另一个命令是unshare,它会离开当前的 namespace,创建且加入新的 namespace,然后执行参数中指定的命令。

例如,运行下面这行命令之后,pid 和 net 都进入了新的 namespace。

1
2

unshare --mount --ipc --pid --net --mount-proc=/proc --fork /bin/bash

如果从 shell 上运行上面这行命令的话,好像没有什么变化,但是因为 pid 和 net 都进入了新的 namespace,所以我们查看进程列表和 ip 地址的时候应该会发现有所不同。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14

# ip addr

1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

 # ps aux

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND

root         1  0.0  0.0 115568  2136 pts/0    S    22:55   0:00 /bin/bash

root        13  0.0  0.0 155360  1872 pts/0    R+   22:55   0:00 ps aux

果真,我们看不到宿主机上的 IP 地址和网卡了,也看不到宿主机上的所有进程了。

另外,我们还可以通过函数操作 namespace。

第一个函数是clone,也就是创建一个新的进程,并把它放到新的 namespace 中。

1
2

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

clone 函数我们原来介绍过。这里面有一个参数 flags,原来我们没有注意它。其实它可以设置为 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID。CLONE_NEWNET 会将 clone 出来的新进程放到新的 namespace 中。

第二个函数是setns,用于将当前进程加入到已有的 namespace 中。

1
2

int setns(int fd, int nstype);

其中,fd 指向 /proc/[pid]/ns/ 目录里相应 namespace 对应的文件,表示要加入哪个 namespace。nstype 用来指定 namespace 的类型,可以设置为 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID 和 CLONE_NEWNET。

第三个函数是unshare,它可以使当前进程退出当前的 namespace,并加入到新创建的 namespace。

1
2

int unshare(int flags);

其中,flags 用于指定一个或者多个上面的 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID 和 CLONE_NEWNET。

clone 和 unshare 的区别是,unshare 是使当前进程加入新的 namespace;clone 是创建一个新的子进程,然后让子进程加入新的 namespace,而当前进程保持不变。

这里我们尝试一下,通过 clone 函数来进入一个 namespace。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

#define _GNU_SOURCE

#include <sys/wait.h>

#include <sys/utsname.h>

#include <sched.h>

#include <string.h>

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

 static int childFunc(void *arg)

{

    printf("In child process.\n");

    execlp("bash", "bash", (char *) NULL);

    return 0;

}

 int main(int argc, char *argv[])

{

    char *stack;

    char *stackTop;

    pid_t pid;

     stack = malloc(STACK_SIZE);

    if (stack == NULL)

    {

        perror("malloc"); 

        exit(1);

    }

    stackTop = stack + STACK_SIZE;

     pid = clone(childFunc, stackTop, CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD, NULL);

    if (pid == -1)

    {

        perror("clone"); 

        exit(1);

    }

    printf("clone() returned %ld\n", (long) pid);

     sleep(1);

     if (waitpid(pid, NULL, 0) == -1)

    {

        perror("waitpid"); 

        exit(1);

    }

    printf("child has terminated\n");

    exit(0);

}

在上面的代码中,我们调用 clone 的时候,给的参数是 CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET,也就是说,我们会进入一个新的 pid、network,以及 mount 的 namespace。

如果我们编译运行它,可以得到下面的结果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# echo $$

64267

 # ps aux | grep bash | grep -v grep

root     64267  0.0  0.0 115572  2176 pts/0    Ss   16:53   0:00 -bash

 # ./a.out           

clone() returned 64360

In child process.

 # echo $$

1

 # ip addr

1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

 # exit

exit

child has terminated

 # echo $$           

64267

通过 echo $$,我们可以得到当前 bash 的进程号。一旦运行了上面的程序,我们就会进入一个新的 pid 的 namespace。

当我们再次 echo

的时候,就会发现当前bash的进程号变成了1。上面的程序运行了一个新的bash,它在一个独立的pidnamespace里面,自己是1号进程。如果运行ipaddr,可以看到,宿主机的网卡都找不到了,因为新的bash也在一个独立的networknamespace里面,等退出了,再次echo的时候,就会发现当前bash的进程号变成了1。上面的程序运行了一个新的bash,它在一个独立的pidnamespace里面,自己是1号进程。如果运行ipaddr,可以看到,宿主机的网卡都找不到了,因为新的bash也在一个独立的networknamespace里面,等退出了,再次echo