从零实现简易容器运行时:深入解析Namespace、Cgroups与UnionFS原理
1. 项目概述从零到一亲手打造一个简易容器运行时最近几年容器技术几乎重塑了软件交付和部署的形态。我们每天都在用docker run、kubectl apply享受着容器带来的环境一致性、资源隔离和快速部署的便利。但你是否好奇过当你在命令行敲下docker run -it ubuntu bash时背后到底发生了什么docker这个“魔法师”是如何凭空变出一个隔离的进程沙盒的lixd/mydocker这个项目就是一个绝佳的“解构魔法”的实践。它不是一个生产级的容器引擎而是一个极简的、用于学习和理解容器核心原理的 Go 语言实现。通过跟随这个项目你将亲手用代码实现一个简易的 Docker理解 Namespace、Cgroups、Union File System 这些听起来高大上的概念到底是如何在操作系统层面被调用和组合的。这不仅仅是“会用 Docker”而是真正“懂 Docker”对于任何想深入云原生、系统编程领域的开发者来说都是一次宝贵的内功修炼。2. 核心原理深度拆解容器技术的三大基石容器的本质是一种特殊的进程。它通过一系列 Linux 内核提供的机制让这个进程觉得自己独享系统资源并且拥有一个独立的文件系统视图。mydocker项目清晰地揭示了支撑这一本质的三大核心技术。2.1 Linux Namespace制造“视觉欺骗”的隔离墙Namespace 是 Linux 内核用于隔离系统资源的一种机制。你可以把它想象成给进程戴上了一副“VR眼镜”。戴上眼镜后进程看到的系统视图如进程树、网络接口、主机名等是专属于它自己的与主机和其他“戴眼镜”的进程隔离开来。mydocker主要涉及以下几种关键的 NamespaceUTS Namespace隔离主机名和域名。这是最简单的一个在mydocker中我们通过syscall.Sethostname为容器进程设置一个独立的主机名如mydocker-container这样在容器内执行hostname命令看到的就是我们设置的名字而非宿主机名。PID Namespace隔离进程 ID。在这个 Namespace 中进程的 PID 从 1 开始重新编号。在mydocker里我们启动的容器进程将成为该 Namespace 内的 PID 1即 init 进程。这实现了容器内只能看到自己的进程ps aux命令的输出是干净的。Mount Namespace隔离文件系统挂载点。这是实现容器拥有独立“根文件系统”的关键。通过pivot_root或chroot系统调用我们可以将容器进程的根目录 (/) 切换到我们准备好的一个目录例如一个包含 busybox 的文件夹这样容器进程就无法访问宿主机的真实根目录了。IPC Namespace隔离进程间通信资源如消息队列、共享内存。mydocker可能不显式使用但理解它有助于明白为什么容器间的共享内存需要特殊配置。Network Namespace隔离网络设备、IP 地址、端口等。这是实现容器网络的基础。mydocker的简单实现可能先使用none模式无网络更复杂的实现会创建 veth pair将一端放入容器的 Network Namespace另一端连接到宿主机网桥并配置 IP 和路由。注意在 Go 中调用这些 Namespace 相关的系统调用通常使用syscall.Unshare或syscall.Clone并传入特定的 flag如syscall.CLONE_NEWUTS。一个关键细节是Unshare是针对当前进程的而Clone是在创建新进程子进程时指定。mydocker通常采用Clone方式让子进程“出生”在全新的 Namespace 集合中。2.2 Control Groups (Cgroups)精打细算的资源管家如果说 Namespace 负责“隔离视图”那么 Cgroups 就负责“限制资源”。它允许你将进程分组并对整个组的资源使用如 CPU、内存、磁盘 I/O、网络带宽进行限制、审计和隔离。在mydocker中实现资源限制是核心功能之一。其操作流程通常如下确定 Cgroups 层级与子系统Linux 的 Cgroups 以文件系统形式暴露在/sys/fs/cgroup/下。每个子系统如cpu,memory,pids管理一类资源。我们需要决定将容器进程放入哪个层级。创建控制组在对应的子系统目录下如/sys/fs/cgroup/memory/mydocker/创建一个以容器 ID 命名的文件夹即创建了一个新的控制组。设置资源限制向该控制组内的特定文件写入值。例如向memory.limit_in_bytes写入100000000来限制内存为约 100MB向cpu.cfs_quota_us和cpu.cfs_period_us写入数值来限制 CPU 使用率。将进程加入控制组将容器进程的 PID 写入该控制组的cgroup.procs文件。此后该进程及其所有子进程的资源消耗都将受到该控制组的限制。清理容器退出时需要将进程移出控制组并删除创建的控制组目录防止资源泄漏。// 伪代码示例设置内存限制 cgroupPath : filepath.Join(/sys/fs/cgroup/memory, containerId) os.MkdirAll(cgroupPath, 0755) // 限制内存为100M ioutil.WriteFile(filepath.Join(cgroupPath, memory.limit_in_bytes), []byte(100000000), 0644) // 将进程PID加入该cgroup ioutil.WriteFile(filepath.Join(cgroupPath, cgroup.procs), []byte(strconv.Itoa(pid)), 0644)实操心得Cgroups v1 的接口是文件操作看似简单但陷阱不少。比如在设置memory.limit_in_bytes时如果值小于当前已使用的内存内核可能会触发 OOM Killer 立即杀掉组内进程。在开发调试时建议先从较大的限制值开始逐步收紧。2.3 Union File System 与 Rootfs容器的“分层行李箱”容器镜像的“分层”特性以及容器运行时“写时复制”的能力主要归功于 Union File System联合文件系统如 OverlayFS、AUFS。镜像层只读一个 Docker 镜像由多个只读层叠加而成。每一层是文件系统的一组增量的变化如添加一个文件修改一个配置。容器层可写当基于镜像启动一个容器时会在所有只读层之上添加一个全新的、空的可写层。联合挂载通过 OverlayFS将只读层lowerdir和可写层upperdir联合挂载到一个挂载点mergeddir。用户看到的是 mergeddir 这个统一的视图。当读取文件时从上往下查找当写入文件时如果文件在只读层则会在可写层创建一个副本进行修改Copy-on-Write。在mydocker的简易实现中可能不会完整实现一个 OverlayFS 驱动但一定会涉及准备根文件系统rootfs这一关键步骤。通常的做法是从一个基础镜像如 busybox 的 tar 包解压到一个目录如/root/rootfs这个目录就是容器的“根”。使用pivot_root系统调用将这个目录切换为容器进程的新的根目录。pivot_root比古老的chroot更安全它能更好地处理/proc、/sys等虚拟文件系统的挂载。# 准备rootfs的示例命令在宿主机执行 mkdir -p /root/rootfs tar -xvf busybox.tar -C /root/rootfs # 之后在Go代码中需要将容器的rootfs (/root/rootfs) 挂载为一个临时目录然后调用pivot_root3. mydocker 核心流程与代码实现解析理解了三大基石后我们来看mydocker如何将它们串联起来。其核心是一个父进程-子进程模型并且大量使用了 Go 语言对 Linux 系统调用的封装。3.1 命令解析与运行时框架一个典型的mydocker run命令背后程序会经历以下阶段命令解析使用如github.com/urfave/cli这样的库来解析命令行参数获取容器名、镜像、要执行的命令、资源限制参数等。初始化环境创建用于容器运行时的工作目录例如/var/run/mydocker/container-id/用于存放容器的日志、配置和终端设备文件。创建容器对象将解析到的参数和生成的唯一容器 ID 封装到一个结构体中这个结构体贯穿容器的整个生命周期。3.2 核心run函数fork 出容器进程这是整个项目的灵魂。在 Go 中我们无法直接fork但可以通过syscall.Clone或结合cmd与SysProcAttr来创建一个拥有新 Namespace 的进程。func NewParentProcess(tty bool, containerName string) (*exec.Cmd, *os.File) { // 1. 创建用于父子进程通信的管道 readPipe, writePipe, _ : os.Pipe() // 2. 构造命令这里使用 /proc/self/exe 来重新执行当前程序但进入子进程逻辑 cmd : exec.Command(/proc/self/exe, init) cmd.SysProcAttr syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC, } if tty { cmd.Stdin os.Stdin cmd.Stdout os.Stdout cmd.Stderr os.Stderr } // 3. 将管道的一端传递给子进程用于接收初始化参数 cmd.ExtraFiles []*os.File{readPipe} return cmd, writePipe }这段代码的精妙之处在于Cloneflags指定了要创建的所有新的 Namespace子进程将在一个全新的、隔离的环境中“出生”。exec.Command(“/proc/self/exe”, “init”)让新进程再次执行自己但传入参数init。这意味着同一个程序会根据参数判断是运行父进程逻辑负责创建还是子进程逻辑负责初始化容器环境。管道通信父进程需要通过管道或环境变量告诉子进程一些信息比如容器ID、要执行的命令、资源限制等因为子进程在新的Namespace里无法直接通过内存共享获取。3.3init函数容器内部的初始化当程序以init参数启动时它运行的是子进程的代码也就是未来容器内的“1号进程”。func RunContainerInitProcess() error { // 1. 从管道读取父进程传递过来的命令如 /bin/sh cmdArray : readCommandArray() if len(cmdArray) 0 { return fmt.Errorf(run container get user command error, cmdArray is nil) } // 2. 挂载特定的文件系统如/proc。这是容器内能看到进程信息的关键。 // 必须在新Mount Namespace内做否则会影响宿主机。 syscall.Mount(“proc”, “/proc”, “proc”, syscall.MS_NOEXEC|syscall.MS_NOSUID|syscall.MS_NODEV, “”) // 3. 使用 pivot_root 切换根文件系统到准备好的 rootfs 目录 // ... pivot_root 系统调用 ... // 4. 设置主机名 (UTS Namespace) syscall.Sethostname([]byte(containerName)) // 5. 在新的根目录下查找命令的绝对路径 path, err : exec.LookPath(cmdArray[0]) if err ! nil { return err } // 6. 执行用户指定的命令替代当前进程。 // 至此容器环境初始化完毕用户进程开始运行。 return syscall.Exec(path, cmdArray[0:], os.Environ()) }syscall.Exec是这个阶段的关键。它用指定的可执行文件如/bin/sh替换当前进程的镜像、数据和堆栈但保留 PID。这样容器内的 PID 1 进程就优雅地从我们的初始化程序变成了用户想要的 shell 或应用程序。3.4 父进程的收尾工作子进程在容器内欢快地运行父进程在宿主机侧还需要做几件重要的事设置 Cgroups父进程持有子进程的 PID可以据此将子进程加入到之前创建好的 Cgroups 控制组中实施资源限制。等待子进程结束通过cmd.Wait()等待容器进程退出并获取其退出状态码。清理资源容器退出后父进程负责卸载容器相关的文件系统挂载点如 rootfs。删除为容器创建的 Cgroups 目录。删除容器的工作目录。关闭管道等打开的文件描述符。4. 功能扩展与高级特性实现思路一个基础的mydocker run跑起来后我们可以参考 Docker 的功能尝试实现更多特性让这个玩具更像一个真正的容器运行时。4.1 容器网络从 none 到 bridge最简单的网络模式是--netnone容器只有 loopback 设备。要实现--netbridge类似 Docker 的默认网络步骤要复杂得多创建 veth pair使用ip link add命令创建一对虚拟网卡如veth0主机端和veth1容器端。将一端放入容器在父进程中通过setns系统调用需操作容器的 Network Namespace 文件描述符将veth1移动到容器的 Network Namespace 内。配置主机端将veth0加入宿主机的网桥如docker0或自定义的mydocker0。配置容器端在容器的 Namespace 内为veth1配置 IP 地址并设置默认路由指向网桥的 IP。配置 NAT 与 iptables为了让容器能访问外网需要在宿主机上配置 SNAT源地址转换规则。为了让外部能访问容器的端口需要配置 DNAT目的地址转换规则即端口映射。这个过程涉及大量对netlink套接字Go 中可用github.com/vishvananda/netlink库和iptables命令的调用是容器网络编程中最复杂的部分之一。4.2 镜像管理实现 pull 和 commit一个完整的容器引擎需要有镜像管理功能。mydocker pull本质是从一个 Registry如 Docker Hub下载指定镜像的 manifest 文件和一系列 layer 的 tar 包然后将其解压到本地存储目录按照 layer 的顺序组织好。需要处理 HTTP 请求、认证、以及镜像存储的格式。mydocker commit将当前容器的可写层upperdir打包成一个新的 tar 包并生成一个新的镜像元数据文件描述其父镜像、创建命令等。这相当于创建了一个新的镜像层。4.3 数据卷Volume支持数据卷的本质是将宿主机上的一个目录在容器启动时绑定挂载到容器内的指定路径。在mydocker中实现需要在容器初始化过程中init函数里在调用pivot_root之后、执行用户命令之前增加一个步骤// 伪代码挂载数据卷 for _, volume : range volumes { // volume 格式为 “宿主机路径:容器内路径” hostPath, containerPath : parseVolume(volume) // 确保宿主机路径存在 os.MkdirAll(hostPath, 0755) // 在容器的根文件系统下创建目标目录 absContainerPath : filepath.Join(rootfs, containerPath) os.MkdirAll(absContainerPath, 0755) // 执行绑定挂载 syscall.Mount(hostPath, absContainerPath, “bind”, syscall.MS_BIND|syscall.MS_REC, “”) }绑定挂载 (MS_BIND) 使得容器内对containerPath的读写直接反映在宿主机的hostPath上实现了数据的持久化和共享。5. 开发调试与常见问题排查实录亲手实现一个容器运行时会遇到无数在单纯使用 Docker 时不会遇到的底层问题。以下是几个典型的“坑”和解决思路。5.1 容器进程启动失败报 “exec: \“/bin/sh\“: no such file or directory”这是最常见的问题。原因几乎总是在容器的 rootfs 里没有你要执行的命令或者其动态链接库不完整。排查步骤检查你的 rootfs 目录如/root/rootfs下是否有/bin/sh这个文件。ls -la /root/rootfs/bin/sh如果没有说明你的 rootfs 准备不完整。确保你解压的基础镜像如 busybox包含了最基本的工具。如果有使用ldd /root/rootfs/bin/sh检查其依赖的动态库。确保所有这些库文件也存在于 rootfs 的对应路径下如/lib,/lib64。对于 busybox它通常是静态链接的所以没有这个问题。但如果你使用一个精简的 Ubuntu rootfs很可能缺库。解决方案使用chroot命令临时切换到 rootfs 环境来调试是最直接的方法sudo chroot /root/rootfs /bin/sh。如果这个命令也失败错误信息会非常明确。5.2 容器内无法看到/proc下的进程信息在容器内执行ps aux发现只列出一个进程或者ls /proc发现是空的。这是因为/proc文件系统没有正确挂载。原因在init进程中必须在调用pivot_root之后再挂载/proc、/sys、/dev/pts等虚拟文件系统。而且必须在新的 Mount Namespace 内做否则会污染宿主机。解决确保你的RunContainerInitProcess函数中有类似下面的代码// 挂载 /proc defaultMountFlags : syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV syscall.Mount(“proc”, “/proc”, “proc”, uintptr(defaultMountFlags), “”) // 如果需要还可以挂载 /sys 和 tmpfs 到 /dev syscall.Mount(“tmpfs”, “/dev”, “tmpfs”, syscall.MS_NOSUID|syscall.MS_STRICTATIME, “mode755”)5.3 容器退出后Cgroups 目录残留导致资源泄漏如果容器异常退出父进程的清理逻辑没有执行就会在/sys/fs/cgroup/下留下以容器 ID 命名的空目录。影响通常不影响运行但显得不专业且可能干扰资源监控。解决强化父进程的清理逻辑确保在Wait()返回后无论正常还是异常都执行清理步骤。可以考虑使用defer语句或捕获 panic。同时可以实现一个简单的mydocker rm命令用于手动清理这些残留资源。5.4 使用-it参数时终端控制异常想要实现类似 Docker 的-it交互式终端功能需要处理复杂的终端设置。创建伪终端PTY在父进程中使用pty.Start()来自github.com/creack/pty包来启动子进程这会得到一个主从终端对。终端尺寸同步需要监听宿主机终端窗口的 resize 事件通过syscall.SIGWINCH信号并实时地将新的行列数通过pty.Setsize设置到容器的伪终端上。信号转发当用户在宿主机终端按CtrlCSIGINT或Ctrl\SIGQUIT时需要将这些信号转发给容器内的前台进程组。这涉及到会话组Session和进程组PGID的设置通常需要调用syscall.Setpgid和syscall.Setsid并通过pty发送信号。这是mydocker项目中最具挑战性的部分之一需要深入理解 Unix 的进程、会话和终端控制。通过lixd/mydocker这个项目我们像解剖青蛙一样将复杂的容器技术拆解成了一个个具体的系统调用和文件操作。从 Namespace 的隔离、Cgroups 的限制到 rootfs 的切换和网络的搭建每一步都加深了对 Linux 内核机制的理解。这个过程会让你在日后使用 Kubernetes、调试容器网络问题、或进行系统级编程时拥有完全不同的视角和底气。虽然这个“玩具”容器不会用于生产环境但它所揭示的原理正是所有现代容器技术的基石。