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进程的角色。这个进程的特殊之处在于:
- 它收养所有孤儿进程,负责回收僵尸进程
- 发送给它的SIGKILL信号会被忽略
- 它终止时,整个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从上到下搜索,返回第一个找到的版本。当容器写入文件时:
- 如果文件存在于lower层,触发copy-up操作——将文件复制到upper层
- 对upper层副本进行修改
- 后续读取直接访问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是一个单体架构,后来被拆分为:
- dockerd:高层API和镜像管理
- containerd:容器生命周期管理
- runc:底层的OCI运行时
这种分层架构让Kubernetes等编排系统可以直接使用containerd,而不需要依赖完整的Docker引擎。
容器安全:隔离的边界与漏洞
容器的隔离不是绝对的。与虚拟机不同,所有容器共享同一个内核。这意味着内核漏洞可能突破容器边界。
CVE-2019-5736:runc逃逸
2019年2月披露的CVE-2019-5736是一个严重的容器逃逸漏洞。攻击者可以通过以下步骤获得主机root权限:
- 在容器内用恶意程序替换/bin/sh
- 当主机执行
docker exec时,runc会进入容器执行指定的命令 - 恶意程序打开/proc/self/exe获取runc的二进制文件描述符
- 利用这个描述符覆盖主机上的runc二进制文件
- 下一次
docker exec执行的就是恶意代码
修复方案是让runc在执行容器命令前,通过/proc/self/exe打开并验证自己的二进制文件,确保没有被修改。
Dirty Pipe:内核漏洞的容器逃逸
CVE-2022-0847(Dirty Pipe)是Linux内核的另一个严重漏洞。它允许非特权进程向任意可读文件写入数据,条件是:
- 内核版本 5.8 - 5.16.10
- 进程有读取目标文件的权限
攻击者可以在容器内利用这个漏洞修改主机上的关键文件(如/etc/passwd或runc二进制),实现容器逃逸。
强化容器安全的策略
- 使用User Namespace:将容器内的root映射到主机上的非特权用户
- 启用Seccomp:限制容器可以使用的系统调用
- 应用AppArmor或SELinux:提供强制访问控制
- 使用只读根文件系统:减少攻击面
- 选择安全运行时: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%。这个差距主要来自虚拟机的硬件抽象层和客户内核的开销。
容器的启动时间通常在秒级,而虚拟机需要分钟级。这个差异来自:
- 不需要启动独立的内核
- 不需要硬件初始化
- 镜像分层允许共享已加载的层
但容器的高密度也带来了挑战。在一个节点上运行数百个容器时,容器间的资源竞争、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如何限制资源、文件系统如何分层——对于诊断性能问题、排查安全漏洞、设计可靠的系统架构都至关重要。
容器技术的本质是操作系统层面的虚拟化。它通过巧妙的内核机制,在不牺牲性能的前提下实现了进程隔离和资源控制。这种平衡是容器技术能够成功的关键,也是理解容器技术的核心。
参考资料
- Kerrisk, M. (2013). Namespaces in operation, part 2: the namespaces API. LWN.net.
- Heo, T. (2015). Control Group v2. Linux Kernel Documentation.
- Rosen, R. (2015). Namespaces and Cgroups – the basis of Linux Containers. CMU Course Notes.
- Menage, P. & Seth, R. (2007). Adding Generic Process Containers to the Linux Kernel. OLS 2007.
- Felter, W. et al. (2015). An updated performance comparison of virtual machines and Linux containers. IEEE ISPASS 2015.
- Grattafiori, A. (2016). Understanding and hardening Linux containers. IOActive White Paper.
- CVE-2019-5736. runc container escape vulnerability. NIST National Vulnerability Database.
- CVE-2022-0847. Dirty Pipe vulnerability. NIST National Vulnerability Database.
- OCI Runtime Specification. Open Container Initiative.
- Bernstein, D. (2014). Containers and cloud: From LXC to Docker to Kubernetes. IEEE Cloud Computing.