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


![change-rootfs-by-pivot-root.png](https://img.lixueduan.com/docker/mydocker/cover/change-rootfs-by-pivot-root.png)

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

<!--more-->


> 完整代码见：[https://github.com/lixd/mydocker](https://github.com/lixd/mydocker)  
> 欢迎 Star

---

推荐阅读以下文章对 docker 基本实现有一个大致认识：
* **核心原理**：[深入理解 Docker 核心原理：Namespace、Cgroups 和 Rootfs](https://www.lixueduan.com/posts/docker/03-container-core/)
* **基于 namespace 的视图隔离**：[探索 Linux Namespace：Docker 隔离的神奇背后](https://www.lixueduan.com/posts/docker/05-namespace/)
* **基于 cgroups 的资源限制**
  * [初探 Linux Cgroups：资源控制的奇妙世界](https://www.lixueduan.com/posts/docker/06-cgroups-1/)
  * [深入剖析 Linux Cgroups 子系统：资源精细管理](https://www.lixueduan.com/posts/docker/07-cgroups-2/)
  * [Docker 与 Linux Cgroups：资源隔离的魔法之旅](https://www.lixueduan.com/posts/docker/08-cgroups-3/)
* **基于 overlayfs 的文件系统**：[Docker 魔法解密：探索 UnionFS 与 OverlayFS](https://www.lixueduan.com/posts/docker/09-ufs-overlayfs/)
* **基于 veth pair、bridge、iptables 等等技术的 Docker 网络**：[揭秘 Docker 网络：手动实现 Docker 桥接网络](https://www.lixueduan.com/posts/docker/10-bridge-network/)

---

开发环境如下：
```bash
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. 概述

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

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

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

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

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

即：**本章会为我们切换容器的 rootfs，以实现文件系统的隔离**。

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




## 2. 准备 rootfs

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

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



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

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

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



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



首先拉取镜像

```shell
docker pull busybox
```

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

```shell
# 执行一个交互式命令，让容器能一直后台运行
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 包解压

```bash
mkdir busybox
tar -xvf busybox.tar -C busybox/
```

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

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

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

```shell
[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 文件系统**。

原型如下：

```c
#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 目录中。



### 具体实现

具体实现如下：

```go
/*
*
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 的时候指定：

```go
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 命令，即可根据输出内容确定文件系统是否切换了。

```shell
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 限制到宿主机指定目录，但是容器中对文件的操作也会影响到宿主机，具体如下：
进入容器并往文件中写入内容：
```bash
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
```
到宿主机对应目录查看该文件：
```bash
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](https://github.com/lixd/mydocker)
> 欢迎 Star



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

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

```bash 
# 克隆代码
git clone -b feat-rootfs https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 查看文件系统是否变化
./mydocker run -it  /bin/ls
```





---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/docker/mydocker/04-change-rootfs-by-pivot-root/  

