跳转到内容
← 返回系统剖析
系统剖析操作系统 · 虚拟化 · 系统安全29 分钟阅读

Linux 容器:它不是虚拟机

Linux Containers: Not a Virtual Machine

在一台跑着 Debian 的机器上敲 docker run --rm -it ubuntu:24.04 bash,一秒之内你会拿到一个 Ubuntu 的 shell。cat /etc/os-release 说这是 Ubuntu,apt 能用,/usr/lib 里躺着 Ubuntu 版本的 libc。可是敲一句 unam…

容器命名空间cgroups联合文件系统隔离

在一台跑着 Debian 的机器上敲 docker run --rm -it ubuntu:24.04 bash,一秒之内你会拿到一个 Ubuntu 的 shell。cat /etc/os-release 说这是 Ubuntu,apt 能用,/usr/lib 里躺着 Ubuntu 版本的 libc。可是敲一句 uname -r,返回的却是宿主机 Debian 的内核版本号。

你没有启动第二个操作系统。你启动的是一个普通进程——它只是被内核骗了,以为自己看见的是整台机器。

这篇文章要拆的就是这个骗局:它由哪几部分组成、每一部分骗的是哪一项,以及骗不过去的地方在哪里。

破除误解

误解一:容器是"轻量级虚拟机"。

这句话把两种完全不同的东西按结果归了类。虚拟机的做法是模拟一整套硬件——CPU、内存、磁盘控制器、网卡——然后在上面装一个完整的操作系统内核,客户机内核以为自己在管硬件,其实在管 hypervisor 递过来的假硬件。容器里没有第二个内核。容器里的进程和宿主机上的其他进程一样,execve 之后由同一个内核调度、走同一份系统调用表、用同一套页表机制。

这不是理论差别,是可以当场验证的:在宿主机上敲 ps aux | grep nginx,你能直接看到容器里那个 nginx 进程,PID 是宿主机的 PID(比如 31402),而容器内部 ps 看到它是 PID 1。同一个进程,两个编号。虚拟机里的进程你在宿主机上是看不见的,你只能看见一个叫 qemu-system-x86_64 的巨大进程。

误解二:镜像里是 Ubuntu,所以容器里"装了个 Ubuntu"。

一个 Linux 发行版 = 内核 + 用户态。镜像里只有用户态那一半:/bin/lib/etc、包管理器的数据库。内核那一半从来不在镜像里。所以 ubuntu:24.04 镜像其实只有大约 30 MB 压缩体积——一个真正的 Ubuntu 安装盘是它的一百倍。

这个事实有直接后果:镜像里的用户态程序调用的是宿主机内核的系统调用。如果某个程序需要内核 5.8 才有的 faccessat2,而宿主机跑的是 4.19,它在这台机器上就是会失败——换个镜像没用,因为问题不在镜像里。

误解三:内核里有个叫"容器"的对象。

翻遍 Linux 源码,你找不到 struct container,也没有 container_create() 系统调用。内核提供的是三组互不相干的机制,容器运行时(runc、crun)把它们按顺序调用一遍,"容器"就出现了。它是一套配置的名字,不是一个内核实体。

正因如此,容器可以是"半个"的:你可以只隔离网络不隔离进程,也可以只限内存不隔离任何东西。Docker 给你的是一套被选定的默认组合,而不是唯一可能的组合。

内核里到底发生了什么

runc 启动一个容器时,做的事情本质上是下面这串系统调用(简化到只剩骨架):

c
/* 1. 造出一组新的"视图",同时 fork 出子进程 */
clone(CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET |
      CLONE_NEWIPC | CLONE_NEWUTS | CLONE_NEWUSER, ...);

/ 2. 把新进程的 PID 写进 cgroup,从此它的资源被记账 / write("/sys/fs/cgroup/mycontainer/cgroup.procs", "31402");

/ 3. 把镜像的若干只读层叠成一个可写的根目录 / mount("overlay", "/rootfs", "overlay", 0, "lowerdir=/layers/3:/layers/2:/layers/1," "upperdir=/rw/diff,workdir=/rw/work");

/ 4. 换根,然后卸掉旧根——旧根一卸,宿主机文件系统就再也看不见了 / pivot_root("/rootfs", "/rootfs/.old"); umount2("/.old", MNT_DETACH);

/ 5. 变成容器里的 1 号进程 / execve("/usr/sbin/nginx", argv, envp); ```

这五步分别对应三种机制:第 1 步是 namespaces,第 2 步是 cgroups,第 3 步是联合文件系统,第 4、5 步只是收尾。下面逐个拆开。

namespaces:改写进程看见的世界

namespace 解决的是可见性问题:让进程查询系统状态时,内核返回一份被裁剪过的答案。

打个比方:内核里有很多张全局表——进程表、挂载表、网卡表、主机名。namespace 做的事情是把每张表拆成多份,然后给每个进程发一张"你查哪一份"的门牌。进程调 getpid() 时,内核不是查全局进程表,而是查这个进程所属的 PID namespace 那一份。

Linux 的 namespace 是一种一种加进内核的,历时十八年,这解释了为什么它们的覆盖范围参差不齐:

namespace隔离什么进入内核的版本年份
mount (CLONE_NEWNS)挂载点列表2.4.192002
UTS主机名、域名2.6.192006
IPCSystem V IPC、POSIX 消息队列2.6.192006
PID进程号空间2.6.242008
network网卡、路由表、iptables、端口2.6.242008
userUID/GID 映射与 capabilities3.8(可用)2013
cgroupcgroup 层级的根位置4.62016
timeCLOCK_MONOTONIC / CLOCK_BOOTTIME5.62020

最关键的一个是 PID namespace。新建一个 PID namespace 后,第一个进程的 PID 是 1,它在内核眼里获得了 init 的特殊语义:信号默认不杀它(没有安装处理函数的信号对 PID 1 无效),而且它必须负责回收孤儿进程的僵尸状态。这直接造成两个著名的坑——docker stop 发的 SIGTERM 被应用无声吞掉,最后靠十秒超时后的 SIGKILL 收场;以及长期运行的容器里僵尸进程堆积,因为业务程序从没打算当 init。--init 参数塞进去一个几十 KB 的 tini,就是为了补这个语义。

namespace 在内核里是有身份的对象,你甚至能直接看见它的编号:

bash
$ ls -l /proc/self/ns/
net -> 'net:[4026531993]'    # 方括号里是这个 namespace 的 inode 号
pid -> 'pid:[4026531836]'
```

两个进程的这串数字相同,就说明它们在同一个 namespace 里。setns() 系统调用(Linux 3.0,2011 年)允许一个已经跑起来的进程加入别人的 namespace——docker execnsenter 就是这么工作的:新进程加入目标容器的六个 namespace,然后 execve

你不需要 Docker 也能造出半个容器:

bash
$ sudo unshare --pid --net --mount --fork --mount-proc /bin/bash
# ps aux            # 只看得见 bash 和 ps 自己
# ip link           # 只有一块 down 掉的 lo,没有 eth0
```

namespace 的设计取舍在这里就暴露了:它是逐项虚拟化,不是整体虚拟化。好处是增量、可组合、可以只用其中一个(Kubernetes 的 Pod 就是"共享 network 和 IPC,但各自独立 mount 和 PID"的一组容器,这种拼法只有逐项方案才做得到)。代价是永远有没被 namespace 化的角落dmesg 看到的是宿主机内核日志,/sys 大部分内容没有隔离,绝大多数 sysctl 参数仍然全机共享,time namespace 到今天也只虚拟了单调时钟和启动时钟,不包括墙钟时间——容器不可能有自己的"现在几点"。

cgroups:把资源账单挂到进程组上

namespace 管"看得见什么",cgroups 管"用得了多少"。这是两个正交的问题,也是两套完全独立的内核代码。

cgroups 源于 Google 工程师 Paul Menage 和 Rohit Seth 在 2006 年提交的 "process containers",2008 年随 2.6.24 进入主线时改名为 control groups。它的模型很朴素:把进程编成树状的组,每个控制器(controller)在自己那条记账路径上多加一次检查。

  • cpu 控制器挂在 CFS 调度器上。cgroup v2 里写 cpu.max200000 100000,意思是"每 100 毫秒的周期内最多用 200 毫秒 CPU 时间",也就是 2 核。
  • memory 控制器挂在页分配路径上。memory.max 是硬上限,触到就在这个 cgroup 内部触发 OOM killer。你看到容器退出码 137,那是 128 + 9(SIGKILL)。
  • pids 控制器限制进程数,是 fork 炸弹唯一的现成解药。
  • io 控制器限制块设备读写带宽与 IOPS。

cgroup v1 的每个控制器各有一棵独立的层级树,一个进程在 cpu 树上的位置和它在 memory 树上的位置可以毫无关系——听起来灵活,实际让"内存回收时该按谁的 IO 配额去写脏页"这类跨控制器问题无解。v2(4.5 版本,2016 年)改成单一统一层级,牺牲了那份灵活性换来了控制器之间能互相协作。

cgroup v1cgroup v2
层级每个控制器一棵树全系统一棵树
内存软限制只有 soft_limit_in_bytes,基本不起作用memory.high,超了就限速并主动回收,不杀进程
进程归属可以挂在任意节点只有叶子节点能放进程
压力信号PSI(*.pressure),能读到"因等 CPU/内存/IO 而停顿的时间占比"

memory.high 是 v2 最被低估的改进:v1 只有"没到上限"和"被杀掉"两种状态,v2 多了中间的一档,让内存吃紧的容器先变慢而不是直接死。

cgroups 的设计取舍:它只记账、不改视图。 这句话是无数生产事故的根源。给容器设了 memory.max=512M,容器里 free -h 显示的仍然是宿主机的 256 GB,因为 /proc/meminfo 根本没有被 namespace 化,它读的是全局数据。于是所有"读 /proc 猜自己有多少资源"的运行时全部算错:

  • JVM 默认按物理内存的一个比例定堆大小,在容器里就按 256 GB 去算,然后被 cgroup 的 OOM killer 秒杀。这个问题直到 JDK 10(2018)引入并默认打开 -XX:+UseContainerSupport 才修好,Java 8 靠 8u191 回移。
  • Go 从 1.5 版(2015)起把 GOMAXPROCS 设成宿主机的 CPU 核数,在只给 1 核配额的容器里开出几十个 P,制造大量无意义的抢占与上下文切换。直到 Go 1.25(2025)运行时才默认去读 cgroup 配额(小数配额向上取整)。
  • nproctop、监控 agent 至今大多仍然报宿主机的数字,除非用 lxcfs 之类的工具把假的 /proc/meminfo 挂进去。

还有一个更隐蔽的失效场景:CFS 限流引起的尾延迟。CPU 配额是按 100 毫秒的周期发放的,一个多线程服务如果在周期前 20 毫秒里把额度用光,剩下 80 毫秒所有线程一起被挂起——平均 CPU 利用率可能只有 20%,p99 延迟却出现整齐的百毫秒级台阶。监控面板上一片绿,用户在骂。

联合文件系统:镜像为什么能分层

第三样东西回答的是:"容器的根目录从哪来,而且为什么启动时不用复制几百 MB 文件。"

答案是 OverlayFS:Miklós Szeredi 写的联合文件系统,2014 年进入 Linux 3.18,次年的 3.19 加上多层支持。它的挂载语法直接说明了它的结构:

bash
mount -t overlay overlay \
  -o lowerdir=/layers/3:/layers/2:/layers/1,\
upperdir=/rw/diff,workdir=/rw/work \
  /merged
```
  • lowerdir只读的若干层,从左到右优先级递减。第 3 层里的 /etc/nginx.conf 会盖住第 1 层的同名文件。
  • upperdir唯一可写的一层,容器运行期的所有改动都落在这里。
  • workdir:内核做原子操作时用的暂存目录,必须和 upperdir 在同一个文件系统上。
  • /merged:合并后呈现给容器的视图。

读一个文件时,内核从最上层往下依次 lookup,找到就返回;这一路上没有任何拷贝。的时候才发生关键动作——copy-up:如果目标文件来自某个只读层,内核先把整个文件复制到 upperdir,再在副本上执行写入。

删除更巧妙。只读层的文件删不掉,于是 OverlayFS 在 upperdir 里创建一个字符设备节点、主次设备号都是 0/0,作为"墓碑"(whiteout)。lookup 撞见这个节点就停止下探,对上层呈现为"文件不存在"。整目录被覆盖时则给目录打上 trusted.overlay.opaque="y" 扩展属性,表示"别再往下找了"。

这套结构解释了 Dockerfile 的两条常识为什么成立:

  1. 每条 RUN/COPY 产生一层,层从 Docker 1.10(2016)起用内容哈希(sha256 摘要)寻址。内容一样,摘要就一样,于是同一层在磁盘上只存一份、在网络上只传一次。十个基于 python:3.12 的镜像共用同一份 Python 运行时层。
  2. 在后面的层里 rm 掉前面层的大文件,镜像不会变小——你只是加了一块墓碑,被删的数据仍然完整躺在下层里,docker save 照样把它打包带走。误提交的密钥就是这样泄漏的:从最终视图里看不见,从层里 tar -x 一下就有。

共享只读层还有个容易忽略的运行时红利:一百个容器跑同一个镜像,它们打开的是同一批 inode,libc 在页缓存里只有一份。这是容器密度远高于虚拟机的真正原因之一——虚拟机里每个客户机内核都会把自己那份 libc 独立缓存一遍。

联合文件系统的取舍很清楚:用读时零拷贝写时全文件拷贝。这个交易对"启动即读、很少写"的应用极其划算,对另一类应用则是灾难。数据库容器往一个 4 GB 的数据文件里改 8 个字节,copy-up 会先复制这 4 GB。这正是所有有状态服务都必须把数据目录挂成 volume 或 bind mount 的原因——绕开联合文件系统,直接落到宿主机的 ext4/XFS 上。此外层数也不是免费的:overlay2 驱动最多支持 128 个下层,层越多,一次未命中的 lookup 要穿越的目录就越多。

三件东西如何合成一个容器

把前面三节并排放,容器的定义就清楚了:

你想要的性质由谁提供内核里的实现方式
看不见别人的进程、网卡、挂载点namespaces全局表拆成多份,进程持有"查哪一份"的引用
用不超过 2 核 / 512 MBcgroups调度器与页分配器在每次记账时检查所属组的上限
根目录来自可分发、可复用的镜像联合文件系统只读层内容寻址 + 写时复制到可写层

再加上启动时那句 pivot_root 和收尾的 execve,一个容器就成立了。没有第四种东西,也没有一个把它们绑在一起的内核对象——把它们绑在一起的是 runc 的代码和一份 OCI 运行时规范的 JSON。

这个思路有很长的前史。1979 年 Unix V7 的 chroot 只换了文件系统的根,进程表、网络、用户全是共享的,root 用户轻易就能爬出去。2000 年 FreeBSD 的 Jails(Poul-Henning Kamp 与 Robert Watson,论文标题就叫《Jails: Confining the omnipotent root》)第一次把进程视图和网络一起关进去。2005 年的 Solaris Zones 走得更远。Linux 走的是相反的路——不做一个"容器"特性,而是把隔离拆成正交的小机制,一件一件加进内核,最后由用户态自由组合。2008 年的 LXC 是第一次系统性组合,2013 年 3 月 Solomon Hykes 的 Docker 加上了镜像格式和分发协议,2015 年 6 月成立的 OCI 把运行时与镜像格式写成了规范。

共享内核:速度与隔离强度是同一件事的两面

容器"快"的原因,说到底只有一句:它跳过了操作系统启动

一台虚拟机开机要走固件自检、引导程序、内核解压、内核初始化、设备探测、init 系统拉起几十个服务——这些是几百毫秒到几十秒的成本,而且每台虚拟机都要独立付一遍。容器启动做的是 clone + 几次 mount + execve,是进程创建的成本,毫秒量级。内存上也一样:客户机内核连同它的页缓存常驻几十上百 MB,容器则一分钱不花,因为它复用的内核已经在那儿了。

同一个事实也就是隔离强度的天花板。容器和宿主机之间的边界,是系统调用接口——数百个入口、几千万行内核代码,全部对容器内的进程开放。虚拟机的边界要窄得多:客户机能碰到的是 hypervisor 暴露的少数几个虚拟设备和 VM exit 路径。边界越窄,能被攻破的地方越少。

共享内核还带来一批功能上的硬失效,不是调参能解决的:

  • 容器不能加载内核模块,不能改内核版本,不能打不同的内核补丁。要跑一个需要 5.15 特性的程序,宿主机的 4.19 内核不会因为镜像标签写着 "ubuntu:22.04" 就变身。
  • Windows 容器必须跑在 Windows 主机上,Linux 容器必须跑在 Linux 上。Docker Desktop 在 macOS 上跑 Linux 容器,靠的是先起一台真正的 Linux 虚拟机。
  • 没被 namespace 化的东西全是共享的:内核锁、CPU 的 L3 缓存、内存带宽、页缓存的换出决策。cgroups 能限 CPU 时间和内存字节数,限不了缓存污染。一个疯狂扫描大数组的邻居容器会把你的 L3 缓存冲掉,你的延迟变差,而两边的资源配额指标都是绿的。

安全边界的真实位置

先说结论:容器是一个打包与限流的单元,不是一条信任边界。

默认状态下,容器里的 root 就是宿主机的 UID 0——只是被 capabilities 和 seccomp 削弱过的 UID 0。Docker 默认从 Linux 那四十来种 capability 里保留十四种(CHOWNSETUIDNET_BIND_SERVICE 等),丢掉 SYS_ADMINSYS_MODULE 这些危险的;默认的 seccomp 配置额外屏蔽掉约四十个系统调用。这些都是有用的削减,但削减完之后仍然有几百个系统调用可达,其中任何一个的内核漏洞都是一次逃逸。

历史上的实例足够说明性质:

  • CVE-2019-5736(2019):容器内进程通过 /proc/self/exe 覆写宿主机上的 runc 二进制文件,下一个容器启动时以宿主机 root 执行攻击者代码。这个洞甚至不在内核里,在运行时里。
  • CVE-2022-0492:cgroup v1 的 release_agent 机制,在容器持有 CAP_SYS_ADMIN 时可被用来让内核以宿主机身份执行任意程序。
  • CVE-2024-21626("Leaky Vessels",2024 年 1 月):runc 1.1.11 及更早版本泄漏了一个内部文件描述符,把 WORKDIR 设成 /proc/self/fd/N 就能让容器的工作目录落在宿主机文件系统里。修复版本是 1.1.12。

真正能把边界收紧的手段是 user namespace:把容器里的 UID 0 映射到宿主机上一个没有特权的高位 UID(比如 165536),这样即使逃出去,拿到的也只是个普通用户。Podman 的 rootless 模式就建立在这上面。代价是映射本身带来的麻烦——挂载的 volume 文件属主要跟着换、某些需要真实特权的操作会失败,所以它至今没有成为 Docker 的默认。

如果你需要的是真正的多租户边界——跑用户上传的任意代码、跑不受信任的 CI 任务——业界的答案是在容器外面再套一层

方案隔离机制代价
普通容器(runc)namespaces + cgroups + seccomp + capabilities边界 = 整个系统调用接口
gVisor(Google,2018)用 Go 写的用户态内核 Sentry 拦截系统调用,自己实现大部分语义系统调用密集与网络密集负载明显变慢;部分冷门系统调用不支持
Kata Containers(2017)每个容器(或 Pod)跑在自带内核的轻量虚拟机里恢复了客户机内核的内存与启动开销
Firecracker(AWS,2018)极简设备模型的 microVM,为 Lambda / Fargate 而生NSDI 2020 论文报告约 125 毫秒启动、单实例内存开销低于 5 MiB

Firecracker 的数字值得多看一眼:它证明"虚拟机就是慢"其实是设备模型臃肿造成的,而不是虚拟化本身的必然。把模拟设备砍到只剩 virtio-net、virtio-block 和一个串口,虚拟机的启动时间就掉到了和容器同一个数量级——同时保住了那条窄得多的边界。这也说明前面那条"速度—隔离"的取舍并非铁律,而是一次具体的工程选择。

所以正确的用法是:同一个信任域内的服务,容器足够;跨信任域,容器只是纵深防御里的一层,不是最外那层。 NIST 在 SP 800-190(2017)里给的建议是同一台宿主机上不要混跑不同敏感级别的容器——这条建议至今没有过时。

跨域连接

  • 公地治理:共享内核在结构上就是一片公共资源池——CPU 时间、内存带宽、页缓存、内核锁都没有产权归属,谁多用一点别人就少一点。cgroups 恰恰不是一堵墙,而是一套配额与计量制度:它给每个组设定可提取的上限、持续监测实际用量、对越界者施加分级制裁(先靠 memory.high 限速,再由 OOM killer 终止)。这与奥斯特罗姆总结的公地自治规则同构,也同样继承了它的软肋——计量不到的维度就治理不了。L3 缓存与内存带宽因为无法按进程组低成本记账,至今仍是彻底的开放公地,"吵闹邻居"问题的根在这里,而不在配额设得对不对。
  • 安全工程:容器安全的正确读法是纵深防御与瑞士奶酪模型。namespace、capabilities、seccomp、user namespace 各自都是一层带孔的薄板——CVE-2024-21626 穿透的是运行时那一层,CVE-2022-0492 穿透的是 cgroup 那一层。安全工程的核心教训在于:当所有孔洞对齐时事故发生,因此关键在层与层的独立性,而非任何单层的厚度。这解释了为什么 gVisor 与 Kata 的价值不只是"更强",而是它们引入的失效模式与 namespace 不相关;也解释了为什么把容器当成唯一屏障,在工程上等价于单层设计。
  • 供应链:镜像分层就是一份可执行的物料清单。基础镜像是通用件,FROM python:3.12 相当于采购一个标准模组——复用把边际构建成本压到接近零,也把上游的单点风险原封不动传导到全部下游。基础镜像里的一个 libc 漏洞会同时波及成千上万个衍生镜像,而它们的 Dockerfile 里根本没写过 libc。制造业用 BOM 追溯和二级供应商审计应对这件事,容器生态给出的对应物是 SBOM 与镜像签名,逻辑完全一致:可复用性和可追溯性必须成对出现,否则复用只是把风险放大
  • 失效分析:容器的典型故障几乎都是"症状与机制错位"。退出码 137 看着像应用崩溃,实际是 cgroup 的 OOM killer;p99 延迟出现整齐的百毫秒台阶,看着像网络抖动,实际是 CFS 在周期末尾把所有线程一起挂起;JVM 堆算错则源于 /proc/meminfo 从未被 namespace 化。失效分析的方法论要求从症状回溯到失效机制而非停在相关现象,而容器这个案例给出的普遍教训是:当一个系统由多种正交机制拼装而成,故障表征会跨机制串味,只有掌握拼装图的人才能定位根因
  • 活字印刷术:从整版雕刻到活字,改变的是复制的最小单位——改一个字不必重刻一整块版。镜像分层做的是同一件事:把不可分割的整机磁盘映像拆成内容寻址的只读单元,改一行配置只新增一层,分发成本从"内容大小"降到"引用大小"。而活字的两个前提条件也一并被继承了:字模必须规格统一才能互换(对应 OCI 规范与 sha256 摘要),且只有当同一批字反复出现时收益才显现——这正是基础镜像不统一的组织里层缓存几乎命中不了的原因。

参考文献

  • Kamp, P.-H. & Watson, R. N. M. Jails: Confining the Omnipotent Root. Proceedings of the 2nd International SANE Conference (2000). (Jails 的原始论文,容器式隔离的直接前身)
  • Menage, P. B. Adding Generic Process Containers to the Linux Kernel. Proceedings of the Linux Symposium (2007). (cgroups 进入内核前的设计说明)
  • Kerrisk, M. Namespaces in Operation. LWN.net 系列文章(2013)。(对各类 namespace 语义最细致的公开讲解)
  • The Linux Kernel Documentation. Control Group v2Overlay FilesystemDocumentation/admin-guide/cgroup-v2.rstDocumentation/filesystems/overlayfs.rst)。(cgroup v2 接口与 OverlayFS 行为的权威定义)
  • NIST. SP 800-190: Application Container Security Guide (2017). (容器安全边界的官方风险框架)
  • Agache, A. et al. Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI (2020). (microVM 如何在保住虚拟机边界的前提下逼近容器的启动速度)

延伸阅读

  • Rice, L. Container Security. O'Reilly (2020). (逐个机制讲清容器的攻击面与加固手段)
  • Kerrisk, M. The Linux Programming Interface. No Starch Press (2010). (进程、信号、capabilities、挂载的系统性基础)
  • Velichko, I. Learning Containers From The Bottom Up.(在线教程,用原始系统调用一步步手工造出一个容器)