手写docker—文件系统隔离(四)
容器文件系统隔离
前面实现了 container
容器创建及容器内部的资源管理和限制,但是容器内部的文件系统仍然和宿主机文件系统有关联,对容器内部文件系统的修改会影响到宿主机文件系统,因此需要单独隔离容器内部文件系统。
ufs介绍
Union File System
:UFS 是一个将其它文件系统联合到一个联合挂载点的文件系统服务。它可以将许多文件系统例如 aufs
、overlay
联合挂载到一个目录下形成单一的文件系统。
这些文件系统分支是只读或者可读写的,所以对这个虚拟后的联合文件系统进行写操作的时候,系统是真正写到了一个新的文件中。这个虚拟联合文件系统是可以对任何文件进行操作的,但它并没有真正地改变原文件,而是采用写时复制技术管理资源。
比如,现在有两个目录,目录结构如下:
$ tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x
通过联合挂载模式,将两个目录挂载到一个公共的目录上:
mkdir C
mount -t aufs -o dirs=./A:./B none ./C
查看挂载目录结构,发现多个文件系统下的文件挂载到了同一个目录:
$ tree ./C
./C
├── a
├── b
└── x
aufs文件系统
AUFS
只是 Docker 使用的存储驱动的一种,除了 AUFS 之外,Docker 还支持了不同的存储驱动,包括 aufs、devicemapper、overlay2、zfs
和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成为了推荐的存储驱动,但是在没有 overlay2
驱动的机器上仍然会使用 aufs
作为 Docker 的默认驱动。
overlay2文件系统
Overlayfs 是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如 ext4fs 和 xfs 等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行“合并”,然后向用户呈现。
文件系统搭建
overlay
文件系统具有如下特点:
- 上下层同名目录合并;
- 上下层同名文件覆盖;
lower dir
只提供数据不能写,upper dir
可以读写;lower dir
文件写时拷贝;
搭建一个 overlay2
文件系统目录:
[root@localhost overlay2]# mkdir ./{merged,work,upper,lower}
[root@localhost overlay2]# touch ./upper/{a,b}
[root@localhost overlay2]# touch ./lower/{a,c}
[root@localhost overlay2]# tree
.
├── lower
│ ├── a
│ └── c
├── merged
├── upper
│ ├── a
│ └── b
└── work
# 挂载文件目录
sudo mount -t overlay overlay -o lowerdir=./lower,upperdir=./upper,workdir=./work ./merged
# 挂载后文件目录结构
[root@localhost overlay2]# tree
.
├── lower
│ ├── a
│ └── c
├── merged
│ ├── a
│ ├── b
│ └── c
├── upper
│ ├── a
│ └── b
└── work
└── work
overlay
实际文件目录结构如上图,其中 lower-dir
、upper-dir
都存在 文件a
,因此 lower-dir
中的文件被覆盖了,只显示 upper-dir
中的文件。
修改文件
lower 为底层目录,只提供数据不能写;upper 上层目录,可以读写。
# 修改 文件b、文件c
echo "will-persist" > ./merged/b
echo "wont-persist" > ./merged/c
# 从不同视图查看
[root@localhost overlay2]# echo "will-persist" > ./merged/b
[root@localhost overlay2]# echo "wont-persist" > ./merged/c
[root@localhost overlay2]# cat ./merged/b
will-persist
[root@localhost overlay2]# cat ./merged/c
wont-persist
[root@localhost overlay2]# cat ./upper/b
will-persist
[root@localhost overlay2]# cat ./lower/c
# 查看 upper 目录
[root@localhost overlay2]# ll ./upper/
总用量 8
-rw-r--r--. 1 root root 0 2月 4 21:19 a
-rw-r--r--. 1 root root 13 2月 4 21:41 b
-rw-r--r--. 1 root root 13 2月 4 21:41 c
[root@localhost overlay2]# cat ./upper/c
wont-persist
可以发现,overlay
文件系统对 lower-dir
层面文件修改不会在原目录生效,而是会在 upper-dir
中新建文件并修改。
overlay
文件系统采用 COW(copy-on-write) 写时复制技术,在对只读文件修改时复制数据到 upper-dir
目录中。
删除文件
[root@localhost lower]# echo fff >> f
[root@localhost lower]# ls ../merged/
a b c f
[root@localhost lower]# cd ../merged/
[root@localhost merged]# ls ../lower/
a c f
[root@localhost merged]# ls
a b c f
[root@localhost merged]# rm -rf f
[root@localhost merged]# ls ../lower/
a c f
[root@localhost merged]# ls ../upper/
a b c f
[root@localhost merged]# rm -rf ../upper/f
[root@localhost merged]# ls
a b c f
[root@localhost merged]# ls ../upper/
a b c
[root@localhost merged]# ls
a b c f
[root@localhost merged]# cat f
fff
可以发现 overlay
删除 lower-dir
中的文件,其实也是在 upper-dir
中创建一个标记,表示这个文件已经被删除了,而不是删除真正的 lower-dir
的文件。
删除文件或文件夹时,会在 upper-dir
中添加一个同名标识文件,这个文件叫做 whiteout
文件。
添加文件
添加新文件,尝试在挂载目录中新增文件 g
,查看各层级目录中文件情况:
[root@localhost merged]# echo ggg >> g
[root@localhost merged]# ls
a b c f g
[root@localhost merged]# ls ../upper/
a b c g
[root@localhost merged]# cat ../upper/g
ggg
overlay
挂载目录中新增文件实际上是在 upper-dir
目录中新增文件,并不会影响到其它层级目录。
如果删除 upper-dir
目录中的文件,挂载目录 merge-dir
中对应文件也会删除:
[root@localhost merged]# rm -rf ../upper/g
[root@localhost merged]# ls
a b c f
容器镜像原理
我们在使用 docker 时会发现,docker 的文件修改不会影响到原宿主机,其次 docker 内部的文件系统是怎么来的?这里我们探究下 docker 镜像的实现原理。
文件系统
容器中的文件系统是基于 Mount Namespace
进行隔离的,在实现挂载操作后,容器内进程的视图会发生变化,所以我们在容器内看到的文件或目录都是独立于宿主机的。
rootfs
Mount Namespace
会修改容器进程对文件系统挂载点的认知,而这个挂载在容器根目录上,用来为容器进程提供隔离后执行环境的文件系统,就是容器镜像(又名 rootfs)。rootfs 只是提供了操作系统的文件、配置和目录,并不包括系统内核。docker 中所有容器都单独拥有 rootfs,但是都使用同一个操作系统内核。
镜像层
docker 镜像的设计采用了镜像层的思想,比如我们通过 dockerfile
制作镜像的每一步操作,都会生成一个镜像层。镜像层的结构方便实现 rootfs 复用,不必每次都新建一个 rootfs,而是在原 rootfs 上增量更新。
组成 docker 镜像的目录可能分布在系统不同地方,通过 UFS 技术将不同位置的目录联合挂载(union mount)到同一个目录下。
如上是容器内 rootfs 组成结构,除了底层只读的镜像层(Image layers),还包括可读写层(存储容器中产生的实时数据),init层(容器启动时初始化修改的数据部分)。
容器 rootfs 隔离
准备 rootfs
Docker 镜像中包含了文件系统,可以直接挂载运行,这里我们需要先准备自己的文件系统然后进行挂载。
即宿主机上首先在某一个目录下准备一个精简的文件系统,然后容器运行时挂载这个目录作为 rootfs。busybox
文件系统作为一个简单且完整的文件系统,我们可以使用 docker export
将一个运行的容器 busybox
打包成一个 tar压缩包,接着解压这个压缩包并将结果作为容器的文件系统使用。
# 拉取镜像
docker pull busybox
# 运行容器
docker run -d busybox top
# 获取运行的容器ID,打包成 tar 压缩包
containerId=$(docker ps --filter "ancestor=busybox:latest" | grep -v IMAGE|awk '{print $1}')
echo "containerId" $containerId
# 导出运行的容器
docker export -o busybox.tar $containerId
# 解压压缩包获取目标文件系统
mkdir busybox
tar -xvf busybox.tar -C busybox/
其中,busybox
文件系统的目录结构如下:
[root@docker ~]# ls -l busybox
total 16
drwxr-xr-x 2 root root 12288 Dec 29 2021 bin
drwxr-xr-x 4 root root 43 Jan 12 03:17 dev
drwxr-xr-x 3 root root 139 Jan 12 03:17 etc
drwxr-xr-x 2 nfsnobody nfsnobody 6 Dec 29 2021 home
drwxr-xr-x 2 root root 6 Jan 12 03:17 proc
drwx------ 2 root root 6 Dec 29 2021 root
drwxr-xr-x 2 root root 6 Jan 12 03:17 sys
drwxrwxrwt 2 root root 6 Dec 29 2021 tmp
drwxr-xr-x 3 root root 18 Dec 29 2021 usr
drwxr-xr-x 4 root root 30 Dec 29 2021 var
挂载 rootfs
将容器的 rootfs
挂载到刚刚创建好的 busybox
目录下,实际挂载效果可以通过 pivot_root
函数来实现。
#include <unistd.h>
int pivot_root(const char *new_root, const char *put_old);
new_root
:新的根文件系统路径;put_old
:存储旧文件系统的路径。
执行pivot_root
系统调用,新的根文件系统会变为new_root
,并且保存旧的文件系统到put_old
。
这里需要注意 pivot_root
系统调用和 chroot
系统调用的区别:
pivot_root
系统调用将整个系统切换到新的 root 目录,并且移除旧文件系统的依赖,方便umount
旧文件系统;chroot
系统调用是针对某个进程,系统的其它部分仍运行在旧的文件系统下。
代码
在由子进程执行容器初始化操作时,首先执行根文件系统 rootfs
的挂载、proc
目录的挂载:
func mountProc() {
pwd, err := os.Getwd()
if err != nil {
log.Errorf("Get current location failed %v", err)
return
}
log.Infof("Current location is %s", pwd)
// change mount transferMode for private
if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
log.Errorf("mount default namespace failed, err = %v", err)
return
}
// remount rootfs
if err = privotRoot(pwd); err != nil {
log.Errorf("reMount failed %v", err)
}
// mount proc
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
if err := syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), ""); err != nil {
log.Errorf("mount proc failed, err = %v", err)
return
}
}
容器宿主机写操作隔离
上一步已经实现了使用宿主机某个文件目录 /root/busybox
作为容器的根目录,但是容器内的修改等操作依然会影响到宿主机的原始目录。所以需要对容器和镜像进一步隔离,使得容器中的操作不会影响到原宿主机目录 /root/busybox
。
Docker 在使用镜像启动一个容器时会使用 init-layer
、read-write-layer
。其中 init-layer
存储容器启动时修改的系统数据,read-write-layer
存储容器执行过程中产生的数据。最后将上述两个层级关联的目录统一挂载到一个 mnt 目录下,然后将这个 mnt 目录当作容器启动的根目录。所以我们在实现时,也需要先统一挂载好 overlayfs 目录,然后将当前进程切换到挂载好的 merge
目录下。
UFS 是一种将多个文件系统联合挂载的技术,UFS 有多种实现,包括 AUFS
、Overlays
。这里使用 Overlays
来实现联合挂载,Overlays
一般分为 lower
、upper
、merged
、work
4 个目录:
lower
:只读层,只负责提供数据;upper
:读写层,所有的数据修改发生在这一层;merged
:视图层,可以看到所有lower
、upper
层内容;work
:overlays
内部使用。
为了隔离容器和宿主机的写操作,我们将镜像目录(/root/busybox
)作为 lower
目录,这样保证对镜像内容的修改都在 upper
目录中新生成一份副本。merged
目录作为容器 rootfs
保证可以看到所有文件内容。
docker 在启动时会创建两个 layer:write layer
、container-init layer
。其中 write layer
为可读写层,container-init-layer
为只读层。在容器启动前我们需要先 mount
好 overlays
目录然后执行 privotRoot
时切换到 mount 好的 overlays merge
目录。
基于 overlays
进行文件系统挂载,需要执行包括 createLower()
、createDirs()
、mountOverlayFS()
三个步骤:
- 准备
busybox
目录,并且解压busybox.tar
文件,解压后的文件目录作为容器只读层(可以理解为容器结构中最底层的镜像层); - 准备
mount overlays
目录,创建好挂载overlays
需要的upper
、work
、merged
目录(可以理解为容器结构中最顶部的可读写层); - 实现
mount overlays
,将merged
目录作为挂载点,然后把busybox
、upper
挂载到merged
目录; - 更新
rootfs
为挂载点/root/merged
目录,并切换宿主机目录root/busybox
为/root/merged
目录;
Overlays 挂载
联合挂载
首先创建好容器使用的 upper-dir
、lower-dir
、work-dir
、merged-dir
四个基础目录,其中 lower-dir
就是上一节创建的文件系统目录。然后通过 mount
命令对 upper-dir
、lower-dir
、work-dir
、merged-dir
系统目录进行联合挂载到 merged
目录。
/**
* create an Overlay fileSystem as container root workingspace
* 1)create lower-dir;
* 2)create upper-dir、work-dir;
* 3)create merged-dir and mount as overlayFS;
* 4)mount volume if exists;
*/
func NewWorkSpace(rootPath string, mntUrl string) {
createLower(rootPath)
createDirs(rootPath)
mountOverlayfs(rootPath, mntUrl)
}
/**
* create readOnly directory of lower-dir
*/
func createLower(rootPath string) {
busyboxUrl := rootPath + "busybox/"
busyboxTarUrl := rootPath + "busybox.tar"
_, err := os.Stat(busyboxUrl)
if err != nil && os.IsNotExist(err) {
log.Errorf("volume::createLower can't find %s file", busyboxUrl)
if err = os.Mkdir(busyboxUrl, Perm0755); err != nil {
log.Warnf("volume::createLower directory %s failed", busyboxUrl)
}
if _, err := exec.Command("tar", "-xvf", busyboxTarUrl, "-C", busyboxUrl).CombinedOutput(); err != nil {
log.Warnf("volume::createLower untar %s failed %v", busyboxTarUrl, err)
}
}
}
/**
* create upper-dir and work-dir of overlayFS
*/
func createDirs(rootPath string) {
upperUrl := rootPath + "upper/"
if err := os.Mkdir(upperUrl, Perm0755); err != nil {
log.Warnf("mkdir upper-dir %s error %v", upperUrl, err)
}
workUrl := RootUrl + "work/"
if err := os.Mkdir(workUrl, Perm0755); err != nil {
log.Warnf("mkdir work-dir %s error %v", workUrl, err)
}
}
/**
* mountOverlayFS
* mount -t overlay overlay -o lowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged
*/
func mountOverlayfs(rootUrl string, mntUrl string) {
// create mntUrl
if err := os.Mkdir(mntUrl, Perm0777); err != nil {
log.Warnf("mkdir mntUrl %s failed %v", mntUrl, err)
}
// combine arguments
// e.g. lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work
dirs := "lowerdir=" + rootUrl + "busybox" + ",upperdir=" + rootUrl + "upper" + ",workdir=" + rootUrl + "work"
cmd := exec.Command("mount", "-t", "overlay", "overlay", "-o", dirs, mntUrl)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("%v", err)
}
}
上述联合挂载操作需要在初始化 container
进程时执行:
rootDir := "/root/"
mntDir := "/root/merged/"
processCmd.ExtraFiles = []*os.File{readPipe}
processCmd.Dir = mntDir
processCmd.Env = append(os.Environ(), envSlice...)
// create overlay2 fileSystem as container root workingspace
NewWorkSpace(rootDir, mntDir)
取消挂载目录
Docker 会在删除容器时删除容器对应的 write layer
、container-init layer
,而保留镜像的所有内容。我们在退出并删除容器进程时,需要执行如下操作:
umount overlays
:取消联合挂载目录;- 删除其它目录:删除
upper-dir
、work-dir
、merged-dir
,保留lower-dir
。
/**
* Delete overlayfs workingPlace
* 1)uninstall volume;
* 2)uninstall and delete merged directory;
* 3)uninstall and delete upper-dir、work-dir;
*/
func DeleteWorkSpace(rootUrl, mntUrl string) {
unmountOverlayfs(mntUrl)
removeDirs(rootUrl)
}
/**
* unmount overlayfs
*/
func unmountOverlayfs(mntUrl string) {
cmd := exec.Command("umount", mntUrl)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("unMountDir %s failed %v", mntUrl, err)
}
if err := os.RemoveAll(mntUrl); err != nil {
log.Errorf("removeMountDir %s error %v", mntUrl, err)
}
log.Infof("volume::unmountOverlayfs unmountFS %s successfully", mntUrl)
}
/**
* remove directories(lower-dir、upper-dir、work-dir) but save (lower-dir)
*/
func removeDirs(rootUrl string) {
writeDir := rootUrl + "upper/"
if err := os.RemoveAll(writeDir); err != nil {
log.Errorf("volume::removeDirs remove upper-dir %s failed %v", writeDir, err)
}
workDir := rootUrl + "work/"
if err := os.RemoveAll(workDir); err != nil {
log.Errorf("volume::removeDirs remove work-dir %s failed %v", workDir, err)
}
log.Infof("volume::removeDirs upper-dir %s work-dir %s successfully", writeDir, workDir)
}
测试
# 启动 docker 容器
[root@localhost Mydocker]# ./Mydockker run -it /bin/sh
{"level":"info","msg":"resourceConfig: \u0026{ 0 }","time":"2024-02-06T01:15:07+08:00"}
{"level":"warning","msg":"mkdir mntUrl /root/merged/ failed mkdir /root/merged/: file exists","time":"2024-02-06T01:15:07+08:00"}
{"level":"info","msg":"run::sendInitCommands all commands:/bin/sh","time":"2024-02-06T01:15:07+08:00"}
{"level":"info","msg":"exec init command","time":"2024-02-06T01:15:07+08:00"}
{"level":"info","msg":"Current location is /root/merged","time":"2024-02-06T01:15:07+08:00"}
{"level":"info","msg":"init::ContainerResourceInit execuatble path=/bin/sh","time":"2024-02-06T01:15:07+08:00"}
# 向容器内 /tmp/hello.txt 文件中写入数据
/ # echo helloWorld > tmp/hello.txt
# 查看宿主机 /root 目录
[root@localhost ~]# ls -al
总用量 129792
drwxr-xr-x. 13 root root 161 2月 5 16:18 busybox
-rw-------. 1 root root 4497408 2月 4 16:44 busybox.tar
drwxr-xr-x. 1 root root 29 2月 6 01:15 merged
drwxr-xr-x. 4 root root 29 2月 6 01:15 upper
drwxr-xr-x. 3 root root 18 2月 6 01:15 work
# 查看上述修改对宿主机的影响
[root@localhost ~]# ls busybox/tmp/
[root@localhost ~]# ls upper/tmp
hello.txt
[root@localhost ~]# ls work/work/
[root@localhost ~]# ls merged/
bin dev etc home lib lib64 proc root sys tmp usr var
[root@localhost ~]# cat upper/tmp/hello.txt
helloWorld
可以发现我们对容器内的文件操作并不会影响宿主机的源文件目录 /root/busybox
,而是会在 upper-dir
中新创建文件 hello.txt
,这也符合 overlayfs
文件系统的特点。
# 容器内执行退出操作
/ # exit
{"level":"info","msg":"volume::unmountOverlayfs unmountFS /root/merged successfully","time":"2024-02-06T01:21:32+08:00"}
{"level":"info","msg":"volume::removeDirs upper-dir /root/upper/ work-dir /root/work/ successfully","time":"2024-02-06T01:21:32+08:00"}
# 切换宿主机查看目录
[root@localhost ~]# ls -al
总用量 129792
dr-xr-x---. 19 root root 4096 2月 6 01:21 .
dr-xr-xr-x. 17 root root 224 5月 18 2023 ..
-rw-r--r--. 1 root root 0 5月 11 2023 0a
-rw-r--r--. 1 root root 0 5月 11 2023 1a
-rw-r--r--. 1 root root 0 5月 11 2023 1a?1a?2a?2:q
drwxr-xr-x. 4 root root 62 4月 8 2023 6.824
-rw-------. 1 root root 1418 11月 16 2022 anaconda-ks.cfg
-rw-------. 1 root root 12149 2月 6 00:13 .bash_history
-rw-r--r--. 1 root root 18 12月 29 2013 .bash_logout
-rw-r--r--. 1 root root 176 12月 29 2013 .bash_profile
-rw-r--r--. 1 root root 176 12月 29 2013 .bashrc
drwxr-xr-x. 9 root root 4096 4月 9 2023 boost_1_71_0
-rw-r--r--. 1 root root 118520935 4月 9 2023 boost_1_71_0.tar.gz
drwxr-xr-x. 13 root root 161 2月 5 16:18 busybox
-rw-------. 1 root root 4497408 2月 4 16:44 busybox.tar
drwxr-xr-x. 7 root root 88 1月 6 18:36 .cache
drwxr-xr-x. 3 root root 18 1月 25 23:23 cgroup
drwxrwxr-x. 15 root root 4096 4月 4 2023 cmake-3.22.3
-rw-r--r--. 1 root root 9779118 4月 4 2023 cmake-3.22.3.tar.gz
drwx------. 4 root root 27 1月 6 18:36 .config
-rw-r--r--. 1 root root 100 12月 29 2013 .cshrc
drwx------. 3 root root 63 6月 5 2023 .docker
-rw-r--r--. 1 root root 14686 8月 10 23:08 .gdbinit
drwxr-xr-x. 3 root root 19 4月 4 2023 .local
drwxr-xr-x. 1 root root 6 2月 5 17:04 merged
发现在退出容器进程时,会自动删除 upper-dir
、work-dir
、merged
目录,保留原宿主机的 lower-dir
目录。