容器通过Namespace和Cgroups将自己与宿主机隔离,那么容器里的进程看到文件系统又是什么样子的呢?容器里的程序应该看到完全独立的文件系统,这样它就可以在自己的容器目录(比如 /tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。
但真的是这样情况吗?
一听到文件系统的隔离我们就能想到Mount Namespace。那我们接下来就来讲讲它。
1.Mount Namespace
Mount Namespace:用来隔离各个进程看到的挂载点视图,在不同的Namespace进程中,看到的文件系统层次是不一样的。
我们来个小验证对这个问题进行验证下:在创建子进程时开启指定的 Namespace。
#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};
int container_main(void* arg)
{
printf("Container - inside the container!\n");
execv(container_args[0], container_args);
printf("Something's wrong!\n");
return 1;
}
int main()
{
printf("Parent - start a container!\n");
int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
waitpid(container_pid, NULL, 0);
printf("Parent - container stopped!\n");
return 0;
}
这段代码的功能非常简单:在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。
而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。
来一起编译下程序:
$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
这样我们就进入了容器的内部,这时我们执行ls查看文件,发现/tmp目录下的内容和宿主机完全一样,
$ ls /tmp
# 你会看到好多宿主机的文件
即使开启了Mount Namespace。容器进程看到的文件也和宿主机的文件一样。
原因是:我们需要真正的“挂载”操作后才,进程的视图才会被改变,在此之前,新创建的容器直接继承宿主机的各个挂载点,解决办法是,在创建进程时除了声明要启用 Mount Namespace 之外,我们需要先告诉容器进程,我们要挂载哪个目录。
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 /tmp
可以看到这次ls /tmp目录则是个空文件,证明挂载生效了,可以用mount -l检查下:
$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)
可以看到,容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。
tmpfs:是Linux/Unix系统上的一种基于内存的文件系统。tmpfs可以使用您的内存或swap分区来存储文件。
Mount Namespace对进程的改变一定伴随着挂载(mount)操作才能生效,可不可以有更方便的方式,我们可以在容器启动之前重新挂载它的整个根目录,那么引出了chroot(change root file system)。
2.chroot
chroot:针对正在运行的软件进程和子进程,改变它外显的根目录,
为了让容器看起来更“真实”,一般在根目录挂载一个完整的操作系统的文件系统。比如 Ubuntu16.04 的 ISO,这样容器启动后,在容器里执行ls,查看根目录,则就是Ubuntu的文件目录。
而这个挂载在容器根目录,用来在容器进程提供隔离后执行环境文件的系统,就是所谓的“容器镜像”。他有什么更专业的名字,叫做rootfs(跟文件系统)。
你可能会想怎么又扯到rootfs去了?
chroot是一种在Unix-like系统中改变进程的根目录的方法,它可以将进程的文件系统隔离开,使得进程只能访问到指定的目录及其子目录。而rootfs是一种虚拟文件系统,它创建了一个独立的根目录,并将进程的文件系统限定在这个根目录下。通过使用rootfs,可以实现容器化技术,将应用程序及其依赖项隔离开来,提供更高的安全性和可移植性。因此,可以说rootfs是基于chroot思想的一种扩展和应用。
3.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 完全不同。
需要注意的是,rootfs只是包含运行的文件,其实并不包含宿主机的内核,容器还是运用的宿主机共享操作系统内核,这样的坏处是,要处理内核全局的配置则需要多加小心,更改一处则宿主机的也会被更改,这也是容器比虚拟机主要缺陷之一,虚拟机是模拟出一整个内核系统使用,随便怎么折腾都行。
但也就是这一点,也是容器的优点,由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起,这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。
现在有一个问题,难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次 rootfs 吗?
比如,我在Ubuntu操作系统的 ISO做了一个rootfs,然后又在里面安装了一个java环境,用来部署我的Java应用,那么我另一个同事在发布java应用时显然是想用我的环境的java的rootfs,而不是重复这个流程。
那么假设如果可以每做有意义的操作,则就保存一个rootfs出来,但是这样的话就会碎片化,新旧两个rootfs没有任何的关联,这样的话会产生很多重复的文件。
我们希望更改在一个rootfs里并且以增量的方式进行修改,所以docker在使用rootfs里添加了自己的创新。
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs,这个想法用到了联合文件系统。
4.联合文件系统介绍
联合文件系统 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 中生效。
而在Docker中使用的是AuFS,你可能会有些疑问,那么我们让ChartGpt帮忙回答下吧!
如提问:UnionFS和 AuFS区别,也是Docker容器借鉴的UnionFS?
如提问:UnionFS和 AuFS都是linux上的功能吗?
5.AuFS
在Docker中使用的是AuFS,对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:
/var/lib/docker/aufs/diff/<layer_id>
看一下这个目录的作用:
启动一下容器
docker run -d ubuntu:latest sleep 3600
这时候,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镜像实际上是由五个层组成,这五个层就是5个增量rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)为/var/lib/docker/aufs/mnt/,如:
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
那看这个目录肯定有完整的操作系统了,
$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
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... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0
si=972c6d361e6b32ba,然后使用这个 ID,你就可以在 /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 里面。
总共分为3个部分,只读层、可读写层、初始化层、只读层。
5.1 只读层
第一个部分只读层:
是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是 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
5.2 可读写层
第二部分:可读写层
这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。
你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
容器中的可读写层的作用专门用来存放增删改查的rootfs 后产生的增量,这个被修改容器的内容,也可以通过命令推送到 Docker hub上,
5.3 init层
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。
最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。
6.Volume
1.容器里进程新建的文件,怎么让宿主机获取到?
2.宿主机上的文件和目录,怎么才能让容器里的进程访问到?
这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。
Docker它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目录当中:
$ docker run -v /test ...
$ docker run -v /home:/test ...
这两种声明方式,本质是一样的,都是将目录挂载到容器/test下,第一种的话没有指定宿主机目录,所以默认会在宿主机创造临时目录/var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的/test上,而第二种情况则直接把宿主机的目录/home挂载到容器/test上。
Docker的Volume实际上实现并不复杂,在容器启动后在rootfs准备好后,在执行 chroot 之前,Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。
而这里使用到的挂载技术,就是linux的绑定挂载(bind mount)机制,它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。
其实,这个挂载过程就是inode替换的过程,
inode:可以l理解为是存放文件的“对象”,而dentry也叫目录项,就是访问这个inode所使用的“指针”,
正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。
今天这一讲我们就理解了容器镜像是是什么,以及采用了哪些思想以及工具搭建了现在的容器,虽然今天的内容比较多,但是掌握了受益非浅,看待容器的角度也不同了,也不会觉得它那么神秘了。
本章节根据张磊老师的《深入剖析 Kubernetes》课程来整理的笔记,希望能够给你带来更多的成长!