如何通俗解释Docker是什么?

如何通俗解释Docker是什么?

这个问题是15年提出的,扫了下至今(2022.05)的所有回答都在解释docker是什么与怎么用,那我这个回答就来补充上docker大体上是怎么实现的,用100行C/C++把思路捋一下。懒得看文字的可以直接戳代码:

首先声明,我这个回答只是一份搬运,实现完全参考2018年的GOTO Conference:

这个视频仅以数十行Go实现了一个UTS、pid、filesystem隔离的“迷你docker”,并能限制资源的使用。关于cgroup的部分我个人不是很感兴趣,所以转述为C/C++的时候只实现了前半部分。

好,如果你能够接受:

我建议你直接去看原视频,不要听我转述

如果你:

那继续

最常听到的说法,“docker是一种轻量级、进程级VM”,但这种描述并不能完全解答疑惑,用专业术语去解释专业术语也像没说:所谓的“轻量级、进程级VM”又tm是什么?

如果对普通的VM是怎样也没什么概念,那“docker是一种xx的VM”就更没有意义了,因为根本没有参考的标准。

比如linux的kvm[1],他能够借助硬件的帮助以相对(软件方法)较低的开销实现同指令集(x86)上的全虚拟化。翻译成人话,就是你可以在x86_linux(host OS)上起一个新的x86_linux(guest OS)虚拟机,完全与host独立的操作系统,而且这个操作系统本身不知道虚拟化的存在。当guest OS来到某些执行边界需要host OS介入时,会发生异常trap到host OS里,有kvm处理,再恢复guest OS执行。这个“会发生异常trap到host OS里”就需要硬件的支持,比如Intel VT或者AMD-V;而“同指令集”(在这里就是x86了)是说,你不能借助kvm来实现在x86上跑一个arm_linux的操作系统,只能虚拟出x86_linux。

再比如qemu-system[2]的虚拟化,好吧这其实是emulation,就可以做到cross-architecture的VM,你可以在x86上面随便跑mips或者risc-v的独立OS,但这是软件实现的,具体来说是动态二进制翻译(DBT[3], Dynamic Binary Translation),所以跟kvm这种性能完全没法比,不过如果你是在x86上虚拟x86,qemu也可以借助kvm来加速。

首先docker并不会运行一个独立的操作系统,而只是给进程提供了一种假象,使进程运行得就好像在一个独立的操作系统上。这也是为什么docker被称为进程级、轻量级的VM:他根本不需要另起一个操作系统。

问题来了,没有独立的操作系统,那还能起到VM的作用吗?

这个问题可以反过来想:真的有必要隔离出一个完整的操作系统吗?

并不。很多时候其实我们只是想减轻运维的负担:为程序提供一个统一的“运行环境”。这所谓的“运行环境”,具体来说是系统库、运行时、第三方库等等的文件。如果我们仅仅想保持文件环境的一致,只需要提供一份独立的磁盘镜像即可,并不需要替换操作系统。

当然,一个程序也不只是看到文件,他还看到当前所有进程、网络端口使用等等的一系列系统信息,我们要同时能够隔离这些系统信息,保证每个进程都看到、以为自己是独立运行的。

所有这些,其实都不至于非得另起一个操作系统,因为内核本身就提供一些基础设施(namespace、cgroup等)来达到这一效果,这也是docker得以实现的基础。

【打个广告】对docker不熟悉可以考虑跟下深蓝学院的docker课,比较务实,注重理论和实践,课程内容也比较实用:

我们从实现一个“复读机”开始,也即把我们传递给它的命令直接执行,而不提供任何的隔离功能,这个程序的名字就叫mocker吧(笑。

借助execv一族的函数,这一点可以很容易实现:把命令中的参数丢给execv函数即可。我们模仿docker run 来实现一个mocker run (略去头文件与一些声明):

可以简单运行个ls:

可以运行个bash,对比父子进程的pid:

为了不让bash覆盖掉mocker父进程,我们修改run,多加一个fork,将execv放到子进程中:

得到的输出与先前是类似的:

不过在这里我们会看到子进程的ps 相较之前多显示了一个mocker进程,这是因为我们把bash的执行放到了fork后的子进程中,而父进程的mocker也就得以保留下来,等待子进程的退出,借助ps可以看到进程关系:

接下来,我们实现对UTS的隔离。这里的所谓UTS起到的作用是,显示主机名(hostname)。比如我们可以在host中看到自己的hostname:

同样,在我们的container中也可以看到hostname:

如果我们在container中修改了hostname,那么在host中我们也会察觉到这一修改:

hostname的改动不会立马在@ 后展现出来,但当我们再起一个子进程bash 时,hostname就已被替换为先前container修改的值了。

我们当然不希望container中的行为影响到host,那接下来我们借助namespace技术隔离UTS。从代码的角度我们只要对子进程部分略微改动:

我们稍后再解释这是在做什么,先看看这点改动是否奏效:

child对hostname的修改,没有影响到host,我们实现了对UTS的隔离(用特权级是出于unshare调用的要求)。为了方便区分子进程的hostname,也为了让这个程序看起来正式一些,我们在启动container(也就是我们的这个子进程)的时候自动修改hostname,使之在终端上展现出来:

看起来还真像那么回事,对吧?

现在我们回过头来看看这个unshare,这所谓的namespace是干嘛的。

类比于C++的namespace,内核中的namespace技术也是为了限制“你能看到什么”。namespace也有不同的类别[4],从mount到pid再到网络等等,uts也是其中之一。不同的namespace类别负责限制进程在“某一方面”都能看到什么。根据不同的需求,这个“某一方面”可以是主机名(比如上面我们隔离的hostname),也可以是挂载或者pid等等。

最直观也是最早引入内核的是mount namespace[5],用于实现挂载方面的隔离。把不同进程放在不同的mount namespace中,这样一来你在一个mount namespace中进行的挂载操作,其他mount namespace中的进程看不到,以此实现挂载方面的隔离。

系统启动时,会创建一个global initial mount namespace,所有进程都在其中[5]。当某个进程主动要求创建一个新的mount namespace时,它和它的子进程就都会放到这个新的mount namespace中,实现隔离,同时可以想象到,这些不同的mount namespace之间也会像进程的父子层级关系一样,形成自己的层级关系并由kernel负责维护。

具体到实现上,每个mount namespace都会由一个mnt_namespace[6]kernel object负责维护,当我们在进行诸如open等涉及pathname resolution的syscall时,会经过当前进程对应的mnt_namepace “翻译”,得到这个进程最终看到的pathname对应的inode。当发生unshare[7]时,根据传入的flag,不同的namespace(mount、pid、uts等等)实例被创建/拷贝,并修改当前进程的指向,实现不同namespace间对system resource view的隔离。

类似mount,UTS namespace则是隔离了不同进程的hostname view,这样一来我们修改了container的UTS namespace中的主机名时,host进程namespace中的主机名就不会受到影响了。

接下来我们试着隔离pid namespace。

上面我们在container中执行ps时可以看到container的pid是从host侧pid增长得来的。比如我们可以在程序中打印:

运行结果:

然而我们希望container获得一个全新的pid空间,如此一来我们的container的pid应该是1。为了实现这一点,我们仍借助unshare创建一个新的PID namespace:

这时候再次运行我们就可以看到,container真的以pid 1运行了:

于是你尝试ps,却发现咦,涛声依旧了,妈的咋又不灵了,pid咋又回去了?

别急,这是正常的,这涉及到ps是怎么打印进程信息的。ps是通过读取 /proc目录下的pseudo filesystem来获取当前进程信息的[8],虽然我们将container放到了一个新的PID namespace中,但在读取/proc目录时得到的信息仍然与host进程是相同的,而这一信息仍然是基于原先的PID namespace维护的,所以我们ps看到的pid就又回去了。不过这并不耽误我们的pid在自己进程的视角中是1,因为getpid并不依赖于/proc 中的信息。不管怎么说,我们仍然是成功地隔离了pid view的,只是不那么明显罢了。

另外或许你会发现我们调用unshare创建PID namespace是在父进程中进行的,而不像之前UTS那样在子进程中调用。这其实是kernel实现方面的问题,创建PID namespace的进程本身不会被放入新的PID namespace中[9],而是他的子进程们才会进入新的PID namespace,而fork等函数创建的第一个子进程会以pid 1作为这个PID namespace的intial process,接收orphaned进程等等[10]。

对container来说filesystem的隔离算是非常重要的一部分,所谓的依赖环境正是通过不同的磁盘镜像来实现的。不过在谈这个问题之前,我必须要澄清一点,就是不同的mount namespace本身不能提供filesystem的隔离。

咦,刚不是才说mount namespace能提供不同的mount view吗,怎么又不能了?

也许这个比喻不太恰当,但mount isolation与filesystem isolation间的区别就好比数学家关心一个方程解是否存在,解是否唯一,但工程人员关心这个方程的解是几。

mount isolation关心一个mount point被挂载在一个mount namespace中时,会不会也出现在其他mount namespace中(share、private等等),而filesystem isolation关心的是这个mount point里的内容挂载到哪里去了,里面装的是什么内容。

光说或许很难理解,不妨通过一个例子来看。假如我很巧,手边正好有一个ubuntu filesystem的拷贝在~目录下:

当然,这其中的/proc、/sys等pseudo filesystem都是空的,因为我们只拷贝了真正的磁盘文件。

我们可以对子进程代码做简单的修改,将container对整个filesystem的view限制在这个目录下:

在这部分中我们只做了两处修改:chroot和chdir。其中非常核心的就是这个chroot,它的功能是修改当前进程及后续子进程眼中的根目录,让后续的absolute path文件访问全都从这个新的根目录开始[11],而我们这里就把它改到了我们的文件系统镜像;紧跟着chroot我们用chdir将目录切换到新设置的/,看起来更像一回事了:

但要强调的是,chroot并不是一个面向安全的函数,而只是为了view isolation,你可以很轻易地通过相对目录访问trick[12]来逃出这个根目录。

原先的ubuntu-fs目录中有一个ubuntu-fs.tar文件, 在这里我们也又在container的根目录里看到它了,如此一来我们也实现了对文件系统的隔离:

虽然做到了filesystem isolation,我们仍然未解决/proc目录的问题,这时我们运行ps干脆就开始报错了:

这是因为我们将根目录切换到ubuntu-fs后,后续的proc访问也全都是基于这个basepath了,而很明显,ubuntu-fs下的/proc里是什么都没有的。

不过这可以通过一个简单的挂载来解决:

运行发现说哎这就好啦,就不报错啦,就能显示啦,甚至还体现出来pid隔离啦:

还可以在container中查看挂载过的/proc

但别急着高兴,如果你这时候回到host查看所有挂载过的proc文件系统会发现:

这个挂载点也出现在了host中。也即,虽然我们让container获得一致的pid isolation view,但没能将这个mount point本身的存在从host中隔离出来。

我们退出container,umount掉container挂载的/proc,来看看最后的mount namespace问题。

相比于其他部分,mount的问题一些,比较直观的体现是,你没法通过简单地加一个flag实现预期中的mount point isolation,也就是说:

类比CLONE_NEWUTS、CLONE_NEWPID仅通过给unshare加一个CLONE_NEWNS是没办法实现对mount point的isolation的(可以运行试一试),需要重新设置根目录mount point的propagation type(对“为什么需要这几行”不是三言两语就能解释清的,我把这个放在末尾,有兴趣的话可以去看看,但这里就不展开了):

如此一来我们运行container的时候挂载/proc就不会propagate到host view,container的/procmount point visibility被限制在了container的mount namespace中。不过我们仍然可以在container运行着的时候在host中看到这个mount namespace的存在:

这次搬运的原视频是我在20年看到的,当时就感觉是非常好的资源,即便你对namespace、cgroup所知甚少(在当时我甚至是不知道有这两项技术存在的),也能大致理解她在说什么。用到的Go也是很简单的语法,只要对C系的语言有所了解应该就能看懂。

最近也是出于研究需要重新回忆这方面的内容,闲暇之余在知乎搜了一圈发现到现在也没人提到过这个资源,实在是太可惜了。

在知乎总能看到有人分享很好的资源,这次我也来贡献点力量,希望对于想更进一步了解容器技术的人,这篇回答能多少解决他们的一些疑惑~

这部分的讨论主要是参考了lwn上的这篇文章:

在一开始提到mount namespace的时候,我们说他隔离了不同mount namespace间mount point可见性的隔离。然而在一开始kernel实现了这个feature的时候,人们发现这样会导致一些易用性的问题,主要是因为mount namespace提供的隔离有点强过头了。

最直观的一个例子是,如果我们clone一个新的mount namepsace而不chroot,完全隔离会导致我们在原先的mount namespace下挂载一个新的设备时,新的mount namespace完全看不到这个设备,结果就是我们不得不手动在所有需要的mount namespace中手动挂载一次。也就是说,我们在希望不同mount namespace隔离的同时也希望它们之间能够实现部分mount point的共享。

出于上面这些考虑,后续的内核实现了shared subtree feature,借助这个feature来实现自动的mount/unmount事件传播。具体来说,每个mount point会带有一个propagation type,它决定这个mount point下的直接mount point(也即“mount point下的mount point创建/删除是否propagate不归我管”)创建/删除如何propagate到其他mount points。

propagation type分为四类,分别是:

MS_UNBINDABLE:这一个不可以被bind的private mount point,也即除了具备MS_PRIVATE外,它不能被当作其他bind mount的source mount point[13]

那么peer group是怎么产生的呢?有两种途径:

也即原先MS_SHARED的mount point在clone后的mount point也自动进入它的peer group。

说了这么多,听起来很啰嗦,但为了理解我们到底要解决什么问题,这些background是必要的。

我们回过头来考虑一个问题:当创建(而非clone或者bind)一个新的mount point的时候,它的默认propagation type是什么?

在linux中是按照如下规则来确定的:

很明显按照这个规则,在最开始挂载root的时候它的propagation type会是MS_PRIVATE,而且如果我们不显式地声明,那么后续的创建所有mount point都会是MS_PRIVATE。

而我们刚才也说了,像诸如设备的挂载,我们其实反而希望它们能够自动propagate到不同的其他mount namespace所clone出来的对应mount point,从这个角度来看MS_SHARED反而是一个更好的default type。

考虑到上面的种种原因,systemd会将系统中初始mount namespace的mount point全部设置为MS_SHARED,因此在实际的linux发行版中,默认的propagation type其实是MS_SHARED。

不过对于像unshare[15]这样的工具(是command line tool的unshare而不是我们上面调用的库函数的那个unshare),它的本意就是将新的进程放在一个完全隔离的mount namespace中执行,因此在启动一个新的mount namespace中,它会重新将所有mount point全部设置为MS_PRIVATE,就类似于clone完mount namespace后立即:

递归地将根目录下的所有mount point重置为MS_PRIVATE。

回到我们的那一处代码中的修改来:

在这里我们必须要弄清mount之前发生了什么。我们clone了一个mount namespace,而先前mount namespace中mount point的propagation type可以在host中看到:

因此当我们clone了namespace后,root mount point会是MS_SHARED状态,此时我们chroot并挂载/proc时,由于新mount namespace的root mount point与host mount namespace在相同peer group中,我们在root mount point下发生的immediate mount会反映到host namespace中,结果就是我们在host中也观测到了一个新的proc mount point。值得注意的是,我们虽然将container的根目录给chroot到了ubuntu-fs下,却不会影响ubuntu-fs/proc是root mount point的immediate child mount point这个事实。因为root mount point和根目录并不是一回事(类似mount point hierarchy vs. directory hierarchy),chroot只是改变了container视角中的根目录位置,却没有改变ubuntu-fs不是一个mount point的事实,也因此root mount point的propagation type会左右container中/proc在peer group中的可见性(怎么感觉车轱辘话在来回说。。

为了让/proc的挂载对其他mount point(这里也即其他mount namespace)不可见,我们用类似unsharecommand line tool的做法:

将根目录下的所有mount point递归地设置为MS_SLAVE(MS_PRIVATE也可,MS_LAVE可以让host的mount操作对container单向可见),于是我们传递给mount的flag就是MS_SLAVE和MS_REC。