容器没有魔法:一堵墙、一块水表,和一场认知跃迁
一个困扰了我很久的问题
刚接触 Docker 那会儿,我脑子里一直有个疙瘩:
“容器号称’轻量级虚拟化’,但它到底轻在哪?它和虚拟机之间那道鸿沟,究竟画在哪里?”
虚拟机我懂——起一个 Hypervisor,模拟一套完整的硬件,上面跑一个完整的 Guest OS。几十 GB 的磁盘、几 GB 的内存、分钟级的启动,代价清清楚楚。
但容器呢?docker run nginx,一秒钟不到,一个"隔离的环境"就出来了。它没有自己的内核,没有自己的操作系统,甚至没有自己的硬件。那它到底"隔离"了什么?
这个问题我翻了很多博客,看了很多架构图,始终觉得隔靴搔痒。直到有一天,我强迫自己抛开所有 Docker 的命令和概念,只问一个问题:
Linux 内核到底做了什么,才让一个普通进程"以为"自己独占了一台机器?
答案只有两个词:Namespace 和 CGroup。
今天这篇文章,我不想写教程,不想贴命令大全。我想用一个比喻,把这两件事彻底讲透。讲完之后你会发现,Docker 里那些曾经让你困惑的行为——host 模式为什么不用映射端口、内存限制为什么比你想的"多"、PID 1 为什么杀不死——全都会变成"哦,原来如此"。
先讲一个比喻:一栋公寓楼
把 Linux 内核想象成一栋公寓楼。
- 这栋楼有一套供电系统、一套供水系统、一套中央空调(= 一个内核)。
- 楼里住了很多住户(= 进程)。
- 每个住户有自己的房间(= 进程空间)。
现在问题来了:如果所有住户共用一个大厅、共用一个门牌号、共用一本电话簿,那他们之间毫无隐私,互相干扰。
物业(= 内核)做了两件事:
第一件事:砌墙、装门、分门牌号 → Namespace
给每个住户砌起四面墙,装上独立的门。
- 你从自己房间往外看,看不到隔壁住了谁。
- 你的门牌号是 101,隔壁是 102,互相不知道对方的真实身份。
- 你房间里有自己的电话(网络栈),打不到隔壁的号码。
- 你房间里有自己的厨房布局(文件系统),和隔壁的灶台位置无关。
Namespace 的本质:不创造新资源,只修改"你能看到什么"。
墙不是真的把楼拆成两半。墙只是挡住了你的视线。你依然和隔壁共享同一根承重梁、同一套电路、同一个内核。
第二件事:装水电表、设限额 → CGroup
光有墙还不够。如果 101 的住户开了 50 台空调,整栋楼的电都会被拉垮。
于是物业给每个房间装了独立的水表和电表,并且设定了限额:
- 你这个月最多用 200 度电(CPU 配额)。
- 你的水箱最多装 200 升水(内存上限),用完了直接断水(OOM Kill)。
- 你的房间最多住 10 个人(进程数上限)。
CGroup 的本质:不隔离视图,只限制用量。
它不管你能不能看到隔壁,它只管你能用多少。
两件事合在一起
Namespace 让你"以为"自己住的是独栋别墅。 CGroup 确保你不会把整栋楼的电用光。
一个容器,就是一间砌了墙、装了水电表的公寓房间。
它没有自己的地基(没有独立内核),没有自己的供电系统(没有独立硬件),但它感觉像是独立的。
而虚拟机呢?虚拟机是在旁边盖了一栋新楼。有自己的地基、自己的供电、自己的供水。代价是几十 GB 的地基和分钟级的施工时间。
这就是容器和虚拟机之间那道鸿沟的全部真相。
拆墙看看:Namespace 到底隔离了什么?
Linux 内核提供了 6 种 Namespace,每一种对应"公寓房间"的一个隔离维度。我用最直白的话逐个解释:
① PID Namespace —— 门牌号
你在自己房间里数人头,从 1 开始数。你就是 1 号,你的猫是 2 号。
你不知道隔壁房间也有一个 1 号。你也不知道物业(宿主机)给你分配的真实编号其实是 29831。
你看到的 PID 1,只是你房间里的 1 号。在整栋楼的花名册上,你是另一个编号。
这就是为什么容器里 ps aux 只看到两三个进程,而宿主机上有几百个。不是进程消失了,是你的门牌号系统换了。
② UTS Namespace —— 房间名牌
你在自己门口挂了个牌子,写着"小明的家"。隔壁挂的是"小红的家"。
你改了牌子,不影响隔壁。也不影响物业大楼门口挂的那块总牌。
容器里
hostname my-container,改的只是自己门口的牌子。
③ Mount Namespace —— 厨房布局
你的厨房里,灶台在左边,冰箱在右边。隔壁的厨房,灶台在右边,冰箱在左边。
你们用的是同一栋楼的燃气管道和水管,但家具的摆放方式是各自独立的。
容器的
/目录和宿主机的/可以完全不同。容器里看到的/usr/bin/nginx和宿主机上的可能根本不是同一个文件。这就是 OverlayFS + Mount Namespace 的功劳。
④ Network Namespace —— 内线电话
你的房间里有一部电话,号码是 172.17.0.2。隔壁的电话是 172.17.0.3。
你们不能直接拨对方的号码,必须通过物业前台(docker0 网桥 + iptables)转接。
你的电话线上能插什么设备(eth0、lo)、能拨什么外线(路由表),都和隔壁无关。
容器里
ip addr看到的网卡和 IP,是它自己那部内线电话的号码,不是宿主机的真实网卡。
⑤ IPC Namespace —— 隔音墙
你和隔壁之间有一道隔音墙。你没法通过"敲墙"和隔壁传暗号(共享内存、信号量、消息队列)。
⑥ User Namespace —— 角色扮演
你在自己房间里自称"国王"(UID=0),觉得自己无所不能。
但物业(宿主机内核)的花名册上,你只是个普通住户(UID=10000)。你喊"把隔壁的门给我拆了",物业根本不理你。
这就是为什么容器内的 root 不等于宿主机的 root。
一张图总结
| |
注意:两个容器共享同一个内核。 没有第二个内核。这就是"轻量"的全部来源,也是"隔离不如虚拟机彻底"的全部原因。
看看水电表:CGroup 到底限制了什么?
Namespace 解决了"看到什么",CGroup 解决的是"能用多少"。
CGroup 的核心思想极其朴素:把一组进程归入一个"组",然后对整个组设上限。
内存:水箱
| |
这意味着这个容器里所有进程加起来,能用的内存(包括缓存)不能超过 256MB。
一旦超了,内核不会温柔地提醒你。它会直接打开排水阀——挑一个最"胖"的进程杀掉。这就是 OOM Kill。
注意:256MB 不只是你程序
malloc的那部分。文件系统缓存(Page Cache)、内核数据结构、共享库映射……全都算在内。这就是为什么"我明明只用了 100MB 却被 Kill 了"。
CPU:空调配额
| |
意思是:每 100 毫秒里,你最多吹 50 毫秒的冷风。也就是 0.5 个核。
你可以把风扇开到最大,但时间到了就断电。你的进程不会被杀,只是被冻住(throttling),等下一个周期再继续。
进程数:房间容量
| |
你的房间最多住 100 个人。第 101 个人想进来?门打不开(fork 失败)。
磁盘 IO:电梯载重
你的行李(磁盘读写)每秒最多过 50 公斤。超了就在电梯口排队。
一个重要的认知
CGroup 的限制是硬性的、内核级的。它不是 Docker 在用户态做的检查,而是内核在每个调度周期、每次内存分配时强制执行的。你绕不过去,除非你有内核级权限(那就是另一个安全话题了)。
认知跃迁:理解之后,那些"玄学"全通了
当你真正理解了"一堵墙 + 一块水表",下面这些曾经让你困惑的事情,会瞬间变得清晰:
💡 “host 网络模式为什么不用映射端口?”
因为 --network host 的意思是:不砌那面网络隔离的墙。
容器直接共享宿主机的 Network Namespace。它没有自己的"内线电话",它用的就是宿主机那部电话。
你映射 -p 8080:80 的意思是"把前台的 8080 转接到你房间的 80"。但你压根没有自己的房间电话,你就坐在前台。所以 -p 没有意义,Docker 会直接忽略它。
你不需要"转接",因为你就坐在总机旁边。
💡 “为什么容器里 PID 1 杀不死?”
因为 Linux 内核对 PID 1 有特殊保护:init 进程不会收到未显式注册的信号。
在你的"房间"里,你是 1 号。内核认为 1 号是"房间的管家",不能随便被干掉。你发 kill -9 1,内核说:“你确定?你确认过你的信号处理器了吗?没有?那我不理你。”
这不是 Docker 的设计,是 Linux 内核对 PID 1 的古老保护机制。容器只是恰好把某个进程放到了 PID 1 的位置上。
💡 “为什么容器比虚拟机启动快?”
因为容器没有打地基。
虚拟机要:分配虚拟硬件 → 加载 BIOS → 引导内核 → 启动 init → 启动服务。这是"盖一栋新楼"。
容器要:clone() 一个新进程 + 设几个 Namespace + 写几个 CGroup 文件。这是"在已有大楼里隔一间房"。
一个 syscall 的事 vs 一套操作系统的事。 这就是几百毫秒 vs 几十秒的差距来源。
💡 “为什么容器逃逸是可能的?”
因为墙不是真的墙。墙只是挡住了视线。
所有容器共享同一个内核。如果一个进程利用内核漏洞获得了足够权限,它就能"翻墙"——看到其他房间的进程、读写其他房间的文件、甚至控制整栋楼的物业系统。
虚拟机的隔离是硬件级的(VT-x、EPT),容器的隔离是进程级的(Namespace、CGroup)。安全等级天然不同。
这就是为什么在强安全场景(多租户、不可信代码执行)下,人们会用 gVisor、Kata Containers 或 Firecracker 在容器外面再套一层轻量虚拟化——相当于在房间里再加一道真正的防盗门。
💡 “为什么删除容器里的文件,宿主机磁盘没释放?”
因为 OverlayFS 是分层的。
你的房间家具是从楼下仓库(镜像层)搬上来的。你把桌子扔了,不是真的把桌子劈了烧了,而是在你的房间地板上放了一张纸条:“这里原来有张桌子,现在没了”(whiteout 文件)。
桌子还在仓库里。你的楼层(可写层)只是多了一张纸条。只有重建容器(重新装修),纸条才会被清掉。
💡 “为什么 docker stop 要等 10 秒?”
因为 docker stop 先发 SIGTERM(礼貌地敲门:“请收拾东西准备离开”),等 10 秒,如果进程还没走,再发 SIGKILL(直接破门)。
但如果你的 PID 1 没有注册 SIGTERM 处理器(很多应用用 sh -c "my-app" 启动,sh 是 PID 1,它不转发信号),那 10 秒内没人应门,最后只能被破门。
解决方案:用
exec形式启动,或者用tini当 PID 1。本质上是确保有人应门。
回到那个最初的问题
“容器到底’隔离’了什么?”
现在你可以精确地回答了:
容器隔离了视图(Namespace),限制了用量(CGroup)。它没有隔离内核,没有隔离硬件。
- 它让你看到一个独立的进程空间、独立的主机名、独立的网络、独立的文件系统。
- 它让你最多使用指定量的 CPU、内存、磁盘 IO。
- 但它没有给你一个新的内核。你和所有其他容器、和宿主机,跑在同一个内核上。
Docker、containerd、runc、Kubernetes……这些工具做的事情,归根结底就是:
帮你调
clone()、帮你写/sys/fs/cgroup/下的文件、帮你挂载 OverlayFS、帮你配 iptables。
没有一行代码是"发明"。全是 Linux 内核从 2002 年(Namespace)和 2006 年(CGroup)就提供的能力。Docker 只是把这些零件组装成了一个好用的产品。
最后
我见过太多人学 Docker 的方式是:背命令 → 背 YAML → 背参数 → 遇到问题搜参数。
这条路能走,但走不远。因为你记不住所有参数,也搜不到所有问题。
但如果你理解了"一堵墙和一块水表",你就拥有了一个推导能力:
- 遇到网络问题 → 想"这面墙砌了吗?砌的是哪种墙?"
- 遇到资源问题 → 想"这块水表限额是多少?是不是算上了缓存?"
- 遇到安全问题 → 想"这面墙够厚吗?需不需要再加一道防盗门?"
- 遇到性能问题 → 想"是不是翻墙(Namespace 切换)的开销太大了?要不要拆掉这面墙?"
命令会过时,参数会变化,工具会更迭。但内核原语二十年没变过,未来二十年也不会变。
那才是你真正的护城河。