| 本篇位置 | 把前四部分的机制再用一遍,这次是为了把人和人隔开 |
| 运行环境 | 容器里的 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 篇细说。
- cgroup、time。
三、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.high 和 memory.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.highvsmemory.max和cpu.weightvscpu.max是同一种设计:软的让你难受,硬的直接动手。 - 联合文件系统管"根目录从哪来":只读层叠加 + 可写层,写的时候 copy-up。⚠️ 这是写时复制在这门课里的第四次出现。 后果:镜像共享省内存,但容器里改大文件很慢,数据目录必须挂 volume。
- 虚拟机用二级地址转换——⭐ 第 12 篇那层中间人被加了两次。
- ⭐ 容器的隔离是"够用"不是"强",而且和邻居共用一个内核。中间地带(Firecracker、gVisor)用极小的虚拟机换隔离强度。
思考题
NSpid: 8 1说明同一进程有两个号。那么,容器里的进程能不能知道自己在外面的 pid 是几?如果能,这算不算信息泄露?- 第四节说数据库数据目录必须挂 volume。除了 copy-up 的性能问题,还有什么理由?(提示:容器删掉之后可写层怎么样了?)
- 容器里
nproc返回宿主机的核数,而 cgroup 只给了 0.5 核。一个默认"按核数开线程池"的运行时会怎样?这个问题该在哪一层修? - 二房东那个比方里,虚拟机是"隔成独立公寓",容器是"共用锅炉但各有水表"。那 Firecracker 这类微虚拟机对应什么?