【译】从零开始用 Go 构建容器:现代命名空间 + cgroup v2

本文是对 Build a Container from Scratch in Go (Modern Namespaces + cgroup v2) 这篇文章的翻译,以下是正文


我用 Go 从零构建了一个容器,代码行数尽可能少,并在过程中了解了容器内部通常发生的各种事情——也就是 Docker 抽象掉的那些细节。

虽然网上也有类似的文章,但我写这篇是因为 Linux 内核的一些变化,导致很多博客在理解和代码实现上已经有些过时了。

什么是容器?

首先理解一下容器是什么。你可能已经知道,从根本上说,容器就是“将依赖打包起来,以便以可重复、安全的方式交付代码”。

但让我们深入理解背后的概念:命名空间(Namespaces)、控制组(Cgroups)和文件系统隔离,这些才是容器的基石。

命名空间

Linux 命名空间是内核的一个基础特性,它提供资源隔离,让不同的进程集合看到不同的资源视图。

命名空间非常重要,稍后我们会看到它的使用频率,因为它们是容器构建的核心技术。当你用 Docker 或 Podman 创建容器时,它会自动为你创建命名空间。

命名空间主要有 6 种类型(注意,我们会在编写代码时更详细地介绍它们,因为我喜欢边做边学,不会一开始就塞给你太多信息):

  • PID —— 分配独立的进程 ID。新命名空间中的第一个进程获得 PID 1。
  • Network —— 通过虚拟以太网对提供独立的网络栈。
  • MNT —— 维护独立的挂载点列表,允许在不影响宿主机的情况下挂载/卸载文件系统。
  • USER —— 拥有自己的用户 ID 和组 ID,允许一个进程在自己的命名空间内拥有 root 权限,但在别处没有。
  • IPC —— 隔离进程间通信资源,如 POSIX 消息队列。
  • UTS —— 允许同一系统上的不同进程拥有不同的主机名和域名。

Cgroups

理解 cgroups 的表面含义很容易——它代表控制组(control groups),是 Linux 内核的一个特性,用于限制、统计和隔离进程集合的资源使用,包括 CPU、内存、磁盘 I/O 和网络。

关于 cgroups 可以讲很多,但这里我只介绍实现所需的基础知识,后面还会再提到。

为了方便一步步深入理解,我们把代码分成几个阶段。

这里是完整代码的 GitHub 链接(请给个 star):

https://github.com/faizanfirdousi/container-from-scratch

阶段 0

首先,明确一下我们要做什么:主要目标是创建一个与宿主机隔离的容器。换句话说,我们要构建一个沙盒环境,运行一个拥有自己文件系统、进程命名空间和资源限制的 shell,从而与宿主机实现强隔离。

容器可以运行任何东西。为了简单起见,我们将使用 Alpine Linux 容器,因为它体积小,同时也拥有完整的组件,能提供完整的系统感觉。最终,我们将创建一个容器——或者说一个进程——它认为 Alpine 目录就是整个计算机。

为此,我们需要 Alpine 的根文件系统。在开始编码前先准备好:

1
2
3
4
# 下载并解压 Alpine Linux rootfs
docker export $(docker create alpine) -o alpine.tar
mkdir -p /home/faizan/alpine-rootfs
tar -xf alpine.tar -C /home/faizan/alpine-rootfs

如果你没有 Docker,可以通过 curl 安装。这里用它只是为了简单。

阶段 1

首先,我们创建一个简单的程序来运行命令,但它还没有任何隔离。这似乎有点反直觉,但有一个很好的理由:通过观察没有隔离时会发生什么,当我们后续添加每个隔离原语时,你才能真正理解它们的作用。

 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
package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s run <cmd> [args...]\n", os.Args[0])
        os.Exit(1)
    }

    switch os.Args[1] {
    case "run":
        run()
    default:
        panic("Unknown command")
    }
}

func run() {
    fmt.Printf("Running %v\n", os.Args[2:])

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(cmd.Run())
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

这段代码做了什么

程序首先检查传递给它的参数。第一个参数必须是 run,告诉我们的程序要执行一个命令。run 之后的所有内容都成为要执行的命令及其参数——例如 /bin/bash/bin/sh

run 函数被调用时,它使用 Go 的 exec.Command 创建一个新进程。工作原理很简单:os.Args[2] 包含命令本身(如 /bin/bash),os.Args[3:] 包含要传递给该命令的任何附加参数。... 语法将这些参数展开,逐个传递给命令。

然后我们将标准输入、输出和错误流连接起来。这很重要,因为它允许命令与你的终端自然交互。你可以像任何普通命令一样输入内容并看到输出。最后,cmd.Run() 实际执行命令并等待它完成。

测试

1
go run main.go run /bin/bash

你应该会看到类似这样的输出:

1
2
Running [/bin/bash]
[root@archlinux cfs]#

测试一下是否隔离:运行 pshostname 等命令。

图片

现在让我展示一下测试结果。在 shell 中运行 ps,仔细观察输出。

注意到什么有趣的事情了吗?PID 并不是从 1 开始(就像在真正的容器中那样)。相反,你看到的 PID 是 46628、46629、46669 等——这些正是宿主机看到的进程 ID。如果你在宿主机上打开另一个终端窗口并运行 ps aux,你会看到完全相同的 PID 值。这证明我们与宿主机共享着同一个进程命名空间。

现在检查主机名。

图片

运行 hostname,你会看到你机器的真实主机名。试着用 sudo hostname test-container 修改它,然后再次运行 hostname 确认修改成功。退出这个 shell,再检查你宿主机的 hostname——它也被修改了!我们直接修改了宿主系统的 hostname。我们的“容器”内部没有任何隔离屏障来保护宿主机免受修改的影响。

文件系统也是一样。当你运行 ls / 时,你看到的是实际宿主机的根目录。你在 /tmp 中创建的任何文件都会出现在宿主机上。我们完全在宿主机系统上操作,没有任何隔离。

为什么这很重要

我们现在构建的东西基本上只是一个执行命令的包装器——根本没有发生任何容器化。但这个基线很重要。在接下来的步骤中,当我们添加 Linux 命名空间时,你会看到情况会发生多么戏剧性的变化。PID 将从 1 开始,修改 hostname 不会影响宿主机,我们还将拥有自己隔离的文件系统。

阶段 2

现在把 run() 函数更新为如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func run() {
    fmt.Printf("Running %v\n", os.Args[2:])

    // 重新执行我们自己作为 "child",并进入新的命名空间
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    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,
        Unshareflags: syscall.CLONE_NEWNS,
    }

    must(cmd.Run())
}

并添加这个新的 child() 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func child() {
    fmt.Printf("Running %v as child\n", os.Args[2:])

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    must(syscall.Sethostname([]byte("container")))

    if err := cmd.Run(); err != nil {
        fmt.Println("Process exited:", err)
    }
}

让我们理解一下这里在做什么。首先,我们需要创建命名空间。在 run() 中我们写下:

1
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)

这是关键洞察:我们不是直接运行 /bin/bash。相反,我们重新执行我们自己的 Go 程序/proc/self/exe 是当前运行二进制文件的路径),但使用了一个新的参数列表:["child", "/bin/bash"]

为什么需要这种奇怪的自我重新执行?因为 Linux 命名空间只能在创建新进程时建立,你不能为一个已经运行的进程创建命名空间。所以我们需要让我们的代码再次运行,但这一次处于全新的隔离命名空间内部

然后我们使用以下代码实际创建命名空间:

1
2
3
4
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    Unshareflags: syscall.CLONE_NEWNS,
}

正如我在博客开头所说,不同的命名空间隔离不同的东西。这里我们使用 Linux 系统调用来创建命名空间。具体来说,我们使用带有特殊标志的 clone() 系统调用。

我们使用的系统调用属于 CLONE_* 家族。每个 CLONE_NEW* 标志告诉 Linux 内核:“当你创建这个子进程时,把它放到一个独立的、针对该资源的命名空间中。”让我分解一下每个标志的作用:

CLONE_NEWUTS 创建一个新的 UTS(Unix Time-Sharing)命名空间。这隔离了主机名和域名。有了这个标志,当子进程将其主机名改为 “container” 时,宿主机的主机名完全保持不变。如果没有这个标志,内部修改 hostname 会影响到你的实际宿主机——这正是我们在阶段 1 中看到的情况。

CLONE_NEWPID 创建一个新的 PID(进程 ID)命名空间。这非常重要。这意味着子进程获得了一个完全独立的进程 ID 编号系统。在容器内部,shell 会认为自己是 PID 1——第一个进程,就像真正的 Linux 启动中的 init 系统。但从宿主机的角度看,同一个进程可能只是 PID 15234。这就是为什么在真正的容器中运行 ps aux 时,你只能看到容器内的进程,而不是宿主机的所有进程。

CLONE_NEWNS 创建一个新的 Mount 命名空间。这隔离了文件系统挂载点。我们在容器内部做的任何挂载(如挂载 /proc/tmp)都不会出现在宿主机上,反之亦然。这对文件系统隔离至关重要。

这些标志之间的 |(竖线)是位或操作——它表示同时启用多个命名空间。本质上我们在说:“为子进程同时创建这三类命名空间。”

Unshareflags 配合 CLONE_NEWNS 是一个额外的安全措施。它使挂载命名空间“不可共享”,以防止挂载传播,基本上确保容器内的任何文件系统变更绝对不会泄露到宿主机。

测试

现在测试一下我们的命名空间隔离是否真的有效。运行你更新后的代码:

你应该会看到:

1
2
3
Running [/bin/bash]
Running [/bin/bash] as child
[root@container cfs]#

你会注意到提示符中的主机名已经显示为 container——这正是 Sethostname 调用生效的结果。

现在检查修改 hostname 是否保持隔离:修改容器内的 hostname,然后检查宿主机的 hostname 是否也被改变了。

接下来检查 PID 命名空间隔离——这个很有趣也很重要。

在容器内部运行:

1
echo $$

在你的宿主机上(另一个终端):

1
ps aux | grep bash

图片

同一个进程,两个不同的 PID! 在命名空间内部它是 PID 7(对你来说可能是其他较小的数字),从宿主机看它是 PID 44999。这证明了 PID 隔离正在工作。

测试 /proc 问题

在容器内部运行 ps

1
ps aux

你会看到宿主机的所有进程——systemd、kthreadd、Chrome,等等。为什么?因为即使我们有 PID 命名空间隔离,我们仍然在读取宿主机的 /proc 文件系统。ps 命令从 /proc 获取信息,而我们还没有隔离 /proc

阶段 3

更新你的 child() 函数,添加文件系统隔离。首先,在 cmd 设置之后立即添加环境变量:

 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
func child() {
    fmt.Printf("Running %v \n", os.Args[2:])

    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // 添加这些环境变量
    cmd.Env = []string{
        "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
        "HOME=/root",
        "TERM=xterm",
    }

    must(syscall.Sethostname([]byte("container")))

    // 添加文件系统隔离
    must(syscall.Chroot("/home/faizan/alpine-rootfs"))
    must(os.Chdir("/"))

    // 挂载 /proc 文件系统
    must(syscall.Mount("proc", "proc", "proc", 0, ""))

    // 在 /tmp 挂载 tmpfs
    must(os.MkdirAll("tmp", 0755))
    must(syscall.Mount("tmpfs", "tmp", "tmpfs", 0, ""))

    if err := cmd.Run(); err != nil {
        fmt.Println("Process exited:", err)
    }

    // 退出时清理挂载
    syscall.Unmount("proc", 0)
    syscall.Unmount("tmp", 0)
}

环境变量

1
2
3
4
5
cmd.Env = []string{
    "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
    "HOME=/root",
    "TERM=xterm",
}

这是一个虽小但重要的细节。当子进程被生成时,默认会继承父进程的环境变量,这意味着它会继承你宿主机的 PATH、宿主机的 HOME 等等。这是一个问题,因为在我们下一步进行文件系统隔离之后,那些宿主机路径在容器内部将根本不存在。

所以我们显式设置了一个干净、最小的环境。PATH 告诉 shell 去哪里找可执行文件。HOME 设置家目录。TERM=xterm 确保终端行为正常工作,比如清屏、箭头键、彩色输出。没有 TERM,很多终端程序会表现异常或拒绝运行。

Chroot(关键步骤)

1
2
must(syscall.Chroot("/home/faizan/alpine-rootfs"))
must(os.Chdir("/"))

chroot 为这个进程及其所有子进程重新定义了 / 的含义。在这个调用之后,当 shell 打开 /etc/passwd 时,内核将其解析为 /home/faizan/alpine-rootfs/etc/passwd。宿主机的文件系统变得完全不可见。运行 ls /,你会看到 Alpine 的 binetcusr,就像一台真正的 Alpine 机器。

chroot 改变了 / 指向的位置,但你的当前工作目录并不会移动。所以如果你在运行代码的目录下,chroot 之后你仍然停留在那个目录——也就是在“监狱”外面。从那里,一个进程可以简单地执行 cd ../../.. 然后直接走出去。os.Chdir("/") 立即将你移入监狱内部,这样就没有地方可以逃逸了——非常完美。

挂载 /proc —— 修复 ps 读取的问题

1
must(syscall.Mount("proc", "proc", "proc", 0, ""))

chroot 之后,Alpine 的 /proc 是一个空目录。ps 命令从 /proc/<pid>/status 读取所有信息,如果不挂载东西,ps 就会失败。

因此我们挂载一个新的 procfs。关键细节:这不是宿主机 /proc 的副本。内核根据当前 PID 命名空间能看到什么来填充它。由于我们处于阶段 2 的 CLONE_NEWPID 内部,所以只有容器的进程会出现在这里。现在运行 ps aux,你只会看到容器的 shell。宿主机进程的洪流消失了。这是 PID 隔离和文件系统隔离终于协同工作。

在 /tmp 挂载 tmpfs

1
2
must(os.MkdirAll("tmp", 0755))
must(syscall.Mount("tmpfs", "tmp", "tmpfs", 0, ""))

tmpfs 是基于内存的文件系统。容器内部写入 /tmp 的任何内容都存在于 RAM 中,永远不会触及宿主机的 /tmp,并且在容器退出的那一刻就会消失。这也是为什么阶段 2 中的 CLONE_NEWNS 很重要。如果没有挂载命名空间,这些挂载会传播到宿主机的挂载表。挂载命名空间是前提条件,而这些挂载是实际负载。

测试

现在检查一切:是否显示容器自身的文件系统和进程,而不是宿主机的。

图片

阶段 4 —— Cgroups

现在我们有了一个真正隔离的容器:自己的文件系统、自己的进程树、自己的主机名。但仍然存在一个问题:容器内的进程没有任何限制来阻止它消耗宿主机的所有 CPU、生成数千个进程或吃掉所有 RAM。容器内部一个简单的 fork bomb 就会让整台机器崩溃。

这就是 cgroups(控制组)要解决的问题。命名空间控制进程能看到什么,而 cgroups 控制进程能使用多少资源。

因此,用这个函数更新你的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func cg() {
    cgroupRoot := "/sys/fs/cgroup/"
    cgroupName := "container-cgroup"
    containerCgroup := filepath.Join(cgroupRoot, cgroupName)

    os.WriteFile(
        filepath.Join(cgroupRoot, "cgroup.subtree_control"),
        []byte("+pids +memory +cpu"),
        0644,
    )

    must(os.MkdirAll(containerCgroup, 0755))

    must(os.WriteFile(filepath.Join(containerCgroup, "pids.max"), []byte("20"), 0700))
    must(os.WriteFile(filepath.Join(containerCgroup, "memory.max"), []byte("52428800"), 0700))
    must(os.WriteFile(filepath.Join(containerCgroup, "cpu.max"), []byte("50000 100000"), 0700))

    must(os.WriteFile(
        filepath.Join(containerCgroup, "cgroup.procs"),
        []byte(strconv.Itoa(os.Getpid())),
        0700,
    ))
}

Cgroup v2

这正是我写这篇博客的主要原因。你看,大多数博客都是基于 cgroups v1 的,但很早以前内核就已经切换到 cgroup v2 了,所以代码也得相应地改变。

Cgroup v2 使用统一层级结构。每个控制器——pids、memory、cpu——都位于 /sys/fs/cgroup/ 下的单棵树中。这与 v1 不同,v1 为每个控制器使用单独的树,例如 /sys/fs/cgroup/pids//sys/fs/cgroup/memory/ 等等。如果你看过使用 v1 路径的老容器博客,那就是为什么它们在现代内核上无法工作的原因。

操作 cgroups 完全通过文件系统进行——你创建目录,向文件写入内容。不需要特殊的系统调用。

1
2
3
4
5
os.WriteFile(
    filepath.Join(cgroupRoot, "cgroup.subtree_control"),
    []byte("+pids +memory +cpu"),
    0644,
)

首先,我们在根 cgroup 上启用控制器。向 subtree_control 写入 +pids +memory +cpu 告诉内核让这些控制器可用于我们后续创建的子 cgroup。这就像在使用功能之前先解锁它。

创建 Cgroup

1
must(os.MkdirAll(containerCgroup, 0755))

这就是创建 cgroup 的全部操作。你在 /sys/fs/cgroup/ 下创建一个目录,内核会识别它,将其视为一个新的 cgroup,并自动在其中填充控制文件:pids.maxmemory.maxcpu.maxcgroup.procs 等等。不需要其他任何操作。

设置限制

1
2
3
must(os.WriteFile(filepath.Join(containerCgroup, "pids.max"), []byte("20"), 0700))
must(os.WriteFile(filepath.Join(containerCgroup, "memory.max"), []byte("52428800"), 0700))
must(os.WriteFile(filepath.Join(containerCgroup, "cpu.max"), []byte("50000 100000"), 0700))

pids.max 是最直接重要的一个。将其设置为 20 意味着一旦进程数达到 20,内核就会开始拒绝 fork()clone() 调用。容器内部的 fork bomb 只会自己崩溃,宿主机不受影响。

memory.max52428800 字节 —— 50 MiB。如果容器超过这个限制,内核的 OOM killer 会介入并杀死超限的进程。宿主机的内存永远不会受到威胁。

cpu.max 的格式是 $MAX $PERIOD,单位是微秒。50000 100000 的意思是:在每 100ms 的时间窗口内,这个 cgroup 最多获得 50ms 的 CPU 时间,即单核的 50%。容器内部失控的 CPU 循环会在内核调度器层面被限流。你的机器保持响应。

将进程移入

1
2
3
4
5
must(os.WriteFile(
    filepath.Join(containerCgroup, "cgroup.procs"),
    []byte(strconv.Itoa(os.Getpid())),
    0700,
))

之前的所有操作都只是配置。这行代码才是真正执行限制的。将我们的 PID 写入 cgroup.procs 会将当前进程移入 cgroup。从这一刻起,这个进程及其产生的所有子进程——包括 shell 和用户运行的所有东西——都将受到上述三个限制的约束。

测试

在容器内部尝试一个 fork bomb:

1
:(){ :|:& };:

由于 pids.max = 20,内核会在超过限制后拒绝每一个 fork() 调用。容器 shell 会死掉。在宿主机上打开另一个终端,一切正常运行。如果没有 cgroups,那个 fork bomb 会需要硬重启才能恢复。


好了!你已经从零构建了一个真正的容器,具备了:使用命名空间的隔离、使用 chroot 的文件系统虚拟化、以及使用 cgroup v2 的资源限制。这本质上就是 Docker 在底层做的事情,只不过 Docker 有更多的功能、更好的用户体验和生产级工具。

使用 Hugo 构建
主题 StackJimmy 设计