本章内容介绍docker实现文件系统隔离的几个技术点。docker镜像,docker容器的文件读写模型以及其依赖的技术基础:联合文件系统(unionFS)
本章内容分为4部分:
- 0.容器文件系统隔离的基础 chroot
- 1.联合文件系统 UnionFS;
- 2.docker镜像
- 3.docker容器文件读写模型
chroot 根路径隔离
当我第一次接触docker的时候,印象最深刻的就是,可以在一个docker容器里面任意安装软件,任意捣鼓容器里面的文件,不用担心会影响操作系统本身的文件系统,如安装了某些软件之后,担心卸载不干净。而对于dokcer而言,我们可以启动一个全新的容器,新启动的容器里的文件系统是全新的。
我们知道,linux操作系统的发行是分为 shell + 内核的。shell是用户的界面,用户通过shell来调用内核提供的功能,调用过程会导致发生用户态到内核态的转换。可以认为,Ubuntu和centos系统最大的区别是shell外壳不一样,他们可以使用相同的linux 内核。
在linux系统中,我们执行shell命令都是linux文件系统中的可执行文件。例如: sh , base , ssh 等。我们能直接使用这些命令式因为它们对应的可执行文件的路径都被写入到环境变量 PATH中。我们可以通过命令 which $command
来查找command 对应的可执行文件:
[root@master workspace]# which sh bash ssh
/usr/bin/sh
/usr/bin/bash
/usr/bin/ssh
我们可以将这些可执行文件放到一个特定的路径下,它们一样可以被其他进程所使用。 那么,试想一下,如果我们给某个特定的路径下都存放大多数的linux shell命令执行文件,我们就可以在该路径下执行大部分的命令,例如在 /test/ 路径下有以下文件:
[root@master ~]# mkdir /test/
[root@master ~]# cd /test/
[root@master test]# pwd
/root/test
[root@master test]# ll
total 2680
-rwxr-xr-x 1 root root 960472 Apr 21 22:32 bash
-rwxr-xr-x 1 root root 33072 Apr 21 22:32 echo
-rwxr-xr-x 1 root root 960472 Apr 21 22:32 sh
-rwxr-xr-x 1 root root 778752 Apr 21 22:32 ssh
[root@master test]# ./echo -e "hello "
hello
在linux系统中,我们可以将一个进程囚禁"到一个指定的路径下,修改了该进程对操作系统的文件系统的视图,使得它将指定的路径视为自己的根路径(/),例如囚禁到 /test/下,那么该进程不能访问 /var/,/opt/等的文件,/test/路径作为了该进程的根路径。 我们可以使用chroot命令来达到囚禁进程的目的,可参考这里:chroot
以下举例将一个bash进程 囚禁 到一个指定的目录 /root/jail 下
# 1. 在宿主机上新建路径
mkdir -p /root/jail/{bin,lib,lib64,var,etc}
# 2. 拷贝必要的文件到 路径 /root/jail 下
list=`ldd /bin/ls | egrep -o '/lib.*\.[0-9]'`
for i in $list; do sudo cp -vf $i /root/jail/$i; done
list=`ldd /bin/bash | egrep -o '/lib.*\.[0-9]'`
for i in $list; do sudo cp $i -vf /root/jail/$i; done
# 启动一个 shell,并将其囚禁到 /root/jail 下
chroot /root/jail /bin/bash
# 查看当前 bash进程的根路径文件
[root@worker1 ~]# chroot /root/jail /bin/bash
bash-4.2# bin/ls /
# 返回结果:
bin etc lib lib64 var
可以看到,被囚禁的进程只能访问路径 /root/jail 下的文件。
联合文件系统
Union File System 也叫 UnionFS ,是dokcer制作镜像以及docker容器文件读写模型的主要依赖技术。UnionFS 的主要作用是:将多个path挂载到同一个挂载点上,实现多个path的合并操作(上层同名文件会将下层的文件覆盖),最后通过挂载点向上层应用(或者用户)呈现一个path合并之后的视图。
联合文件系统有多种实现,比如Ubuntu系统的union FS实现为AUFS,centos则使用overlay/overlay2.
这里我们简单介绍的overlay。overlayFS将文件路径分为三类:
- lowerdir
- upperdir
- merged
其中,lowerdir作为最底层,它里面的文件是只读的,而upperdir是上层,可读写。merged为lowerdir和upperdir合并之后暴露给用户的视图,用户在merged层修改的内容最终会反馈到upperdir。
lowerdir本身也是分层的,在一个overlayfs中,lower可以由多个路径组成(一个路径表示一层)。overlayfs的lower层原生支持128层。
想详细深入的可以参考 docker官方对 overlayFS的介绍
下面通过一个overlay 联合文件挂载的例子,来说明其主要工作原理:
- 假设我们新建多个路径以及文件,最后整个目录结构如下:
# A/B 路径下都有一个命名为 x.txt 的文本文件
[root@master mount-demo]# tree
.
├── A
│ ├── a.txt
│ └── x.txt # 文本内容为 A-x
├── B
│ ├── b.txt
│ └── x.txt # 文本内容为 B-x
├── merge
├── upper
└── work
└── work
- 执行挂载文件操作:
mount -t overlay overlay -o lowerdir=A:B,upperdir=upper,workdir=work merge
# 这句命令的意思是:
1. 要将 A,B 按顺序作为 lower层,且A是叠加在B之上的;
2. upper文件夹作为upper层,通过overlay 联合挂载到路径 merge下面。
经过上面的的操作将多个目录联合挂在到一个路径下之后,我们再次查看文件目录结构
.
├── A
│ ├── a.txt
│ └── x.txt
├── B
│ ├── b.txt
│ └── x.txt
├── merge
│ ├── a.txt
│ ├── b.txt
│ └── x.txt # 查看可发现文本内容为 A-x
├── upper
└── work
└── work
可以看到执行UnionFS 挂载后,查看merge路径的文件结构树,得到的视图是路径A,B 合并之后的结果。 其中,A,B都存在的 x.txt文件,最后合并后在merge路径下 x.txt 的内容是 A-x ,也就是说,如果存在同名的情况下,A路径(上层)的 x.txt 文件会覆盖掉 B路径(底层)的x.txt文件。具体原理可 以从overlayFS 的文件结构看出,图片来源于docker官方网站对 overlayFS 的介绍:
可以看到,当存在重名的文件时,处于上层的文件会覆盖掉下层的文件
- 然后,我们尝试在merge路径下修改x.txt 文件
echo "merge x" > merge/x.txt
再次查看upper的x.txt 文件内容
[root@master workspace]# cat upper/x.txt
merge x
查看lower层的A,B两个路径下的x.txt 文本内容
[root@master workspace]# cat A/x.txt
A x
[root@master workspace]# cat B/x.txt
B x
在merge路径下新增一个new.txt 文件:
[root@master merge]# echo "new file" > new.txt
[root@master workspace]# tree
.
├── A
│ ├── a.txt
│ └── x.txt
├── B
│ ├── b.txt
│ └── x.txt
├── merge
│ ├── a.txt
│ ├── b.txt
│ ├── new.txt
│ └── x.txt
├── upper
│ ├── new.txt
│ └── x.txt
└── work
└── work
6 directories, 10 files
通过上面的几步实验,我们不难发现,修改merge层的文件,只会影响到upper层的文件。联合文件系统的写操作有以下特点:
- 在merge路径下 编辑/写入一个文件,其首先会从upper层中寻找看是否存在目标文件,如果存在,则直接修改,如果不存在,则跳入到lower层查找,如果找到了,会把lower层的目标文件拷本一份到upper层,然后对拷贝到upper层的目标文件进行编辑,如果在lower层也查不到,则会将编辑的文件保存一份到upper层。这样,最后用户在merge层看到的视图就是 编辑/写入 的文件。
docker镜像的分层原理
从上面中我们已经大致了解了联合文件系统的基本原理了,那么它有什么作用呢?
我们知道,当我们通过某种手段将一个程序运行所需要的全部依赖都准备好放到任意的一个路径下,我们就可以在该路径下将目标程序跑起来。在docker没有出来之前,业界上已经这方面有一些解决方案,通常大家都将制作依赖的这一个过程称为 RootFS。举一个很粗暴的例子:我们可以将整个操作系统镜像打包,然后移植到生产环境上,这样就能够保证程序运行环境的一致性。(当然现实中大家都不会这么做)但是这些方式都没有做到很便捷,同时,通常来说很多程序运行的依赖都是相似,大部分都是相同的。如果我们直接为每个程序都完完全全做rootfs,那么需要占用很多磁盘空间。
为了摆脱上面提到的问题,docker的创始人做了一个小小的创新,docker镜像是的程序运行的依赖(rootfs),他将docker镜像分层了多个层,这样,不同的镜像文件如果存在相同的层,那么只需要存储一份就可以了,最后通过联合文件系统将多层镜像文件统一挂载到一个路径下,最终向用户(程序)呈现一个多层文件合并之后的视图。
同时,通过docker tag
命令来对镜像做版本管理,一般来说,一个docker镜像从v1升级到v2,仅仅是少部分层发生了改变,这样,就能大大节省磁盘空间的开销了。我们可以这么理解将docker tag:
1. 对一个docker container打tag: 将当前container的upperdir保存起来,新建一个docker image meta文件,并在保存的upperdir加入到原镜像的lowerdir层,且是lowerdir的最上层。 因此,我们不断地对一个container打tag,就是不断将docker 容器读写层保存为只读层的过程。
2. 重新build一个docker镜像,版本号加一: 运行dockerfile,制作出每一层,docker引擎查询当前磁盘是是否已经存在此层,不存在则存储,存在则跳过存储这一步,直到所有层的制作都完成。
目前比较新的版本的docker已经默认使用overlay2 作为联合文件挂载的驱动。可以通过命令 docker info
查看选项 Storage Driver:
[root@master ~]# docker info
Client:
Debug Mode: false
Server:
Containers: 21
Running: 0
Paused: 0
Stopped: 21
Images: 16
Server Version: 19.03.9
Storage Driver: overlay2
Backing Filesystem: xfs
Supports d_type: true
Native Overlay Diff: true
在构建 docker镜像的时候,每一层都是一个RUN指令运行的结果,因此我们在构建镜像的时候为了避免出现多个零散的镜像层,一般都建议构建镜像的dockerfile文件中,在一个RUN指令下声明多个linux shell 命令。
如果两个不同的镜像都包含了一个相同的RUN指令,那么它们最后构建出来的该镜像层都是一样的,这个时候,docker引擎只会存储一份到其本地磁盘,这样就能有效节省空间了。
也就是说,docker引擎存储的任意一层,都可以成为任意镜像文件的组成部分。当一个docker镜像被构建完成后,会对应一个meta文件,该meta文件中含有一个清单列表,说明该镜像文件包含了哪些层。
所以当我们基于一个docker镜像运行一个容器的时候,docker引擎会根据docker镜像包含的层,去检查本地磁盘中是否已经存在,不存在则会去docker镜像仓库中下载到本地。这一个过程和maven构建一个java工程的过程很相似:pom 文件定义了整个工程的依赖,maven package会将所有依赖的jar包集合到target目录下,形成一个可以可执行的jar包。
在不改变docker引擎data-root配置的情况下,所有的层默认都存放在路径 /var/lib/docker/overlay2 下。
下面我们可以通过 命令 docker image inspect $image_id
来从查看一个docker镜像文件的分层
例如,查看镜像 neo4j:4.2 的分层
docker image inspect ce22583052bf
...
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/docker/overlay2/b053be7b0c7ce158e0ae2a940725a1cb05410a847afc5e33751432714115cefa/diff:/var/lib/docker/overlay2/79491074db328e88ba5d9c6845d6ef25d0562b1cee53b12b8b0c5f60674b8eec/diff:/var/lib/docker/overlay2/67fb5253eb3265763994c8f7e73c7525734d0de75439757aa357bc12c52a6b8d/diff:/var/lib/docker/overlay2/540ebc40c3042157a68836b99ff42f799804819957609bccac7afda6e79429ba/diff:/var/lib/docker/overlay2/16db979d325fb0ed4de4c108d5515de4a36cb86abd8ecca6b267803fabffc3c6/diff:/var/lib/docker/overlay2/5b3cb69ddf6af96a6ecf9ce08c33d89cf9c61dcaa4f061e6bbd4d5358c25a785/diff:/var/lib/docker/overlay2/f0f4ca7f82591ddb4bbdc1f02d1f1d287eef07af67b92091c5d4a1202ad8d0fa/diff",
"MergedDir": "/var/lib/docker/overlay2/79b1d1a11f4b250098f36268c286c20f1e32aa9c1b6dc6176282c3965f0e5b21/merged",
"UpperDir": "/var/lib/docker/overlay2/79b1d1a11f4b250098f36268c286c20f1e32aa9c1b6dc6176282c3965f0e5b21/diff",
"WorkDir": "/var/lib/docker/overlay2/79b1d1a11f4b250098f36268c286c20f1e32aa9c1b6dc6176282c3965f0e5b21/work"
},
"Name": "overlay2"
},
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:87c8a1d8f54f3aa4e05569e8919397b65056aa71cdf48b7f061432c98475eee9",
"sha256:4082a04f93b64a34739a41860c91cf7b38cf21998aea7d7707574500659246d7",
"sha256:d35a27c18ee575b18aca3a8fd442628ed312c45344dbbe2cfeb8da1060b4d48e",
"sha256:23949383c40ce379a2f38578bcaa133b5866ac7c589b0504feec213025798550",
"sha256:f42e22be1790f77b595b342d763de1442dc914d048c1ad1bb04ff23fdb21383b",
"sha256:47bd5c80bb3dfaa7498bd8cd300723fb7589b335ade6860c1220b273916ee08c",
"sha256:3fcb5b114f4208b7dbd69449bf428b2f2053a867de814a10e3b3986b6b434d9f",
"sha256:53fa3449befa143a7c390faf3f74490074453d71d621d039ee5135d26b03c6f7"
]
}
...
关注 GraphDriver.Data
- LowerDir: 在docker镜像中这一层称为init,构成容器文件系统的文件路径集合,通过 冒号 : 分割,越是后面的表示越底层。 由于组成LowerDir的镜像层是不可修改的,因此他们可以被一个宿主机上所有容器共用的一层。
- UpperDir: 镜像的upper层,为什么会有这一层?镜像不是一个静态集合吗?(如有读者这一层原因的,请不吝指教)。经过测试,验证发现该层的内容为dockerfile最后一个RUN或者COPY指令的结果。
docker容器的文件系统
经过上面介绍的,我们知道,docker镜像可以包含一个程序运行的所有依赖。 结合chroot的应用,我们可以基于docker镜像作为 rootfs,chroot将进程囚禁到rootfs上,就能实现一个进程的文件系统隔离的功能了。 实际上,docker容器就是这个做的:
docker将一个镜像作为lowerdir层,并且建立一个upperdir层,upperdir层也称为容器读写层(联想我们在docker容器里编辑或者新增文件)。最后将这两层挂载到一个特定的路径下,最终形成docker容器的rootfs。
再次强调,联合文件系统采用了copy-on-wirte的方式来读写文件:当对一个文件进行编辑的时候,会先从上至下逐层查找,如果找到了就copy到upper层,然后进行在upperdir层对文件进行修改。
其中,docker将读写层分为两部分:
这里解释一下init层。Init 层夹在只读层和读写层之间。是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
可以通过命令 docker container inspect $container_id | jq .[0].GraphDriver.Data.MergedDir
获取到一个docker容器的联合文件挂载点:
[root@web /]# docker container inspect 44e7af519cdc | jq .[0].GraphDriver.Data.MergedDir
"/data0/docker/overlay2/07821abe25ab8127470ffabab401b98ecded371168fe4f7d9bfc87c32337fc41/diff"
总结
结合之前几篇对docker技术原理的理解,我们做一个总结: docker容器本质上是一个/一组特殊的进程,它由三部分组成: rootfs + namespaces + cgroup 其中:
- rootfs: 包括了联合文件系统,docker镜像和chroot技术;
- namespaces: 包括了mount-namespace,pid-namespace,network-namespace等手段实现资源虚拟化操作;
- cgroup: 利用linux的cgroup对容器做系统资源(cpu,内存等)做限制。