名称空间是在 OS 之上实现容器与主机隔离,以及容器之间互相隔离的 Linux 内核核心技术。根据《Docker 最初的 2 小时 (Docker 从入门到入门)》一文,名称空间本质上就是在不同的工作组里面封官许愿,让大家在各自的部门里面都是 manager,而且彼此不冲突。本文接下来从细节做一些讨论。
由于本文敲的命令既有可能位于主机,又有可能位于新的名称空间(模拟容器),为了避免搞乱你的脑子,下面主机命令一概采用本颜色,而模拟容器类的命令一概采用本颜色。色盲读者,敬请谅解。
名称空间(Namespace),它表示着一个(identifier)的可见范围。一个标识符可在多个名称空间中定义,它在不同命名空间中的含义是互不相干的。这样,在一个新的名称空间中可定义任何标识符,它们不会与任何已有的标识符发生冲突,只要已有的定义都处于其他命名空间中。再次回忆一下这个封官许愿图,大家都是官:
名称空间是 C++、Java 里面常见的概念。比如下面最简单的程序,在 2 个独立的名称空间里面各自的函数都是叫 func(),func 就是一个(identifier),可以并存于多个名称空间。
- #include <iostream>
- using namespace std;
- // 第一个命名空间
- namespace first_space{
- void func(){
- cout << "Inside first_space" << endl;
- }
- }
- // 第二个命名空间
- namespace second_space{
- void func(){
- cout << "Inside second_space" << endl;
- }
- }
- int main ()
- {
- // 调用第一个命名空间中的函数
- first_space::func();
- // 调用第二个命名空间中的函数
- second_space::func();
- return 0;
- }
Docker 要营造 OS 级别的虚拟化,需要实现一点,让每个容器都感觉自己拥有整个的独立 OS,但是实际上,在 Docker 下,多个容器实际上是运行于相同的 OS 内核上面:
所以,内核需要提供某种意义上的抽象,让各个容器感觉自己拥有独立的 OS,让它们自己运行的时候觉得不是在一个整体的 OS 里面运行,而是各个容器感觉自己独有一个 OS,这个 OS 最好和底层实际的主机资源隔离,才能实现容器运行的平台无关性。这个抽象可以从这几个角度展开:
现在每个容器内部的进程应该拥有独立的 PID,不能在同一个 OS 的一个大池子里面(尽管实际上是,但是在容器内部要意识不到)。典型的,在 Linux 里面,init 进程的 PID 是 1,容器化后,应该每个容器都有一个 1 以及由 1 衍生的子进程和子进程的子进程(子子孙孙无穷匮)。但是这个容器内部的 1 进程,在容器内部它是 1,但是最终它肯定是属于底下那个同一个 OS 大池子里面的某一个 PID。
类似你在上海呼叫电话号码 88888888,和在武汉呼叫电话号码 88888888,在各自的城市都觉得是 88888888,但是在全国(底下唯一的 kernel)范围内则分别是 021-88888888 和 027-88888888。
这种映射关系类似于:
与 PID 类似,在容器内部的进程间通信应该被从全局的 Linux 的进程间通信隔离开来。在没有名称空间的情况下,Linux System V IPC 都会有各自的 ID。譬如:
$ ipcs
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x00000000 524288 baohua 600 524288 2 dest
0x00000000 327681 baohua 600 1048576 2 dest
0x00000000 425986 baohua 600 524288 2 dest
…
------ Semaphore Arrays --------
key semid owner perms nsems
0x002fa327 0 root 666 2
------ Message Queues --------
key msqid owner perms used-bytes messages
但是在各个容器内部,ID 与 ID 之间应该互相隔离。容器内部应该看不到主机的 IPC,而一个容器也看不到另外一个容器的 IPC。譬如在这台主机上跑 Ubuntu 14.04 的 bash,目前还没有发现 IPC:
:~$ docker run -it --rm ubuntu:14.04 bash
:/# ipcs
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
------ Semaphore Arrays --------
key semid owner perms nsems
------ Message Queues --------
key msqid owner perms used-bytes messages
要让容器各自感觉独立,那么从底层的主机名独立也是很重要的。比如,我的主机名现在可以通过 hostname 命令获取:
:~/develop/linux$ hostname
baohua-VirtualBox
而运行的 Docker 内部的主机名则可以用 docker run 的 - h 参数指定,现在我们指定为 "container":
:~/develop/linux$ docker run -h container -it --rm ubuntu:14.04 bash
:/# hostname
container
这样容器内部的进程,就不觉得自己在 "baohua-VirtualBox" 这个机器上面跑。
如果我们 docker run 中不指定 hostname,会有一个随机分配的数值做 hostname:
:~$ docker run -it --rm ubuntu:14.04 bash
:/# hostname
0c7951083f70
比如我用我的电脑,我是用 baohua 这个用户名。但是在 Docker 的容器里面,为了体现虚拟化的概念,容器肯定要和实际的主机分离,这个时候,容器里面应该有自己的用户名。
:~/develop/linux$ docker run -h container -it --rm ubuntu:14.04 bash
:/#
登陆到容器后,我们得到的用户名是 root。
看到这个 root,我们会疑惑?它是否会拥有类似主机的 root 权限,比如甚至都可以跑到 sysfs 里面卸掉一个 CPU?这个显然是不可能的:
:/sys/devices/system/cpu/cpu1# sh -c 'echo 0> online'
sh: 1: cannot create online: Read-only file system
因为在容器里面,sysfs 都是只读的。实际上,我们并不太希望容器里面控制真实的主机。这个 root 权限发挥的作用,更多的是在容器内部,它针对虚拟化后的资源,拥有的 root 权限,比如可以在容器内部执行 mount。
下面我们验证容器内部的 root 权限的作用:容器启动后,我们在根目录下创建文件 1,并且在其中写入 hello,之后在容器内创建用户名 baohua,以 baohua 这个用户,再在 1 里面写入 hello 就不会有权限:
$ docker run -h container -it --rm ubuntu:14.04 bash
:/# touch 1
:/# echo hello > 1
:/# useradd baohua
:/# su baohua
:/$ echo hello > 1
bash: 1: Permission denied
既然我们强调容器与主机的剥离,我们显然不应该把主机的文件系统暴露给容器内部。众所周知,Linux 应用的运行不能没有根文件系统以及 proc,sys,dev 等特殊的文件系统。所以容器内部也不能不拥有自己的这些文件。但是另外一方面,容器内部看到的东西和主机看到的应该不一样,否则主机就直接暴露给了容器,不能体现虚拟化概念。
Linux 的 mount 名称空间可以实现不同 mount 命名空间的进程看到的文件系统层次不一样。也就是说,不同的容器,以及容器与主机之间,可以出现不同目录结构;当然也可以出现相同的目录结构,但是他们在磁盘的位置可以不一样。
为了支持名称空间,Linux 内核增加了几个 CLONE_NEW 开头的 flag,它们是 CLONE_NEWNS(针对 mount)、CLONE_NEWUTS(针对主机名)、CLONE_NEWIPC、CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWUSER,当我们执行 clone() 时候,每多带上一个 flag,就表明相应的名称空间会多创建一份,体现"NEW" 这个单词的含义。
- clone(child_stack=0xb6cf1424, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, …)
我们编译运行它,在子进程的 bash shell 里面查看自身 PID,结果是 18374,看起来很正常:$ gcc main.c
- #define _GNU_SOURCE
- #include <sched.h>
- #include <unistd.h>
- #include <stdlib.h>
- #include <sys/wait.h>
- #include <signal.h>
- #include <stdio.h>
- #define STACK_SIZE (1024 * 1024)
- #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while (0)
- static char child_stack[STACK_SIZE];
- int child_main(void *arg)
- {
- printf("child\n");
- execlp("/bin/bash","bash",NULL,NULL);
- return 1;
- }
- int main()
- {
- pid_t child_pid;
- child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD,NULL);
- if (child_pid == -1)
- errExit("clone");
- wait(NULL);
- return 0;
- }
- @@ -23,7 +23,7 @@ int child_main(void *arg)
- int main()
- {
- pid_t child_pid;
- - child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD,NULL);
- + child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD | CLONE_NEWPID,NULL);
- if (child_pid == -1)
- errExit("clone");
编译后运行:
$ sudo setcap all+eip ./a.out
[sudo] password for baohua:
:~/develop/training/namespace$./a.out
child
:~/develop/training/namespace$echo $$
1
前面一步 setcap 的目的是为了给程序执行 CLONE_NEWPID 的能力。后面 echo $$ 显示的结果是 1,子进程的 bash shell 是新的 PID 名称空间的 init 进程。但是在主机环节中,bash 的 PID 是多少呢?运行命令 ps --ppid:
$ ps --ppid `pidof a.out`
PIDTTY TIME CMD
19094 pts/8 00:00:00 bash
运行之,进入 bash 子进程看 proc 目录,发现只有 1 和 20 两个进程
- @@ -16,6 +16,7 @@ static char child_stack[STACK_SIZE];
- int child_main(void *arg)
- {
- printf("child\n");
- + system("mount -t proc proc /proc");
- execlp("/bin/bash","bash",NULL,NULL);
- return 1;
- }
- @@ -23,7 +24,7 @@ int child_main(void *arg)
- int main()
- {
- pid_t child_pid;
- - child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD | CLONE_NEWPID,NULL);
- + child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD | CLONE_NEWPID | CLONE_NEWNS,NULL);
- if (child_pid == -1)
- errExit("clone");
只需要在 child_main() 函数里面增加一行代码:
执行结果如下,我们现在发现在新的进程的名称空间内 / mnt 目录下面有文件 1,而且内容是 hello。
- mount("/home/baohua/test-dir","/mnt", "none", MS_BIND, NULL);
sudo ./a.out
child
:~/develop/training/namespace# cd /mnt/
:/mnt# ls
1
:/mnt# cat 1
hello
现在在前面程序中 clone() 的 flags 增加 CLONE_NEWNET,修改 1 行代码:
在新的进程的名称空间内,运行 ifconfig 和 ip link list,可以说网络环境是十分的单纯::~/develop/training/namespace#ifconfig
- @@ -27,7 +26,8 @@ int child_main(void *arg)
- int main()
- {
- pid_t child_pid;
- - child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD |CLONE_NEWPID | CLONE_NEWNS,NULL);
- + child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD |CLONE_NEWPID |
- + CLONE_NEWNS |CLONE_NEWNET,NULL);
:~/develop/training/namespace$ifconfig
docker0 Link encap:Ethernet HWaddr00:00:00:00:00:00
inet addr:172.17.42.1 Bcast:0.0.0.0 Mask:255.255.0.0
…
eth0 Link encap:Ethernet HWaddr00:0c:29:ef:11:2f
inet addr:192.168.47.128 Bcast:192.168.47.255 Mask:255.255.255.0
inet6 addr: fe80::20c:29ff:feef:112f/64 Scope:Link
…
lo Link encap:Local Loopback
inet addr:127.0.0.1 Mask:255.0.0.0
inet6 addr: ::1/128 Scope:Host
…
:~/develop/training/namespace$ip link list
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0:
link/ether 00:0c:29:ef:11:2f brd ff:ff:ff:ff:ff:ff
3: docker0:
link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff
:~/develop/training/namespace#ping 127.0.0.1
connect: Network is unreachable
:~/develop/training/namespace#ip link set dev lo up
:~/develop/training/namespace#ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes ofdata.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64time=0.035 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64time=0.022 ms
^C
--- 127.0.0.1 ping statistics ---
2 packets transmitted, 2 received, 0%packet loss, time 999ms
rtt min/avg/max/mdev =0.022/0.028/0.035/0.008 ms
下面查看 a.out 子进程 bash 在主机中的 PID 是 21405(接下来添加虚拟网卡的时候需要这个数值):
$ ps --ppid `pidof a.out`
PIDTTY STAT TIME COMMAND
19093 pts/8 S 0:00 ./a.out
21405 pts/8 S+ 0:00 bash
$ sudo ip link add name veth0 type vethpeer name veth1 netns 21405
上述命令设置了连接的一对虚拟网络设备,它是这么工作的:发送给 veth0 的数据包将会被 veth1 收到,发送给 veth1 数据包将会被 veth0 收到。
我们进入新的名称空间的 bash,敲如下命令,发现新的名称空间里面真的多出来 veth1 虚拟网卡!
:~/develop/training/namespace#ip link list
1: lo: <LOOPBACK> mtu 65536 qdiscnoop state DOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: veth1: <BROADCAST,MULTICAST> mtu 1500 qdisc noopstate DOWN mode DEFAULT group default qlen 1000
link/ether 3e:7a:86:a3:8b:9d brdff:ff:ff:ff:ff:ff
而主机上面则涌现出了新的 veth0 网卡:
$ ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0:<BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP modeDEFAULT group default qlen 1000
link/ether 00:0c:29:ef:11:2f brd ff:ff:ff:ff:ff:ff
3: docker0:<NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWNmode DEFAULT group default
link/ether 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff
24: veth0: <BROADCAST,MULTICAST> mtu 1500 qdisc noopstate DOWN mode DEFAULT group default qlen 1000
link/etherb2:80:d7:36:b5:84 brd ff:ff:ff:ff:ff:ff
在新名称空间内执行如下命令:
:~/develop/training/namespace#ifconfig veth1 10.1.1.1/24 up
主机上执行如下命令:
$ sudo ifconfig veth0 10.1.1.2/24 up
而后我们会发现在新名称空间可以 ping 通 10.1.1.2,而主机可以 ping 通 10.1.1.1,这样就实现了双向通信。
下面我们继续安置 CLONE_NEWUTS 标记,来实现主机名的分裂。修改 2 行代码
- @@ -19,6 +19,7 @@ int child_main(void *arg)
- printf("child\n");
- system("mount -t proc none /proc");
- mount("/home/baohua/test-dir", "/mnt","none", MS_BIND, NULL);
- + sethostname("container",10);
- execlp("/bin/bash","bash",NULL,NULL);
- return 1;
- }
- @@ -27,7 +28,7 @@ int main()
- {
- pid_t child_pid;
- child_pid = clone(child_main,child_stack+STACK_SIZE,SIGCHLD |CLONE_NEWPID |
- - CLONE_NEWNS |CLONE_NEWNET,NULL);
- + CLONE_NEWNS | CLONE_NEWNET |CLONE_NEWUTS, NULL);
- if (child_pid == -1)
- errExit("clone");
编译运行后,在 bash 中敲 hostname 命令,获取主机名,发现变为了 "container"。
:~/develop/training/namespace$sudo ./a.out
[sudo] password forbaohua:
child
:~/develop/training/namespace#hostname
container
先看如下最简单的程序,只在 clone() 时候使用 CLONE_NEWUSER:
它的运行结果如下,看起来在子进程里面(新的名称空间里面),我们得到的用户是 nobody:
- #define _GNU_SOURCE
- #include <sched.h>
- #include <unistd.h>
- #include <stdlib.h>
- #include <sys/wait.h>
- #include <signal.h>
- #include <stdio.h>
- #define STACK_SIZE (1024 * 1024)
- #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while (0)
- static char child_stack[STACK_SIZE];
- static int child_main(void *arg)
- {
- printf("child\n");
- execlp("/bin/bash","bash",NULL,NULL);
- return1;
- }
- int main()
- {
- pid_tchild_pid;
- child_pid= clone(child_main,child_stack+STACK_SIZE,SIGCHLD | CLONE_NEWUSER, NULL);
- if(child_pid == -1)
- errExit("clone");
- wait(NULL);
- return0;
- }
:~/develop/training/namespace$gcc user.c
:~/develop/training/namespace$./a.out
child
:~/develop/training/namespace$
在子进程对应的 shell 里面,敲 id 命令,看一下自身的 ID,发现都是 65534:
:~/develop/training/namespace$id
uid=65534(nobody)gid=65534(nogroup) groups=65534(nogroup)
clone() 用了 CLONE_NEWUSER 的参数后,子进程运行于新的 USER 名称空间,内部看到的 UID 和 GID 已经与外部不同了,在默认情况下以 ID 65534 运行。
其实我们可以把主机的 ID,与新 USER 名称空间的 ID 进行一个映射,比如我们启动子进程的时候,实际上是以 baohua 这个用户启动的,则说明 bash 子进程,在主机对应的用户是 baohua。但是,在新的名称空间内部,它究竟映射到哪个用户呢?这个我们可以通过修改进程的 / proc/pid/uid_map 和 / proc/pid/gid_map 这 2 个文件来进行 ID 的内外映射。
主机里面 baohua 的 ID 是 1000:
$ id baohua
uid=1000(baohua)gid=1000(baohua)groups=1000(baohua),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),108(lpadmin),124(sambashare),131(docker)
我们现在把 uid 1000 对应的 baohua 映射到新名称空间内部的 root 用户(uid 为 0),在主机中运行如下命令:
$ ps --ppid `pidofa.out`
PID TTY TIME CMD
27321 pts/6 00:00:00 bash
我们手动进行映射:
$ sudo sh -c 'echo 01000 1> /proc/27321/uid_map'
$ sudo sh -c 'echo 01000 1> /proc/27321/gid_map'
之后在子进程再次敲 id 命令,发现重大不同。
:~/develop/training/namespace$id
uid=0(root)gid=0(root) groups=0(root),65534(nogroup)
发现自身的 uid、gid 变为了 0。接下来,只用 su
下面我们用程序实现这个过程:
- #define _GNU_SOURCE
- #include <sched.h>
- #include <unistd.h>
- #include <stdlib.h>
- #include <sys/wait.h>
- #include <sys/mount.h>
- #include <signal.h>
- #include <stdio.h>
- #define STACK_SIZE (1024 * 1024)
- #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); } while (0)
- static char child_stack[STACK_SIZE];
- static void set_map(char* file, intinside_id, int outside_id)
- {
- FILE*mapfd = fopen(file, "w");
- if(NULL == mapfd) {
- perror("openfile error");
- return;
- }
- fprintf(mapfd,"%d %d %d", inside_id, outside_id, 1);
- fclose(mapfd);
- }
- static void set_uid_map(pid_t pid, int inside_id,int outside_id)
- {
- charfile[256];
- sprintf(file,"/proc/%d/uid_map", pid);
- set_map(file,inside_id, outside_id);
- }
- static void set_gid_map(pid_t pid, intinside_id, int outside_id)
- {
- charfile[256];
- sprintf(file,"/proc/%d/gid_map", pid);
- set_map(file,inside_id, outside_id);
- }
- static int child_main(void *arg)
- {
- sleep(1);//wait for 1 second to make certain uid_map and gid_map is written
- printf("child\n");
- system("mount-t proc none /proc");
- mount("/home/baohua/test-dir","/mnt", "none", MS_BIND, NULL);
- sethostname("container",10);
- execlp("/bin/bash","bash",NULL,NULL);
- return1;
- }
- int main()
- {
- pid_tchild_pid;
- child_pid= clone(child_main,child_stack+STACK_SIZE,SIGCHLD | CLONE_NEWPID |
- CLONE_NEWNS| CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWUSER, NULL);
- if(child_pid == -1)
- errExit("clone");
- set_uid_map(child_pid,0, getuid());
- set_gid_map(child_pid,0, getgid());
- wait(NULL);
- return0;
- }
上述代码中,父进程会通过 set_uid_map() 和 set_gid_map() 这 2 个函数,进行新名称空间内部的用户 0 与主机的用户 1000 的映射。由于子进程执行 bash 之前延迟了 1 秒,所以我们在子进程进入 shell 的时候,它已经直接是 root 用户了:
$ ./a.out
child
:~/develop/training/namespace#
那么,它针对主机资源的实际权限是不是 root 呢,实验一下它是否可以访问 / dev/sda1:
:~/develop/training/namespace#cat /dev/sda1
cat: /dev/sda1:Permission denied
下面我们在 bash 里面启动一些 stress 进程:
:~/develop/training/namespace#stress --cpu 8 --io 4 --vm 2 --vm-bytes 128M --timeout 100000s&
[1] 46
直接在新名称空间内看 ps:
但是我们在主机里面看 ps 呢?
我们则发现,所有的 stress 进程在主机里面都是对应用户 baohua 的,而在新的名称空间里面则是 root。
所以这个关系类似:
来源: http://www.bubuko.com/infodetail-1971277.html