容器:把这个骗局再演一遍
这本书从第一页就在说同一件事:操作系统让每个进程以为自己独占整台机器。这一章我们不再旁观——你亲手把同样的把戏再叠一层。做完你会发现,容器一点也不神秘,它只是这个骗局的第二季。
先破除一个比喻
「容器是轻量级虚拟机」——这个说法帮很多人入了门,但它会在你排查问题时把你带进沟里。
| 虚拟机 | 容器 | |
|---|---|---|
| 跑着几个内核 | 宿主一个 + 每个 VM 一个 | ★ 只有一个,大家共用 |
| 里面是什么 | 一个完整的操作系统 | ★ 宿主上的几个普通进程 |
| 宿主能看见吗 | 只看见一个 qemu 进程 | ★ ps aux 里直接就能看见容器里的进程 |
| 隔离靠什么 | 硬件虚拟化(VT-x) | 内核的几个数据结构 |
| 启动要多久 | 秒级(要引导内核) | 毫秒级(就是 fork + exec) |
| 隔离有多硬 | 很硬 | ★ 内核一旦有洞,所有容器一起完蛋 |
第三行值得你亲手验证一次。在跑着容器的宿主上敲 ps aux——你会直接看到容器里那些进程,和其他进程混在一起,毫无区别。它们没躲在任何东西后面。
容器 = 一个普通进程 + 被裁剪过的视野(namespace)+ 被限制的用量(cgroup)
没有第三样东西。没有虚拟硬件,没有第二个内核,没有模拟层。
「进入容器」这个说法也是个比喻——你没有进入任何地方,你只是启动了一个看不见外面的进程。
两套完全不同的机制
这是本章最重要的区分,也是绝大多数容器问题的根源:
| namespace | cgroup | |
|---|---|---|
| 管什么 | ★ 你能看见什么 | ★ 你能用多少 |
| 违反时 | 看不见就是看不见 | 超了就限流或者杀掉 |
| 举例 | 看不见宿主的进程、网卡、文件 | CPU 最多 0.5 核,内存最多 512 MB |
| 怎么创建 | clone() 的 CLONE_NEW* 标志(第 7 章) | 往 /sys/fs/cgroup/ 里写文件 |
记住这个区分,因为它俩是各管各的,而且经常不同步——那正是下面几个经典坑的来源。
下面这台机器让你一道一道摘掉一个进程能看见的东西。请从「全部关掉」开始,然后一个一个打开,看它的世界怎么一点点缩小。
最后点「全部打开」——那时你会看到这本书的主线在什么地方合上。
主线在这里合上了
把上面全部打开之后,那个进程的处境是:
- 它看到的进程列表里,自己是 1 号,没有别人。
- 它看到的文件系统,是镜像里那一套,宿主的文件一个都看不见。
- 它看到的网卡、路由表、端口,全是自己的。
- 它看到的 CPU 和内存,是配额里那些。
换句话说:它以为自己独占一台机器。
前面二十一章讲的是内核骗每一个进程的那套办法——切时间片让你以为独占 CPU、页表让你以为独占内存、fd 让你以为独占设备。
而这一章,你亲手把同一个把戏又演了一遍:这一次不是骗它「你独占 CPU」,而是骗它「你独占整台机器」。
容器不是更轻的虚拟机。它是这个骗局的第二层。
这个视角一旦建立,容器的两个核心特性都变成了显然的推论:
- 为什么启动这么快——因为它就是
fork+ 几个标志位 +exec(第 6、7 章)。没有内核要引导,没有硬件要初始化。 - 为什么隔离没有虚拟机硬——因为骗局的实施者只有一个内核。第一层骗局靠的是 CPU 硬件(第 2 章那两个比特),第二层骗局靠的只是内核里的几个数据结构。内核一旦被攻破,第二层立刻全部失效。
那三个经典的坑
它们全都源于同一件事:namespace 管「看见什么」,cgroup 管「用多少」,而这两者不同步。
坑一:容器里的程序数错 CPU 核数
你给容器限了 0.5 核(cgroup)。但 CPU 核数不属于任何 namespace——/proc/cpuinfo 是宿主的,容器里看到的仍然是全部 16 核。
于是:
容器限额:0.5 核(cgroup 说的) 容器看到:16 核(/proc/cpuinfo 说的) JVM: 开 16 个 GC 线程 Go runtime:GOMAXPROCS = 16 Netty: 开 32 个 EventLoop 线程 线程池默认: Runtime.availableProcessors() = 16
结果是一堆线程去抢 0.5 核的配额,疯狂触发 CFS 限流(第 8 章),上下文切换爆炸,性能远不如老老实实开 1 个线程。
现代运行时补上了这个洞:JDK 10+ 的 UseContainerSupport(默认开)、Go 1.25 开始也能感知 cgroup 配额。但很多老版本、以及大量第三方库仍然直接读 /proc/cpuinfo。遇到「容器里性能莫名其妙差」,先确认程序数对了几个核。
坑二:free 看到的是宿主的内存
同理。free -m、/proc/meminfo 显示的都是宿主的内存,不是你的配额。所以「按可用内存的 70% 设置缓存大小」这类自适应逻辑,在容器里会算出一个远超配额的数字,然后在某次写入时被 cgroup OOM 杀掉(第 15 章)。
正确的读法是直接读 cgroup:
$ cat /sys/fs/cgroup/memory.max # cgroup v2 536870912 $ cat /sys/fs/cgroup/cpu.max 50000 100000 # 每 100 ms 最多用 50 ms = 0.5 核
坑三:PID 1 的两个义务
这个前面两章都提过,这里把它归位:容器里你的进程是 PID 1,于是它继承了 init 的两个义务:
- 收养孤儿并收尸(第 5、6 章)——不做的话僵尸堆积,最终 PID 耗尽。
- 处理信号(第 21 章)——PID 1 没注册处理器的信号会被静默丢弃,所以
docker stop的SIGTERM收不到,只能等超时被SIGKILL。
两个问题一个解法:用 tini 或 dumb-init 当 PID 1(docker run --init 就是干这个的)。它什么业务都不做,只负责转发信号和不停 wait()。
容器限制了你的资源,但没有骗过你关于资源的认知。
namespace 骗了你「有哪些进程、哪些文件、哪些网卡」,但 cgroup 的限额不改变你看见什么——它只在你超额时打你。
这个不对称是设计使然(cgroup 本来就是独立于 namespace 发展出来的),但它是容器时代最大的一类困惑来源。下次遇到「容器里表现和宿主不一样」,先问:这个信息来自 namespace 还是 cgroup?
镜像也不神秘
顺带说一下另外半边。镜像的核心是 overlayfs——一种把多个目录「叠」在一起看的文件系统:
你看到的 / ← 合并视图
┌──────────────────┐
│ 可写层(容器自己) │ ← 你的所有修改写在这里
├──────────────────┤
│ 镜像层 3:你的应用 │ ┐
├──────────────────┤ │ 只读
│ 镜像层 2:依赖 │ │ 所有用同一镜像的容器共享
├──────────────────┤ │
│ 镜像层 1:基础系统 │ ┘
└──────────────────┘
读取时从上往下找第一个匹配;写入时先把文件复制到可写层再改——这就是 copy-up,和第 13 章的写时复制是同一个思路,只是粒度从「页」变成了「文件」。
这也解释了两个现象:
- 为什么容器启动这么快:不用复制镜像,只是叠一层可写层上去。
- 为什么容器里改大文件很慢:改一个 1 GB 文件的一个字节,overlayfs 要先把整个 1 GB 复制到可写层。所以数据库的数据目录一定要挂 volume,绕开这一层。
Docker 在 2013 年出现时,它用的技术一个都不是新的:
chroot——1979 年,Unix V7。最原始的「换个根目录」。- FreeBSD Jails——2000 年,已经有了相当完整的隔离。
- Solaris Zones——2004 年。
- Linux namespace——2002 年开始陆续进主线。
- cgroup——2007 年,Google 贡献的(他们内部早就在大规模用了)。
- LXC——2008 年,已经把这些拼起来了。
Docker 真正的贡献不在内核层,而在上层:一个可分层、可缓存、可分发的镜像格式,一个 Dockerfile,一个 docker run。
换句话说,它解决的不是「怎么隔离」(那早就解决了),而是「怎么把一个应用连同它的整个环境打包、传给别人、一条命令跑起来」。
这是个很值得记住的教训:把已有的能力包装成人能用的东西,价值可能远大于发明新能力。
「Android 的应用沙箱,和容器是一回事吗?」
思路一样,零件不同。Android 的隔离用了四层,而它们几乎都在这本书里出现过:
| 层 | 机制 | 本书哪章 |
|---|---|---|
| 每个 App 一个 uid | 传统 Unix 权限 | 第 5 章 |
| SELinux 强制访问控制 | 细到每个系统调用的策略 | — |
| seccomp-bpf | 白名单,禁掉大部分系统调用 | 第 3 章 |
| 地址空间隔离 | ★ 页表——最硬的那一层 | 第 11 章 |
Android 用得比较少的是 namespace(有一些挂载 namespace,用于存储隔离),因为它的隔离目标不太一样:容器要的是「假装自己是一整台机器」,Android 要的是「App 之间互不干扰,但共享同一套系统服务」。
而 Android 13 之后引入的 AVF(Android Virtualization Framework)走的是另一条路——用 pKVM 在 EL2(第 2 章那张 ARM 表)跑真正的虚拟机,用来保护那些「连内核都不能信任」的场景。因为正如本章开头说的:容器的隔离,内核一破就全破。
这一章的一句话
容器不是更轻的虚拟机,它就是宿主上一个普通进程——只是被 namespace 摘掉了视野、被 cgroup 限制了用量。你在这一章亲手把全书那个骗局又演了一遍,而它的所有特性(启动快、隔离不够硬、数错 CPU 核数)都是这个事实的直接推论。
卷 V 结束。剩下两章是收尾:先把前面所有机制接回你日常遇到的那些「玄学现象」,再交给你那张价目表。