从零开始写 Docker(五)---基于 overlayfs 实现写操作隔离
本文为从零开始写 Docker 系列第五篇,在 pivotRoot
基础上通过 overlayfs 实现写操作隔离,达到容器中写操作和宿主机互不影响。
完整代码见:https://github.com/lixd/mydocker 欢迎 Star
推荐阅读以下文章对 docker 基本实现有一个大致认识:
- 核心原理:深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs
- 基于 namespace 的视图隔离:探索 Linux Namespace:Docker 隔离的神奇背后
- 基于 cgroups 的资源限制
- 基于 overlayfs 的文件系统:Docker 魔法解密:探索 UnionFS 与 OverlayFS
- 基于 veth pair、bridge、iptables 等等技术的 Docker 网络:揭秘 Docker 网络:手动实现 Docker 桥接网络
开发环境如下:
root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 20.04.2 LTS
Release: 20.04
Codename: focal
root@mydocker:~# uname -r
5.4.0-74-generic
注意:需要使用 root 用户
1. 概述
上一篇中已经实现了使用宿主机 /root/busybox 目录作为容器的根目录,但在容器内对文件的操作仍然会直接影响到宿主机的 /root/busybox 目录。 本节要进一步进行容器和镜像隔离,实现在容器中进行的操作不会对镜像(宿主机/root/busybox目录)产生任何影响的功能。
什么是 overlayfs?
overlayfs 是 UFS 的一种实现,UnionFS 全称为 Union File System ,是一种为 Linux FreeBSD NetBSD 操作系统设计的,把其他文件系统联合到一个联合挂载点的文件系统服务。
它使用 branch 不同文件系统的文件和目录“透明地
”覆盖,形成一个单一一致的文件系统。
这些 branches 或者是 read-only 或者是 read-write 的,所以当对这个虚拟后的联合文件系统进行写操作的时候,系统是真正写到了一个新的文件中。看起来这个虚拟后的联合文件系统是可以对任何文件进行操作的,但是其实它并没有改变原来的文件,这是因为 unionfs 用到了一个重要的资管管理技术叫写时复制。
写时复制(copy-on-write,下文简称 CoW),也叫隐式共享,是一种对可修改资源实现高效复制的资源管理技术。
它的思想是,如果一个资源是重复的,但没有任何修改,这时候并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。
创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少未修改资源复制带来的消耗,但是也会在进行资源修改的时候增减小部分的开销。
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 文件只有一份。这,就是“合并”的含义。
这就是联合文件系统,目的就是将多个文件联合在一起成为一个统一的视图。
UFS 有多种实现,例如 AUFS、Overlayfs 等,这里使用比较主流的 Overlayfs。
关于 Overlayfs 详细介绍可以看一下这篇文章:Docker 魔法解密:探索 UnionFS 与 OverlayFS
里面详细介绍了 overlayfs 各个特性,以及 docker 中是如何使用 Overlayfs 的。
这里对需要用到部分做简要说明:
首先,overlayfs 一般分为 lower、upper、merged 和 work 4个目录。
- lower 只读层,该层数据不会被修改
- upper 可读写层,所有修改都发生在这一层,即使是修改的 lower 中的数据。
- merged 视图层,可以看到 lower、upper 中的所有内容
- work 则是 overlayfs 内部使用
在本文实现中使用我们的镜像目录(busybox 目录) 作为 lower 目录,这样可以保证镜像内容被修改。
merged 目录由于可以看到全部内容,因此作为容器 rootfs 目录,即 pivotRoot 会切换到 merged 目录。
upper 目录则是用于保存容器中的修改,因为 overlayfs 中所有修改都会发生在这里。
2. Mount Overlayfs
Docker 在使用镜像启动一个容器时,会新建2个layer: write layer和 container-init layer。
write layer是容器唯一的 可读写层; 而 container-init layer 是为容器新建的只读层,用来存储容器启动时传入的系统信息。
不过在实际的场景下,它们并不是以write layer和container-init layer命名的
最后把 write layer、container-init layer 和相关镜像的 layers 都 mount 到一个 mnt 目录下,然后把这个 mnt 目录作为容器启动的根目录。
同样的,我们在容器启动前,也需要先 mount 好 overlayfs 目录,然后执行 privotRoot 时直接切换到 mount 好的 overlayfs merge 目录即可。
NewWorkSpace 函数是用来创建容器文件系统的,它包括 createLower、createDirs和mountOverlayFS。 分为以下步骤:
1)准备 busybox 目录,之前都是手动解压准备 /root/busybox 目录,这次把解压逻辑加入到代码中。只需要准备好 busybox.tar 文件即可。容器启动时自动将 busybox.tar 解压到 busybox 目录下,作为容器的只读层。
2)准备 overlayfs 目录,创建好挂载 overlayfs 需要的 upper、work 和 merged 目录
3)实现 mount overlayfs,将 merged 目录作为挂载点,然后把 busybox、upper 挂载到 merged 目录。
4)更新 pivotRoot 调用目录,将 rootfs 从宿主机目录 root/busybox 切换到上一步中挂载的/root/merged 目录
最后 NewParentProcess 函数中将容器使用的宿主机目录 root/busybox 替换成/root/merged。
// NewWorkSpace Create an Overlay2 filesystem as container root workspace
func NewWorkSpace(rootPath string) {
createLower(rootPath)
createDirs(rootPath)
mountOverlayFS(rootPath)
}
// createLower 将busybox作为overlayfs的lower层
func createLower(rootURL string) {
// 把busybox作为overlayfs中的lower层
busyboxURL := rootURL + "busybox/"
busyboxTarURL := rootURL + "busybox.tar"
// 检查是否已经存在busybox文件夹
exist, err := PathExists(busyboxURL)
if err != nil {
log.Infof("Fail to judge whether dir %s exists. %v", busyboxURL, err)
}
// 不存在则创建目录并将busybox.tar解压到busybox文件夹中
if !exist {
if err := os.Mkdir(busyboxURL, 0777); err != nil {
log.Errorf("Mkdir dir %s error. %v", busyboxURL, err)
}
if _, err := exec.Command("tar", "-xvf", busyboxTarURL, "-C", busyboxURL).CombinedOutput(); err != nil {
log.Errorf("Untar dir %s error %v", busyboxURL, err)
}
}
}
// createDirs 创建overlayfs需要的的upper、worker目录
func createDirs(rootURL string) {
upperURL := rootURL + "upper/"
if err := os.Mkdir(upperURL, 0777); err != nil {
log.Errorf("mkdir dir %s error. %v", upperURL, err)
}
workURL := rootURL + "work/"
if err := os.Mkdir(workURL, 0777); err != nil {
log.Errorf("mkdir dir %s error. %v", workURL, err)
}
}
// mountOverlayFS 挂载overlayfs
func mountOverlayFS(rootURL string, mntURL string) {
// mount -t overlay overlay -o lowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged
// 创建对应的挂载目录
if err := os.Mkdir(mntURL, 0777); err != nil {
log.Errorf("Mkdir dir %s error. %v", mntURL, err)
}
// 拼接参数
// e.g. lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/merged
dirs := "lowerdir=" + rootURL + "busybox" + ",upperdir=" + rootURL + "upper" + ",workdir=" + rootURL + "work"
// dirs := "dirs=" + rootURL + "writeLayer:" + rootURL + "busybox"
cmd := exec.Command("mount", "-t", "overlay", "overlay", "-o", dirs, mntURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("%v", err)
}
}
接下来,在 NewParentProcess 函数中将容器使用的宿主机目录/root/busybox 替换成root/mnt 。这样 ,使用 OverlayFS 系统启动容器的代码就完成了。
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
// 省略其他代码
cmd.ExtraFiles = []*os.File{readPipe}
mntURL := "/root/merged/"
rootURL := "/root/"
NewWorkSpace(rootURL, mntURL)
cmd.Dir = mntURL
return cmd, writePipe
}
3. Unmount Overlayfs
Docker 会在删除容器的时候,把容器对应 WriteLayer 和 Container-init Layer 删除,而保留镜像所有的内容。本节中在容器退出的时候也会删除 upper、work 和 merged 目录只保留作为镜像的 lower 层目录即 busybox。
具体步骤如下:
- 1)unmount overlayfs:将/root/merged目录挂载解除
- 2)删除其他目录:删除之前为 overlayfs 准备的 upper、work、merged 目录
由于 overlayfs 的特性,所有修改操作都发生在 upper 目录,因此目录删除后容器对文件系统的更改,就都已经抹去了。
DeleteWorkSpace 函数包括 umountOverlayFS 和 deleteDirs。
// DeleteWorkSpace Delete the AUFS filesystem while container exit
func DeleteWorkSpace(rootURL string, mntURL string) {
umountOverlayFS(mntURL)
deleteDirs(rootURL)
}
func umountOverlayFS(mntURL string) {
cmd := exec.Command("umount", mntURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("%v", err)
}
if err := os.RemoveAll(mntURL); err != nil {
log.Errorf("Remove dir %s error %v", mntURL, err)
}
}
func deleteDirs(rootURL string) {
writeURL := rootURL + "upper/"
if err := os.RemoveAll(writeURL); err != nil {
log.Errorf("Remove dir %s error %v", writeURL, err)
}
workURL := rootURL + "work"
if err := os.RemoveAll(workURL); err != nil {
log.Errorf("Remove dir %s error %v", workURL, err)
}
}
4. 测试
首先将busybox.tar
放到 /root 目录下:
$ ls
busybox.tar
然后启动我们的容器
root@mydocker:~/feat-overlayfs/mydocker# ./mydocker run -it /bin/sh
{"level":"info","msg":"resConf:\u0026{ 0 }","time":"2024-01-16T13:36:38+08:00"}
{"level":"info","msg":"enter NewWorkSpace","time":"2024-01-16T13:36:38+08:00"}
{"level":"info","msg":"enter createLower","time":"2024-01-16T13:36:38+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-16T13:36:38+08:00"}
再次查看宿主机的 /root 目录:
root@mydocker:~# ls /root
busybox busybox.tar merged upper work
可以看到,多了几个目录:busybox、merged、upper、work。
在容器中新建一个文件:
/ # echo KubeExplorer > tmp/hello.txt
/ # ls /tmp
hello.txt
/ # cat /tmp/hello.txt
KubeExplorer
然后切换到宿主机:
root@mydocker:~# ls busybox/tmp
root@mydocker:~# ls upper/tmp
hello.txt
root@mydocker:~# ls merged/tmp
hello.txt
可以发现,这个新创建的文件居然不在 busybox 目录,而是在 upper 中,然后 merged 目录中也可以看到。
这就是 overlayfs 的作用了。
写操作不会修改 lower 目录(busybox),而是发生在 upper 中,即在 upper 中 tmp 目录并创建了 hello.txt 文件。
而 merged 作为挂载点自然是能够看到 hello.txt 文件的。
最后在容器中执行 exit 退出容器。
/ # exit
然后再次查看宿主机上的 root 文件夹内容。
root@mydocker:~# ls /root
busybox busybox.tar
可以看到,upper、work 和 merged 目录被删除,作为镜像的 busybox 层仍然保留。
并且 busybox 中的内容未被修改:
root@mydocker:~# ls /root/busybox
bin dev etc home proc root sys tmp usr var
至此,基本实现了 Docker 的效果:
- 1)镜像中的文件不会被修改
- 2)容器中的修改不会影响宿主机
- 3)容器退出后,修改内容丢失
5. 小结
overlayfs 引入具体流程如下:
- 1)自动解压 busybox.tar 到 busybox 作为 lower 目录,类似 docker 镜像层
- 2)容器启动前准备好 lower、upper、work、merged 目录并 mount 到 merged 目录
- 3)容器启动后使用 pivotRoot 将 rootfs 切换到 merged 目录
- 后续容器中的修改由于 overlayfs 的特性,都会发生在 upper 目录中,而不会影响到 lower 目录
- 4)容器停止后 umount 并移除upper、work、merged 目录
最后在推荐一下 overlayfs 这篇文章
完整代码见:https://github.com/lixd/mydocker 欢迎 Star
相关代码见 feat-overlayfs
分支,测试脚本如下:
需要提前在 /root 目录准备好 busybox.tar 文件,具体看上一篇文章第二节。
# 克隆代码
git clone -b feat-overlayfs https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 查看文件系统是否变化
./mydocker run -it /bin/ls