从零手写一个容器运行时:吃透 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
你会看到里面只有两个进程:bash 和 ps。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,我们最常用的是这几种:
| Namespace | Clone Flag | 隔离的内容 |
|---|---|---|
| UTS | CLONE_NEWUTS | 主机名、域名 |
| PID | CLONE_NEWPID | 进程 ID(容器里第一个进程是 PID 1) |
| Mount | CLONE_NEWNS | 挂载点(历史原因 flag 叫 NEWNS,NS = mount namespace 的老名字) |
| Network | CLONE_NEWNET | 网卡、IP、端口、路由表 |
| IPC | CLONE_NEWIPC | System V IPC、POSIX 消息队列 |
| User | CLONE_NEWUSER | UID/GID 映射(rootless 容器的核心) |
| Cgroup | CLONE_NEWCGROUP | cgroup 根目录视图 |
创建带 namespace 的进程有三个系统调用:
clone():创建新进程时直接带上 namespace flag(最常用)。unshare():让当前进程脱离原 namespace 进入新的。setns():让进程加入一个已存在的 namespace(docker exec的原理)。
2.1 Go 里怎么创建 namespace
Go 标准库把这套封装进了 exec.Cmd 的 SysProcAttr。关键字段是 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 一个致命的矛盾
理论上流程应该是:
clone()出一个带新 namespace 的子进程;- 在子进程里挂载 rootfs、mount /proc、设置 hostname;
- 子进程
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(如 mount、reboot、kexec_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 取消了 cpu 和 cpuacct 的分离、内存的 memsw(swap 单独用 memory.swap.max)、freezer 语义变了。K8s 从 1.25 起正式支持 cgroup v2,很多老监控(读 cpuacct.usage)在 v2 上会读不到,得改读 cpu.stat。
六、踩坑清单(血泪总结)
- pivot_root 报 EINVAL:99% 是没先
mount("", "/", MS_PRIVATE|MS_REC)。systemd 把 / 设成 shared 传播,pivot_root 拒绝工作。 - pivot_root 报 EBUSY / new_root 不是挂载点:new_root 必须是独立挂载点,用
mount --bind把它挂到自己身上凑一个。 - 容器里
ps报错 / 看到宿主机进程:忘了在容器内mount -t proc proc /proc,或者没建 PID namespace。 - hostname 改了影响宿主机:没加
CLONE_NEWUTS,或在错误的进程里调Sethostname。 - Go 里直接 clone 后设置 namespace 行为诡异:别硬来,用
/proc/self/exere-exec 模式,把 namespace 创建交给内核在进程诞生那一刻完成。 - cgroup v2 写 memory.max 报 ENOENT/EPERM:忘了在父层级
cgroup.subtree_control里+memory;控制器没被父组开放,子组根本没有这个文件。 - cgroup 目录删不掉(EBUSY):里面还有活进程或子 cgroup,先确认
cgroup.procs为空。 - 容器能 ping 通网关却上不了外网:漏了
net.ipv4.ip_forward=1或 iptables MASQUERADE 规则。 - exec 后容器立刻退出:
syscall.Exec的第一个参数要是绝对路径,用exec.LookPath解析,别直接传sh。 - rootless 下 overlayfs 挂载失败:老内核不允许非特权用户挂 overlay,换 fuse-overlayfs 或升级到 5.11+ 内核。
七、总结与展望
我们用不到 400 行 Go,拼出了一个五脏俱全的 mini-docker:
- namespace 给进程换了一套视野(
/proc/self/exere-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 讲一遍,面试官会知道你是真懂。