实现一个简易容器
第四十章:实现一个简易容器
"容器"这个词被滥用了。很多工程师认为容器是某种轻量级虚拟机——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 进程:
- 使用宿主机的 CPU 时钟
- 使用宿主机的物理内存
- 通过宿主机的内核进行系统调用
它只是被 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 个危险调用,如 ptrace、reboot、kexec_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:控制进程能"看到"什么(PID、网络、文件系统、主机名)
- Cgroup v2:通过
/sys/fs/cgroup文件系统限制进程能"使用"多少资源 - OverlayFS:通过分层文件系统(lowerdir/upperdir/merged)实现镜像的分层存储和写时复制
- 进程自调用模式:解决了 Go 无法在
clone()后运行任意代码的问题
代码实现中,我们构建了:namespace 隔离的进程启动、cgroup 资源限制、chroot 文件系统切换、/proc /sys /dev 虚拟文件系统挂载、OCI 镜像层下载与解包。
进阶部分讨论了容器网络(veth pair + 网桥 + NAT)、系统调用过滤(seccomp)、细粒度特权控制(capabilities)、rootless 容器(User namespace + UID 映射),以及与 runc 的功能对比。
理解了这些,你就理解了 Docker 的核心工作原理:Docker 本质上是一个围绕这些 Linux 内核特性构建的工具链,提供了镜像管理、网络插件、日志驱动等上层抽象。当容器出现问题时,问题往往在 namespace 配置、cgroup 限制或文件系统挂载这三个层面——现在你知道该往哪里看了。