1982年3月,Bill Joy在BSD开发过程中添加了chroot系统调用,约一年半后随4.2BSD正式发布。这个简单的功能让进程看到一个被重新定义的根文件系统,被认为是现代容器技术的起点。但chroot只是文件系统的隔离,进程仍然共享同一个内核视图,看到相同的进程列表、网络配置和主机名。

四十多年后,当我们运行一个Docker容器时,背后发生的是一场精心编排的内核魔法:八种不同类型的namespace将进程与系统资源隔离,cgroups精确控制着CPU、内存和I/O的使用,而OverlayFS让容器镜像的分层存储成为可能。这三项技术的组合,构成了现代容器技术的根基。

Namespace:从全局资源到私有视图

Namespace的核心思想是将原本全局共享的内核资源包装成抽象层,让每个namespace内的进程都觉得自己拥有独立的资源实例。Linux内核目前实现了8种namespace类型,每种隔离不同维度的系统资源。

UTS Namespace:最简单的开始

UTS(UNIX Time-Sharing System)namespace是最简单的namespace类型,只隔离两个系统标识符:主机名和NIS域名。它的实现只需要在进程描述符的nsproxy对象中添加一个uts_ns成员。

在内核实现中,gethostname系统调用从原来的读取全局变量system_utsname变成了调用utsname()函数,该函数返回当前进程所属UTS namespace中的hostname。这个改动很小,但意义深远——它证明了namespace机制的可行性。

// 旧实现(Linux 2.6.11)
asmlinkage long sys_gethostname(char __user *name, int len) {
    if (copy_to_user(name, system_utsname.nodename, i))
        return -EFAULT;
}

// 新实现
SYSCALL_DEFINE2(gethostname, char __user *, name, int, len) {
    struct new_utsname *u = utsname();  // 获取当前namespace的utsname
    if (copy_to_user(name, u->nodename, i))
        return -EFAULT;
}

PID Namespace:进程树的隔离

PID namespace是容器隔离的核心。在新的PID namespace中创建的第一个进程PID为1,扮演着init进程的角色。这个进程的特殊之处在于:

  1. 它收养所有孤儿进程,负责回收僵尸进程
  2. 发送给它的SIGKILL信号会被忽略
  3. 它终止时,整个namespace内的所有进程都会被杀死

PID namespace可以嵌套,最多32层。这个特性被CRIU(Checkpoint/Restore In Userspace)项目利用——在迁移进程到另一台机器时,通过创建独立的PID namespace避免PID冲突。

但PID namespace有一个微妙的细节:在unshare(CLONE_NEWPID)之后,调用进程本身并不会进入新的PID namespace,只有它创建的子进程才会。这就是为什么/proc/PID/ns/目录下有两个不同的文件:pid指向进程当前所在的PID namespace,而pid_for_children指向子进程将加入的PID namespace。

Network Namespace:独立的网络栈

Network namespace创建了一个完整的网络栈副本,包括:

  • 网络设备(包括loopback)
  • 路由表
  • iptables规则
  • 端口号空间
  • /proc/net和/sys/class/net目录

新创建的network namespace只包含loopback设备。物理网卡仍然留在初始的network namespace中,但可以通过ip link set eth0 netns myns命令移动到其他namespace。

容器之间的网络通信依赖于veth pair(虚拟以太网对)。veth pair是成对出现的虚拟网络设备,像一根虚拟网线连接两个namespace:一端在容器内(通常叫eth0),另一端在主机上连接到网桥(docker0)。

graph LR
    A[容器Network Namespace] -->|veth pair| B[主机Network Namespace]
    A --> C[eth0]
    B --> D[vethXXX]
    D --> E[docker0网桥]

User Namespace:权限的魔法

User namespace是最强大的namespace类型,也是唯一一个不需要CAP_SYS_ADMIN权限就能创建的namespace(自Linux 3.8起)。

User namespace的核心能力是UID/GID映射:容器内的root用户(UID 0)可以映射到主机上的普通用户(比如UID 100000)。这意味着一个进程在容器内拥有root权限,但在主机上只是一个普通用户——这正是rootless容器的实现基础。

映射关系通过/proc/PID/uid_map和/proc/PID/gid_map文件配置:

# 将容器内的UID 0-65535映射到主机的UID 100000-165535
echo "0 100000 65536" > /proc/$PID/uid_map

但User namespace也带来了安全挑战。2013年3月,CVE-2013-1858被发现——这是一个利用user namespace的权限提升漏洞,允许普通用户获得主机上的root权限。内核开发者迅速修复了这个问题,但它揭示了一个重要教训:隔离机制的任何漏洞都可能成为攻击者的突破口。

Mount Namespace:文件系统视图的隔离

Mount namespace是最复杂的namespace类型。它隔离进程看到的文件系统挂载点列表,但它的行为与直觉不同:

新创建的mount namespace会继承父namespace的所有挂载点,但之后两个namespace的挂载操作互不影响。更复杂的是"共享子树"(Shared Subtrees)机制——一个挂载点可以标记为shared、slave、private或unbindable,控制挂载事件如何在namespace之间传播。

容器通常使用pivot_root而不是chroot来切换根文件系统。chroot只是改变进程的根目录概念,但进程仍然可以通过/proc访问原来的文件系统。pivot_root则将当前mount namespace的根挂载点交换到新位置,然后卸载旧的根,彻底切断对原文件系统的访问。

Cgroups:资源限制的精确控制

如果说namespace解决了"隔离"问题,cgroups解决的则是"限制"问题。Cgroups(Control Groups)是Google工程师Paul Menage和Rohit Seth在2006年开始开发的项目,最初叫"process containers",2007年更名为cgroups并合并到Linux内核主线,随2.6.24版本(2008年1月发布)正式推出。

从v1到v2的演进

Cgroups v1的设计是多个独立的层级树,每个控制器(cpu、memory、blkio等)可以有独立的层级结构。这种设计带来了灵活性,但也导致了复杂性:

  • 进程可能属于多个cgroup层级,每个层级对应不同的控制器
  • 不同控制器的层级结构可能不一致
  • 迁移进程时需要在多个层级中操作

Cgroups v2采用单一层级树设计,所有控制器共享同一个层级结构。这简化了管理,也解决了v1的一些根本性问题。从Linux 4.17开始,v2成为默认选项,但v1仍被广泛支持以保持兼容性。

资源控制的三种模型

Cgroups v2定义了四种资源分配模型:

权重(Weights):按比例分配资源,如cpu.weight。权重范围[1, 10000],默认100。只有活跃的子cgroup参与分配,因此是工作保持(work-conserving)的。

限制(Limits):设置资源使用上限,如memory.max、io.max。可以超配(所有子cgroup的限制之和可以超过父cgroup的总资源)。

保护(Protections):设置资源使用下限,如memory.min、memory.low。当资源紧张时,受保护的cgroup优先获得资源。同样可以超配。

分配(Allocations):独占分配有限资源,如cpuset.cpus。不能超配——所有子cgroup的分配之和不能超过父cgroup的可用资源。

Memory Controller:最复杂的子系统

Memory controller是cgroups中最复杂的控制器。它跟踪每个cgroup的内存使用,并在超过限制时触发回收或OOM Killer。

一个常见的陷阱是memory.limit_in_bytes和memory.memsw.limit_in_bytes的区别。前者只限制物理内存,后者限制物理内存+Swap的总和。如果只设置了memory.limit_in_bytes,进程可能会大量使用Swap导致性能下降。

当内存使用接近限制时,内核会尝试回收页面。回收失败则触发OOM Killer。可以通过memory.oom_control禁用OOM Killer,但这会导致进程在内存不足时挂起而不是被杀死——这在某些关键服务场景下是有用的。

# 设置内存限制为1GB
echo 1G > /sys/fs/cgroup/memory/mycontainer/memory.limit_in_bytes

# 禁用OOM Killer
echo 1 > /sys/fs/cgroup/memory/mycontainer/memory.oom_control

CPU Controller:从份额到带宽

CPU controller提供了两种控制方式:

  • cpu.weight:按权重分配CPU时间,类似nice值但作用域是cgroup
  • cpu.max:设置CPU带宽控制(CFS quota),格式为"quota period"

cpu.max更精确但可能导致CPU利用率不足。例如,echo "50000 100000" > cpu.max表示在每100ms的时间窗口内,该cgroup最多使用50ms的CPU时间。

OverlayFS:分层存储的秘密

容器镜像的分层结构依赖于联合文件系统(Union Filesystem)。Docker目前默认使用OverlayFS,它比早期的AUFS和devicemapper更高效。

OverlayFS将多个目录叠加成一个统一的视图:

  • Lower层:只读的基础镜像层,可以有多个
  • Upper层:可写的容器层
  • Merged层:统一视图
  • Work层:OverlayFS内部使用的工作目录

当容器读取文件时,OverlayFS从上到下搜索,返回第一个找到的版本。当容器写入文件时:

  1. 如果文件存在于lower层,触发copy-up操作——将文件复制到upper层
  2. 对upper层副本进行修改
  3. 后续读取直接访问upper层

Copy-up操作会带来性能开销,特别是写入大文件时。但它的好处是多个容器可以共享同一个lower层(基础镜像),极大地节省了磁盘空间和启动时间。

graph TB
    A[Merged Layer 容器视图] --> B[Upper Layer 可写层]
    A --> C[Lower Layer 1 镜像层]
    A --> D[Lower Layer 2 基础层]
    B --> E[容器修改]
    C --> F[应用代码]
    D --> G[基础镜像]

OCI标准:容器运行时的通用接口

2015年,Docker将容器运行时runc捐赠给开放容器倡议(OCI),定义了容器运行时的标准接口。

OCI Runtime Spec定义了容器的配置格式(config.json)和生命周期操作(create、start、state、delete等)。任何符合规范的运行时(runc、crun、runsc等)都可以运行OCI格式的容器包。

这种标准化带来了运行时的可替换性:

  • runc:标准的OCI运行时
  • crun:用C实现的轻量级运行时,比runc更快
  • runsc:gVisor的运行时,提供更强的隔离
  • kata-runtime:Kata Containers的运行时,基于轻量级虚拟机

容器运行时的架构也发生了演变。早期Docker是一个单体架构,后来被拆分为:

  1. dockerd:高层API和镜像管理
  2. containerd:容器生命周期管理
  3. runc:底层的OCI运行时

这种分层架构让Kubernetes等编排系统可以直接使用containerd,而不需要依赖完整的Docker引擎。

容器安全:隔离的边界与漏洞

容器的隔离不是绝对的。与虚拟机不同,所有容器共享同一个内核。这意味着内核漏洞可能突破容器边界。

CVE-2019-5736:runc逃逸

2019年2月披露的CVE-2019-5736是一个严重的容器逃逸漏洞。攻击者可以通过以下步骤获得主机root权限:

  1. 在容器内用恶意程序替换/bin/sh
  2. 当主机执行docker exec时,runc会进入容器执行指定的命令
  3. 恶意程序打开/proc/self/exe获取runc的二进制文件描述符
  4. 利用这个描述符覆盖主机上的runc二进制文件
  5. 下一次docker exec执行的就是恶意代码

修复方案是让runc在执行容器命令前,通过/proc/self/exe打开并验证自己的二进制文件,确保没有被修改。

Dirty Pipe:内核漏洞的容器逃逸

CVE-2022-0847(Dirty Pipe)是Linux内核的另一个严重漏洞。它允许非特权进程向任意可读文件写入数据,条件是:

  • 内核版本 5.8 - 5.16.10
  • 进程有读取目标文件的权限

攻击者可以在容器内利用这个漏洞修改主机上的关键文件(如/etc/passwd或runc二进制),实现容器逃逸。

强化容器安全的策略

  1. 使用User Namespace:将容器内的root映射到主机上的非特权用户
  2. 启用Seccomp:限制容器可以使用的系统调用
  3. 应用AppArmor或SELinux:提供强制访问控制
  4. 使用只读根文件系统:减少攻击面
  5. 选择安全运行时:gVisor或Kata Containers提供更强的隔离

gVisor通过在用户态实现Linux内核接口(称为Sentry)来隔离容器。容器内的系统调用被拦截并由Sentry处理,而不是直接传递给主机内核。这大大减少了内核攻击面,但带来了15-50%的性能开销。

Kata Containers则采用完全不同的方法——每个容器运行在一个轻量级虚拟机中。这提供了与虚拟机相当的安全隔离,同时保持了容器的使用体验。启动时间约100ms,比传统虚拟机快很多,但仍比普通容器慢。

性能视角:容器的代价

容器的性能开销主要来自三个方面:

Namespace切换:理论上,namespace本身不带来运行时开销。进程访问的资源仍然由内核管理,只是看到了不同的视图。但某些操作可能需要额外的检查,比如访问/proc文件系统时需要根据进程的namespace过滤内容。

Cgroups开销:Cgroups的资源统计和限制需要额外的记账操作。Memory controller在每次内存分配和释放时更新统计,CPU controller在调度时考虑cgroup的配额。这些操作在快速路径上,但设计良好,开销很小。

OverlayFS开销:OverlayFS的主要开销来自copy-up操作。第一次写入lower层的文件时需要复制整个文件到upper层。对于大文件,这可能造成明显的延迟。此外,OverlayFS的元数据操作也比原生文件系统稍慢。

实际测试表明,容器可以达到裸金属性能的96%以上,而虚拟机通常只能达到80-90%。这个差距主要来自虚拟机的硬件抽象层和客户内核的开销。

容器的启动时间通常在秒级,而虚拟机需要分钟级。这个差异来自:

  1. 不需要启动独立的内核
  2. 不需要硬件初始化
  3. 镜像分层允许共享已加载的层

但容器的高密度也带来了挑战。在一个节点上运行数百个容器时,容器间的资源竞争、iptables规则的规模、cgroup层级树的深度都会影响性能。这些问题在Kubernetes等编排系统中通过资源配额、限制范围和服务网格等机制来管理。

从技术到工程:容器生态的演变

容器技术的发展可以分为几个阶段:

探索期(1979-2007):从chroot到FreeBSD Jail、Solaris Zones,各种隔离技术在不同的Unix变体中出现。但这些技术都是特定于某个操作系统的。

标准化期(2008-2013):Linux容器(LXC)项目将namespace和cgroups整合为一个完整的容器解决方案。LXC提供了创建和管理容器的工具,但使用门槛仍然很高。

爆发期(2013-2015):Docker的出现改变了容器技术的格局。它引入了镜像的概念,提供了简单的构建和分发工具,让容器从系统管理员的工具变成了开发者的日常工作流。

成熟期(2015至今):OCI的成立标准化了容器格式和运行时。Kubernetes成为容器编排的事实标准。安全容器(gVisor、Kata)和沙箱技术(WebAssembly)为容器安全提供了新的解决方案。

今天,容器已经不仅仅是部署工具,而是云原生架构的基础设施。理解它的底层原理——namespace如何隔离资源、cgroups如何限制资源、文件系统如何分层——对于诊断性能问题、排查安全漏洞、设计可靠的系统架构都至关重要。

容器技术的本质是操作系统层面的虚拟化。它通过巧妙的内核机制,在不牺牲性能的前提下实现了进程隔离和资源控制。这种平衡是容器技术能够成功的关键,也是理解容器技术的核心。


参考资料

  1. Kerrisk, M. (2013). Namespaces in operation, part 2: the namespaces API. LWN.net.
  2. Heo, T. (2015). Control Group v2. Linux Kernel Documentation.
  3. Rosen, R. (2015). Namespaces and Cgroups – the basis of Linux Containers. CMU Course Notes.
  4. Menage, P. & Seth, R. (2007). Adding Generic Process Containers to the Linux Kernel. OLS 2007.
  5. Felter, W. et al. (2015). An updated performance comparison of virtual machines and Linux containers. IEEE ISPASS 2015.
  6. Grattafiori, A. (2016). Understanding and hardening Linux containers. IOActive White Paper.
  7. CVE-2019-5736. runc container escape vulnerability. NIST National Vulnerability Database.
  8. CVE-2022-0847. Dirty Pipe vulnerability. NIST National Vulnerability Database.
  9. OCI Runtime Specification. Open Container Initiative.
  10. Bernstein, D. (2014). Containers and cloud: From LXC to Docker to Kubernetes. IEEE Cloud Computing.