第 40 章

实现一个简易容器

第四十章:实现一个简易容器

"容器"这个词被滥用了。很多工程师认为容器是某种轻量级虚拟机——Docker 镜像里装着操作系统,容器里运行着进程,它们被神秘地隔离开来。这个理解不完全错,但它掩盖了一个更重要的事实:容器不是新技术,它是 Linux 内核已有特性的组合

本章要揭开这层神秘感。我们将从 Linux 的 namespace 和 cgroup 出发,用 Go 实现一个能够:隔离进程、挂载文件系统、限制内存和 CPU 使用的简易容器运行时。这个过程会让你理解 Docker 和 Kubernetes 在做什么,以及当容器出现问题时,该往 Linux 内核的哪个方向排查。


Level 1 · 容器是什么:不是虚拟机

虚拟机 vs 容器:本质区别

虚拟机(VM)通过 Hypervisor 模拟完整的硬件:CPU 指令集、内存控制器、网络卡、磁盘控制器。Guest OS 运行在模拟的硬件上,对底层物理机一无所知。这是真正的隔离——两个 VM 可以运行完全不同的操作系统内核(Linux 和 Windows 同时运行在一台物理机上)。

容器共享宿主机的 Linux 内核。容器内的进程就是宿主机上的普通进程,只不过通过 Linux namespace 机制让它"看不到"其他进程、"看不到"其他文件系统、"看不到"其他网络接口。通过 cgroup 机制限制它能使用的 CPU 时间和内存大小。

虚拟机架构:
┌─────────────────────────────────────┐
│ App  │  App  │  App                │
├──────┴───────┴──────────────────────┤
│            Guest OS                 │
├─────────────────────────────────────┤
│            Hypervisor               │
├─────────────────────────────────────┤
│           Host OS / 物理硬件        │
└─────────────────────────────────────┘

容器架构:
┌───────────┬───────────┬─────────────┐
│  Container│  Container│  Container  │
│ (ns+cg)  │ (ns+cg)  │  (ns+cg)   │
├───────────┴───────────┴─────────────┤
│         Host Linux Kernel           │
├─────────────────────────────────────┤
│              物理硬件               │
└─────────────────────────────────────┘

这个架构差异带来了根本的性能差异:容器启动时间是毫秒级(进程启动),VM 启动时间是秒级(操作系统启动)。容器的内存开销是进程的内存开销,VM 的内存开销包含完整 Guest OS(通常数百 MB)。

为什么说容器是"进程"

运行 docker run -d nginx 后,在宿主机上执行 ps aux | grep nginx,你会看到 nginx 进程出现在宿主机的进程列表中(只是 PID 不同)。这个 nginx 进程:

它只是被 namespace 和 cgroup "包装"了——获得了一个受限的"世界观"。

两个核心机制:Namespace 和 Cgroup

Linux Namespace(1991 年引入,2.6.x 版本完善):控制进程能"看到"什么。创建新 namespace 后,新进程拥有该 namespace 的独立视图,与其他 namespace 的进程互不干扰。

Linux Cgroup(Control Groups,2008 年引入,2.6.24):控制进程能"使用"多少资源。对 CPU、内存、磁盘 I/O、网络带宽设置上限。

没有 namespace:进程可以看到所有其他进程(ps aux 看到整台机器的进程),可以操作所有文件,可以使用任意端口。 没有 cgroup:进程可以消耗机器的全部内存(OOM killer 会杀死其他进程),可以占用全部 CPU。

namespace + cgroup = 容器的隔离性 + 资源限制。

Go 适合实现容器运行时的原因

Go 的标准库提供了对 Linux 系统调用的直接访问(syscall 包),以及方便的进程管理(os/exec)。Go 程序编译为静态二进制(无外部依赖),适合在容器内运行(容器内可能没有完整的 C 运行时)。runc(Docker 的默认容器运行时)正是用 Go 写的。


Level 2 · Linux Namespace 与 Cgroup 详解

Linux 的七种 Namespace

Namespace 常量 隔离内容 典型用途
PID CLONE_NEWPID 进程 ID 空间 容器内的进程看到的 PID 从 1 开始
Network CLONE_NEWNET 网络接口、路由表、防火墙规则 容器有独立的网络栈
Mount CLONE_NEWNS 文件系统挂载点 容器有独立的文件系统视图
UTS CLONE_NEWUTS 主机名和域名 容器有独立的 hostname
IPC CLONE_NEWIPC 消息队列、共享内存、信号量 容器间的 IPC 资源隔离
User CLONE_NEWUSER 用户和组 ID 映射 容器内的 root 映射到宿主机的普通用户
Cgroup CLONE_NEWCGROUP cgroup 根目录视图 容器看不到宿主机的 cgroup 层次

在 Go 中,通过 syscall.SysProcAttr 设置 Cloneflags 来指定要创建的 namespace:

cmd := exec.Command("bash")
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS |
                syscall.CLONE_NEWPID |
                syscall.CLONE_NEWNS  |
                syscall.CLONE_NEWNET |
                syscall.CLONE_NEWIPC,
}

PID Namespace 详解

PID namespace 创造了一个独立的进程 ID 空间。namespace 内的第一个进程(即容器的 init 进程)在该 namespace 内的 PID 为 1,但在宿主机上有一个不同的 PID(比如 4782)。

PID 1 在 Linux 中有特殊含义:它是孤儿进程的"收容所"(其他进程退出后,其子进程会被重新挂载到 PID 1)。更重要的是,PID 1 不会响应默认的 SIGTERM 信号,这是 Docker 容器无法优雅停止的常见原因(进程没有处理 SIGTERM,或者没有把信号转发给子进程)。

容器内部看到的进程树:

PID 1: /init 或 /bin/sh (容器的入口进程)
  PID 2: nginx worker
  PID 3: nginx worker

宿主机看到的:

PID 4782: /bin/sh
  PID 4783: nginx worker
  PID 4784: nginx worker

Mount Namespace 与 pivot_root

Mount namespace 隔离了文件系统的挂载点视图,但这只是第一步。真正让容器有独立文件系统的关键是 pivot_root(或传统的 chroot):

chroot(简单版):改变进程的根目录(/)。进程看不到原根目录之外的文件。但 chroot 有安全问题:特权进程可以通过 chroot + 相对路径"逃脱"到真实根目录。

pivot_root(生产版):真正切换文件系统根目录,比 chroot 更安全。将旧根挂载到新根的某个子目录下,然后将新根切换为根。

// 简化版:使用 chroot 演示原理
// 生产级容器运行时使用 pivot_root
func setupRootFS(newroot string) error {
    // 将 newroot 挂载为 bind mount(使其成为独立挂载点,pivot_root 的前提)
    if err := syscall.Mount(newroot, newroot, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
        return fmt.Errorf("bind mount: %w", err)
    }
    // chroot 到新根
    if err := syscall.Chroot(newroot); err != nil {
        return fmt.Errorf("chroot: %w", err)
    }
    // 切换工作目录到新根
    return os.Chdir("/")
}

Cgroup v2:资源限制的现代接口

Cgroup v2(Linux 4.5+,现代发行版已默认启用)通过 /sys/fs/cgroup 文件系统暴露接口。每个 cgroup 是一个目录,通过写入特定文件来设置限制。

主要的资源控制文件:

/sys/fs/cgroup/
└── mycontainer/          # 为容器创建的 cgroup 目录
    ├── cgroup.procs      # 写入 PID 以将进程加入此 cgroup
    ├── memory.max        # 内存上限(字节数,或 "max" 表示无限制)
    ├── memory.current    # 当前内存使用量(只读)
    ├── cpu.max           # CPU 限制(格式:quota period,如 "50000 100000" 表示 50%)
    ├── cpu.stat          # CPU 使用统计(只读)
    └── pids.max          # 最大进程数

操作 cgroup 的 Go 代码:

const cgroupBase = "/sys/fs/cgroup"

// createCgroup 为容器创建一个 cgroup。
func createCgroup(name string) (string, error) {
    cgPath := filepath.Join(cgroupBase, name)
    if err := os.MkdirAll(cgPath, 0755); err != nil {
        return "", fmt.Errorf("create cgroup dir: %w", err)
    }
    return cgPath, nil
}

// setMemoryLimit 设置容器的内存上限(字节)。
// 超过此限制的进程会触发 OOM killer(先杀容器内的进程,再考虑宿主机进程)。
func setMemoryLimit(cgPath string, limitBytes int64) error {
    return os.WriteFile(
        filepath.Join(cgPath, "memory.max"),
        []byte(strconv.FormatInt(limitBytes, 10)),
        0644,
    )
}

// setCPULimit 设置 CPU 限制。
// quota/period = CPU 占用率。如 quota=50000, period=100000 表示 50% 的 CPU。
func setCPULimit(cgPath string, quota, period int) error {
    value := fmt.Sprintf("%d %d", quota, period)
    return os.WriteFile(filepath.Join(cgPath, "cpu.max"), []byte(value), 0644)
}

// addProcessToCgroup 将进程加入 cgroup(从此刻起,该进程受 cgroup 限制)。
func addProcessToCgroup(cgPath string, pid int) error {
    return os.WriteFile(
        filepath.Join(cgPath, "cgroup.procs"),
        []byte(strconv.Itoa(pid)),
        0644,
    )
}

OverlayFS:容器文件系统的分层秘密

Docker 镜像使用分层文件系统,OverlayFS 是最常用的实现。OverlayFS 将多个"层"叠加成一个统一视图:

容器的文件系统视图(OverlayFS 合并结果):
  /etc/nginx/nginx.conf   ← 来自 upperdir(容器可写层,如果已修改)
  /usr/sbin/nginx         ← 来自 lowerdir(镜像层,只读)
  /bin/bash               ← 来自 lowerdir(基础镜像层,只读)

挂载命令:
mount -t overlay overlay \
  -o lowerdir=/image/layer2:/image/layer1:/image/base,\
     upperdir=/container/rw,\
     workdir=/container/work \
  /container/merged

lowerdir:只读层(镜像层)。可以有多个,用 : 分隔,越靠左优先级越高。 upperdir:可写层(容器的修改都在这里)。容器删除文件时,在 upperdir 创建一个 whiteout 文件(特殊标记)。 workdir:OverlayFS 内部使用的工作目录,必须与 upperdir 在同一文件系统上。 merged:合并后的视图,挂载到这个目录供容器使用。

写时复制(Copy-on-Write,CoW):容器第一次修改 lowerdir 中的文件时,OverlayFS 自动将文件复制到 upperdir,然后修改 upperdir 中的副本。lowerdir 中的原始文件不受影响。


Level 3 · 从零实现简易容器运行时

项目结构

minicontainer/
├── main.go          # 入口:解析命令,分发到 parent 或 child 模式
├── container.go     # 容器生命周期管理
├── cgroup.go        # Cgroup 操作
├── network.go       # 网络配置(veth pair)
├── rootfs.go        # 文件系统准备(OverlayFS/chroot)
└── Makefile

Step 1:主入口与进程自调用模式

容器运行时有一个特殊之处:它需要在新 namespace 中运行一段"初始化代码",然后才能 exec 用户指定的命令。Go 进程不能像 C 中的 clone() 后在子进程中运行任意函数,因为 Go 运行时在 main() 之前已经初始化了 goroutine 和 GC。

解决方案是进程自调用(self-invoking):容器运行时启动自身的一个副本作为"容器内初始化进程",通过环境变量传递配置,子进程完成 namespace 内的初始化后再 exec 用户命令。

// main.go
package main

import (
	"fmt"
	"os"
)

func main() {
	// 通过环境变量判断是"父进程模式"还是"子进程(容器内)模式"
	// 这是 runc 等工具的常见做法
	switch os.Args[1] {
	case "run":
		// 父进程模式:创建 namespace,启动子进程
		if err := runParent(os.Args[2:]); err != nil {
			fmt.Fprintf(os.Stderr, "error: %v\n", err)
			os.Exit(1)
		}
	case "_child":
		// 子进程模式:在新 namespace 内进行初始化,然后 exec 用户命令
		// 这个模式由 runParent 通过 Cmd.Start() 触发,用户不应直接调用
		if err := runChild(); err != nil {
			fmt.Fprintf(os.Stderr, "child error: %v\n", err)
			os.Exit(1)
		}
	default:
		fmt.Fprintf(os.Stderr, "usage: minicontainer run <command> [args...]\n")
		os.Exit(1)
	}
}

Step 2:父进程——创建 Namespace 并启动子进程

// container.go
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
)

// ContainerConfig 保存容器的运行时配置。
type ContainerConfig struct {
	Command    []string // 容器内运行的命令
	RootFS     string   // 容器根文件系统路径
	MemLimit   int64    // 内存限制(字节),0 表示无限制
	CPUQuota   int      // CPU 配额(微秒),与 CPUPeriod 配合使用
	CPUPeriod  int      // CPU 周期(微秒),默认 100000(100ms)
	Hostname   string   // 容器的主机名
}

// runParent 在宿主机进程中运行,负责:
// 1. 解析配置
// 2. 创建新的 namespace(通过 Cloneflags)
// 3. 启动"子进程"(即容器内的初始化进程)
// 4. 配置 cgroup(必须在子进程启动后、子进程初始化前完成)
// 5. 等待子进程退出
func runParent(args []string) error {
	cfg := &ContainerConfig{
		Command:   args,
		RootFS:    "./rootfs",   // 简化:使用固定路径,生产级应支持 OCI 镜像
		MemLimit:  128 * 1024 * 1024, // 默认 128MB 内存限制
		CPUQuota:  50000,         // 50% CPU
		CPUPeriod: 100000,        // 100ms 周期
		Hostname:  "container",
	}

	// 将配置序列化到环境变量,传递给子进程
	// 生产级实现会使用 Unix socket 或 pipe 传递更复杂的配置
	env := []string{
		fmt.Sprintf("MC_COMMAND=%s", strings.Join(cfg.Command, ",")),
		fmt.Sprintf("MC_ROOTFS=%s", cfg.RootFS),
		fmt.Sprintf("MC_HOSTNAME=%s", cfg.Hostname),
	}

	// 使用自身可执行文件启动子进程,指定 "_child" 模式
	self, err := os.Executable()
	if err != nil { return err }

	cmd := exec.Command(self, "_child")
	cmd.Stdin  = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	cmd.Env = append(os.Environ(), env...)

	// 关键:通过 Cloneflags 创建新的 namespace
	// 这些 flag 对应 clone(2) 系统调用的参数
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS |  // 独立 hostname
			syscall.CLONE_NEWPID |           // 独立 PID 空间(子进程的 PID 在容器内为 1)
			syscall.CLONE_NEWNS  |           // 独立挂载命名空间
			syscall.CLONE_NEWNET |           // 独立网络命名空间
			syscall.CLONE_NEWIPC,            // 独立 IPC 命名空间
		// 注意:CLONE_NEWUSER 需要额外的 UID/GID 映射配置(rootless 容器)
	}

	// 启动子进程(此时 namespace 已创建,但子进程尚未完成初始化)
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("start container process: %w", err)
	}

	// 子进程启动后,在宿主机侧配置 cgroup
	// 必须在子进程完成初始化之前完成(子进程会等待信号)
	cgroupName := fmt.Sprintf("minicontainer-%d", cmd.Process.Pid)
	if err := setupCgroup(cgroupName, cmd.Process.Pid, cfg); err != nil {
		cmd.Process.Kill()
		return fmt.Errorf("setup cgroup: %w", err)
	}

	// 通知子进程"cgroup 已设置,可以继续"
	// 简化实现:写入一个临时文件;生产级使用 pipe 或 sync.fd
	readyFile := fmt.Sprintf("/tmp/mc-ready-%d", cmd.Process.Pid)
	os.WriteFile(readyFile, []byte("ready"), 0644)
	defer os.Remove(readyFile)

	return cmd.Wait()
}

// setupCgroup 为容器进程配置 cgroup 资源限制。
func setupCgroup(name string, pid int, cfg *ContainerConfig) error {
	cgPath, err := createCgroup(name)
	if err != nil { return err }

	if cfg.MemLimit > 0 {
		if err := setMemoryLimit(cgPath, cfg.MemLimit); err != nil {
			return fmt.Errorf("set memory limit: %w", err)
		}
	}
	if cfg.CPUQuota > 0 {
		if err := setCPULimit(cgPath, cfg.CPUQuota, cfg.CPUPeriod); err != nil {
			return fmt.Errorf("set cpu limit: %w", err)
		}
	}
	// 最后,将容器进程加入 cgroup(从此刻起受到资源限制)
	return addProcessToCgroup(cgPath, pid)
}

Step 3:子进程——Namespace 内的初始化

// runChild 在新的 namespace 内运行(由父进程通过 Cloneflags 创建)。
// 职责:
// 1. 等待父进程完成 cgroup 配置(通过临时文件同步)
// 2. 设置 hostname(UTS namespace)
// 3. 准备文件系统(chroot)
// 4. 挂载必要的虚拟文件系统(/proc, /sys, /dev)
// 5. exec 用户指定的命令(替换当前进程)
func runChild() error {
	// 等待父进程完成 cgroup 配置
	pid := os.Getpid() // 注意:这是在新 PID namespace 中,PID 为 1;
	                   // 但 /proc/self 等路径仍然工作

	// 通过宿主机 PID 找到 ready 文件(父进程以宿主机 PID 命名)
	// 简化实现:轮询等待(生产级应使用 pipe)
	hostPID := os.Getenv("HOST_PID") // 父进程应该传入这个值
	if hostPID == "" {
		// 回退:从 /proc/self/status 读取宿主机 PID
		hostPID = getHostPID()
	}
	readyFile := fmt.Sprintf("/tmp/mc-ready-%s", hostPID)
	for i := 0; i < 100; i++ {
		if _, err := os.Stat(readyFile); err == nil { break }
		time.Sleep(10 * time.Millisecond)
	}

	// 设置容器 hostname(UTS namespace 已隔离,不会影响宿主机)
	hostname := os.Getenv("MC_HOSTNAME")
	if hostname == "" { hostname = "container" }
	if err := syscall.Sethostname([]byte(hostname)); err != nil {
		return fmt.Errorf("set hostname: %w", err)
	}

	// 准备根文件系统
	rootfs := os.Getenv("MC_ROOTFS")
	if rootfs == "" { rootfs = "./rootfs" }
	if err := setupRootFS(rootfs); err != nil {
		return fmt.Errorf("setup rootfs: %w", err)
	}

	// 挂载必要的虚拟文件系统
	if err := mountVirtualFS(); err != nil {
		return fmt.Errorf("mount virtual fs: %w", err)
	}

	// 获取要运行的命令
	commandStr := os.Getenv("MC_COMMAND")
	if commandStr == "" { return fmt.Errorf("MC_COMMAND not set") }
	args := strings.Split(commandStr, ",")

	// 查找命令的完整路径
	path, err := exec.LookPath(args[0])
	if err != nil { return fmt.Errorf("command not found: %s", args[0]) }

	// exec 替换当前进程(子进程从此成为用户指定的进程,容器内 PID 为 1)
	// syscall.Exec 不返回(成功时),它替换当前进程的程序映像
	return syscall.Exec(path, args, os.Environ())
}

// setupRootFS 将容器根文件系统切换到 newroot。
func setupRootFS(newroot string) error {
	// 确保 newroot 是绝对路径
	abs, err := filepath.Abs(newroot)
	if err != nil { return err }

	// bind mount:让 newroot 成为独立挂载点(pivot_root 的要求)
	if err := syscall.Mount(abs, abs, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
		return fmt.Errorf("bind mount rootfs: %w", err)
	}

	// 使用 chroot(简化版,pivot_root 更安全但配置更复杂)
	if err := syscall.Chroot(abs); err != nil {
		return fmt.Errorf("chroot: %w", err)
	}
	return os.Chdir("/")
}

// mountVirtualFS 在容器内挂载 /proc、/sys、/dev 等虚拟文件系统。
// 这些文件系统不对应磁盘上的真实数据,而是内核的内部状态暴露给用户空间的接口。
func mountVirtualFS() error {
	mounts := []struct {
		target string
		fstype string
		flags  uintptr
		data   string
	}{
		// /proc:进程信息。工具如 ps、top 依赖此目录。
		// 挂载到容器后,/proc 显示的是当前 PID namespace 内的进程(隔离效果)。
		{"/proc", "proc", syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV, ""},
		// /sys:内核和设备信息。cgroup v2 也通过 /sys/fs/cgroup 暴露。
		{"/sys", "sysfs", syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV, ""},
		// /dev:设备文件。使用 tmpfs 而非真实设备,防止容器访问宿主机设备。
		{"/dev", "tmpfs", syscall.MS_NOSUID | syscall.MS_STRICTATIME, "mode=755"},
		// /dev/pts:伪终端(ssh、bash 交互需要)
		{"/dev/pts", "devpts", syscall.MS_NOSUID | syscall.MS_NOEXEC, "newinstance,ptmxmode=0666"},
	}

	for _, m := range mounts {
		// 确保挂载点目录存在
		os.MkdirAll(m.target, 0755)
		if err := syscall.Mount("none", m.target, m.fstype, m.flags, m.data); err != nil {
			return fmt.Errorf("mount %s: %w", m.target, err)
		}
	}

	// 创建基本的设备节点(简化,生产级使用 mknod 或 bind mount 宿主机设备)
	devLinks := map[string]string{
		"/dev/stdin":  "/proc/self/fd/0",
		"/dev/stdout": "/proc/self/fd/1",
		"/dev/stderr": "/proc/self/fd/2",
	}
	for dst, src := range devLinks {
		os.Symlink(src, dst)
	}

	return nil
}

Step 4:Cgroup 操作

// cgroup.go
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strconv"
)

const cgroupBase = "/sys/fs/cgroup"

func createCgroup(name string) (string, error) {
	cgPath := filepath.Join(cgroupBase, name)
	if err := os.MkdirAll(cgPath, 0755); err != nil {
		return "", fmt.Errorf("mkdir %s: %w", cgPath, err)
	}
	return cgPath, nil
}

func setMemoryLimit(cgPath string, limitBytes int64) error {
	// memory.max:设置内存硬上限
	// 超过后,内核 OOM killer 会终止 cgroup 内的进程
	path := filepath.Join(cgPath, "memory.max")
	return os.WriteFile(path, []byte(strconv.FormatInt(limitBytes, 10)), 0644)
}

func setCPULimit(cgPath string, quota, period int) error {
	// cpu.max 格式:"quota period"(微秒)
	// 例如 "50000 100000" 表示每 100ms 内最多使用 50ms CPU,即 50% CPU
	// "max 100000" 表示无 CPU 限制
	path := filepath.Join(cgPath, "cpu.max")
	value := fmt.Sprintf("%d %d", quota, period)
	return os.WriteFile(path, []byte(value), 0644)
}

func setPIDLimit(cgPath string, maxPIDs int) error {
	// pids.max:限制 cgroup 内的最大进程数,防止 fork 炸弹
	path := filepath.Join(cgPath, "pids.max")
	return os.WriteFile(path, []byte(strconv.Itoa(maxPIDs)), 0644)
}

func addProcessToCgroup(cgPath string, pid int) error {
	// cgroup.procs:写入 PID 将进程(及其线程)加入此 cgroup
	// 注意:进程加入 cgroup 后,其后续创建的子进程也自动在此 cgroup 中
	path := filepath.Join(cgPath, "cgroup.procs")
	return os.WriteFile(path, []byte(strconv.Itoa(pid)), 0644)
}

// getCgroupStats 读取 cgroup 的资源使用统计。
func getCgroupStats(cgPath string) (memUsage int64, cpuUsageNs int64, err error) {
	// 读取内存使用量
	memData, err := os.ReadFile(filepath.Join(cgPath, "memory.current"))
	if err != nil { return 0, 0, err }
	memUsage, _ = strconv.ParseInt(strings.TrimSpace(string(memData)), 10, 64)

	// 读取 CPU 使用统计(从 cpu.stat 文件解析)
	cpuData, err := os.ReadFile(filepath.Join(cgPath, "cpu.stat"))
	if err != nil { return memUsage, 0, err }
	for _, line := range strings.Split(string(cpuData), "\n") {
		if strings.HasPrefix(line, "usage_usec ") {
			parts := strings.Fields(line)
			if len(parts) == 2 {
				usec, _ := strconv.ParseInt(parts[1], 10, 64)
				cpuUsageNs = usec * 1000 // 微秒转纳秒
			}
		}
	}
	return memUsage, cpuUsageNs, nil
}

// cleanupCgroup 删除 cgroup 目录(清理资源)。
// 注意:必须先确保 cgroup 内没有进程,才能删除目录。
func cleanupCgroup(cgPath string) error {
	return os.Remove(cgPath) // rmdir(只能删除空目录)
}

Step 5:拉取和解包 OCI 镜像层

// rootfs.go(骨架实现)
package main

import (
	"archive/tar"
	"compress/gzip"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

// OCIManifest 是 OCI 镜像清单的简化结构。
type OCIManifest struct {
	Layers []struct {
		Digest string `json:"digest"` // 格式:sha256:<hex>
		Size   int    `json:"size"`
	} `json:"layers"`
}

// PullAndUnpack 从 Docker Hub 拉取镜像并解压到 destDir。
// 简化版:仅支持公开镜像,不处理认证。
// 生产级应使用 OCI Distribution Spec 规范的完整实现。
func PullAndUnpack(image, tag, destDir string) error {
	// 1. 获取 Token(Docker Hub 要求)
	tokenURL := fmt.Sprintf(
		"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
		image)
	token, err := fetchToken(tokenURL)
	if err != nil { return fmt.Errorf("fetch token: %w", err) }

	// 2. 获取镜像清单
	manifestURL := fmt.Sprintf("https://registry-1.docker.io/v2/%s/manifests/%s", image, tag)
	manifest, err := fetchManifest(manifestURL, token)
	if err != nil { return fmt.Errorf("fetch manifest: %w", err) }

	// 3. 逐层下载并解压(层的顺序很重要:从 base 到最新层)
	for i, layer := range manifest.Layers {
		fmt.Printf("Pulling layer %d/%d: %s\n", i+1, len(manifest.Layers), layer.Digest[:19])
		layerURL := fmt.Sprintf(
			"https://registry-1.docker.io/v2/%s/blobs/%s", image, layer.Digest)
		if err := downloadAndExtract(layerURL, token, destDir); err != nil {
			return fmt.Errorf("extract layer %s: %w", layer.Digest[:19], err)
		}
	}
	return nil
}

// downloadAndExtract 下载一个 tar.gz 层并解压到 destDir,处理 whiteout 文件。
func downloadAndExtract(url, token, destDir string) error {
	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("Authorization", "Bearer "+token)
	resp, err := http.DefaultClient.Do(req)
	if err != nil { return err }
	defer resp.Body.Close()

	gz, err := gzip.NewReader(resp.Body)
	if err != nil { return err }
	defer gz.Close()

	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF { break }
		if err != nil { return err }

		target := filepath.Join(destDir, hdr.Name)

		// 处理 whiteout 文件(OverlayFS 的删除标记)
		// .wh.<filename> 表示该文件在此层被删除
		base := filepath.Base(hdr.Name)
		if strings.HasPrefix(base, ".wh.") {
			// 删除目标文件(模拟 OverlayFS 的 whiteout)
			deleted := filepath.Join(filepath.Dir(target), strings.TrimPrefix(base, ".wh."))
			os.RemoveAll(deleted)
			continue
		}

		switch hdr.Typeflag {
		case tar.TypeDir:
			os.MkdirAll(target, hdr.FileInfo().Mode())
		case tar.TypeReg:
			f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode())
			if err != nil { return err }
			io.Copy(f, tr)
			f.Close()
		case tar.TypeSymlink:
			os.Symlink(hdr.Linkname, target)
		}
	}
	return nil
}

// fetchToken 从 Docker Hub 认证服务获取 Bearer Token。
func fetchToken(url string) (string, error) {
	resp, err := http.Get(url)
	if err != nil { return "", err }
	defer resp.Body.Close()
	var result struct{ Token string `json:"token"` }
	json.NewDecoder(resp.Body).Decode(&result)
	return result.Token, nil
}

// fetchManifest 获取镜像的 OCI 清单。
func fetchManifest(url, token string) (*OCIManifest, error) {
	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil { return nil, err }
	defer resp.Body.Close()
	var m OCIManifest
	json.NewDecoder(resp.Body).Decode(&m)
	return &m, nil
}

完整运行示例

# 准备一个最小的 rootfs(使用 Alpine Linux)
mkdir -p rootfs
docker export $(docker create alpine) | tar -C rootfs -xf -

# 编译并运行
go build -o minicontainer .

# 在容器中运行 sh
sudo ./minicontainer run /bin/sh

# 容器内验证隔离效果:
# $ hostname
# container
# $ ps aux
#   PID USER  COMMAND
#     1 root  /bin/sh          # 容器内 PID 为 1
# $ cat /proc/meminfo | grep MemTotal
# (看到的内存总量受 cgroup 限制影响,某些内核版本会反映 cgroup 限制)

Level 4 · 进阶:网络、Seccomp、Rootless 容器与 OCI 规范

容器网络:veth pair 与网桥

容器的网络隔离通过 Network namespace 实现,但隔离后容器无法访问外部网络。需要通过 veth pair(虚拟以太网对)将容器 namespace 与宿主机连接:

宿主机 Network namespace:
  docker0 (bridge 网桥, 172.17.0.1/16)
    └── veth0 (宿主机侧)

容器 Network namespace:
  eth0 (容器侧, 172.17.0.2/16)  ← 与宿主机 veth0 成对

veth pair 就像一根网线的两端:从一端发出的数据包,从另一端出来。宿主机侧的 veth0 接入 docker0 网桥,容器侧的 eth0 作为容器的网卡。通过 NAT 规则(iptables -t nat -A POSTROUTING),容器可以访问外部网络。

// network.go(骨架)
func setupContainerNetwork(containerPID int, ip, gateway string) error {
	// 1. 创建 veth pair(需要 root 权限或 CAP_NET_ADMIN)
	// ip link add veth0 type veth peer name eth0
	exec.Command("ip", "link", "add", "veth0", "type", "veth", "peer", "name", "eth0").Run()

	// 2. 将 eth0 移入容器的 network namespace
	// ip link set eth0 netns <pid>
	exec.Command("ip", "link", "set", "eth0", "netns", strconv.Itoa(containerPID)).Run()

	// 3. 将 veth0 接入 docker0 网桥
	exec.Command("ip", "link", "set", "veth0", "master", "docker0").Run()
	exec.Command("ip", "link", "set", "veth0", "up").Run()

	// 4. 在容器 namespace 内配置 eth0 的 IP 地址
	// (通过 nsenter 进入容器 namespace)
	exec.Command("nsenter", "-t", strconv.Itoa(containerPID), "-n",
		"ip", "addr", "add", ip+"/16", "dev", "eth0").Run()
	exec.Command("nsenter", "-t", strconv.Itoa(containerPID), "-n",
		"ip", "route", "add", "default", "via", gateway).Run()

	return nil
}

Seccomp:系统调用过滤

即使有 namespace 和 cgroup,容器内的进程仍然可以调用所有 Linux 系统调用。某些系统调用很危险:ptrace(可以附加到任意进程)、mount(可以修改挂载表)、reboot(关闭系统)。

Seccomp(Secure Computing Mode)允许你定义一个系统调用白名单(或黑名单),内核会拒绝不在名单中的调用:

// 使用 libseccomp-golang 设置 seccomp 过滤器(生产级实现)
import "github.com/seccomp/libseccomp-golang"

func applySeccompFilter() error {
	filter, err := seccomp.NewFilter(seccomp.ActErrno.SetReturnCode(int16(syscall.EPERM)))
	if err != nil { return err }

	// 白名单:只允许必要的系统调用
	allowed := []seccomp.ScmpSyscall{
		seccomp.GetSyscallFromNameWithArch("read", seccomp.ArchAMD64),
		seccomp.GetSyscallFromNameWithArch("write", seccomp.ArchAMD64),
		seccomp.GetSyscallFromNameWithArch("open", seccomp.ArchAMD64),
		seccomp.GetSyscallFromNameWithArch("close", seccomp.ArchAMD64),
		seccomp.GetSyscallFromNameWithArch("mmap", seccomp.ArchAMD64),
		// ... 添加其他必要的系统调用
	}
	for _, call := range allowed {
		filter.AddRule(call, seccomp.ActAllow)
	}
	return filter.Load()
}

Docker 的默认 seccomp 配置文件包含约 300 个允许的系统调用(阻止了约 44 个危险调用,如 ptracerebootkexec_load)。

Linux Capabilities:细粒度特权控制

传统的 Unix 权限模型是二元的:root(全能)或非 root(受限)。Linux Capabilities 将 root 的能力分解成约 40 个独立能力:

Capability 含义
CAP_NET_BIND_SERVICE 绑定 1024 以下的端口
CAP_NET_ADMIN 网络管理(创建 veth、修改路由)
CAP_SYS_ADMIN 广泛的管理操作(mount、sethostname 等)
CAP_MKNOD 创建特殊设备文件
CAP_CHOWN 修改文件所有者

容器化时,应该 drop 所有不必要的 capabilities,只保留必要的:

// 在 SysProcAttr 中设置 capabilities
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: ...,
    // 保留:NET_BIND_SERVICE(允许绑定低端口)
    // 丢弃:SYS_ADMIN, NET_ADMIN, MKNOD 等危险能力
    AmbientCaps: []uintptr{CAP_NET_BIND_SERVICE},
}

Rootless 容器

普通容器需要 root 权限(创建 namespace、配置 veth、操作 cgroup)。Rootless 容器通过 User namespace 让非 root 用户也能运行容器:

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUSER | ...,
    // UID/GID 映射:容器内 uid=0(root)映射到宿主机 uid=1000(普通用户)
    UidMappings: []syscall.SysProcIDMap{
        {ContainerID: 0, HostID: os.Getuid(), Size: 1},
    },
    GidMappings: []syscall.SysProcIDMap{
        {ContainerID: 0, HostID: os.Getgid(), Size: 1},
    },
}

User namespace 是最强大也最复杂的 namespace:它允许非特权用户在 namespace 内拥有 root 权限,但这个"root"对宿主机没有影响。Podman 的 rootless 模式正是基于此实现。

与 runc 的对比

我们实现的简易容器运行时大约 500 行代码,runc 有约 10 万行。差距在哪里?

功能 我们的实现 runc
PID/Mount/UTS namespace 支持 支持
Network namespace 骨架 完整支持(veth/bridge/macvlan)
User namespace(rootless) 骨架 完整支持
Cgroup v1/v2 v2 基础 v1 + v2 完整支持
OCI 运行时规范 不符合 完全符合
Seccomp 骨架 完整支持(BPF 程序)
Capabilities 未实现 完整支持
网络插件(CNI) 支持
pivot_root vs chroot chroot pivot_root
状态持久化 完整状态机

runc 是 OCI 运行时规范的参考实现,遵循 config.json 定义容器配置。Kubernetes 通过 CRI(Container Runtime Interface)调用 containerd,containerd 再调用 runc。


本章小结

本章揭示了容器的本质:容器不是虚拟机,而是 Linux 内核现有机制(namespace + cgroup)的组合应用。

代码实现中,我们构建了:namespace 隔离的进程启动、cgroup 资源限制、chroot 文件系统切换、/proc /sys /dev 虚拟文件系统挂载、OCI 镜像层下载与解包。

进阶部分讨论了容器网络(veth pair + 网桥 + NAT)、系统调用过滤(seccomp)、细粒度特权控制(capabilities)、rootless 容器(User namespace + UID 映射),以及与 runc 的功能对比。

理解了这些,你就理解了 Docker 的核心工作原理:Docker 本质上是一个围绕这些 Linux 内核特性构建的工具链,提供了镜像管理、网络插件、日志驱动等上层抽象。当容器出现问题时,问题往往在 namespace 配置、cgroup 限制或文件系统挂载这三个层面——现在你知道该往哪里看了。

本章评分
4.5  / 5  (3 评分)

💬 留言讨论