本篇位置 把前四部分的机制再用一遍,这次是为了把人和人隔开
运行环境 容器里的 Linux。本篇演示要 --privileged(要在容器里再造 namespace)

一、你已经在容器里跑了二十八篇了

这门课从第 1 篇起,所有代码都在容器里跑。第 1 篇那个 fork 演示里,父进程的 pid 是 1——当时留了一句话:容器把"虚拟化"这一招原样又用了一遍,这次骗的是进程号。

现在把它拆开。

先说清楚一件常被搞错的事:

⚠️ 容器不是轻量级虚拟机。它根本不是虚拟机。

虚拟机虚拟的是硬件:里面跑着一个完整的、独立的内核。 容器里跑的进程,用的就是宿主机的那个内核。它就是宿主机上一个普通进程,只是被内核换了几副眼镜。

打个比方

回到第 1 篇那个二房东。

虚拟机是:把这栋楼隔成几套完全独立的公寓,每套有自己的水表、电表、锅炉、门牌。彼此之间除了共用地基,什么都不共享。安全,但每套都要一整套设备。

容器是:还是合租。 大家共用同一个锅炉(内核),但二房东给每个人重新编了一套房号、单独装了信箱、单独装了水表

  • 你在自己屋里看,房号是 1 号——别人也是 1 号
  • 你的信箱里只有你的信。
  • 你的水表只走你的量,超了就给你关阀门。

⭐ 三件事,正好对应容器的三根支柱:

比方里 机制 管什么
重新编的房号、单独的信箱 namespace 你看得见什么
单独的水表和阀门 cgroup 你能用多少
你那间屋子里的家具 联合文件系统 你的根目录从哪来

而锅炉只有一个——这句话是第 30 篇的全部内容。

二、namespace:给你换一副眼镜

namespace 的作用是:让一组进程看到的某种系统资源,和别人看到的不是同一份。

Linux 有八种,我们看三种最能说明问题的。

PID namespace:同一个进程,两个号码

unshare --pid --fork --mount-proc sleep 60 &
sleep 0.5
outer=$(pgrep -x sleep | head -1)
echo "  在外面看:这个 sleep 的 pid 是 $outer"
grep NSpid /proc/$outer/status
  在外面看:这个 sleep 的 pid 是 8
  内核记的 NSpid(外层 → 内层):NSpid:	8	1

NSpid: 8 1 这一行是内核自己写的:同一个进程,在外层 namespace 里是 8 号,在内层里是 1 号。

不是两个进程,是一个进程有两个号码

⚠️ 请注意这和第 8 篇的虚拟地址是一模一样的把戏:一个东西有两套编号,中间一张映射表,上层不知道下层的编号存在。只不过这次映射的不是内存地址,是进程号。

而"里面是 1 号"这件事有实际后果:容器里的 1 号进程要负责给孤儿进程收尸(第 3 篇讲过)。如果你的容器入口是一个不会 wait 的脚本,僵尸就会一直攒着。

挂载 namespace:同一个路径,两份内容

mkdir -p /tmp/ns && echo "宿主的内容" > /tmp/ns/file
unshare --mount bash -c "mount -t tmpfs none /tmp/ns; echo '里面的内容' > /tmp/ns/file; cat /tmp/ns/file"
cat /tmp/ns/file
  namespace 内看到:里面的内容
  namespace 外看到:宿主的内容

同一个绝对路径 /tmp/ns/file,两个进程 cat 出完全不同的东西。

⭐ 这就是"文件路径"这一层被虚拟化了。和第 1 篇那个"同一个地址,两份内容"的演示,是同一句话换了个对象。

网络 namespace:网卡消失了

echo "  容器里的网卡:$(ls /sys/class/net | tr '\n' ' ')"
unshare --net --mount bash -c "mount -t sysfs none /sys; ls /sys/class/net"
  容器里的网卡:eth0 lo 
  新 net namespace 里:lo 

eth0 直接不存在了。 新的网络 namespace 有自己的一整套网络栈:网卡、路由表、防火墙规则、端口号空间。

⚠️ 所以两个容器可以同时监听 80 端口而不冲突——它们的"80 端口"根本不是同一个东西。

剩下的几种

  • UTS:主机名。
  • IPC:System V 消息队列、共享内存。
  • User:⭐ 最重要的一个——它让容器里的 root(uid 0)映射成宿主机上一个普通的非特权用户。第 30 篇细说。
  • cgrouptime

三、cgroup:给你装个水表

namespace 管"看得见什么",它完全不管"用多少"。一个 namespace 里的进程可以把整台机器的 CPU 和内存吃光。

管这件事的是 cgroup(控制组)。这门课前面已经用过它好几次了:

  • 第 7 篇--cpus=0.5 设的是 cpu.max。⭐ 那一篇实测过它是靠冻住你来执行的——171 个周期全部被限流,累计冻了 12.78 秒。
  • 第 15 篇--memory=256m 设的是 memory.max。⭐ 那一篇实测过缓存被卡在 252 MB,每遍扫描内核都要赶走 800 MB

cgroup v2 的接口就是一个目录树,每个目录是一个组,里面的文件是旋钮:

/sys/fs/cgroup/<组名>/
    cpu.max          "25000 100000"  → 每 100 毫秒最多用 25 毫秒
    cpu.weight       相对权重(就是第 7 篇的 CFS 权重)
    memory.max       硬上限,超了就 OOM
    memory.high      软上限,超了就拼命回收但不杀
    io.max           磁盘 IOPS 和带宽上限
    pids.max         最多能有多少个进程(防 fork 炸弹)

⭐ 值得注意的是 memory.highmemory.max 的区别,它和第 7 篇 cpu.weight / cpu.max 的区别是同一种设计一个是"尽量别超,超了让你难受",一个是"绝对不许超,超了就动手"。 好的限额配置通常两个都要设。

四、镜像分层:你的根目录是叠出来的

第三根支柱是联合文件系统(overlayfs)。

第 1 篇让你 docker build 的那个镜像,它的每一条指令产生一,层是只读的。启动容器时,把这些层起来,最上面再放一层可写层:

     ┌─────────────────────────┐
     │  可写层(容器自己的改动) │  ← 你在容器里创建、修改的文件都在这
     ├─────────────────────────┤
     │  层3:apt-get install …  │  ┐
     ├─────────────────────────┤  │
     │  层2:…                  │  ├ 只读,多个容器共享同一份
     ├─────────────────────────┤  │
     │  层1:debian:13-slim     │  ┘
     └─────────────────────────┘

这不是比喻,/proc/mounts 里就写着(第 26 篇之后你应该能读懂这个):

overlay on / type overlay (rw,lowerdir=…:…:…:…,upperdir=…,workdir=…)

lowerdir 后面用冒号分隔的就是那几层只读层,upperdir 是可写层。

读文件:从上往下找,找到第一个就用。 写文件:⭐ 先把它从下层复制到可写层,再改——这叫 copy-up

⚠️ 这就是写时复制,你在这门课里已经见过三次了:第 3 篇 fork 的地址空间、第 24 篇 SSD 的 FTL、第 28 篇的 CoW 文件系统。第四次在这里。

它带来两个很实际的后果:

  • 十个容器跑同一个镜像,那几层在磁盘和页缓存里只有一份。 这是容器"轻"的一大半原因。
  • ⚠️ 在容器里改一个大文件,第一次会很慢——要先把整个文件复制上来。数据库的数据目录必须挂 volume,不能留在可写层,原因就在这。

五、那虚拟机呢

虚拟机走的是完全不同的路:虚拟硬件

它的核心机制叫二级地址转换(x86 的 EPT、arm 的 Stage-2):

  • 客户机内核以为自己在管物理内存,它维护一套页表(客户虚拟 → 客户物理)。
  • 宿主机再维护一套(客户物理 → 真实物理)。
  • 硬件一次走完两级。

第 12 篇那个"加一层中间人",在这里被加了两次。 而 TLB 未命中的代价也相应地从"四次内存访问"变成了更多——这是虚拟机内存访问天然比裸机慢一点的原因。

CPU 的虚拟化用的是根模式/非根模式——比第 4 篇讲的用户态/内核态再低一级:客户机内核以为自己在内核态,实际上它跑在非根模式的"内核态",执行敏感指令时会陷出到宿主机(VM exit)。

虚拟机 容器
内核 各自一个 共用宿主机的
启动时间 几秒到几十秒 几十毫秒
内存开销 每个几百 MB 起 几 MB
隔离强度 (逃逸要打穿虚拟机监控器) ⚠️ (逃逸只要打穿内核)
能跑别的操作系统 不能

⭐ 中间地带是近几年的热点:Firecracker、gVisor、Kata——用极小的虚拟机或用户态内核来换隔离强度,同时保住启动速度。AWS Lambda 跑在 Firecracker 上,正是因为它要在同一台物理机上跑不同客户的代码。

六、代价与取舍

容器换来了什么: 几乎没有运行时开销的隔离(它就是普通进程)、几十毫秒的启动、镜像分层带来的共享和分发效率。

它花了什么:

  • ⚠️ 隔离是"够用"而不是"强"。这是第 30 篇。
  • 抽象泄漏得厉害。 容器里 top 看到的是宿主机的 CPU 数(除非用了 lxcfs),/proc/meminfo 也是;很多运行时因此把线程池开得过大。⭐ cgroup 限制了你能用多少,但没有改变你看到的数字——限额和视图是两套机制,它们没有对齐。
  • 网络和存储要额外一层(虚拟网桥、volume),各有各的开销。

它放弃了什么:“一台机器一个内核,内核崩了大家一起崩"这个共同命运。 虚拟机把这条也隔开了,容器没有。你的容器和邻居的容器共享同一个锅炉——锅炉炸了,谁也跑不掉。

七、小结

  • ⚠️ 容器不是轻量级虚拟机。 虚拟机虚拟硬件、各自跑一个内核;容器里的进程就是宿主机上的普通进程,只是被换了几副眼镜。
  • namespace 管"看得见什么”。实测三样:
    • NSpid: 8 1——同一个进程,外面 8 号里面 1 号。和第 8 篇的虚拟地址是同一个把戏,只是映射的对象换成了进程号。
    • 同一个路径,两个进程 cat 出不同内容
    • 新网络 namespace 里 eth0 消失,所以两个容器能同时监听 80 端口。
  • ⚠️ 容器里 pid 1 要负责收尸(第 3 篇),入口脚本不 wait 就会攒僵尸。
  • cgroup 管"能用多少",这门课已经用过它好几次(第 7 篇 cpu.max 靠冻住你、第 15 篇 memory.max 逼出淘汰)。⭐ memory.high vs memory.maxcpu.weight vs cpu.max 是同一种设计:软的让你难受,硬的直接动手。
  • 联合文件系统管"根目录从哪来":只读层叠加 + 可写层,写的时候 copy-up。⚠️ 这是写时复制在这门课里的第四次出现。 后果:镜像共享省内存,但容器里改大文件很慢,数据目录必须挂 volume
  • 虚拟机用二级地址转换——⭐ 第 12 篇那层中间人被加了两次
  • 容器的隔离是"够用"不是"强",而且和邻居共用一个内核。中间地带(Firecracker、gVisor)用极小的虚拟机换隔离强度。

思考题

  1. NSpid: 8 1 说明同一进程有两个号。那么,容器里的进程能不能知道自己在外面的 pid 是几?如果能,这算不算信息泄露?
  2. 第四节说数据库数据目录必须挂 volume。除了 copy-up 的性能问题,还有什么理由?(提示:容器删掉之后可写层怎么样了?)
  3. 容器里 nproc 返回宿主机的核数,而 cgroup 只给了 0.5 核。一个默认"按核数开线程池"的运行时会怎样?这个问题该在哪一层修?
  4. 二房东那个比方里,虚拟机是"隔成独立公寓",容器是"共用锅炉但各有水表"。那 Firecracker 这类微虚拟机对应什么?

延伸