Skip to content

容器实现原理

理解容器技术最有效的方式,是放下 Docker、Kubernetes 这些上层工具,回到 Linux 内核本身。容器并不是一种新的虚拟化技术,也没有发明任何新的内核机制,它的本质是一个被严格约束的普通进程。我们在终端里敲下 docker run 启动的所谓"容器",在宿主机的 ps 输出中和任何其他进程没有任何结构上的区别——它同样由内核调度,同样触发系统调用,同样共享宿主机的内核地址空间。容器与普通进程之间唯一的差异,在于内核为它套上了一层又一层的约束:限制它能看见什么、能用多少资源、能调用哪些接口。这些约束分散在 Namespace、Cgroup、联合文件系统、Capabilities、Seccomp、SELinux 等几项独立的内核特性中,容器的全部"魔法"都来自它们的组合。

这种理解方式有一个直接的工程推论:容器的隔离强度上限是这些内核机制本身的强度,而它们最初并不是为强隔离设计的。Namespace 提供的是视图隔离而非安全边界,容器逃逸的本质就是绕过这些约束回到宿主机命名空间。因此在生产环境中,真正可靠的容器安全来自多层防御的叠加,而不是某一项机制的承诺。

系统调用:容器隔离的起点

所有的容器隔离最终都会落到三个系统调用上,理解它们就理解了容器运行时在创建阶段究竟做了什么。clone 用于创建一个新进程,并在创建的同时把它放进一个新的 Namespace——通过 CLONE_NEWPIDCLONE_NEWNET 这样的标志位指定要新建哪些命名空间,新进程从第一条指令开始就处于隔离视图中。unshare 的作用类似,但它不创建新进程,而是把调用者自身移入新的 Namespace,这在不便 fork 的场景下更灵活。setns 则是反向操作,让一个已有进程加入一个已存在的 Namespace,其参数是指向 /proc/<pid>/ns/ 下对应命名空间文件的文件描述符。

容器运行时(如 runc)创建容器的过程,本质上就是一串对这些系统调用的编排:先 unshareclone 建立所需的 Namespace,再 setns 把初始化进程切换进去,期间穿插着挂载 rootfs、设置 Cgroup、应用安全策略等操作。所谓"启动一个容器",剥去 CLI 的外衣,就是一段精心安排的系统调用序列。

Namespace:构建隔离的视图

Namespace 是容器视图隔离的核心,它的作用可以用一句话概括:让进程看到一个被裁剪过的内核视图。目前 Linux 提供了八种 Namespace,每一种裁剪一类内核资源。

PID Namespace 让其中的进程从 1 开始编号,并且只能看到同一 Namespace 内及子 Namespace 的进程,容器内那个 PID 1 的身份就是这么来的。Network Namespace 提供独立的网络栈,包括网卡接口、路由表、iptables 规则和端口空间,这正是容器能拥有独立 IP、Docker bridge 网络得以实现的基础。Mount Namespace 隔离文件系统挂载点视图,UTS Namespace 隔离主机名与域名,IPC Namespace 隔离 System V 消息队列和 POSIX 信号量。Cgroup Namespace 隔离 Cgroup 层级视图,Time Namespace 隔离单调时钟,这两者相对较新,用于满足更精细的隔离需求。

这其中最值得单独讨论的是 User Namespace。它是目前唯一允许非特权用户创建的 Namespace(自内核 3.8 起),其机制是把容器内的 UID/GID 映射到宿主机上一段非特权区间——容器内看似 root 的用户,在宿主机上实际对应一个无权限的 UID。这一特性是 rootless 容器得以实现的关键,让普通用户也能在不获取宿主机 root 的前提下运行容器。但 User Namespace 也曾因扩大攻击面而引入安全争议,在内核版本较旧的环境中,部分发行版默认关闭了非特权 User Namespace,这也提醒我们:底层机制的成熟度直接决定了上层的可用边界。

需要反复强调的一点是,Namespace 实现的是视图隔离,而不是安全隔离。它回答的是"进程能看见什么",而不是"进程能做什么"。一个拥有 CAP_SYS_ADMIN 的进程即便身处独立的 Namespace,依然可能通过内核漏洞或挂载宿主机文件系统等方式影响宿主机。把 Namespace 当作安全防线是容器安全中最常见的认知误区。

Rootfs 与分层文件系统

有了 Mount Namespace 还不够,容器还需要一个独立的根文件系统,否则它看到的仍是宿主机的目录树。早期的做法是 chroot,它只修改进程解析路径时的根目录起点,但旧的挂载点仍可通过已打开的文件描述符访问,对特权进程而言存在逃逸风险。现代容器运行时普遍使用 pivot_root,它会真正交换根挂载点并把旧根移走,语义上比 chroot 更干净、更安全,chroot 仅作为某些受限环境下的回退方案。

光有独立的根目录并不能支撑"镜像"这个概念。容器镜像的核心设计是分层,这依赖联合文件系统(UnionFS),当前主流实现是 OverlayFS。OverlayFS 将若干只读的 lower 层和一个可写的 upper 层叠加,对外呈现一个统一的合并视图,写入操作通过写时复制(copy-on-write)落到 upper 层。镜像的每一层对应一个 lower 目录,多个容器共享同一个镜像时,只读层在磁盘上只存一份;容器运行时的修改全部写入各自独立的 upper 层,互不干扰。这种设计既是镜像分层复用的工程基础,也是 docker commit 之所以能将容器改动固化为新镜像的底层原因。

Cgroup:资源记账与限制

如果说 Namespace 解决的是"看得见什么",那么 Cgroup 解决的是"能用多少"。Cgroup(Control Group)按进程组进行资源记账和限制,覆盖 CPU、内存、块设备 I/O、进程数等多个维度。容器中 --cpus--memory--pids-limit 这些参数,最终都翻译成对 Cgroup 控制文件(如 v2 下的 cpu.maxmemory.maxpids.max)的写入。

Cgroup 经历了两个主要版本。v1 为每种资源维护一棵独立的层级树,控制器之间互不相关,这导致了配置碎片化和层级管理上的复杂;v2 采用统一层级,所有控制器共享同一棵树,接口也收敛为 cpu.maxmemory.max 这样的统一文件。当前 systemd 与主流发行版已默认使用 v2,理解 v2 的接口是排查生产资源问题的前提。值得注意的是,Cgroup 不仅负责限制,还负责记账,容器运行时和监控系统能够读取 cpu.statmemory.current 等文件获得精确的资源使用数据,这是 Prometheus cAdvisor、Kubernetes metrics 等监控体系的数据来源。

在工程实践中,Cgroup 限制往往与 systemd 的资源单元深度耦合。Kubernetes 和现代容器运行时普遍通过 systemd 来管理 Cgroup(即 cgroupdriver=systemd),把每个容器映射为一个 systemd scope,由 systemd 负责层级组织和 OOM 处理。这意味着排查容器资源问题时,systemctl status 与 Cgroup 文件常常需要结合起来看。

安全边界:从视图隔离到真正的约束

Namespace 与 Cgroup 搭建起了容器的骨架,但要真正缩小攻击面,还需要一组额外的安全机制。默认情况下,容器以一组被裁剪过的 Linux Capabilities 运行。Capabilities 把传统的 root 特权拆解为数十项细粒度权限(如 CAP_NET_ADMINCAP_SYS_PTRACE),容器只保留其中必要的一小部分,并默认移除 CAP_SYS_ADMIN 这类过于宽泛的危险权限。--cap-drop--cap-add 的本质就是对这个集合的增减。

Seccomp 在更底层切断了攻击路径。它通过 BPF 过滤器限制进程可以发起的系统调用集合,Docker 默认的 Seccomp 配置会屏蔽数十个容器正常使用所不需要、但历史上曾多次成为内核漏洞入口的系统调用。考虑到内核系统调用是容器与宿主机共享内核的直接通道,缩小这个接口集合对于降低内核漏洞被利用的概率意义显著。

SELinux 和 AppArmor 则提供了强制访问控制(MAC)层。它们独立于传统的属主权限(DAC),依据预定义的策略对进程能访问的文件、端口、能力进行额外裁决:即便一个进程在 DAC 层面对某文件有读写权限,只要 MAC 策略不允许,访问依然被拒绝。SELinux 基于类型标记(label/type)进行判定,策略表达力强但配置门槛高;AppArmor 基于路径编写 profile,相对直观。两者都为容器套上了又一道约束,在容器逃逸场景下能起到关键的兜底作用。

此外还有几个常被忽略但重要的细节。no-new-privs(通过 prctl 设置)阻止进程通过 execve 获取新的特权,封堵了 setuid 二进制提权这类经典路径;只读根文件系统配合可写的挂载卷,能让容器在重启后恢复到已知干净状态;而 User Namespace 的 UID 映射,让容器内的 root 在宿主机上对应无权限身份,从源头削弱了逃逸后的危害。这些机制各自都不算复杂,但组合起来构成了容器安全的纵深防御。

从内核特性到运行时

把上述机制拼装成一个可用的容器,需要一份明确的约定,这正是 OCI(Open Container Initiative)规范存在的意义。OCI 定义了镜像格式和运行时配置(runtime spec)两套标准,其中运行时配置本质上就是一份描述容器应使用哪些 Namespace、Cgroup 限制、Capabilities、Seccomp 配置、rootfs 布局的 JSON 文件。runc 是这份规范的参考实现,也是 Docker 和 containerd 之下真正调用内核系统调用、组装容器的组件。

当一条 docker run 命令执行时,背后的链路是清晰可追踪的:Docker Daemon 或 containerd 解析参数生成 OCI 配置,调用 runc;runc 按配置依次建立 Namespace、挂载并 pivot_root 切换 rootfs、设置 Cgroup 限制、应用 Capabilities 与 Seccomp、打上 SELinux/AppArmor 标签,最后 exec 容器入口进程。整个过程没有任何神秘的虚拟化层,每一步都对应着可被 strace 捕获的具体系统调用。理解了这条链路,再回头看 Docker 的网络、存储、资源限制等特性,会发现它们不过是对同一组内核特性的不同封装方式而已。

这种"一切皆内核特性"的认知,对工程实践有持续的价值。它解释了为什么容器之间共享内核、一个内核漏洞可以威胁同节点所有容器,也解释了为什么内核版本与发行版的安全更新对容器集群如此关键;它让我们在排查容器异常行为时知道该去读 Cgroup 文件和 Namespace 链接,而不是停留在日志层面。容器技术的简洁与强大,恰恰源于它没有重新发明轮子,而是把 Linux 内核已经准备好的能力组织成了一套标准化的、可复现的交付单元。