ARTICLE — POSTS

My Docker 3

今日目标

  1. 使用 pivot_root 系统调用实现文件系统隔离

pivot_root vs chroot

chroot 只是改动了进程的根路径,并没有真正切断对原文件系统的访问。pivot_root 是 Linux 专门为容器场景设计的接口,更严格、也更安全。两者的关键差异:

维度

chroot

pivot_root

作用域

仅当前进程的根目录指针

整个 mount namespace

对老 root 的处理

仍处于挂载状态,可被再次访问

必须挂到新根下的某目录,再 umount 即可彻底消失

文件系统要求

不限制

新老根必须位于不同文件系统

逃逸风险

存在:硬链接 + 未关闭的 fd + fchdir 可回到原 root

无

典型用途

早期 jail、构建 chroot 环境

容器运行时(如 Docker、runC)的根文件系统切换

工作原理图解与说明

chroot 的原理

chroot 只是改变了进程 task_struct 中关于根目录的指针。

[新目录 /home/user/jail] <-- chroot 把进程的 "/" 指向这里

隐患:老根目录在内核中依然处于挂载状态。如果进程在新目录里创建了一个硬链接,或者利用未关闭的老根文件描述符,再调用一次 fchdir,就可以轻松跳回真正的系统根目录。

pivot_root 的原理

pivot_root 是一次真正的"偷天换日"。它要求新根和老根必须在不同的文件系统(挂载点)上。

  1. 交换:pivot_root 把当前 mount namespace 的根挂载点切换为参数中传入的新路径;与此同时,原来的根文件系统会被挂载到新根下的 put_old 目录(例如 /new_root/.pivot_root)。
  2. 隔离:对 put_old 调用 umount(..., MNT_DETACH),老的文件系统在当前命名空间里彻底消失。新环境中的进程无论拥有多大权限,也无法访问到宿主机的任何文件,因为路径已经不存在了。
[新文件系统] ───> 变成新的 "/"
[老文件系统] ───> 挂载到新 "/" 下的某个临时目录(例如 /new_root/.pivot_root)

执行 umount(MNT_DETACH) 后:

[新文件系统] ───> "/"
                    ↳ 旧 root 在当前 mount namespace 内不可见

补充:man 2 pivot_root 明确要求新老根不能位于同一文件系统(new_root 必须是 put_old 的下层目录反之亦然),这也是为什么 pivotRoot 里要先做一次 bind mount,把目录变为为一个独立的挂载点。

代码实现

func pivotRoot(root string) error {
	l := slog.Default()

	l.Info("rebinding", "path", root)
	if err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
		return fmt.Errorf("failed to rebind `%s`: %w", root, err)
	}

	pivotDir := path.Join(root, pDir)

	l.Info("making directory", "path", pivotDir)
	if err := os.Mkdir(pivotDir, 0o777); err != nil {
		return fmt.Errorf("failed to make directory `%s`: %w", pivotDir, err)
	}

	l.Info("pivoting root", "new", root, "old", pivotDir)
	if err := syscall.PivotRoot(root, pivotDir); err != nil {
		return fmt.Errorf("failed to pivot root `%s`: %w", root, err)
	}

	l.Info("changing current directory to `/`")
	if err := os.Chdir("/"); err != nil {
		return fmt.Errorf("failed to change current directory to `/`: %w", err)
	}

	// 彻底移除 old root
	pivotDir = path.Join("/", pDir)
	l.Info("unmounting old root", "path", pivotDir)
	if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
		return fmt.Errorf("failed to unmount `%s`: %w", pivotDir, err)
	}

	l.Info("removing old root", "path", pivotDir)
	if err := os.Remove(pivotDir); err != nil {
		return fmt.Errorf("failed to remove `%s`: %w", pivotDir, err)
	}

	return nil
}

几个值得一提的细节:

  • 首次 Mount(root, root, "bind", MS_BIND|MS_REC, "") 是为了让 root 成为一个独立的挂载点。pivot_root(2) 要求新根不能与当前根位于同一文件系统,所以不能直接对一个普通目录调用 pivot_root,必须先 bind mount 自己。
  • pivotDir 命名上常写作 .pivot_root,前缀加点是为了让 ls 默认不显示它,也避免和容器里普通目录冲突。
  • Unmount(..., MNT_DETACH) 而不是普通 umount:当老根上还可能有别的挂载点(比如早期挂上去的 dev)时,MNT_DETACH 会做延迟卸载,绕过"device is busy"的问题。

实验:使用 busybox

先将其从 docker image 中提取出来作为 rootfs:

$ docker export -o busybox.tar 
$ tar xf ./busybox.tar -C busybox
$ sudo ./mydocker run -i -t /bin/sh # 此处 /bin/sh -> ./busybox/bin/sh
{"time":"2026-06-18T18:21:13.745774141+08:00","level":"INFO","source":{"function":"mydocker/docker.Init","file":"/home/hpxx/mydocker/docker/init.go","line":19},"msg":"initiating"}
{"time":"2026-06-18T18:21:13.745823495+08:00","level":"INFO","source":{"function":"mydocker/docker.Init","file":"/home/hpxx/mydocker/docker/init.go","line":21},"msg":"opening pipe"}
{"time":"2026-06-18T18:21:13.745828363+08:00","level":"INFO","source":{"function":"mydocker/docker.Init","file":"/home/hpxx/mydocker/docker/init.go","line":31},"msg":"reading from pipe"}
{"time":"2026-06-18T18:21:13.745840408+08:00","level":"INFO","source":{"function":"mydocker/docker.setMounts","file":"/home/hpxx/mydocker/docker/init.go","line":66},"msg":"remounting `/`"}
{"time":"2026-06-18T18:21:13.74584865+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":91},"msg":"rebinding","path":"/home/hpxx/mydocker/busybox"}
{"time":"2026-06-18T18:21:13.745859792+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":98},"msg":"making directory","path":"/home/hpxx/mydocker/busybox/.pivot_root"}
{"time":"2026-06-18T18:21:13.745918667+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":103},"msg":"pivoting root","new":"/home/hpxx/mydocker/busybox","old":"/home/hpxx/mydocker/busybox/.pivot_root"}
{"time":"2026-06-18T18:21:13.74624612+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":108},"msg":"changing current directory to `/`"}
{"time":"2026-06-18T18:21:13.746253862+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":114},"msg":"unmounting old root","path":"/.pivot_root"}
{"time":"2026-06-18T18:21:13.746298791+08:00","level":"INFO","source":{"function":"mydocker/docker.pivotRoot","file":"/home/hpxx/mydocker/docker/init.go","line":119},"msg":"removing old root","path":"/.pivot_root"}
{"time":"2026-06-18T18:21:13.746323363+08:00","level":"INFO","source":{"function":"mydocker/docker.setMounts","file":"/home/hpxx/mydocker/docker/init.go","line":75},"msg":"mounting `proc`"}
{"time":"2026-06-18T18:21:13.746354535+08:00","level":"INFO","source":{"function":"mydocker/docker.setMounts","file":"/home/hpxx/mydocker/docker/init.go","line":80},"msg":"mounting `dev`"}
{"time":"2026-06-18T18:21:13.746378272+08:00","level":"INFO","source":{"function":"mydocker/docker.Init","file":"/home/hpxx/mydocker/docker/init.go","line":49},"msg":"executing","argv0":"/bin/sh","argv":["/bin/sh"]}
/ # echo test_test > test_file && /bin/cat test_file
test_test
/ # exit

# 宿主机
$ cat ./busybox/test_file
test_test

可以看到:虽然容器内已无法访问到宿主机,但容器内的写操作却落到了宿主机对应的目录(即容器 rootfs 在宿主机上的实际位置)。这与 Docker 的行为并不相符——Docker 会把容器的写操作交给一个独立、可丢弃的层(AUFS/overlay2 的 upperdir),而我们这里直接把 rootfs 当成读写层。

下一篇会引入 镜像分层 的思路,把只读层与可写层分离:启动时用 overlayfs 把多个只读层叠起来,把所有写入重定向到一个临时目录(upperdir),容器退出后只需删掉 upperdir 就能回到初始状态。

系列

本系列文章

Docker
  1. My Docker 0
  2. My Docker 1
  3. My Docker 2
  4. My Docker 3