首页 / 文章 / 从零构建容器:50行代码实现容器运行时(第2部分)
← 返回
AI技术

从零构建容器:50行代码实现容器运行时(第2部分)

✍️ zhirenhun 📅 2026/5/28 👁 57 阅读 ⏱ 16 分钟
从零构建容器:50行代码实现容器运行时(第2部分)

欢迎回到"监狱"

本系列的第1部分中,我们用 Go 构建了容器的基础。我们成功使用了 CLONE_NEWUTS 命名空间(namespace)和进程分叉来隔离容器的主机名。

但还有一个巨大的安全漏洞。现在,如果我们进入容器的 bash shell,仍然可以看到宿主机(host)的所有文件。我们可以轻松地 cd 脱离所谓的"隔离环境",肆意破坏宿主机。

让我们把它锁住。

`chroot` — 监禁机制

Linux 有一个很棒的系统调用叫 chroot("change root"的缩写)。它能改变指定进程的根目录(/)。对于进程来说,chroot 指向的目录就是整个宇宙——外面的东西根本不存在。

让我们更新 child() 函数,把根目录设为当前工作目录:

func child() {
	fmt.Printf("Running in new child process %v \n", os.Args[2:])
	
	must(syscall.Sethostname([]byte("container")))
	
	// 获取当前目录,把进程锁在里面
	pwd, err := os.Getwd()
	must(err)

	must(syscall.Chroot(pwd))
	// chroot 改变了根目录,但不会自动切换工作目录!
	// 必须显式切换到新的根目录
	must(os.Chdir("/"))
	
	cmd := exec.Command(os.Args[2], os.Args[3:]...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	
	must(cmd.Run())
}

运行 sudo go run main.go run /bin/bash

崩溃了!

panic: fork/exec /bin/bash: no such file or directory

发生了什么?

我们刚才告诉进程,它的当前目录就是整个宇宙。所以当 exec.Command 要执行 /bin/bash 时,它不再去查找电脑真实的硬盘,而是到你的项目文件夹里找名为 bin 的目录下有没有叫 bash 的可执行文件。

因为当前目录里没有这些,所以失败了!我们需要一个真正的根文件系统(root filesystem),提供 shell 所需的基础二进制文件。

(注:像 runC 这样的生产级容器运行时,实际上使用更高级的 pivot_root 系统调用以获得更好的安全性,但 chroot 非常适合理解核心概念!)

镜像

为了解决这个问题,我们需要提供一个真正的根文件系统,包含 shell 所需的基本目录和二进制文件(如 /bin/bash)。

你可以用 Docker 获取一个基本的 Ubuntu 根文件系统。打开一个新的终端标签页,在你的项目目录中运行以下命令:

# 用 Docker 启动一个 Ubuntu 容器,将其文件系统导出为压缩文件 ubuntu.tar
docker export $(docker create ubuntu) > ubuntu.tar

# 创建一个叫 ubuntu-rootfs 的目录,把 tar 文件解压进去
mkdir ubuntu-rootfs
tar -xf ubuntu.tar -C ubuntu-rootfs

这样会创建一个名为 ubuntu-rootfs 的文件夹,内含一整套崭新的 Ubuntu 文件系统。

假设你的项目目录中有这个文件夹,我们把 chroot 改指向它:

	must(syscall.Chroot(filepath.Join(pwd, "ubuntu-rootfs")))
	must(os.Chdir("/"))

现在,运行 sudo go run main.go run /bin/bash,一切完美运行!

你可以执行 ls /,只会看到 ubuntu-rootfs 目录中的文件。试试 cd .. 逃离——你会发现自己在同样的目录里。你完全无法访问宿主机。

PID 和 /proc

我们准备进入下一步。回忆一下,在第1部分中运行 docker run -it ubuntu /bin/bash 时,我们判断自己在一个隔离容器中的方式之一,就是运行 ps aux 并观察到只有两个进程在运行,且 PID 非常低。

让我们来复现这一点。在容器内试试运行 ps aux 查看运行中的进程。

出错了:

Error, do this: mount -t proc proc /proc

ps 命令通过读取 /proc 目录来工作。/proc 是 Linux 中一个特殊的虚拟文件系统(virtual filesystem),包含了运行中进程的实时数据。我们隔离的根文件系统有一个空的 /proc 文件夹,操作系统没有被告知要将实时进程数据挂载进去。因为它是空的,所以 ps 失败!

要解决这个问题,我们需要做两件事:

  1. 用命名空间给容器提供自己隔离的进程 ID(PID)
  2. 挂载(mount)proc 文件系统,这样 ps 等命令才能读取

首先,更新 run() 中的 SysProcAttr,增加 PID 和 Mount 命名空间。(注:CLONE_NEWNS 代表"New Namespace",但它特指 Mount 命名空间!它恰好是 Linux 内核中第一个加入的命名空间,当时没人想到以后还需要更多,所以就直接叫"命名空间"了 🤷)

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}

接下来,在 child() 函数的 chroot 之后挂载 proc 目录。我们还会用 Go 的 defer 关键字确保在函数退出时卸载并清理:

	must(syscall.Chroot(filepath.Join(pwd, "ubuntu-rootfs")))
	must(os.Chdir("/"))
	
	// 挂载 proc 文件系统
	must(syscall.Mount("proc", "proc", "proc", 0, ""))
	// 函数退出时清理
	defer syscall.Unmount("proc", 0)

现在运行容器,输入 ps aux。你只会看到三个进程:exe(我们的 Go 程序)作为 PID 1 运行,bash 作为 PID 2 运行,以及刚运行的 ps 命令!

Cgroups — 保持文明

我们有了隐身斗篷(命名空间)和隔离的宇宙(chroot)。但如果容器里写一个无限的 while 循环,吃光所有的 CPU 和内存呢?

它会完全搞垮宿主机!

为了防止容器耗尽所有资源,Linux 使用了 cgroups(Control Groups,控制组)。Cgroups 像保安一样,确保没有单个容器使用超过它应得的资源份额。

要设置 cgroup,我们可以依靠 Linux 著名的哲学:"一切皆文件。"这意味着我们可以通过创建特定目录、向特殊文件中写入文本来配置内核的资源限制。

让我们在 main.go 中添加一个辅助函数,限制容器最多只能创建 20 个进程:

func cg() {
	cgroups := "/sys/fs/cgroup/"
	pids := filepath.Join(cgroups, "pids")
	
	// 1. 为容器创建一个新的 cgroup
	containerCgroup := filepath.Join(pids, "my-container")
	os.Mkdir(containerCgroup, 0755)
	
	// 2. 向 cgroup 文件中写入限制(最大 20 个进程)
	must(os.WriteFile(filepath.Join(containerCgroup, "pids.max"), []byte("20"), 0700))
	
	// 3. 将当前进程加入此 cgroup
	must(os.WriteFile(filepath.Join(containerCgroup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0700))
}

来分解这个函数做了什么:

  1. 创建组:/sys/fs/cgroup/pids 下创建新目录,Linux 内核自动为我们创建一个新的控制组。
  2. 设定规则: 在那个新目录中,Linux 自动生成一个叫 pids.max 的文件。我们打开文件写入文本 "20",建立一条规则——我们的进程只允许运行 20 个子进程。
  3. 执行规则: Linux 还生成一个叫 cgroup.procs 的文件。我们获取 Go 程序的当前进程 ID(os.Getpid())写入这个文件,告诉内核:"嘿,把这个文件夹的规则应用到我的进程上!"

最后,在 run() 函数中执行子进程前调用这个函数:

func run() {
	fmt.Printf("Running %v \n", os.Args[2:])
	
	args := append([]string{"child"}, os.Args[2:]...)
	cmd := exec.Command("/proc/self/exe", args...)
	
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
	}
	
	// 设置资源限制!
	cg()
	
	must(cmd.Run())
}

就这样,我们的容器正式有了资源限制!因为 cgroup 会向下继承到子进程,容器内运行的一切都受此规则约束。如果恶意脚本试图执行"fork bomb"(无限复制自身来冻结计算机的程序),内核会在它达到 20 个进程的瞬间介入并将其杀死。

揭晓答案

如果你把这一切拼在一起,我们就用大约 50 行代码从零实现了 Docker。

容器不是魔法。它们不是重量级虚拟机。它们仅仅是包裹在命名空间中的标准 Linux 进程,被 chroot 监禁在指定目录中,并由 cgroups 监管。

事实上,你甚至不需要 Go 来实现这一切。一行 bash 就能触发完全相同的隔离:

sudo unshare --uts --pid --mount --fork --root=/home/ubuntu-rootfs --mount-proc /bin/bash

就是这样!下次有人说"it works on my machine(在我机器上是好的)"时,你很清楚要怎样才能把他们的机器部署到生产环境中。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

go containers docker linux
← 上一篇
用AI构建数据库性能测试工具:诚实复盘
下一篇 →
超越幻觉的AI Agent故障模式

📌 相关推荐

走向 Agent 记忆的标准模型
2026/5/31
浏览器内部的悄然 AI 战争
2026/5/31
为什么 AI 会忘记你说过的话(以及如何解决)
2026/5/31
← 返回文章列表