从零开始写 Docker(四)---使用 PivotRoot 切换 Rootfs 实现文件系统隔离

change-rootfs-by-pivot-root.png

本文为从零开始写 Docker 系列第四篇,在mydocker run 基础上使用 pivotRoot 系统调用切换 rootfs,实现容器和宿主机之间的文件系统隔离。

完整代码见:https://github.com/lixd/mydocker
欢迎 Star


推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

1
2
3
4
5
6
7
8
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. 概述

前面几节中,我们通过 NamespaceCgroups 技术创建了一个简单的容器,实现了视图隔离和资源限制。

但是大家应该可以发现,容器内的目录还是当前运行程序的宿主机目录,而且如果运行一下 mount 命令可以看到继承自父进程的所有挂载点。

这貌似和平常使用的容器表现不同

因为这里缺少了镜像这么一个重要的特性。

Docker 镜像可以说是一项伟大的创举,它使得容器传递和迁移更加简单,那么这一节会做一个简单的镜像,让容器跑在有镜像的环境中。

即:本章会为我们切换容器的 rootfs,以实现文件系统的隔离

注意:这里说的隔离还不是完整的隔离,只是把 rootfs 限制到宿主机上的某个目录下。

2. 准备 rootfs

Docker 镜像包含了文件系统,所以可以直接运行,我们这里就先弄个简单的,直接将某个镜像中的所有内容作为我们的 rootfs 进行挂载。

即:先在宿主机上某一个目录上准备一个精简的文件系统,然后容器运行时挂载这个目录作为 rootfs

首先使用一个最精简的镜像 busybox 来作为我们的文件系统。

busybox 是一个集合了非常多 UNIX 工具的箱子,它可以提供非常多在 UNIX 环境下经常使用的命令,可以说 busybox 提供了一个非常完整而且小巧的系统。

因此我们先使用它来作为第一个容器内运行的文件系统。

获得 busybox 文件系统的 rootfs 很简单,可以使用 docker export 将一个容器打成一个 tar包,并解压,解压目录即可作为文件系统使用

首先拉取镜像

1
docker pull busybox

然后使用该镜像启动一个容器,并用 export 命令将其导出成一个 tar 包

1
2
3
4
5
6
7
# 执行一个交互式命令,让容器能一直后台运行
docker run -d busybox top
# 拿到刚创建的容器的 Id
containerId=$(docker ps --filter "ancestor=busybox:latest"|grep -v IMAGE|awk '{print $1}')
echo "containerId" $containerId
# export 从容器导出
docker export -o busybox.tar $containerId

最后将 tar 包解压

1
2
mkdir busybox
tar -xvf busybox.tar -C busybox/

这样就得到了 busybox 文件系统的 rootfs ,可以把这个作为我们的文件系统使用。

这里的 rootfs 指解压得到的 busybox 目录

busybox 中的内容大概是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[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 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核

在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

3. 挂载 rootfs

把之前的 busybox rootfs 移动到/root/busybox 目录下备用。

实现原理

使用pivot_root 系统调用来切换整个系统的 rootfs,配合上 /root/busybox 来实现一个类似镜像的功能。

pivot_root 是一个系统调用,主要功能是去改变当前的 root 文件系统

原型如下:

1
2
3
#include <unistd.h>

int pivot_root(const char *new_root, const char *put_old);
  • new_root:新的根文件系统的路径。
  • put_old:将原根文件系统移到的目录。

使用 pivot_root 系统调用后,原先的根文件系统会被移到 put_old 指定的目录,而新的根文件系统会变为 new_root 指定的目录。这样,当前进程就可以在新的根文件系统中执行操作。

注意:new_root 和 put_old 不能同时存在当前 root 的同一个文件系统中。

pivotroot 和 chroot 有什么区别?

  • pivot_root 是把整个系统切换到一个新的 root 目录,会移除对之前 root 文件系统的依赖,这样你就能够 umount 原先的 root 文件系统。

  • 而 chroot 是针对某个进程,系统的其他部分依旧运行于老的 root 目录中。

具体实现

具体实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/*
*
Init 挂载点
*/
func setUpMount() {
	pwd, err := os.Getwd()
	if err != nil {
		log.Errorf("Get current location error %v", err)
		return
	}
	log.Infof("Current location is %s", pwd)

	// systemd 加入linux之后, mount namespace 就变成 shared by default, 所以你必须显示
	// 声明你要这个新的mount namespace独立。
	// 如果不先做 private mount,会导致挂载事件外泄,后续执行 pivotRoot 会出现 invalid argument 错误
	err = syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")

	err = pivotRoot(pwd)
	if err != nil {
		log.Errorf("pivotRoot failed,detail: %v", err)
		return
	}

	// mount /proc
	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
	_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
	// 由于前面 pivotRoot 切换了 rootfs,因此这里重新 mount 一下 /dev 目录
	// tmpfs 是基于 件系 使用 RAM、swap 分区来存储。
	// 不挂载 /dev,会导致容器内部无法访问和使用许多设备,这可能导致系统无法正常工作
	syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755")
}

func pivotRoot(root string) error {
	/**
	  NOTE:PivotRoot调用有限制,newRoot和oldRoot不能在同一个文件系统下。
	  因此,为了使当前root的老root和新root不在同一个文件系统下,这里把root重新mount了一次。
	  bind mount是把相同的内容换了一个挂载点的挂载方法
	*/
	if err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
		return errors.Wrap(err, "mount rootfs to itself")
	}
	// 创建 rootfs/.pivot_root 目录用于存储 old_root
	pivotDir := filepath.Join(root, ".pivot_root")
	if err := os.Mkdir(pivotDir, 0777); err != nil {
		return err
	}
	// 执行pivot_root调用,将系统rootfs切换到新的rootfs,
	// PivotRoot调用会把 old_root挂载到pivotDir,也就是rootfs/.pivot_root,挂载点现在依然可以在mount命令中看到
	if err := syscall.PivotRoot(root, pivotDir); err != nil {
		return errors.WithMessagef(err, "pivotRoot failed,new_root:%v old_put:%v", root, pivotDir)
	}
	// 修改当前的工作目录到根目录
	if err := syscall.Chdir("/"); err != nil {
		return errors.WithMessage(err, "chdir to / failed")
	}

	// 最后再把old_root umount了,即 umount rootfs/.pivot_root
	// 由于当前已经是在 rootfs 下了,就不能再用上面的rootfs/.pivot_root这个路径了,现在直接用/.pivot_root这个路径即可
	pivotDir = filepath.Join("/", ".pivot_root")
	if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
		return errors.WithMessage(err, "unmount pivot_root dir")
	}
	// 删除临时文件夹
	return os.Remove(pivotDir)
}

然后再 build cmd 的时候指定:

1
2
3
4
5
6
7
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
    cmd := exec.Command("/proc/self/exe", "init")
    // .. 省略其他代码
    // 指定 cmd 的工作目录为我们前面准备好的用于存放busybox rootfs的目录
    cmd.Dir = "/root/busybox"
    return cmd, writePipe
}

到此这一小节就完成了,测试一下。

4. 测试

测试比较简单,只需要执行 ls 命令,即可根据输出内容确定文件系统是否切换了。

1
2
3
4
5
6
7
8
root@mydocker:~/feat-rootfs/mydocker# go build .
root@mydocker:~/feat-rootfs/mydocker# ./mydocker run -it  /bin/ls
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-12T16:19:32+08:00"}
{"level":"info","msg":"command all is /bin/ls","time":"2024-01-12T16:19:32+08:00"}
{"level":"info","msg":"init come on","time":"2024-01-12T16:19:32+08:00"}
{"level":"info","msg":"Current location is /root/busybox","time":"2024-01-12T16:19:32+08:00"}
{"level":"info","msg":"Find path /bin/ls","time":"2024-01-12T16:19:32+08:00"}
bin   dev   etc   home  proc  root  sys   tmp   usr   var

可以看到,现在打印出来的就是/root/busybox 目录下的内容了,说明我们的 rootfs 切换完成。

至此,只是实现了简单的隔离,即将容器 rootfs 限制到宿主机指定目录,但是容器中对文件的操作也会影响到宿主机,具体如下: 进入容器并往文件中写入内容:

1
2
3
4
5
6
7
8
root@mydocker:~/feat-rootfs/mydocker# ./mydocker run -it  /bin/sh
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-16T10:48:16+08:00"}
{"level":"info","msg":"command all is /bin/sh","time":"2024-01-16T10:48:16+08:00"}
{"level":"info","msg":"init come on","time":"2024-01-16T10:48:16+08:00"}
{"level":"info","msg":"Current location is /root/busybox","time":"2024-01-16T10:48:16+08:00"}
{"level":"info","msg":"Find path /bin/sh","time":"2024-01-16T10:48:16+08:00"}
# 创建一个文件,随便写入一点内容
/ # echo 123 > hello.txt

到宿主机对应目录查看该文件:

1
2
root@mydocker:~/feat-rootfs/mydocker# cat ~/busybox/hello.txt
123

至此,说明容器中的操作确实能影响到宿主机,这个和 Docker 是不符合的, 因此下一篇中需要对文件操作也实现隔离。

5.小结

本章核心如下:

  • 准备 rootfs:将运行中的 busybox 容器导出并解压后作为 rootfs
  • 挂载 rootfs:使用pivotRoot 系统调用,将前面准备好的目录作为容器的 rootfs 使用

在切换 rootfs 之后,容器就实现了和宿主机的文件系统隔离。

本章使用 pivotRoot 实现文件系统隔离,加上前面基于 Namespace 实现的视图隔离,基于 Cgroups 实现的资源限制,至此我们已经实现了一个 Docker 容器的几大核心功能。


完整代码见:https://github.com/lixd/mydocker 欢迎 Star

相关代码见 feat-rootfs 分支,测试脚本如下:

需要提前在 /root/busybox 目录准备好 rootfs,具体看本文第二节。

1
2
3
4
5
6
7
8
# 克隆代码
git clone -b feat-rootfs https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 查看文件系统是否变化
./mydocker run -it  /bin/ls
0%