pivot_root 文件系统切换示意图 本篇是"从零实现简易容器"系列的第四章,重点介绍如何通过 Linux 系统调用 pivot_root 更换进程的根文件系统(rootfs),从而实现容器与宿主机之间的文件系统隔离。
> 项目源码地址:https://github.com/lixd/mydocker
> 欢迎 Star 支持
在阅读本文前,建议先了解以下核心技术点:
基础机制 :深入理解 Namespace、Cgroups 与镜像层的核心作用
命名空间隔离 :Linux Namespace 如何构建独立视图
资源控制 :
Cgroups 入门:限制 CPU 与内存使用
Cgroups 子系统详解:blkio、net_cls 等模块解析
Docker 资源管理背后的原理
联合文件系统 :OverlayFS 在容器镜像中的应用
网络模型 :veth、bridge 和 iptables 构建容器通信
实验环境信息如下:
Distributor ID: Ubuntu
Description: Ubuntu 20.04.2 LTS
Release: 20.04
Codename: focal
Kernel: 5.4.0-74-generic
> 注意:需以 root 权限运行操作
1. 引言
此前我们已利用 Namespace 实现了 PID、Mount、UTS 等隔离,并借助 Cgroups 完成了资源配额控制。但当前容器仍共享宿主机的文件系统结构——这意味着容器内执行 ls / 显示的是宿主目录内容,且所有挂载点均被继承。
这显然不符合标准容器的行为。为此,我们需要引入"根文件系统"的概念,即为每个容器提供一个独立的、自包含的基础文件层级。本文将实现这一功能,使容器运行在一个专用的 rootfs 上。
2. 构建容器根文件系统
真正的 Docker 镜像是分层的只读文件系统,但我们这里简化处理,直接提取一个轻量级镜像的内容作为初始 rootfs。
选择 busybox 是因为它集成了大量常用 Unix 工具,体积小且功能完整。
操作步骤如下:
拉取镜像:
docker pull busybox
启动临时容器并导出其文件系统:
docker run -d busybox top
containerId=$(docker ps --filter "ancestor=busybox" | grep -v IMAGE | awk '{print $1}')
docker export -o busybox.tar $containerId
解压 tar 包生成 rootfs 目录:
mkdir -p /root/busybox
tar -xvf busybox.tar -C /root/busybox
此时,/root/busybox 即为我们后续要挂载的根文件系统,其结构如下:
bin/ dev/ etc/ home/ proc/ root/ sys/ tmp/ usr/ var/
请注意:rootfs 不包含内核,仅包含用户空间所需的二进制程序、库和配置文件。
3. 使用 pivot_root 切换根目录
关键在于使用 pivot_root 系统调用替换当前进程的根文件系统。
3.1 系统调用说明
函数原型:
int pivot_root(const char *new_root, const char *put_old);
- new_root:新根目录路径。
- put_old:原根目录将被移至该位置。
调用后,进程的根目录变为 new_root,旧根自动挂载到 put_old。与 chroot 不同,pivot_root 可完全脱离旧根,允许卸载原文件系统。
3.2 实现细节
由于 systemd 默认将 mount namespace 设为 shared,必须显式设置为 private,防止事件传播导致错误。
以下是 Go 中的关键实现逻辑:
func setupRootfs() error {
// 获取当前工作目录(即 /root/busybox)
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("获取当前路径失败: %v", err)
}
// 将当前 mount namespace 设为私有
if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("设置 MS_PRIVATE 失败: %v", err)
}
// 绑定挂载自身,确保 new_root 与原根不在同一文件系统
if err := syscall.Mount(cwd, cwd, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("bind mount 失败: %v", err)
}
// 创建用于存放旧根的临时目录
oldRoot := filepath.Join(cwd, ".old_root")
if err := os.Mkdir(oldRoot, 0700); err != nil && !os.IsExist(err) {
return fmt.Errorf("创建 .old_root 目录失败: %v", err)
}
// 执行 pivot_root 切换
if err := syscall.PivotRoot(cwd, oldRoot); err != nil {
return fmt.Errorf("pivot_root 执行失败: %v", err)
}
// 切换到新的根目录
if err := os.Chdir("/"); err != nil {
return fmt.Errorf("切换工作目录失败: %v", err)
}
// 卸载旧根并清理
if err := syscall.Unmount("/.old_root", syscall.MNT_DETACH); err != nil {
return fmt.Errorf("卸载旧根失败: %v", err)
}
os.Remove("/.old_root")
// 重新挂载必要的虚拟文件系统
mountProc()
mountDev()
return nil
}
func mountProc() {
flags := uintptr(syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV)
syscall.Mount("proc", "/proc", "proc", flags, "")
}
func mountDev() {
tmpfsFlags := syscall.MS_NOSUID | syscall.MS_STRICTATIME
syscall.Mount("tmpfs", "/dev", "tmpfs", uintptr(tmpfsFlags), "mode=755")
}
同时,在父进程启动子进程时指定工作目录:
func NewParentProcess(tty bool) (*exec.Cmd, *os.File) {
cmd := exec.Command("/proc/self/exe", "init")
cmd.Dir = "/root/busybox" // 设置 init 进程的工作目录
// ...其余设置
return cmd, writePipe
}
4. 验证效果
编译并运行测试命令:
go build .
./mydocker run -it /bin/ls
输出应为:
bin dev etc home proc root sys tmp usr var
表明当前环境已是基于 /root/busybox 的独立文件系统,而非宿主机目录。
5. 总结
本文完成了容器文件系统隔离的核心步骤:
从 busybox 镜像提取并构建 rootfs
利用 pivot_root 切换根目录,实现真正意义上的文件系统隔离
挂载 /proc 和 /dev 等必需的虚拟文件系统
结合之前的命名空间隔离与资源限制,我们现已实现了一个具备基本功能的容器运行时。
> 完整代码位于 GitHub 的 feat-rootfs 分支
> 测试前请确保 /root/busybox 目录存在且包含正确文件系统内容