容器-1-Namespace:楚门的世界

本文是一系列对Docker与Kubernetes的学习总结。源材料来源参见文中的链接和最后的参考资料。

容器的原理

虽然有些人会把容器和虚拟机类比,称之为“轻量级的虚拟机”。刚开始接触Docker的时候大多看过下面这张图:
容器-虚拟机vs容器(伪)
忘记这张图吧。从上一节我们讨论的“容器的意义”就可以看到,容器和虚拟机关注的不是一个层面。

但虚拟机和容器也有共通之处:本质都是欺骗。虚拟机的原理是欺骗CPU/物理内存/物理IO,让硬件感觉自己还是在接收宿主机而非虚拟机的指令。而Docker的原理是欺骗一组进程,让这些进程以为自己活在另一个的操作系统里。
用专业一点的术语,就是:容器的核心功能,就是通过约束进程和修改进程的动态表现,为进程创造出一组“边界”。

以电影作为比方,虚拟机就像《火星救援》里在火星上种土豆的宇航员,在火星上模拟地球的环境,连空气和水都需要自己从头开始制备,成本高昂。
火星救援

而容器就是《楚门的世界》。男主(容器进程)生活在一个大型影棚里,所有能接触到的世界只有这个影棚。男主住的房子,吃的东西,呼吸的空气来自于地球,生产成本很低。但他所能阅读的报纸,乘坐的载具,观看的电视都是影棚工作人员提供给他的,受到了严格的限制。
楚门的世界

对于Docker来说,修改进程的动态表现是通过Namespace,构成进程依赖的文件系统是通过union mount,约束进程是通过Cgroups

Namespace-欺骗的6种手段

我们先想一下,对于一台宿主机上的虚拟机,它能看到和接触到的哪些信息必须和宿主机不一样?除了上一节提到的文件系统之外还能列出不少吧。虚拟化技术是通过Hypervisor + Guest OS实现的。
而对于容器,要实现同样的效果是通过Namespace。

进程号不一样

对于Linux系统来说,有几个进程是ID固定的:

  • idle进程:pid=0,系统创建的第一个进程,内核态
  • init进程:pid=1,由0进程创建,用户态,系统中所有其它用户进程的祖先进程
  • kthreadd进程:pid=2,管理和调度其他内核线程

《道德经》有云:道生一,一生二,二生三,三生万物。对于Linux进程要改一下,0生1和2,1和2生万物。
Network Namespace

肯定不能让Docker容器接触到init进程(pid=1),不然容器就能为所欲为了。但对于Docker里其他进程来说,如果自己不是由pid=1的进程创建的,欺骗就出现了严重的漏洞。
一山不容二虎,进程号不可重复。不可能创建出两个PID=1的init进程。所以需要将一个fork出来的普通进程伪装成PID1的init进程,并骗容器里的其他进程相信这点。

通过PID NameSpace可以实现进程号唯一和进程视图隔离

下面是实际一个docker容器启动时,在容器里打印出来的进程

1
2
3
PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                                                    
1 root 20 0 11820 1892 1512 S 0.0 0.0 0:00.15 bash
22 root 20 0 56212 2060 1452 R 0.0 0.0 0:00.01 top

而在宿主机用pstree打印出来的进程树如下:

1
2
3
systemd─┬─containerd─┬─containerd-shim─┬─bash───top
│ │ └─10*[{containerd-shim}]
│ └─14*[{containerd}]

可以看到对于容器来说,bash是PID=1的init进程。在这棵进程树上,containerd-shim可以理解为容器的pid=0进程(当然实际上依然是用户态进程所以还是有很大差别)。
至于为什么宿主机的init进程名是systemd,涉及sysvint和systemd的争议,在这里就不提了。有兴趣的话可以参考这篇。在这里你可以认为systemd是当前版本CentOS上的init进程实现。

主机名不一样

每个容器最好有网络的独立性。这个包括主机名唯一,以及ip和端口不冲突等。先说主机名。

每个Docker容器的主机名等同于容器ID,用这种方式确保唯一。(同一个局域网上hostname重复其实也没大关系,但能做到唯一总更好一些吧)

1
2
[root@269111b56ccd /]# hostname
269111b56ccd

UTS NameSpace可以实现主机名唯一

ip和端口不冲突

每块网卡一个ip。每个容器有一个自己的ip,那么就要靠虚拟网卡veth。
具体的架构可以参见下图:
Network Namespace

所有容器的虚拟机网卡通过bridge桥接到宿主机的网卡上。
我们可以在宿主机上打印出网络接口信息。其中的docker0就是桥接网卡,而veth开头的就是容器的虚拟网卡。

1
2
3
4
5
6
7
8
9
10
[root@mobilesit network-scripts]$ip li
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
link/ether 00:50:56:95:9d:68 brd ff:ff:ff:ff:ff:ff
...
6: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:c5:76:e1:b5 brd ff:ff:ff:ff:ff:ff
8: vethf362a04@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether 5e:c3:81:ae:96:9f brd ff:ff:ff:ff:ff:ff link-netnsid 0

在容器里打印网络接口信息,除了lo这个本地环回接口(localhost)之外,就是虚拟网卡eth0了。

1
2
3
4
5
[root@269111b56ccd /]# ip li
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
7: eth0@if8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0

有了虚拟网卡,端口映射也是小事一碟了。
Network NameSpace可以实现独立虚拟网卡

容器间不可随意通信

IPC是Linux下进程间通信的一种方式。两个独立容器间的进程应该是被隔离的,传小纸条这种行为需要被严格禁止。
微服务间通信是靠HTTP请求和RPC,而进程间通信靠的是共享内存、信号量、消息队列等。

IPC NameSpace可以阻隔容器间通信

不同容器的同名用户互相独立

我们期望在容器中可以任意创建用户,但不至于影响到宿主机。也期望在两个容器里建立同名用户的时候不互相影响。

在说到Docker的实现方式之前,先要提一下UID(User Identifier,用户id)和GID(Group Identifier,组id)。它们分别是用户和组在全系统里的唯一标识。整个Linux系统共用一套内核,内核只维护一套uid和gid。
但朱丽叶也说过:“What’s in a name? That which we call a rose by ant other word would smell as sweet.”。同一个uid可以在不同的容器和宿主机之间显示不同的名字。
我们可以做个实现:在Dockerfile里加上USER参数,以Daemon启动容器。然后分别在宿主机和容器查看进程。
宿主机的结果是:

1
2
3
4
[root@mobilesit 12687]$ps -ef|grep sleep
polkitd 14126 14109 0 12:10 ? 00:00:00 sleep infinity
root 14680 786 0 12:39 ? 00:00:00 sleep 60
root 14720 14234 0 12:39 pts/3 00:00:00 grep --color=auto sleep

而容器里的结果是:

1
2
3
4
UID        PID  PPID  C STIME TTY          TIME CMD
testuser 1 0 0 04:10 ? 00:00:00 sleep infinity
testuser 14 0 0 04:39 pts/0 00:00:00 /bin/bash
testuser 19 14 0 04:40 pts/0 00:00:00 ps -ef

一个是polkitd(Linux控制全局权限的Daemon进程),一个是testuser,看上去名字不相同。
但我们再查询一下uid信息看看。宿主机的结果:

1
uid=999(polkitd) gid=998(polkitd) groups=998(polkitd)

而容器里的结果为:

1
uid=999(testuser) gid=998(testuser) groups=998(testuser)

uid和gid完全相同,可见从本质上是同一个用户。

PS. 实验的时候还有三个未解的疑问,待以后有空再深入研究了。

  • 如果用useradd在容器里创建用户,容器里能查到uid为1000的用户(如果继续建立,就会从1001开始累加)。但宿主机上的/etc/passwd上查不到uid=1000的用户。不知道是不是对/etc/passwd隐藏了。
  • 资料上说进程里通过/proc//uid_map做宿主机和容器间的uid映射。但我从dockerd到containerd-shim,各种进程的uid_map文件都找过了。只看到了root用户的映射,没找到testuser的映射。
  • 在两个容器里分别建立了两个用户(无论是否同名),uid都是1000。但把其中一个容器中的用户删除后,另外一个容器里的用户不受影响。我理解删除的只是映射,但不确定的是如果给用户提权,不知道会不会两个容器都受到影响。

User NameSpace可以实现容器和宿主机之间的用户映射

挂载文件系统隔离

每个容器的文件挂载之间应该是互相独立的。当某个容器里执行了挂载,我们期望其他容器不会也看到这个挂载点。

容器的实现方式是在每个进程中独立维护挂载信息。实际是维护在/proc//mounts,/proc//mountinfo和/proc//mountstats这三个文件中。

Mount NameSpace可以隔离挂载信息

我们会在下一章更详细地介绍Mount Namespace是怎么和Docker的存储引擎配合,创造出每个容器内独立的文件系统。

总结

Docker里分别使用6种Namespace实现了各种资源的隔离,包括:

Namespace 系统调用参数 隔离的资源
PID CLONE_NEWPID 进程号
UTS CLONE_NEWUTS 主机名与域名
Network CLONE_NEWNET 网络设备、网络栈、端口等等
IPC CLONE_NEWIPC 信号量、消息队列和共享内存
User CLONE_NEWUSER 用户和用户组
Mount CLONE_NEWNS 挂载点(文件系统)

对于容器的隔离性其实有不少需要考虑的。随便举个例子,比如系统时间和时区。如果在容器里改了系统时间,是不是宿主机也会受到影响?这些细节就待有兴趣或有需求的时候再来研究了。

另外在看了一部分Kubernetes后回来补充:容器之间的Namespace隔离也不是定死的。Kubernetes Pod内部的容器之间就可以共享Network Namespace,PID Namespace和IPC Namespace等。

参考资料

docker进程号的动手实验
谁是Docker容器的init(1)进程 | shareinto

更详细了解Docker Network Namespace的实现可以参考下文
Docker 原理篇(七)Docker network namespace | 伤神的博客

Docker全系列

Namespace:楚门的世界
Docker存储引擎
Cgroups的计划经济
Docker的意义

本文永久链接 [ https://galaxyyao.github.io/2019/05/17/容器-1-Namespace:楚门的世界/ ]