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主要涉及以下几种关键的 Namespace:
- UTS 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
最简单的网络模式是--net=none,容器只有 loopback 设备。要实现--net=bridge(类似 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,很可能缺库。
- 检查你的 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, “mode=755”)
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设置到容器的伪终端上。 - 信号转发:当用户在宿主机终端按
Ctrl+C(SIGINT)或Ctrl+\(SIGQUIT)时,需要将这些信号转发给容器内的前台进程组。这涉及到会话组(Session)和进程组(PGID)的设置,通常需要调用syscall.Setpgid和syscall.Setsid,并通过pty发送信号。
这是mydocker项目中最具挑战性的部分之一,需要深入理解 Unix 的进程、会话和终端控制。
通过lixd/mydocker这个项目,我们像解剖青蛙一样,将复杂的容器技术拆解成了一个个具体的系统调用和文件操作。从 Namespace 的隔离、Cgroups 的限制,到 rootfs 的切换和网络的搭建,每一步都加深了对 Linux 内核机制的理解。这个过程会让你在日后使用 Kubernetes、调试容器网络问题、或进行系统级编程时,拥有完全不同的视角和底气。虽然这个“玩具”容器不会用于生产环境,但它所揭示的原理,正是所有现代容器技术的基石。