土法炼钢兴趣小组的算法知识备份

用 Go 组装迷你容器运行时:把积木拼起来

目录

前五篇文章,我们用 C 和 shell 一个一个地拆解了容器的内核积木:

现在,把它们拼起来。用 Go。

为什么用 Go?因为容器生态几乎全是 Go 写的 — Docker、containerd、runc、Podman、CRI-O。Go 的 syscallgolang.org/x/sys/unix 包提供了我们需要的所有系统调用封装。更重要的是,Go 的 /proc/self/exe reexec 技巧让容器 init 进程的实现优雅得多。

完整代码在 examples/containers/06-mini-runtime/go build 即可编译。


一、容器运行时的职责

一个最小的容器运行时需要做什么?

miniruntime create <container-id> <rootfs>
miniruntime start <container-id>
miniruntime exec <container-id> <command>
miniruntime kill <container-id> <signal>
miniruntime delete <container-id>

五个命令,对应容器的完整生命周期:

  1. create — 准备 namespace、cgroup、rootfs,但不启动进程
  2. start — 在准备好的环境中启动容器 init 进程
  3. exec — 在已运行的容器中执行命令(类似 docker exec
  4. kill — 给容器进程发送信号
  5. delete — 清理所有资源

二、/proc/self/exe 与 reexec 技巧

容器运行时面临一个鸡生蛋的问题:

  1. 我们需要在新 namespace 里执行 pivot_root、挂载 /proc 等初始化操作
  2. 这些操作必须在子进程里做(因为 namespace 是子进程的)
  3. 但子进程的代码和父进程是同一个二进制文件

解决方案是 reexec:父进程 clone() 创建子进程后,子进程重新执行自己(/proc/self/exe),但传入一个特殊参数(比如 init),告诉自己现在是容器 init 进程,应该执行初始化逻辑。

package main

import (
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        // 父进程:创建子进程并进入新 namespace
        run()
    case "init":
        // 子进程:在新 namespace 里执行初始化
        initContainer()
    }
}

func run() {
    // 重新执行自己,但参数改为 "init"
    cmd := exec.Command("/proc/self/exe", "init")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWPID |
                    syscall.CLONE_NEWUTS |
                    syscall.CLONE_NEWNS |
                    syscall.CLONE_NEWIPC,
    }
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Run()
}

func initContainer() {
    // 此时已经在新 namespace 里了
    // 执行 pivot_root、挂载 /proc 等
    setupRootfs()
    setupProc()

    // 最后 exec 用户指定的命令
    syscall.Exec("/bin/sh", []string{"/bin/sh"}, os.Environ())
}

这个技巧在 runc 里叫 nsexec,是容器运行时的核心模式。


三、容器状态管理

每个容器需要持久化一些状态信息,这样 killdelete 命令才能找到它:

type ContainerState struct {
    ID        string `json:"id"`
    PID       int    `json:"pid"`
    Status    string `json:"status"` // created, running, stopped
    Rootfs    string `json:"rootfs"`
    CgroupDir string `json:"cgroup_dir"`
    CreatedAt string `json:"created_at"`
}

状态文件存在 /run/miniruntime/<container-id>/state.json

const stateDir = "/run/miniruntime"

func saveState(state *ContainerState) error {
    dir := filepath.Join(stateDir, state.ID)
    os.MkdirAll(dir, 0700)

    data, _ := json.MarshalIndent(state, "", "  ")
    return os.WriteFile(filepath.Join(dir, "state.json"), data, 0600)
}

func loadState(id string) (*ContainerState, error) {
    path := filepath.Join(stateDir, id, "state.json")
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("container %s not found", id)
    }
    var state ContainerState
    json.Unmarshal(data, &state)
    return &state, nil
}

四、Cgroup 设置

把前面 C 代码里的 cgroup 操作翻译成 Go:

func setupCgroup(id string, memLimit, cpuQuota string) (string, error) {
    cgroupPath := filepath.Join("/sys/fs/cgroup", "miniruntime-"+id)

    if err := os.MkdirAll(cgroupPath, 0755); err != nil {
        return "", fmt.Errorf("create cgroup: %w", err)
    }

    // 设置内存限制
    if memLimit != "" {
        if err := os.WriteFile(
            filepath.Join(cgroupPath, "memory.max"),
            []byte(memLimit), 0644); err != nil {
            return "", fmt.Errorf("set memory.max: %w", err)
        }
    }

    // 设置 CPU 限制
    if cpuQuota != "" {
        if err := os.WriteFile(
            filepath.Join(cgroupPath, "cpu.max"),
            []byte(cpuQuota), 0644); err != nil {
            return "", fmt.Errorf("set cpu.max: %w", err)
        }
    }

    return cgroupPath, nil
}

func addToCgroup(cgroupPath string, pid int) error {
    return os.WriteFile(
        filepath.Join(cgroupPath, "cgroup.procs"),
        []byte(fmt.Sprintf("%d", pid)), 0644)
}

func removeCgroup(cgroupPath string) error {
    return os.Remove(cgroupPath)
}

五、Rootfs 与 pivot_root

在 Go 里实现 pivot_root

func setupRootfs(rootfs string) error {
    // 切断挂载传播
    if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
        return fmt.Errorf("mount private: %w", err)
    }

    // 把 rootfs bind mount 到自己(pivot_root 要求 new_root 是挂载点)
    if err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
        return fmt.Errorf("bind mount rootfs: %w", err)
    }

    // 创建 old_root 挂载点
    oldRoot := filepath.Join(rootfs, ".old_root")
    os.MkdirAll(oldRoot, 0700)

    // pivot_root
    if err := syscall.PivotRoot(rootfs, oldRoot); err != nil {
        return fmt.Errorf("pivot_root: %w", err)
    }

    // 切换到新根目录
    os.Chdir("/")

    // 挂载 /proc
    os.MkdirAll("/proc", 0755)
    syscall.Mount("proc", "/proc", "proc", 0, "")

    // 挂载 /dev (最小化)
    os.MkdirAll("/dev", 0755)
    syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755")

    // 卸载 old_root
    syscall.Unmount("/.old_root", syscall.MNT_DETACH)
    os.Remove("/.old_root")

    return nil
}

这段代码浓缩了 #03 根文件系统 整篇文章的核心操作。


六、网络设置

网络配置比其他部分复杂,因为需要在宿主机和容器两侧同时操作。我们用 ip 命令简化:

func setupNetwork(pid int, containerIP, bridgeIP string) error {
    vethHost := "veth-host"
    vethContainer := "veth-ct"

    cmds := [][]string{
        // 创建 veth pair
        {"ip", "link", "add", vethHost, "type", "veth", "peer", "name", vethContainer},
        // 把一端移入容器 netns
        {"ip", "link", "set", vethContainer, "netns", fmt.Sprintf("%d", pid)},
        // 宿主机端配置
        {"ip", "addr", "add", bridgeIP + "/24", "dev", vethHost},
        {"ip", "link", "set", vethHost, "up"},
        // 启用 IP 转发
        {"sysctl", "-w", "net.ipv4.ip_forward=1"},
        // NAT
        {"iptables", "-t", "nat", "-A", "POSTROUTING", "-s",
         containerIP + "/24", "-j", "MASQUERADE"},
    }

    for _, args := range cmds {
        cmd := exec.Command(args[0], args[1:]...)
        if out, err := cmd.CombinedOutput(); err != nil {
            return fmt.Errorf("%s: %s: %w", args[0], string(out), err)
        }
    }
    return nil
}

容器内部的网络配置在 init 进程里完成(因为需要在容器的 netns 里操作)。


七、错误处理与资源清理

容器创建是一个多步骤过程,任何一步失败都需要回滚之前的操作。这是容器运行时最容易出 bug 的地方 — namespace 泄漏、cgroup 残留、挂载点残留都是常见问题。

以 cgroup 为例:如果你的 runtime 在创建 cgroup 后、启动容器前崩溃了,那个 cgroup 目录会永远留在 /sys/fs/cgroup 下面。挂载点残留更危险 — pivot_root 失败但 bind mount 已完成,宿主机的文件系统上会多出”幽灵挂载点”,mount | wc -l 会越来越大。

type Cleanup struct {
    steps []func()
}

func (c *Cleanup) Add(fn func()) {
    c.steps = append(c.steps, fn)
}

func (c *Cleanup) Run() {
    // 逆序执行清理
    for i := len(c.steps) - 1; i >= 0; i-- {
        c.steps[i]()
    }
}

func createContainer(id, rootfs string) error {
    cleanup := &Cleanup{}
    defer func() {
        // 只在出错时执行清理
        // 成功的话清理由 delete 命令负责
    }()

    // Step 1: 创建 cgroup
    cgroupPath, err := setupCgroup(id, "256m", "50000 100000")
    if err != nil {
        return err
    }
    cleanup.Add(func() { removeCgroup(cgroupPath) })

    // Step 2: 准备 rootfs overlay
    // ...

    // Step 3: 创建子进程
    // ...

    return nil
}

runc 在这方面做得很好 — 它用了一个两阶段的 init 进程设计:第一个 init 进程做 setup,成功后通过 pipe 通知父进程,然后 exec 成用户进程。如果 setup 失败,父进程能得到错误信息并清理。


八、完整的 create/start 流程

把所有部分串起来:

miniruntime create mycontainer /path/to/rootfs
   │
   ├── 1. 创建 cgroup
   ├── 2. 准备 OverlayFS (upper + work + merged)
   ├── 3. clone() 创建子进程(新 namespace)
   │      子进程:
   │      ├── /proc/self/exe init(reexec)
   │      ├── pivot_root 到 rootfs
   │      ├── 挂载 /proc, /dev, /sys
   │      ├── 阻塞等待 start 信号(通过 pipe)
   │      └── 收到信号后 exec 用户命令
   ├── 4. 父进程把子进程 PID 加入 cgroup
   ├── 5. 配置网络(veth + bridge)
   └── 6. 保存状态到 state.json

miniruntime start mycontainer
   │
   ├── 1. 读取 state.json
   ├── 2. 通过 pipe 发送 start 信号给 init 进程
   └── 3. 更新状态为 "running"

create 和 start 分开是 OCI 规范的要求 — 这让编排系统(如 Kubernetes)可以在 create 之后、start 之前做一些额外配置(比如设置 CPU affinity)。


九、我们的运行时 vs runc

特性 miniruntime runc
Namespace 隔离 PID + UTS + Mount + IPC + Net 全部 8 种
Cgroup v2 基础限制 v1 + v2,systemd driver
Rootfs 手工 pivot_root libcontainer,支持多种 rootfs
网络 简单 veth + bridge 不管网络(交给 CNI)
安全 Seccomp + Capabilities + AppArmor/SELinux
OCI 兼容 部分 完全
代码量 ~500 行 ~15,000 行

差距是巨大的。但核心思路是一样的:namespace + cgroup + rootfs + pivot_root。runc 多出来的代码大部分在处理边界情况和安全加固。

下一篇,我们让这个运行时理解 OCI 规范 — #07 OCI 规范兼容。有了 OCI 兼容,containerd 和 Kubernetes 就能调用我们的运行时了。最后在 #12 runc 源码考古 里,你会看到 runc 如何用 nsenter C 代码解决 Go runtime fork 不安全的问题 — 那是本系列最”反直觉”的工程决策。


相关阅读


By .