容器(Docker) 第二章:静态视图

Author: Lijb
Email: lijb1121@163.com
WeChat: ljb1121

容器的文件系统

根据容器的隔离与限制我们知道, /tmp 目录下的内容跟宿主机的内容是一样的,换句话说即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一 样。原因是因为只有在“挂载”这个操作发生之后,进程的视图 才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

推导

有一个解决办法:创建新进程时,除了声明要启用 Mount Namespace 之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个 /tmp 目录。于是,我 们在容器进程执行前可以添加一步重新挂载 /tmp 目录的操作:

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

可以看到,在修改后的代码里,我在容器进程启动之前,加上了一句 mount(“none”, “/tmp”, “tmpfs”, 0, “”) 语句。就这样,我告诉了容器以 tmpfs(内存盘)格式,重新挂载了 /tmp 目录。

  • 编译修改后的代码
$ gcc -o ns ns.c 
$ ./ns 
Parent - start a container! 
Container - inside the container! 
$ ls /tm

可以看到,这次 /tmp 变成了一个空目录,这意味着重新挂载生效了。我们可以用 mount -l 检 查一下:

$ mount -l | grep tmpfs 

none on /tmp type tmpfs (rw,relatime) 

可以看到,容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。

因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作, 只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一下这个挂 载,你会发现它是不存在的:

# 在宿主机上 
$ mount -l | grep tmpfs 
  • 结论
Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的 改变,一定是伴随着挂载操作(mount)才能生效。

思考:

每当创建一个新容器时,我希望容 器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做 到这一点呢?

rootfs

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。 顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的 位置。它的用法也非常简单。假设,我们现在有一个 $HOME/test 目录,想要把它作为一个 /bin/bash 进程的根目录。

  • 首先,创建一个 test 目录和几个 lib 文件夹
$ mkdir -p $HOME/test 

$ mkdir -p $HOME/test/{bin,lib64,lib} 

$ cd $T 
  • 然后,把 bash 命令拷贝到 test 目录对应的 bin 路径下:
$ cp -v /bin/{bash,ls} $HOME/test/bin
  • 接下来,把 bash 命令需要的所有 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文 件可以用 ldd 命令:
$ T=$HOME/test 

$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')" 

$ for i in $list; do cp -v "$i" "${T}${i}"; done 
  • 最后,执行 chroot 命令,告诉操作系统,我们将使用 $HOME/test 目录作为 /bin/bash 进程 的根目录:
$ chroot $HOME/test /bin/bash 

这时,你如果执行 “ls /”,就会看到,它返回的都是 $ HOME/test 目录下面的内容,而不是宿 主机的内容。
更重要的是,对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被“修改”成 $HOME/test 了。

类似于 Linux Namespace 实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载 一个完整操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容 器里通过执行 “ls /” 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件。
而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容 器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的 rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如 /bin,/etc,/proc 等等:

$ ls 
/ bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var 

当进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。

  • 结论

对 Docker 来说,它最核心的原理实际上就是为待创建的用户进程执行如下操作

1. 启用 Linux Namespace 配置;
		Linux Namespace保证了容器环境隔离
2. 设置指定的 Cgroups 参数;
		Cgroups限制了容器使用资源
3. 切换进程的根目录(Change Root)。
		rootfs保证了容器运行环境的一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

Union File System

Docker 公司在实现 Docker 镜像时并没有沿用以前制作 rootfs 的标准流程, 而是做了一个小小的创新:
当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。
Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

  • 比如,我现在有两个目录 A 和 B,它们分别有两个文件:
$ tree 
. 
├── A 
│  ├── a 
│  └── x 
└── B   
  ├── b   
  └── x 
  • 然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上
$ mkdir C 

$ mount -t aufs -o dirs=./A:./B none ./C 
  • 这时,我再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一起:
$ tree ./C 
./C 
├── a 
├── b 
└── x

可以看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,并且 x 文件只有一份。这,就 是“合并”的含义。此外,如果你在目录 C 里对 a、b、x 文件做修改,这些修改也会在对应的 目录 A、B 中生效。

思考:

在 Docker 项目中,又是如何使用这种 Union File System 的呢?

Docker镜像的分层

Docker 从 Docker Hub 上拉取一个 Ubuntu 镜像实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作 系统的所有文件和目录。不过,与 rootfs 稍微不同的是,Docker 镜像使用的 rootfs,往往由多个“层”组成:

  • 镜像信息
$ docker image inspect ubuntu:latest 
... 
	"RootFS": {
	"Type": "layers",       
	"Layers": [         
		"sha256:f49017d4d5ce9c0f544c...",       
		"sha256:8f2b771487e9d6354080...",      
		"sha256:ccd4d61916aaa2159429...",     
		"sha256:c01d74f99de40e097c73...",      
		"sha256:268a067217b5fe78e000..."   
        
     	]    
     } 

可以看到,这个 Ubuntu 镜像,实际上由五个层组成。这五个层就是五个增量 rootfs,每一层 都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载 在一个统一的挂载点上(等价于前面例子里的“/C”目录)。
这个挂载点就是 /var/lib/docker/aufs/mnt/,比如

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

不出意外的,这个目录里面正是一个完整的 Ubuntu 操作系统

$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3 bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的 Ubuntu 文件系统的呢?
这个信息记录在 AuFS 的系统目录 /sys/fs/aufs 下面。

  • 首先,通过查看 AuFS 的挂载信息,我们可以找到这个目录对应的 AuFS 的内部 ID(也叫: si):
$ cat /proc/mounts| grep aufs 
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufsrw,relatime,si=972c6d361e6b32ba,di
  • 然后使用这个 ID:si=972c6d361e6b32ba,你就可以在 /sys/fs/aufs 下查看被联合挂载在一起的各个层的信息
$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]* /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh /var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh /var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh /var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh /var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh /var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh 

从这些信息里,我们可以看到,镜像的层都放置在 /var/lib/docker/aufs/diff 目录下,然后被 联合挂载在 /var/lib/docker/aufs/mnt 里面。
而且,从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成

img

  • 第一部分,只读层

只读层是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它 们的挂载方式都是只读的(ro+wh,即 readonly+whiteout)。这些层,都以增量的方式分别包含了 Ubuntu 操作系统的一部分

$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0... 
etc sbin usr var 
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2... 
run 
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900... 
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
  • 第二部分,可读写层
它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生 的内容就会以增量的方式出现在这个层中。

可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文 件“遮挡”起来。

比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了 一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文 件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的 含义。我喜欢把 whiteout 形象地翻译为:“白障”。

所以,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是 增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人 使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量 rootfs 的好处
  • 第三部分,Init 层
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一 个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要 在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息 连同可读写层一起提交掉。

所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值