卷 V · 邻里CH 22深度 22/24

容器:把这个骗局再演一遍

这本书从第一页就在说同一件事:操作系统让每个进程以为自己独占整台机器。这一章我们不再旁观——你亲手把同样的把戏再叠一层。做完你会发现,容器一点也不神秘,它只是这个骗局的第二季。

namespacecgroup主线收束容器 ≠ 虚拟机

先破除一个比喻

「容器是轻量级虚拟机」——这个说法帮很多人入了门,但它会在你排查问题时把你带进沟里。

虚拟机容器
跑着几个内核宿主一个 + 每个 VM 一个只有一个,大家共用
里面是什么一个完整的操作系统宿主上的几个普通进程
宿主能看见吗只看见一个 qemu 进程ps aux直接就能看见容器里的进程
隔离靠什么硬件虚拟化(VT-x)内核的几个数据结构
启动要多久秒级(要引导内核)毫秒级(就是 fork + exec)
隔离有多硬很硬内核一旦有洞,所有容器一起完蛋

第三行值得你亲手验证一次。在跑着容器的宿主上敲 ps aux——你会直接看到容器里那些进程,和其他进程混在一起,毫无区别。它们没躲在任何东西后面。

◆ 容器的真正定义

容器 = 一个普通进程 + 被裁剪过的视野(namespace)+ 被限制的用量(cgroup)

没有第三样东西。没有虚拟硬件,没有第二个内核,没有模拟层。

「进入容器」这个说法也是个比喻——你没有进入任何地方,你只是启动了一个看不见外面的进程。

两套完全不同的机制

这是本章最重要的区分,也是绝大多数容器问题的根源:

namespacecgroup
管什么你能看见什么你能用多少
违反时看不见就是看不见超了就限流或者杀掉
举例看不见宿主的进程、网卡、文件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 stopSIGTERM 收不到,只能等超时被 SIGKILL

两个问题一个解法:tinidumb-init 当 PID 1docker run --init 就是干这个的)。它什么业务都不做,只负责转发信号和不停 wait()

◆ 三个坑,一句话

容器限制了你的资源,但没有骗过你关于资源的认知。

namespace 骗了你「有哪些进程、哪些文件、哪些网卡」,但 cgroup 的限额不改变你看见什么——它只在你超额时打你。

这个不对称是设计使然(cgroup 本来就是独立于 namespace 发展出来的),但它是容器时代最大的一类困惑来源。下次遇到「容器里表现和宿主不一样」,先问:这个信息来自 namespace 还是 cgroup?

镜像也不神秘

顺带说一下另外半边。镜像的核心是 overlayfs——一种把多个目录「叠」在一起看的文件系统:

     你看到的 /                    ← 合并视图
    ┌──────────────────┐
    │ 可写层(容器自己) │  ← 你的所有修改写在这里
    ├──────────────────┤
    │ 镜像层 3:你的应用 │  ┐
    ├──────────────────┤  │ 只读
    │ 镜像层 2:依赖     │  │ 所有用同一镜像的容器共享
    ├──────────────────┤  │
    │ 镜像层 1:基础系统 │  ┘
    └──────────────────┘

读取时从上往下找第一个匹配;写入时先把文件复制到可写层再改——这就是 copy-up,和第 13 章的写时复制是同一个思路,只是粒度从「页」变成了「文件」。

这也解释了两个现象:

  • 为什么容器启动这么快:不用复制镜像,只是叠一层可写层上去。
  • 为什么容器里改大文件很慢:改一个 1 GB 文件的一个字节,overlayfs 要先把整个 1 GB 复制到可写层。所以数据库的数据目录一定要挂 volume,绕开这一层。
✎ 掌故 · 这些零件比 Docker 老得多

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 结束。剩下两章是收尾:先把前面所有机制接回你日常遇到的那些「玄学现象」,再交给你那张价目表。