1. 项目概述:从零构建一个简易容器运行时
如果你和我一样,对 Docker 这类容器技术背后的“黑魔法”感到好奇,总想掀开盖子看看里面到底是怎么运作的,那么这个项目就是为你准备的。mydocker是一个用 Go 语言从头开始实现的、极简版的容器运行时。它的目标不是替代 Docker,而是作为一个绝佳的学习工具,帮你彻底搞懂容器技术的三大基石:Namespace(命名空间)、Cgroups(控制组)和Union File System(联合文件系统)。通过亲手实现run、ps、exec、stop等我们熟悉的命令,你将能直观地理解一个进程是如何被隔离、资源如何被限制、以及镜像分层与容器文件系统是如何构建的。无论你是刚接触云原生的新手,还是想深入底层原理的资深开发者,这个项目都能让你获得从“会用”到“懂原理”的实质性飞跃。接下来,我将结合自己的实现经验,为你拆解其中的关键设计与核心实现。
2. 整体架构与核心设计思路
2.1 为什么选择 Go 语言和 Linux 原生技术栈?
在动手之前,技术选型是首要问题。我选择 Go 语言来实现mydocker,主要基于以下几点考量:
- 系统编程能力:Go 语言虽然语法简洁,但其标准库对系统调用(
syscall)有良好的封装,并且通过golang.org/x/sys/unix包可以方便地调用 Linux 特有的系统调用(如unshare,setns),这对于操作 Namespace 和 Cgroups 至关重要。 - 并发模型友好:容器运行时需要管理多个容器的生命周期,处理信号、日志流等,Go 的 Goroutine 和 Channel 模型让这些并发任务变得清晰易管理。
- 部署简单:编译后是单个静态二进制文件,无需复杂的运行时依赖,非常适合作为系统工具分发。
而核心技术栈则完全构建在 Linux 内核提供的基础设施之上:
- Namespace:用于实现视图隔离(进程、网络、挂载点等)。
- Cgroups:用于实现资源限制(CPU、内存、磁盘I/O等)。
- OverlayFS:一种联合文件系统,用于实现镜像分层和容器可写层。
这个选择确保了项目的纯粹性——我们不是在模拟,而是在直接使用和组合操作系统提供的原语来构建容器,这能让你学到最本质的东西。
2.2 核心命令驱动的模块化设计
参考成熟容器运行时的设计,mydocker采用了以命令为核心、模块化组合的架构。整体架构可以简化为下图所示的工作流:
用户命令 (mydocker run/ps/exec/stop...) ↓ 命令行解析层 (cobra/urfave-cli) ↓ 核心引擎层 (ContainerManager, NetworkManager...) ↓ 底层驱动层 (Namespace, Cgroups, OverlayFS, Network) ↓ Linux 内核各层职责解析:
- 命令行解析层:使用像
cobra或urfave/cli这样的库来解析用户输入的命令和参数(如mydocker run -d -name web nginx)。这一层负责将用户意图转化为结构化的内部请求。 - 核心引擎层:这是项目的大脑。它包含几个核心管理器:
ContainerManager:负责容器的全生命周期管理,包括创建、启动、停止、删除,以及容器元数据(状态、ID、PID等)的持久化存储(通常放在/var/run/mydocker/目录下)。ImageManager:负责镜像的拉取(本项目简化版可能直接从 rootfs 目录加载)、存储和层管理。NetworkManager:负责容器网络的创建、连接和销毁,例如管理网桥、veth pair 和 iptables 规则。
- 底层驱动层:这是与 Linux 内核直接交互的一层,是真正执行“魔法”的地方。
Namespace Driver:调用clone()或unshare()系统调用创建新的命名空间。Cgroups Driver:在/sys/fs/cgroup/下创建目录,写入控制文件来设置资源限制。Filesystem Driver:操作 OverlayFS,为每个容器准备lowerdir(镜像层)、upperdir(可写层)、workdir(工作层)和merged(合并视图层)。Network Driver:调用ip link,brctl,iptables等命令或通过 netlink 库来配置网络设备。
这种分层设计使得代码结构清晰,每一层的职责明确,便于单独测试和理解。例如,当你实现mydocker run时,引擎层会依次协调文件系统驱动准备 rootfs、网络驱动配置网络、Namespace 驱动进行隔离,最后通过 Cgroups 驱动施加限制。
3. 核心原理深度解析与实操要点
3.1 Namespace:容器隔离的基石
Namespace 是 Linux 内核提供的一种资源隔离机制,它让进程拥有独立的系统视图。mydocker主要用到以下几种:
- PID Namespace:隔离进程 ID。容器内的第一个进程 PID 为 1,看不到宿主机上的其他进程。
- Mount Namespace:隔离文件系统挂载点。容器内对文件系统的挂载/卸载操作不会影响宿主机。
- UTS Namespace:隔离主机名和域名。容器可以有自己的
hostname。 - IPC Namespace:隔离进程间通信资源(如消息队列、共享内存)。
- Network Namespace:隔离网络设备、IP 地址、端口等。每个容器有独立的网络栈。
- User Namespace:隔离用户和用户组 ID。可以实现以非 root 用户在容器内拥有 root 权限(提升安全性)。
实操要点与核心代码片段:
创建新 Namespace 的关键是使用syscall.Clone或syscall.Unshare。在mydocker的run命令中,我们通常这样启动容器进程:
// 定义需要创建的Namespace标志位 cloneFlags := syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC cmd := &exec.Cmd{ Path: "/proc/self/exe", // 指向当前程序,用于执行子进程逻辑 Args: []string{"init"}, // 子进程将执行 `mydocker init` 命令 SysProcAttr: &syscall.SysProcAttr{ Cloneflags: cloneFlags, // 关键:设置克隆标志,创建新的Namespace }, }注意:
CLONE_NEWNS代表创建新的 Mount Namespace。这里有一个经典“坑”:在创建新的 Mount Namespace 后,默认会继承父 Namespace 的所有挂载点。为了获得一个干净的文件系统视图,我们必须在子进程(init命令)中调用syscall.Mount(“”, “/”, “”, syscall.MS_PRIVATE|syscall.MS_REC, “”)将根挂载点设置为私有,防止挂载事件传播,然后再挂载/proc等必要的文件系统。
3.2 Cgroups:精细化的资源围栏
Cgroups 负责限制、记录和隔离进程组使用的物理资源。其核心概念是“子系统”(subsystem),如cpu、memory、blkio等。操作 Cgroups v1 的典型流程如下:
- 创建控制组:在对应的子系统目录下(如
/sys/fs/cgroup/memory/mydocker/)创建一个文件夹,即创建了一个控制组。 - 设置限制:向该文件夹内的控制文件写入值。例如,向
memory.limit_in_bytes写入100000000来限制内存为约 100MB。 - 添加进程:将容器进程的 PID 写入该文件夹的
tasks或cgroup.procs文件。
实操要点与常见问题:
// 以内存子系统为例 cgroupPath := filepath.Join("/sys/fs/cgroup/memory", “mydocker”, containerId) os.MkdirAll(cgroupPath, 0755) // 1. 创建cgroup目录 // 2. 设置内存限制为100M ioutil.WriteFile(filepath.Join(cgroupPath, “memory.limit_in_bytes”), []byte(“100000000”), 0644) // 3. 将进程PID加入cgroup ioutil.WriteFile(filepath.Join(cgroupPath, “tasks”), []byte(strconv.Itoa(pid)), 0644)重要提示:Cgroups v2 是新的统一层级设计,与 v1 有较大不同。在实现时需要考虑兼容性。
mydocker后期增加了对 v2 的支持,其核心是操作/sys/fs/cgroup/unified或系统默认的 cgroup2 挂载点下的文件,如cgroup.procs和memory.max。判断系统使用 v1 还是 v2 可以通过检查/sys/fs/cgroup/cgroup.controllers文件是否存在。
常见踩坑点:
- 资源泄漏:容器退出后,必须删除为其创建的 Cgroups 目录,否则会残留。删除前需确保
tasks文件为空。 - 参数单位:
memory.limit_in_bytes单位是字节,而cpu.cfs_quota_us是微秒,写错单位会导致限制失效。 - 子系统依赖:有些子系统有依赖关系,比如
cpu和cpuacct常一起使用。
3.3 OverlayFS:构建容器的文件系统
容器镜像分层共享的特性,靠的是联合文件系统。mydocker选用 OverlayFS 替代早期的 AUFS,因为它是主流 Linux 内核的一部分,性能更好。
OverlayFS 四层结构:
- lowerdir:只读层,可以有多层,对应镜像的各个层。最下层是基础镜像(如 busybox)。
- upperdir:可读写层,容器内对文件的修改都发生在这里。
- workdir:OverlayFS 内部的工作目录,必须为空目录,用于准备文件。
- merged:最终的合并视图层,也是容器进程看到的根文件系统。
挂载 OverlayFS 的命令示例:
mount -t overlay overlay -o lowerdir=/lower1:/lower2,upperdir=/upper,workdir=/work /merged在 Go 中的实现:
// 准备各层目录 lowerDir := “/var/lib/mydocker/image/busybox” // 基础镜像层 upperDir := filepath.Join(“/var/lib/mydocker/upper”, containerId) workDir := filepath.Join(“/var/lib/mydocker/work”, containerId) mergedDir := filepath.Join(“/var/lib/mydocker/merged”, containerId) os.MkdirAll(upperDir, 0755) os.MkdirAll(workDir, 0755) os.MkdirAll(mergedDir, 0755) // 构造挂载选项 options := fmt.Sprintf(“lowerdir=%s,upperdir=%s,workdir=%s”, lowerDir, upperDir, workDir) // 执行挂载 if err := syscall.Mount(“overlay”, mergedDir, “overlay”, 0, options); err != nil { log.Fatalf(“Mount overlayfs failed: %v”, err) }实操心得:
workdir必须是空目录,且与upperdir和lowerdir处于同一文件系统(mount namespace)内,否则挂载会失败。在容器退出后,merged目录需要被卸载(syscall.Unmount),upperdir和workdir可以被保留(用于commit生成新镜像)或删除。
3.4 容器网络:手动连接虚拟“网线”
让容器拥有独立的网络并能够与外界通信,是项目中最复杂的部分之一。mydocker实现的是最经典的桥接模式,其步骤可以概括为:
- 创建网桥:在宿主机上创建一个 Linux 网桥(如
mydocker0),并分配一个子网(如172.18.0.1/24)。 - 创建 veth pair:创建一对虚拟以太网设备,类似一根网线的两端。一端(如
vethxxx)放入容器的 Network Namespace,重命名为eth0;另一端(如vethyyy)连接到宿主机网桥mydocker0上。 - 配置容器内网络:在容器的 Network Namespace 内,为
eth0配置 IP 地址(如172.18.0.2),并设置默认路由到网桥 IP。 - 配置 NAT 与转发:通过
iptables配置 SNAT(源地址转换),使容器能访问外网;配置 DNAT 或端口映射,使外部能访问容器服务。
关键命令与 Go 实现思路:
虽然可以用exec.Command调用ip、brctl、iptables命令,但更优雅的方式是使用 Go 的netlink库(如github.com/vishvananda/netlink)直接与内核通信。
// 1. 创建网桥 bridge := &netlink.Bridge{ LinkAttrs: netlink.LinkAttrs{Name: “mydocker0”}, } netlink.LinkAdd(bridge) // ... 配置网桥IP地址 // 2. 创建 veth pair veth := &netlink.Veth{ LinkAttrs: netlink.LinkAttrs{Name: “veth0”}, PeerName: “veth1”, } netlink.LinkAdd(veth) // 3. 将 veth1 放入容器的网络命名空间 // 首先需要获取容器的网络命名空间文件句柄 containerNs, _ := ns.GetNS(fmt.Sprintf(“/proc/%d/ns/net”, containerPid)) defer containerNs.Close() peerLink, _ := netlink.LinkByName(“veth1”) netlink.LinkSetNsFd(peerLink, int(containerNs.Fd())) // 4. 在容器命名空间内配置 IP(需要在 Namespace 内执行) // 这通常通过一个在容器网络命名空间中运行的小程序或设置逻辑来实现网络排查技巧:
- 容器内无法访问外网:首先检查
iptables -t nat -L查看 SNAT 规则是否正确;检查宿主机ip_forward是否已开启 (sysctl net.ipv4.ip_forward=1)。- 宿主机无法访问容器端口:检查
iptables -t nat -L中的 DNAT 端口映射规则,以及FORWARD链的 ACCEPT 策略。- 使用
ip netns exec <ns> bash进入容器的网络命名空间进行调试,这是最直接的网络问题定位方法。
4. 关键功能实现步骤详解
4.1 实现mydocker run:容器的诞生
这是最核心的命令。其内部执行流程是一个精密的协作过程:
- 命令解析与参数准备:解析
-d(后台运行)、-name、-v(卷挂载)、-e(环境变量)等参数。 - 创建容器运行时目录:在
/var/run/mydocker/下为容器创建唯一 ID 和目录,用于存储配置、日志和终端管道。 - 准备容器文件系统:
- 根据镜像名找到
lowerdir。 - 创建容器独有的
upperdir,workdir,mergeddir。 - 使用 OverlayFS 挂载到
mergeddir。 - 如果指定了
-v,则使用bind mount将宿主机目录挂载到容器内的mergeddir对应路径。
- 根据镜像名找到
- 创建启动命令:构造一个
*exec.Cmd,为其设置Cloneflags以创建新的 Namespace,并指定子进程执行mydocker init。 - 启动容器进程:父进程(
run命令)启动 Cmd,并创建用于父子进程通信的匿名管道(用于传递容器内要执行的命令、环境变量等)。 - 子进程初始化(
mydocker init):- 从管道读取用户命令。
- 执行
pivot_root或chroot切换到容器的mergeddir文件系统。 - 挂载
/proc,/sys等虚拟文件系统。 - 设置主机名、配置网络(通过调用网络模块)。
- 最后,通过
syscall.Exec执行用户指定的命令(如/bin/sh),该进程成为容器内的 PID 1。
- 父进程后续工作:
- 如果非后台运行(
-d),则等待子进程结束。 - 如果是后台运行,则将容器 PID、状态等信息写入元数据文件,然后退出。
- 配置 Cgroups,将容器进程 PID 加入对应的控制组。
- 如果非后台运行(
4.2 实现mydocker exec:进入运行中的容器
exec的实现依赖于一个关键系统调用:setns()。它允许一个进程加入到已存在的命名空间中。
- 获取目标容器的 Namespace:通过容器 ID 找到其进程 PID,进而找到其在
/proc/<pid>/ns/下的 Namespace 文件描述符(如net,pid,mnt,ipc,uts)。 - 创建 exec 进程:类似
run,创建一个新的exec.Cmd。 - 加入现有 Namespace:在
cmd.SysProcAttr中设置Cloneflags为 0(不创建新NS),但设置Unshareflags吗?不,这里更常见的做法是,在子进程(mydocker exec的子进程)中,通过syscall.Setns(fd, syscall.CLONE_NEWNET)等调用,逐个加入到容器的各个 Namespace。更简洁的方式是利用cmd.SysProcAttr.Cloneflags的CLONE_NEWNS等标志?不对,对于exec,我们通常是在父进程(mydocker主进程)打开 namespace 文件,然后通过ExtraFiles或环境变量将文件描述符传递给子进程,子进程在开始执行用户命令前调用setns。 - 设置根文件系统:同样需要调用
pivot_root或chroot切换到容器的 rootfs。 - 执行用户命令:最后通过
syscall.Exec执行用户指定的命令(如/bin/bash)。
技术难点:需要加入哪些 Namespace?通常需要加入除 User 和 PID 之外的所有 Namespace(net,mnt,ipc,uts)。对于 PID Namespace,加入后看到的进程 PID 会是容器内的视角,但操作复杂。很多实现(包括早期 Docker)的exec并不加入目标容器的 PID Namespace,因此ps看到的还是宿主机的进程树,但这不影响命令执行。
4.3 实现mydocker commit:容器打包为镜像
这个命令展示了镜像分层的本质:将容器的可写层(upperdir)打包成一个新的只读层。
- 停止容器(可选但推荐):为了保证文件系统一致性,最好先停止容器,或至少确保没有写操作。
- 确定打包内容:容器的
upperdir包含了所有相对于基础镜像的修改。 - 创建镜像存储目录:在镜像仓库目录(如
/var/lib/mydocker/images/)下,创建一个新的目录作为新镜像层,通常以哈希值命名。 - 打包
upperdir:使用tar或 Go 的archive/tar包,将upperdir目录的内容打包成一个tar文件,并保存到新创建的镜像层目录。同时,生成一个描述该层元数据的文件(如json文件),记录父层、创建时间、启动命令等。 - 更新镜像索引:在一个总的镜像元数据文件(如
repositories.json)中,记录新镜像的名称和标签与对应镜像层哈希的映射关系。
关键细节:在打包时,需要排除一些运行时文件,如/proc,/sys,/dev/pts下的内容,以及容器特有的元数据文件。这可以通过在tar.Walk时过滤路径来实现。
4.4 实现后台运行与状态管理
实现-d参数,需要解决两个问题:1) 父进程退出后,容器进程不被终止;2) 记录容器状态供ps等命令查询。
- 脱离终端与进程组:在启动容器进程时,设置
cmd.SysProcAttr.Setsid = true,使容器进程在一个新的会话和进程组中运行,不受原终端退出的影响。 - 重定向标准流:将容器的标准输出、标准错误重定向到日志文件(如
/var/lib/mydocker/containers/<id>/container.log),而不是继承父进程的终端。这样即使没有终端,程序也能正常运行和输出。 - 元数据持久化:在
/var/run/mydocker/或/var/lib/mydocker/containers/<id>/下创建一个config.json文件,记录容器的 ID、名称、创建时间、状态(Running/Stopped)、PID、镜像、命令、端口映射等信息。这是一个简单的状态机。 mydocker ps的实现:遍历/var/lib/mydocker/containers/目录,读取每个容器的config.json文件,并检查/proc/<pid>是否存在来判断进程是否真的在运行,然后格式化输出。
5. 常见问题排查与调试技巧实录
在开发mydocker的过程中,我遇到了无数个“坑”。这里记录下最典型的问题和解决思路,希望能帮你节省大量时间。
5.1 容器启动失败,报错 “invalid argument” 或 “permission denied”
- 可能原因 1:Namespace 创建失败。
- 排查:检查
cloneFlags是否包含了不支持的标志。某些 Namespace(如 User Namespace)可能需要内核特定配置或特权。使用cat /proc/self/ns/*查看当前进程的 Namespace,确保内核支持。 - 解决:以 root 用户运行。对于非 root 用户,需要仔细配置
/etc/subuid,/etc/subgid并启用 User Namespace,这非常复杂,初学建议直接用 root。
- 排查:检查
- 可能原因 2:文件系统挂载失败。
- 排查:检查 OverlayFS 挂载参数是否正确,特别是
lowerdir,upperdir,workdir的路径是否存在,workdir是否为空且与其它层在同一文件系统。 - 解决:手动执行
mount -t overlay ...命令,根据错误信息调整。确保merged目录存在。
- 排查:检查 OverlayFS 挂载参数是否正确,特别是
5.2 容器内进程 PID 不为 1
- 现象:在容器内执行
ps aux,发现第一个进程不是你的启动命令,而是sh或mydocker init。 - 原因:PID Namespace 确实创建了,但容器内第一个执行的进程是
mydocker init这个引导程序,它再通过syscall.Exec执行用户命令。syscall.Exec会替换当前进程的镜像,但某些情况下,如果init进程创建了子进程(比如处理信号),或者syscall.Exec执行失败,就会导致 PID 1 不是预期的程序。 - 解决:确保在
mydocker init的最后,直接调用syscall.Exec,并且在此之前不要启动任何其他 Goroutine。检查syscall.Exec的错误返回。
5.3 容器网络不通,无法 ping 通外网或宿主机
- 排查步骤:
- 检查网桥和 veth:在宿主机执行
ip addr show和brctl show,查看网桥mydocker0是否创建,veth 一端是否已连接上。 - 检查容器内网络配置:使用
mydocker exec <id> ip addr查看容器内eth0是否获取到 IP,路由表是否正确。 - 检查 NAT 规则:
iptables -t nat -L -n -v查看 POSTROUTING 链的 MASQUERADE 规则是否存在。iptables -L FORWARD -n -v查看 FORWARD 链策略是否为 ACCEPT。 - 检查内核转发:
sysctl net.ipv4.ip_forward确认是否为 1。
- 检查网桥和 veth:在宿主机执行
- 一个隐蔽的坑:如果宿主机使用了防火墙(如 firewalld、ufw),它们可能会覆盖或清空 iptables 规则。需要配置防火墙允许网桥的流量,或者暂时关闭防火墙进行测试。
5.4 容器退出后,资源清理不彻底
- 现象:
mydocker rm后,OverlayFS 的merged目录仍处于busy状态无法删除,或者 Cgroups 目录残留。 - 原因:可能有进程仍在使用这些资源。最常见的是,
merged目录被某个进程(可能是宿主机 shell 的当前目录)引用。Cgroups 目录下可能还有僵尸进程。 - 解决:
- 对于文件系统:在
rm时,先强制卸载(unmount -l /path/to/merged),再删除目录。 - 对于 Cgroups:在停止容器时,确保将进程从所有子系统的
tasks文件中移除,然后再删除 Cgroups 目录。可以使用cgdelete工具辅助检查。 - 实现一个守护进程或
reaper机制,定期清理孤儿容器资源,这是生产级运行时必须考虑的。
- 对于文件系统:在
5.5mydocker exec进入容器后,环境“不对劲”
- 现象:通过
exec进入后,发现环境变量和mydocker run启动时不同,或者某些文件不存在。 - 原因:
exec默认不会重新执行容器的入口点(entrypoint)或启动脚本,它只是在一个已有的环境里执行一个新命令。环境变量可能来自mydocker exec进程本身,而非容器初始化时设置的那些。 - 解决:在实现时,
mydocker exec应该从容器的元数据中读取最初通过-e设置的环境变量,并在调用syscall.Exec前通过os.Environ()合并或设置到子进程的环境中去。对于文件系统,确保exec进程正确调用了pivot_root切换到了容器的 rootfs。
开发mydocker的过程,是一个不断与内核细节打交道、不断调试和加深理解的过程。每解决一个这样的问题,你对容器技术的掌握就更深一层。建议你在实现每个功能后,都多进行边界测试,例如尝试不同的命令、传递奇怪的参数、突然杀死进程等,观察系统的行为,这能帮你发现设计中的薄弱环节。