编程 从零手写一个容器运行时:吃透 namespace、cgroup v2 与 overlayfs,用 400 行 Go 造一个能跑的 mini-docker(附完整实现)

2026-08-01 05:47:37 +0800 CST views 10

从零手写一个容器运行时:吃透 namespace、cgroup v2 与 overlayfs,用 400 行 Go 造一个能跑的 mini-docker(附完整实现)

一句话先摆在这:容器从来不是「轻量级虚拟机」,它只是一个被 Linux 内核特性圈起来的、看起来与世隔绝的普通进程。

你每天 docker run,背后 runc 干的事情,拆开看其实就三件:给进程换一套「视野」(namespace)、给进程套一副「镣铐」(cgroup)、再给它一个「假的根目录」(rootfs + overlayfs)。这篇文章我们不谈 Kubernetes、不谈 CRI 的政治斗争,就一件事——用 Go 从零写一个能跑的 mini-docker,跑起来之后你在容器里 ps aux 只看到自己,hostname 是你设的名字,内存超了会被 OOM Kill,改文件不会污染宿主机镜像。

全文代码可直接编译运行(Linux + Go 1.21+,需要 root 或 CAP_SYS_ADMIN)。读完你会彻底理解:为什么容器启动那么快、为什么容器逃逸这么危险、为什么 rootless 容器要绕那么大一圈。


一、背景:容器到底是「什么东西」

1.1 一个反直觉的事实

先做个实验,在你的 Linux 机器上跑:

sudo unshare --fork --pid --mount-proc bash
# 进去之后
ps aux

你会看到里面只有两个进程:bashps。PID 是 1。但你的宿主机明明跑着几百个进程。你没有启动任何虚拟机,没有加载任何 Guest 内核,只是用 unshare 这个系统调用给 bash 换了个 PID namespace 而已。

这就是容器的本质。容器和宿主机共享同一个内核,容器里的进程在宿主机 ps -ef 里真真切切地存在,只是它自己「看不见」别人。这一点决定了容器的两个核心特征:

  • :没有 Guest OS 启动过程,创建容器 = 创建进程 + 配几个内核对象,毫秒级。
  • 不安全(相对 VM):共享内核意味着一个内核漏洞就可能导致容器逃逸。这也是 gVisor、Kata Containers 存在的理由。

1.2 分层:你以为的 Docker 其实是四层

很多人把「容器运行时」当成一个东西,其实是一整条链路:

docker CLI  →  dockerd  →  containerd  →  containerd-shim  →  runc  →  [ 你的进程 ]
   (客户端)     (守护进程)    (高级运行时)     (进程托管)       (低级运行时)
  • 高级运行时(high-level):containerd、CRI-O。负责镜像拉取、管理、gRPC API、生命周期。
  • 低级运行时(low-level):runc、crun。只干一件事——读取 OCI bundle(config.json + rootfs),调用内核 API 把容器「拉起来」,然后自己退出。

我们今天要写的,就是 runc 这一层的极简版。runc 本身也就是把 libcontainer 里 namespace / cgroup / mount 那套系统调用封装了一下。看懂了 mini-docker,你就看懂了 runc 的骨架。

1.3 三大支柱

支柱内核机制解决的问题类比
隔离视图namespace容器只能看到属于自己的进程/网络/挂载点/主机名「视野」
限制资源cgroup容器最多用多少 CPU / 内存 / 进程数「镣铐」
独立文件系统rootfs + overlayfs + pivot_root容器有自己的 /,改文件不影响镜像和宿主机「假的家」

下面逐个击破。


二、核心概念一:Namespace —— 给进程换一套「视野」

Linux 目前有 8 种 namespace,我们最常用的是这几种:

NamespaceClone Flag隔离的内容
UTSCLONE_NEWUTS主机名、域名
PIDCLONE_NEWPID进程 ID(容器里第一个进程是 PID 1)
MountCLONE_NEWNS挂载点(历史原因 flag 叫 NEWNS,NS = mount namespace 的老名字)
NetworkCLONE_NEWNET网卡、IP、端口、路由表
IPCCLONE_NEWIPCSystem V IPC、POSIX 消息队列
UserCLONE_NEWUSERUID/GID 映射(rootless 容器的核心)
CgroupCLONE_NEWCGROUPcgroup 根目录视图

创建带 namespace 的进程有三个系统调用:

  • clone():创建新进程时直接带上 namespace flag(最常用)。
  • unshare():让当前进程脱离原 namespace 进入新的。
  • setns():让进程加入一个已存在的 namespace(docker exec 的原理)。

2.1 Go 里怎么创建 namespace

Go 标准库把这套封装进了 exec.CmdSysProcAttr。关键字段是 Cloneflags

cmd := exec.Command("/proc/self/exe", "init")
cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUTS | // 主机名隔离
        syscall.CLONE_NEWPID | // PID 隔离,容器里第一个进程是 1 号
        syscall.CLONE_NEWNS | // mount 隔离
        syscall.CLONE_NEWNET | // 网络隔离
        syscall.CLONE_NEWIPC, // IPC 隔离
}

注意那个 /proc/self/exe——这是整个 mini-docker 最精妙也最容易踩坑的地方,下一节专门讲。


三、架构:为什么必须「自己调用自己」(re-exec 模式)

3.1 一个致命的矛盾

理论上流程应该是:

  1. clone() 出一个带新 namespace 的子进程;
  2. 在子进程里挂载 rootfs、mount /proc、设置 hostname;
  3. 子进程 exec 用户真正要跑的命令(比如 /bin/sh)。

问题来了:Go 是多线程运行时。Go runtime 一启动就会 fork 出一堆 M(内核线程)来调度 goroutine。而 clone 后如果直接在子进程里做 namespace 初始化,你根本无法保证「在 Go runtime 创建其他线程之前」完成这些操作——多线程环境下调用 clone 处理 namespace 会产生各种未定义行为(这也是为什么 runc 的这部分核心逻辑用 C 写在 nsexec.c 里,通过构造函数在 Go runtime 启动前执行)。

3.2 解法:把自己当成两个程序

mini-docker 用了一个经典技巧——re-exec(重新执行自己)

第一阶段(parent,run 命令)
  └─ exec.Command("/proc/self/exe", "init")  ← 带 Cloneflags 启动
        │  内核在这里创建了全新的 namespace
        ▼
第二阶段(child,init 命令,运行在新 namespace 里)
  └─ 挂载 rootfs / proc / dev → syscall.Exec(用户命令)
        │  Exec 会用用户进程「原地替换」当前进程镜像
        ▼
     [ 用户真正的 /bin/sh,PID=1,视野全新 ]

/proc/self/exe 是一个指向「当前正在执行的二进制自身」的符号链接。所以 exec.Command("/proc/self/exe", "init") 的意思是:再运行一遍我自己,但这次带上 init 参数。父进程通过 Cloneflags 让这个「新的自己」诞生在全新的 namespace 里;子进程通过判断第一个参数是 init 就知道「哦,我现在是容器内的初始化进程」,接着干挂载、exec 的活。

这个模式的美妙之处:namespace 的创建交给内核在进程创建那一刻完成(干净、原子),Go 的多线程问题被完美绕开。runc、mydocker 全都是这个套路。

3.3 整体骨架

package main

import (
    "fmt"
    "os"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("usage: mydocker run <cmd> | init")
        os.Exit(1)
    }
    switch os.Args[1] {
    case "run":
        // 第一阶段:父进程,创建 namespace 并拉起 init
        run(os.Args[2:])
    case "init":
        // 第二阶段:容器内初始化,挂载 + exec 用户命令
        containerInit()
    default:
        fmt.Println("unknown command:", os.Args[1])
        os.Exit(1)
    }
}

四、代码实战

4.1 第一阶段:父进程 run —— 拉起带 namespace 的容器

package main

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

// run 是宿主机侧的入口:创建 namespace、配置 cgroup、启动 init 进程
func run(args []string) {
    // /proc/self/exe init <args...>
    cmd := exec.Command("/proc/self/exe", append([]string{"init"}, args...)...)
    cmd.SysProcAttr = &syscall.SysProcAttr{
        Cloneflags: syscall.CLONE_NEWUTS |
            syscall.CLONE_NEWPID |
            syscall.CLONE_NEWNS |
            syscall.CLONE_NEWNET |
            syscall.CLONE_NEWIPC,
    }
    // 把宿主机的标准输入输出接给容器,这样才能交互
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // 通过环境变量把 rootfs 路径、资源限制传给 init 阶段
    cmd.Env = append(os.Environ(),
        "MYDOCKER_ROOTFS=/var/lib/mydocker/busybox",
        "MYDOCKER_MEM=100m",
        "MYDOCKER_CPU=50000", // cpu.max 的 quota,50000/100000 = 0.5 核
    )

    if err := cmd.Start(); err != nil {
        panic(err)
    }

    // 关键:进程创建后立刻拿到 PID,把它塞进 cgroup(下一节实现)
    setupCgroup(cmd.Process.Pid)

    // 等容器进程退出,再清理 cgroup
    _ = cmd.Wait()
    cleanupCgroup()
}

一个容易被忽视的细节:cgroup 必须在拿到子进程 PID 之后、但趁它还没跑飞之前配置。所以我们用 cmd.Start()(非阻塞)而不是 cmd.Run(),拿到 PID 立刻 setupCgroup,再 cmd.Wait()

4.2 第二阶段:容器内 init —— 隔离环境的真正搭建

package main

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

func containerInit() {
    // os.Args = ["/proc/self/exe", "init", "/bin/sh", ...]
    cmdArgs := os.Args[2:]

    // 1. 设置主机名(因为有 UTS namespace,不会影响宿主机)
    _ = syscall.Sethostname([]byte("mydocker"))

    // 2. 搭建 rootfs(overlayfs + pivot_root),下一节实现
    rootfs := os.Getenv("MYDOCKER_ROOTFS")
    if err := setupRootfs(rootfs); err != nil {
        panic(err)
    }

    // 3. 挂载 /proc —— 没有它 ps/top 都不能用
    defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
    _ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
    // tmpfs 挂 /dev,避免设备文件缺失
    _ = syscall.Mount("tmpfs", "/dev", "tmpfs",
        syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755")

    // 4. 用 exec 替换当前进程为用户真正要跑的命令
    //    syscall.Exec 会让 /bin/sh 直接成为 PID 1,而不是我们的子进程
    path, err := exec.LookPath(cmdArgs[0])
    if err != nil {
        panic(err)
    }
    if err := syscall.Exec(path, cmdArgs, os.Environ()); err != nil {
        panic(err)
    }
}

为什么最后用 syscall.Exec 而不是 exec.Command(...).Run()?因为我们希望用户命令本身成为容器的 1 号进程syscall.Exec(对应 C 的 execve)不创建新进程,而是用新程序的代码段、数据段原地覆盖当前进程——PID 不变,还是 1。这样容器的「主进程」语义才正确:它退出,容器就结束。

4.3 第三支柱:rootfs —— overlayfs + pivot_root

这是容器「像个独立系统」的关键。分两步:用 overlayfs 造一个可写又不污染镜像的文件系统用 pivot_root 把它变成新的根

4.3.1 overlayfs:写时复制的分层文件系统

镜像为什么能分层共享、容器改文件为什么不影响镜像?答案就是 overlayfs。它把多个目录「叠」成一个:

merged(容器看到的 /)  =  upperdir(可写层)  +  lowerdir(只读镜像层)
                            ↑ 容器所有写操作落这里    ↑ 多个容器共享,永远只读
workdir:overlayfs 内部原子操作用的暂存区

规则:读文件时上层优先;写只读层的文件时,先把它拷贝到 upperdir 再改(copy-up,这就是第一次写大文件会慢的原因);删文件时在 upperdir 打一个「whiteout」标记。

package main

import (
    "os"
    "path/filepath"
    "syscall"
)

// setupOverlay 为容器准备一个 overlayfs 挂载点,返回 merged 目录
func setupOverlay(image, containerID string) (string, error) {
    base := filepath.Join("/var/lib/mydocker", containerID)
    lower := image // 只读镜像层
    upper := filepath.Join(base, "upper")
    work := filepath.Join(base, "work")
    merged := filepath.Join(base, "merged")

    for _, d := range []string{upper, work, merged} {
        if err := os.MkdirAll(d, 0755); err != nil {
            return "", err
        }
    }

    // mount -t overlay overlay -o lowerdir=L,upperdir=U,workdir=W merged
    opts := "lowerdir=" + lower + ",upperdir=" + upper + ",workdir=" + work
    if err := syscall.Mount("overlay", merged, "overlay", 0, opts); err != nil {
        return "", err
    }
    return merged, nil
}

多个 lowerdir 用 : 分隔(lowerdir=layer3:layer2:layer1),这正是 Docker 镜像多层堆叠的实现方式。

4.3.2 pivot_root:把「假根目录」变成「真根目录」

有了 merged 目录,还得让容器进程真的把它当 /。老办法是 chroot,但 chroot 能被逃逸(进程若持有 chroot 之外的 fd 或 CAP_SYS_CHROOT 可绕出去)。runc 用的是更彻底的 pivot_root:它把整个 mount namespace 的根交换掉,旧根被挂到一个子目录再卸载,容器彻底看不到宿主机文件系统。

func setupRootfs(image string) error {
    merged, err := setupOverlay(image, "demo")
    if err != nil {
        return err
    }

    // 关键坑 1:必须把根挂载点标记为 private,否则 pivot_root 报 EINVAL
    // systemd 默认把 / 设为 shared,pivot_root 拒绝在 shared 传播下工作
    if err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, ""); err != nil {
        return err
    }

    // 关键坑 2:pivot_root 要求 new_root 是一个「挂载点」,
    // 用 bind mount 把 merged 挂到自己身上,凑出一个独立挂载点
    if err := syscall.Mount(merged, merged, "bind",
        syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
        return err
    }

    // 在 new_root 里建一个放「旧根」的目录
    putOld := filepath.Join(merged, ".pivot_old")
    if err := os.MkdirAll(putOld, 0700); err != nil {
        return err
    }

    // pivot_root(new_root, put_old):
    //   当前根被移动到 put_old,new_root 成为新的 /
    if err := syscall.PivotRoot(merged, putOld); err != nil {
        return err
    }

    // 切到新根
    if err := syscall.Chdir("/"); err != nil {
        return err
    }

    // 卸载并删掉旧根,容器从此看不到宿主机
    putOld = "/.pivot_old"
    if err := syscall.Unmount(putOld, syscall.MNT_DETACH); err != nil {
        return err
    }
    return os.Remove(putOld)
}

两个坑我在注释里标了红——MS_PRIVATE 和 bind mount 自己。几乎每个手写容器的人都会在这两步卡半天,报错还很误导(EINVAL/EBUSY)。记住这个顺序:置私有 → bind 自己 → 建 putOld → pivot_root → chdir → 卸载旧根。

4.4 第二支柱:cgroup v2 —— 给进程套镣铐

namespace 管「看得见什么」,cgroup 管「能用多少」。现在主流发行版(Ubuntu 22.04+、Fedora、Debian 12+)默认都是 cgroup v2(统一层级,unified hierarchy),比 v1 那套七零八落的子系统清爽太多,我们只写 v2。

cgroup v2 的核心概念:

  • 所有控制器(cpu/memory/pids…)挂在同一棵树下,统一在 /sys/fs/cgroup
  • 每个 cgroup 是一个目录,往里 mkdir 就是建子组。
  • 想在子组用某控制器,父组必须先在 cgroup.subtree_control+ 上它。
  • 把进程 PID 写进 cgroup.procs 即完成「拉进笼子」。
package main

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

const cgroupRoot = "/sys/fs/cgroup"

func cgPath() string {
    return filepath.Join(cgroupRoot, "mydocker", "demo")
}

func setupCgroup(pid int) {
    cg := cgPath()
    _ = os.MkdirAll(cg, 0755)

    // 1. 让父层级开放 cpu/memory/pids 控制器给子组
    //    注意:要在 mydocker/ 这一层也开一次
    _ = os.WriteFile(filepath.Join(cgroupRoot, "cgroup.subtree_control"),
        []byte("+cpu +memory +pids"), 0644)
    _ = os.WriteFile(filepath.Join(cgroupRoot, "mydocker", "cgroup.subtree_control"),
        []byte("+cpu +memory +pids"), 0644)

    // 2. 内存上限 100MB(cgroup v2 用字节数,写 memory.max)
    _ = os.WriteFile(filepath.Join(cg, "memory.max"),
        []byte(strconv.Itoa(100*1024*1024)), 0644)

    // 3. CPU 限制:cpu.max = "quota period",50000 100000 = 0.5 核
    _ = os.WriteFile(filepath.Join(cg, "cpu.max"),
        []byte("50000 100000"), 0644)

    // 4. 最多 50 个进程,防 fork 炸弹
    _ = os.WriteFile(filepath.Join(cg, "pids.max"),
        []byte("50"), 0644)

    // 5. 把容器进程塞进这个 cgroup
    _ = os.WriteFile(filepath.Join(cg, "cgroup.procs"),
        []byte(strconv.Itoa(pid)), 0644)
}

func cleanupCgroup() {
    // cgroup v2 里进程退出后,rmdir 空目录即可回收
    _ = os.Remove(cgPath())
}

对比一下就懂 v2 好在哪里:v1 时代限内存要写 /sys/fs/cgroup/memory/xxx/memory.limit_in_bytes,限 CPU 要写 /sys/fs/cgroup/cpu/xxx/cpu.cfs_quota_us,散落在不同挂载点,一个进程要在多棵树里各登记一次。v2 全部统一到一棵树、一个目录,语义清晰得多。

验证一下真的生效了:进容器跑 stress --cpu 4,宿主机 top 看这个容器进程 CPU 稳定卡在 50% 左右;跑一个吃内存的程序超过 100MB,直接被 OOM Kill——你的镣铐上身了。

4.5 第四步:容器网络(veth pair + bridge 简版)

前面 CLONE_NEWNET 给了容器一个空网络栈——只有一张 lo 网卡,连不了外网。要让它上网,经典方案是 veth pair + 网桥

容器 netns                          宿主机
┌──────────┐                    ┌──────────────────────┐
│  eth0    │◄── veth pair ──►│ veth-xxx ─┐            │
│ 10.0.0.2 │  (一根虚拟网线的两头) │           ▼            │
└──────────┘                    │       br-mydocker      │
                                │       10.0.0.1         │
                                │           │            │
                                │      iptables MASQUERADE│──► 物理网卡 ──► 外网
                                └──────────────────────┘
  • veth pair:一对虚拟网卡,像一根网线的两头,一头放容器 netns 里当 eth0,一头留在宿主机插到网桥上。
  • bridge(网桥):宿主机上的虚拟交换机,所有容器的 veth 都插上来,容器间就能互通。
  • iptables MASQUERADE:做 SNAT,容器出去的包源地址换成宿主机 IP,这样才能访问外网。

核心命令(生产里用 netlink 库如 vishvananda/netlink 编程实现,这里给等价的 shell 帮助理解):

# 1. 建网桥
ip link add br-mydocker type bridge
ip addr add 10.0.0.1/24 dev br-mydocker
ip link set br-mydocker up

# 2. 建 veth pair
ip link add veth-host type veth peer name veth-cont

# 3. 一头插网桥
ip link set veth-host master br-mydocker
ip link set veth-host up

# 4. 另一头塞进容器 netns($PID 是容器进程 PID)
ip link set veth-cont netns $PID

# 5. 进容器 netns 配 IP 和路由
nsenter -t $PID -n ip addr add 10.0.0.2/24 dev veth-cont
nsenter -t $PID -n ip link set veth-cont name eth0
nsenter -t $PID -n ip link set eth0 up
nsenter -t $PID -n ip route add default via 10.0.0.1

# 6. 宿主机开转发 + SNAT
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 ! -o br-mydocker -j MASQUERADE

Docker 的默认 bridge 网络(docker0)本质就是这套,只不过它还叠加了 IPAM 地址分配、端口映射(DNAT)、iptables 规则自动管理。理解了 veth pair,你就理解了「为什么容器有独立 IP」「为什么 -p 8080:80 是在做 DNAT」。


五、性能优化与生产考量

5.1 rootless 容器:不用 root 也能跑

前面所有操作都要 root,因为创建大部分 namespace 需要 CAP_SYS_ADMIN。但 user namespace 是唯一普通用户也能创建的,它能把「容器内的 root(UID 0)」映射到「宿主机上的一个普通 UID」:

cmd.SysProcAttr = &syscall.SysProcAttr{
    Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWUTS |
        syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
    UidMappings: []syscall.SysProcIDMap{
        {ContainerID: 0, HostID: os.Getuid(), Size: 1}, // 容器内 root = 宿主机当前用户
    },
    GidMappings: []syscall.SysProcIDMap{
        {ContainerID: 0, HostID: os.Getgid(), Size: 1},
    },
}

这样容器里 whoami 是 root,但它在宿主机上其实只是你这个普通用户的权限——即使逃逸也拿不到宿主机 root。这就是 Podman rootless、Docker rootless 模式的底层原理。代价是:不能直接绑定 1024 以下端口、overlayfs 支持受限(需要 fuse-overlayfs 或较新内核)。

5.2 seccomp:削减系统调用攻击面

共享内核的最大风险是内核漏洞。seccomp-bpf 让你给容器进程装一个「系统调用过滤器」,默认 Docker 屏蔽了 40+ 个危险 syscall(如 mountrebootkexec_load)。原理是用 BPF 程序在每次 syscall 入口处判定放行/拒绝/杀进程。生产容器一定要开,它把可攻击的内核入口从 300 多个砍到几十个。

5.3 overlayfs 的写放大与坑

  • copy-up 开销:第一次修改只读层的大文件会整个拷到 upperdir,改 1 字节拷 1GB。数据库这类频繁小写的负载不要直接跑在 overlayfs 上,用 volume(bind mount 到宿主机目录)。
  • inode 耗尽:大量小文件容器可能把宿主机 inode 用光,overlayfs 的 whiteout 也占 inode。
  • page cache 双份:早期 overlayfs 同一文件在 lower/upper 会占两份 page cache,新内核已优化。

5.4 cgroup v2 vs v1 迁移

如果你的服务还在 v1,注意:v2 取消了 cpucpuacct 的分离、内存的 memsw(swap 单独用 memory.swap.max)、freezer 语义变了。K8s 从 1.25 起正式支持 cgroup v2,很多老监控(读 cpuacct.usage)在 v2 上会读不到,得改读 cpu.stat


六、踩坑清单(血泪总结)

  1. pivot_root 报 EINVAL:99% 是没先 mount("", "/", MS_PRIVATE|MS_REC)。systemd 把 / 设成 shared 传播,pivot_root 拒绝工作。
  2. pivot_root 报 EBUSY / new_root 不是挂载点:new_root 必须是独立挂载点,用 mount --bind 把它挂到自己身上凑一个。
  3. 容器里 ps 报错 / 看到宿主机进程:忘了在容器内 mount -t proc proc /proc,或者没建 PID namespace。
  4. hostname 改了影响宿主机:没加 CLONE_NEWUTS,或在错误的进程里调 Sethostname
  5. Go 里直接 clone 后设置 namespace 行为诡异:别硬来,用 /proc/self/exe re-exec 模式,把 namespace 创建交给内核在进程诞生那一刻完成。
  6. cgroup v2 写 memory.max 报 ENOENT/EPERM:忘了在父层级 cgroup.subtree_control+memory;控制器没被父组开放,子组根本没有这个文件。
  7. cgroup 目录删不掉(EBUSY):里面还有活进程或子 cgroup,先确认 cgroup.procs 为空。
  8. 容器能 ping 通网关却上不了外网:漏了 net.ipv4.ip_forward=1 或 iptables MASQUERADE 规则。
  9. exec 后容器立刻退出syscall.Exec 的第一个参数要是绝对路径,用 exec.LookPath 解析,别直接传 sh
  10. rootless 下 overlayfs 挂载失败:老内核不允许非特权用户挂 overlay,换 fuse-overlayfs 或升级到 5.11+ 内核。

七、总结与展望

我们用不到 400 行 Go,拼出了一个五脏俱全的 mini-docker:

  • namespace 给进程换了一套视野(/proc/self/exe re-exec 是绕开 Go 多线程的关键);
  • cgroup v2 用一棵统一的树给进程套上 CPU/内存/进程数镣铐;
  • overlayfs + pivot_root 造出一个可写又不污染镜像的独立根文件系统;
  • veth pair + bridge 让隔离的网络栈连回了世界。

回到开头那句话:容器就是被内核特性圈起来的普通进程。当你真正手写过一遍,docker run 对你就不再是黑魔法——你会精准地知道每个 flag 背后内核在做什么,排查容器网络不通、OOM、文件系统异常时能直接下探到系统调用层。

再往前看三个方向

  • 安全沙箱化:gVisor 用 Go 写了个用户态内核(Sentry)拦截容器的 syscall,Kata Containers 干脆给每个容器套一个轻量 VM——都是在补「共享内核」这个天生的安全短板。
  • WASM 容器:WasmEdge、Wasmtime 配合 containerd 的 shim,让 .wasm 模块像容器一样被编排。它比容器更轻、启动更快、天然沙箱,可能是 Serverless 的下一站。
  • eBPF 重塑容器网络:Cilium 用 eBPF 替代了 iptables 那套笨重的 NAT 链路,容器网络性能和可观测性上了一个台阶。

想动手的话,把本文代码补齐(加上 exec(setns 进已有容器)、ps(读 cgroup.procs)、commit(把 upperdir 打包成新镜像)几个子命令),你就拥有了一个可以真正日常把玩的 mini-docker。看懂内核这几个「圈进程」的把戏,容器世界对你就彻底祛魅了。

代码即真相。下次面试被问「容器和虚拟机的区别」,别再背「容器更轻量」了——把这篇文章的 re-exec、pivot_root、cgroup.subtree_control 讲一遍,面试官会知道你是真懂。

推荐文章

全栈工程师的技术栈
2024-11-19 10:13:20 +0800 CST
10个几乎无人使用的罕见HTML标签
2024-11-18 21:44:46 +0800 CST
程序员茄子在线接单