手写docker—文件系统隔离(四)

容器文件系统隔离

前面实现了 container 容器创建及容器内部的资源管理和限制,但是容器内部的文件系统仍然和宿主机文件系统有关联,对容器内部文件系统的修改会影响到宿主机文件系统,因此需要单独隔离容器内部文件系统。

ufs介绍

Union File System:UFS 是一个将其它文件系统联合到一个联合挂载点的文件系统服务。它可以将许多文件系统例如 aufsoverlay 联合挂载到一个目录下形成单一的文件系统。

这些文件系统分支是只读或者可读写的,所以对这个虚拟后的联合文件系统进行写操作的时候,系统是真正写到了一个新的文件中。这个虚拟联合文件系统是可以对任何文件进行操作的,但它并没有真正地改变原文件,而是采用写时复制技术管理资源。

比如,现在有两个目录,目录结构如下:

$ 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-dirupper-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-layerread-write-layer。其中 init-layer 存储容器启动时修改的系统数据,read-write-layer 存储容器执行过程中产生的数据。最后将上述两个层级关联的目录统一挂载到一个 mnt 目录下,然后将这个 mnt 目录当作容器启动的根目录。所以我们在实现时,也需要先统一挂载好 overlayfs 目录,然后将当前进程切换到挂载好的 merge 目录下。

UFS 是一种将多个文件系统联合挂载的技术,UFS 有多种实现,包括 AUFSOverlays。这里使用 Overlays 来实现联合挂载,Overlays 一般分为 loweruppermergedwork 4 个目录:

  • lower:只读层,只负责提供数据;
  • upper:读写层,所有的数据修改发生在这一层;
  • merged:视图层,可以看到所有 lowerupper 层内容;
  • workoverlays 内部使用。

为了隔离容器和宿主机的写操作,我们将镜像目录(/root/busybox)作为 lower 目录,这样保证对镜像内容的修改都在 upper 目录中新生成一份副本。merged 目录作为容器 rootfs 保证可以看到所有文件内容。

docker 在启动时会创建两个 layer:write layercontainer-init layer。其中 write layer 为可读写层,container-init-layer 为只读层。在容器启动前我们需要先 mountoverlays 目录然后执行 privotRoot 时切换到 mount 好的 overlays merge 目录。

基于 overlays 进行文件系统挂载,需要执行包括 createLower()createDirs()mountOverlayFS() 三个步骤:

  • 准备 busybox 目录,并且解压 busybox.tar 文件,解压后的文件目录作为容器只读层(可以理解为容器结构中最底层的镜像层);
  • 准备 mount overlays 目录,创建好挂载 overlays 需要的 upperworkmerged 目录(可以理解为容器结构中最顶部的可读写层);
  • 实现 mount overlays,将 merged 目录作为挂载点,然后把 busyboxupper 挂载到 merged 目录;
  • 更新 rootfs 为挂载点 /root/merged 目录,并切换宿主机目录 root/busybox/root/merged 目录;

Overlays 挂载

联合挂载

首先创建好容器使用的 upper-dirlower-dirwork-dirmerged-dir 四个基础目录,其中 lower-dir 就是上一节创建的文件系统目录。然后通过 mount 命令对 upper-dirlower-dirwork-dirmerged-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 layercontainer-init layer,而保留镜像的所有内容。我们在退出并删除容器进程时,需要执行如下操作:

  • umount overlays:取消联合挂载目录;
  • 删除其它目录:删除 upper-dirwork-dirmerged-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-dirwork-dirmerged 目录,保留原宿主机的 lower-dir 目录。

posted @ 2024-02-06 01:45  Stitches  阅读(293)  评论(0)    收藏  举报