本文是一系列对Docker与Kubernetes的学习总结。源材料来源参见文中的链接和最后的参考资料。
容器的原理
虽然有些人会把容器和虚拟机类比,称之为“轻量级的虚拟机”。刚开始接触Docker的时候大多看过下面这张图:
忘记这张图吧。从上一节我们讨论的“容器的意义”就可以看到,容器和虚拟机关注的不是一个层面。
但虚拟机和容器也有共通之处:本质都是欺骗。虚拟机的原理是欺骗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生万物。
肯定不能让Docker容器接触到init进程(pid=1),不然容器就能为所欲为了。但对于Docker里其他进程来说,如果自己不是由pid=1的进程创建的,欺骗就出现了严重的漏洞。
一山不容二虎,进程号不可重复。不可能创建出两个PID=1的init进程。所以需要将一个fork出来的普通进程伪装成PID1的init进程,并骗容器里的其他进程相信这点。
通过PID NameSpace可以实现进程号唯一和进程视图隔离
下面是实际一个docker容器启动时,在容器里打印出来的进程
1 | PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND |
而在宿主机用pstree打印出来的进程树如下:
1 | systemd─┬─containerd─┬─containerd-shim─┬─bash───top |
可以看到对于容器来说,bash是PID=1的init进程。在这棵进程树上,containerd-shim可以理解为容器的pid=0进程(当然实际上依然是用户态进程所以还是有很大差别)。
至于为什么宿主机的init进程名是systemd,涉及sysvint和systemd的争议,在这里就不提了。有兴趣的话可以参考这篇。在这里你可以认为systemd是当前版本CentOS上的init进程实现。
主机名不一样
每个容器最好有网络的独立性。这个包括主机名唯一,以及ip和端口不冲突等。先说主机名。
每个Docker容器的主机名等同于容器ID,用这种方式确保唯一。(同一个局域网上hostname重复其实也没大关系,但能做到唯一总更好一些吧)
1 | [root@269111b56ccd /]# hostname |
UTS NameSpace可以实现主机名唯一
ip和端口不冲突
每块网卡一个ip。每个容器有一个自己的ip,那么就要靠虚拟网卡veth。
具体的架构可以参见下图:
所有容器的虚拟机网卡通过bridge桥接到宿主机的网卡上。
我们可以在宿主机上打印出网络接口信息。其中的docker0就是桥接网卡,而veth开头的就是容器的虚拟网卡。
1 | [root@mobilesit network-scripts]$ip li |
在容器里打印网络接口信息,除了lo这个本地环回接口(localhost)之外,就是虚拟网卡eth0了。
1 | [root@269111b56ccd /]# ip li |
有了虚拟网卡,端口映射也是小事一碟了。
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 | [root@mobilesit 12687]$ps -ef|grep sleep |
而容器里的结果是:
1 | UID PID PPID C STIME TTY TIME CMD |
一个是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/
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 | 伤神的博客