我们接着上篇文章继续讨论,通常情况下我们不希望,也不允许容器和宿主机看到相同的文件系统,那样的话多个容器实例和宿主机会共享相同的文件系统,隔离性就不存在了,因此我们一般情况下通过给每个容器创建独享的mount命名空间来实现文件系统的隔离。
首先我们来通过例子看看如何赋予进程独享的mount命名空间:在自己的机器上运行sudo unshare --mount sh来启动一个新的sh进程,接着创建文件夹source,并在文件夹中创建文件HELLO;然后我们再创建另外一个文件件target,并且通过命令mount --bind source target来将source文件夹bind到target文件夹,如果我们在target文件夹中运行ls -l命令,就可以看到source文件夹中的文件HELLO。如下是在笔者机器上的输出:
vagrant@vagrant:~$ sudo unshare --mount sh
$ mkdir source
$ touch source/HELLO
$ ls source
HELLO
$ mkdir target
$ ls target
$ mount --bind source target
$ ls target
HELLO
上边的输出符合预期,我们接着通过命令findmnt来看看进程sh中具体有哪些mount,命令运行后会输出结果,你可以看到如下这段输出,也就是我们运行mount命令将源文件夹source和目标文件夹target关联在一起:
$ findmnt target
TARGET SOURCE FSTYPE OPTIONS
/home/vagrant/target
/dev/mapper/vagrant--vg-root[/home/vagrant/source]
ext4 rw,relatime,errors=remount-ro,data=ordered
读者可能会想知道,这个bind是否从宿主机可见。我们如果在宿主机上运行相同的命令findmnt,你会发现根本看不到上边的这个mount信息。我们刚才在sh的进程中继续运行命令findmnt,这次不带任何参数,你会发现会返回很长的一个mount列表。如果进程具备隔离性,是不应该看到宿主机上的全部mount信息的,这显然是有问题的。
如果你还记得我们讨论PID命名空间的情况吗,如果只是通过unmount --pid来把进程加入到一个新的PID命名空间中,我们是无法阻止容器看到宿主机的所有进程信息,因为容器进程中运行ps的时候,其实是请求内核读取/proc文件夹,而对于mount命名空间类似,当我们执行findmnt命令的时候,内核读取文件夹/proc/
当我们创建进程的时为进程分配了新的mount命名空间,但是在进程中运行findmnt的时候,使用的仍然是宿主机的/proc文件夹,因此返回的mount信息中包含了进程创建时间点之前宿主机上所有的mount信息,我们可以通过cat进程ID对应的文件来验证:cat /proc/
那么我们如何能让新创建的进程有自己专属的文件系统呢?我们可以通过:1,为容器进程创建新的mount命名空间;2,为进程设置新的root文件系统;3,为进程创建新的proc mount,如下边在笔者的机器运行所示:
vagrant@vagrant:~$ sudo unshare --mount chroot alpine sh
/ $ mount -t proc proc proc
/ $ mount
proc on /proc type proc (rw,relatime)
/ $ mkdir source
/ $ touch source/HELLO
/ $ mkdir target
/ $ mount --bind source target
/ $ mount
proc on /proc type proc (rw,relatime)
/dev/sda1 on /target type ext4 (rw,relatime,data=ordered)
熟悉docker的同学一定有过将宿主机上的某个文件夹挂载到容器中的经验,比如我们要在容器中运行某些应用程序或者读取需要的数据,命令docker run -v <宿主机目录> : <容器目录> ..., 本质上当容器的root文件夹系统被加载后,会立即创建容器上的目标目录,然后宿主机上的目录被bind到容器中的目录上,由于每个容器都有自己的mount命名空间,因此通过这种方式挂载到容器中的目录,对其他容器不可见。
有了mount命名空间的知识后,我们继续讨论Network命名空间。网络(Network)命名空间赋予容器专属的网络设备接口和路由表。我们可以通过命令行工具lsns来查看机器上的network命名空间。进程启动可以通过--net来创建新的命名空间,如下边命令的输出所示:
vagrant@vagrant:~$ sudo lsns -t net
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531992 net 93 1 root unassigned /sbin/init
vagrant@vagrant:~$ sudo unshare --net bash
root@vagrant:~$ lsns -t net
NS TYPE NPROCS PID USER NETNSID NSFS COMMAND
4026531992 net 92 1 root unassigned /sbin/init
4026532192 net 2 28586 root unassigned bash
当进程运行在专属的network命名空间,如果不做任何配置,进程只能看到回路接口(lo)。我们可以通过ip a来验证,如下是在笔者机器上的输出:
vagrant@vagrant:~$ sudo unshare --net bash
root@myhost:~$ ip a
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
只有loopback回路地址也没有啥用,最简单的容器之间进行相互通信都无法实现。为了让容器能够和外界连接,我们需要给容器进程创建virtual Ethernet网络接口,或者严格来说,我们其实创建了一对虚拟以太网接口。
虚拟以太网接口人如其名,就如同办公室中的电缆一样,将容器的网络命名空间和宿主机上的默认网络命名空间连接了起来。我们在保持前边bash进程的前提下,在宿主机上的另外一个控制台窗口上运行命令:root@vagrant:~$ ip link add ve1 netns 32586 type veth peer name ve2 netns 1 来为进程bash创建一对虚拟以太网接口(进程id是32586),其中:
- ip link add命令告诉宿主机我们要增加一个link
- ve1是虚拟以太网这根电缆的容器进程端的名字
- netns 32586表示ve1这一端插入到进程32586这个进程的网络命名空间中
- type veth表示类型,veth表示虚拟以太网
- peer name ve2表示虚拟以太网电缆另外一端的名称为ve2
- netns 1表示ve2插入到进程号1的网络命名空间
上边的命令运行成功后,我们就可以成功的在容器进程中看到ve1(容器端的虚拟以太网接口),如下是在笔者机器上启动的bash进程中运行ip a看到的输出:
root@vagrant:~$ ip a
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ve1@if3:
link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0
不行的是状态DOWN表明这个接口现在无法使用,为了从宿主机到容器来通,我们需要将这个叫ve1@if3的以太网接口激活,这意味着我们需要同时从宿主机上和容器内将这个两端都激活。首先我们在宿主机上,也就是ve2端将虚拟以太网link激活,运行命令:root@vagrant:~$ ip link set ve2 up,接着我们在容器中,也就是ve1端运行相同的命令后,再次通过ip a查看进程的可见网络接口就会发现状态为UP,在笔者的机器上输出如下:
root@vagrant:~$ ip link set ve1 up
root@vagrant:~$ ip a
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: ve1@if3:
link/ether 7a:8a:3f:ba:61:2c brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet6 fe80::788a:3fff:feba:612c/64 scope link
valid_lft forever preferred_lft forever
眼尖的同学会发现ve1@if3这个接口上根本没有ip地址啊,为了容器进程和宿主机IP数据包相互可达,我们需要在以太网的两端设置响应的IP地址,在容器中运行命令:root@vagrant:~$ ip addr add 192.168.1.100/24 dev ve1来为容器端网络端口设置IP段,同样在宿主机上运行命令:root@vagrant:~$ ip addr add 192.168.1.200/24 dev ve1来为宿主机端的以太网设备配置IP地址段。
当我们分别在两端设置好IP地址后,容器的路由表也会插入相应的路由信息,如下的是笔者在bash进程中运行ip route看到的输出:
root@myhost:~$ ip route
192.168.1.0/24 dev ve1 proto kernel scope link src 192.168.1.200
读者可以实际操作一下,这表路由配置信息就允许我们将IP数据包发送到宿主机。
由于我们在前边的文章中详细介绍过cgroup的原理,因此咱这里就不在累述了,简单来说,cgroup让进程只能看到自己专属的cgroup资源控制文件夹。cgroup命名空间和其他的命名空间比起来,被加入到Linux的时间靠后,大部分命名空间是在linux内核版本3.8被引入,而cgroup命名空间知道内核版本v4.6才被引入。
在Linux操作系统中,进程间可以通过共享内存或者消息队列来进行通信,前提是两个进程必须所属相同的IPC命名空间。通常来说,我们不希望一个运行在一个进程中的应用程序访问另外一个进程的内存,因此大部分情况下进程都有自己独享的IPC命名空间。
我们可以通过ipcmk和ipcs来验证,ipcmk用来创建一个共享内存段,而ipcs用来返回IPC的状态,在笔者的机器上输出如下:
$ ipcmk -M 1000
Shared memory id: 98307
$ ipcs
------ Message Queues --------
key msqid owner perms used-bytes messages
48 | Chapter 4: Container Isolation
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0xad291bee 98307 ubuntu 644 1000 0
------ Semaphore Arrays --------
key semid owner perms nsems
0x000000a7 0 root 600 1
我们可以在启动进程的时候,通过unshare --ipc来给进程创建新的IPC命名空间,然后在进程中运行ipcs后,从返回的结果就可以看到这是一个新的IPC命名空间,没有任何进程间通信的结构。
最后我们来说说User命名空间。User命名空间本质上让进程有自己专属的用户(user)和组(group)视图。user命名空间和PID命名空间类似,只是在新的user命名空间中,宿主机上的user和组信息可以有不同的ID。
从安全的角度看,user命名空间是革命性的, 因为我们可以把容器中的0号用户(root用户)映射到宿主机上的非root用户上,这样即便是恶意攻击者从容器中逃逸,那么在宿主机上也只持有一个权限有限的账户。
但是不幸的是,user命名空间目前还不是太被广泛的使用,Docker上默认为开启user命名空间,并且Kubernetes根本就不支持,不过这个命名空间在kubernetes社区正在积极的被讨论是否加入。
我们来实践一下,在自己机器上通过命令unshare --user bash来启动一个新的bash进程,在进程中运行id命令返回用户信息,如下输出所示:
vagrant@vagrant:~$ unshare --user bash
nobody@vagrant:~$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
nobody@vagrant:~$ echo $$
31196
本质上来说,在新的用户命名空间中,宿主机上的用户1000,在宿主机上的ID是nobody,并且ID是65534,具体来说,用户信息从宿主机映射到进程是通过文件/proc/
比如我们为进程31196运行命令sudo echo '0 1000 1' > /proc/31196/uid_map后,用户1000在进程中就有了root权限,我们可以通过运行id命令来验证:
nobody@vagrant:~$ id
uid=0(root) gid=65534(nogroup) groups=65534(nogroup)
root用户在容器进程内部有几乎所有的权限,我们可以通过命令capsh --print | grep Current来验证,输出的很长一串capabilities。咱们在前边的文章介绍过Linux操作系统的capabilities机制,在容器内部,内容允许这个“伪”root用户可以干几乎所有的事情,比如创建命名空间,设置网络等。
如果我们在启动进程的时候需要创建多个新的命名空间,那么一般情况下user命名空间是首先会被创建出来,然后用户会具备root权限,比如我们不通过root权限运行unshare --uts bash会出错,但是运行unshare --uts --user bash不会报错并成功。
注:Docker默认情况下并没有turn on 用户命名空间,主要原因是兼容性问题。比如我们的应用程序在容器中以root权限运行,应用无法监听小于1024的端口,主要原因是容器的root权限没有CAP_NET_BIND_SERVICE能力。
从宿主机的角度来看,虽然我们通常把docker运行起来的应用程序称作“容器实例”,但是从宿主机上看到的更多是“容器化的进程”。运行应用程序的容器实例仍然是Linux操作系统上的一个进程,唯一的区别是这些叫容器的进程只能看到宿主机的一部分,只能访问操作系统文件系统的一部分,只能使用机器的部分资源。
笔者想要强调的是,无论怎么看,运行在Docker中的容器实例是操作系统的一个进程,虽说有自己的进程上下文,但是同一台机器上的进程共享底层的操作系统内核。我们可以同时从宿主机上和容器内部看到应用程序进程,唯一的不同就是进程ID。宿主机上可以看到所有容器进程这个事实决定了容器的隔离边界,从安全的角度来看,如果恶意攻击者攻破了宿主机,那么黑客就有能力影响所有运行在这台机器上的应用程序。
通过这篇文章,读者应该很清楚一个事实,容器和宿主机共享同一个操作系统内核,从安全的角度衍生了新的安全问题。笔者强烈建议将容器进程运行在专属的物理机或者虚拟机上(也就是这些机器上不要跑其他业务负载),原因主要是安全的考虑:
- 当我们使用像kubernetes这样的容器编排平台来部署应用程序,意味着运维人员基本不需要直接登陆到宿主机上。这样安全性也就更好,因为宿主机上只需要跑类似于kubelets和kube proxy这样的插件,攻击面被极大的缩减。
- 我们在设计应用的部署架构时,可以考虑Thin OS,这种定制的OS只包含运行容器应用的必要组件,降低了存在安全漏洞的风险。
- 集群中的宿主机做到immutable,当一台机器故障的时候,我们不是修复这台机器,而是替换这台机器,这样所有机器的安全补丁受统一控制,管理更加容易。
好了,咱们这篇文章就这么多了,下篇文章我们继续讨论容器和虚拟机的差异,这是我们理解容器化部署的基石,敬请期待!