很多用了一两年 Docker 的同学对docker run已经熟得不能再熟但你突然问他Docker 到底凭什么把一个普通进程包装得像一台独立的机器他多半会愣一下然后说一句不就是内核特性嘛——对可究竟是哪几个内核特性、各自管哪一块、怎么配合的就含糊了。我最初也这样。直到有一次排查线上问题容器里的 Nginx 一直报端口被占用可我把容器列表翻了个底朝天也只找到一个实例。后来才意识到宿主机上确实有另一个进程绑了同一个端口而那个进程在容器的网络命名空间里不存在所以我从容器内部看永远看不到它。那一瞬间我才真正理解容器的本质从来不是虚拟机而是换了视角的普通进程。这篇东西就把支持 Docker 的三大 Linux 底层机制一次讲透Namespace 做隔离、Cgroups 控资源、UnionFS 搭存储。不管你是用 Docker Desktop 在本地玩还是在一台云主机上docker run起 MySQL、Redis 集群底层扛事儿的都是这三样。1. 容器的真面目一个换了视角的普通进程1.1 容器不是轻量虚拟机很多人一开始就想错了先纠正一个根深蒂固的误解。虚拟机是什么是用 Hypervisor 虚拟出完整的硬件然后在上面装一个完整的 guest OS 内核你的程序跑在别人家的内核之上。容器是什么是共用宿主机内核你的程序直接面对宿主机内核的系统调用接口根本不存在第二个内核。有点抽象换个说法。虚拟机相当于给你租了一套独立公寓水、电、燃气、门禁全是你自己的。容器相当于住宿舍——同一栋楼同一个楼管宿主机内核只是每人发了一个房间隔断Namespace每层楼装了独立水表电表Cgroups储物架分了层UnionFS。楼管还是那一个但每个住客的体验是这地方好像只有我一个人住。这也是为什么容器内的进程在宿主机的ps里能看到反过来也是同一个道理。很多刚接触容器的人会困惑我宿主机明明只跑了 3 个容器怎么进程数这么多因为容器里的进程对宿主机来说就是普通进程没有任何魔法。你kill掉一个容器进程它的行为和你直接杀宿主机进程一模一样。1.2 三大机制的职责划分与协作关系理解了容器 受限进程这个大前提三大机制的分工就顺理成章了机制解决的问题生活化类比Docker 里的对应Namespace进程能看到什么房间隔断容器间互不可见Cgroups进程能用多少水表电表--cpus、--memoryUnionFS进程的文件从哪来分层储物架镜像与容器层三者缺一不可。没有 Namespace容器里能摸到宿主机的网卡、进程表、主机名没有 Cgroups一个容器里跑死循环可以把整台机器拖死没有 UnionFSDocker 镜像就不可能做到一次构建、到处运行因为你得每个人拷一份完整文件系统副本。还有一层常见误区既然容器共享宿主机内核那为什么容器里可以跑 CentOS、Ubuntu、Alpine 这些不同的发行版因为发行版的差异主要集中在内核之外的用户态——glibc/musl、coreutils、包管理器这些。内核系统调用 ABI 基本一致用户态工具链随便换只要别用到宿主内核没有的内核特性就行。这也是容器能轻的根本原因。2. Namespace内核如何给进程制造单独的世界2.1 7类Namespace分别隔离了什么Namespace 的本质是给一组进程提供独立视图的内核机制。每一个进程都活在某个 namespace 里不同 namespace 里的进程看到的世界可能完全不同。Docker 能实现容器隔离靠的正是它。Linux 目前一共有 8 类 namespace其中容器领域常用的是这 7 类另一个 time namespace 用得少Namespace隔离内容不隔离的后果Docker 默认使用PID进程号、进程列表容器能看到宿主机全部进程使用Network网卡、IP、路由、iptables容器直接占用宿主机端口使用Mount挂载点、文件系统视图容器能看到宿主机所有目录使用UTS主机名、NIS 域名容器改 hostname 会影响宿主机使用IPCSystem V IPC、POSIX 消息队列容器间能互发消息使用UserUID、GID、Capabilities容器内 root 就是宿主机 root默认部分使用CgroupCgroup 根目录视图容器能看到宿主机 cgroup 全貌使用怎么理解隔离拿 PID namespace 举例。同一个进程在它自身的 PID namespace 里是 PID 1在宿主机 PID namespace 里是 PID 2334。这不是两个进程是同一个进程在不同视角下的两个编号。kill 1在容器里杀掉的是这个进程本身在宿主机上可千万别这么干——杀 1 号进程等于关机。2.2 clone、setns与docker execNamespace什么时候生效Namespace 不是凭空创建的而是跟随进程创建的。Linux 的clone()系统调用可以传一堆 flag比如CLONE_NEWPID、CLONE_NEWNET意思就是新建一个 PID namespace / Network namespace让这个子进程成为新 namespace 里的第一个进程。往后这个子进程再 fork 出来的后辈默认都留在这个 namespace 里。这也是为什么docker run启动的容器里第一个进程必须是 PID 1——它不是被特意安排为 1 的而是它本来就是那个新 namespace 的开国元勋。而docker exec走进一个正在运行的容器靠的是另一个系统调用setns()。意思是我这个进程不新建 namespace我申请加入到某个已经存在的 namespace 里去。所以 exec 进去的进程和容器主进程共享同一个世界它们互相看得见、能联网络就像一个屋里住了两个人。实操验证一下不需要 DockerLinux 原生就能玩# 新建 PID Mount UTS namespace并隔离 /proc sudo unshare --pid --fork --mount --uts --mount-proc bash此时在这个 bash 里执行hostname container-demo ps aux ping 127.0.0.1你会发现hostname随便改不影响宿主机ps aux只能看到当前 namespace 里的进程ping能通因为 Network namespace 默认是跟宿主机共享的——unshare 没指定--net所以网络没有隔离。这个实验最有价值的地方在于你能直观感受到 namespace 是挑着隔离的不是一把梭全隔了。2.3 理解Docker的network/pid/host模式如果看到这里还觉得 namespace 有点虚那就用常用的 Docker 网络模式反推--network host的本质就是让容器直接使用宿主机的 Network namespace不新建。所以容器里看到的 IP 就是宿主机 IPss -lntp能看到宿主机所有监听端口。--network none是给容器一个全新的、空的 Network namespace里面啥也没有只有 loopback。--network bridge默认模式是新建一个 Network namespace然后用 veth pair 把容器内的 eth0 和宿主机的 docker0 网桥连起来。再比如docker run --pid host就是让容器共享宿主机 PID namespace。这时候容器里ps -ef跟宿主机一毛一样连你本地装的 IDE 进程都能看见。所以以后遇到容器里看不到进程容器里端口绑定冲突容器里 hostname 改了宿主机变了这类诡异问题先别急着怀疑别的往 namespace 上想八成都是共享 namespace 导致的。顺便提醒一个坑在容器里执行ps -aux如果返回的是宿主机进程列表通常不是 Docker 的 bug而是容器内挂载的/proc不是隔离过的 procfs或者容器用了 host PID namespace。正常的容器里/proc应该是从隔离 procfs 挂载进去的ps 自然只能看到自己人。3. Cgroups内核里的资源账本怎么给容器限额3.1 没有Cgroups的世界有多危险Namespace 只解决看得到什么解决不了能用多少。想象一下一台 16 核 64G 的服务器同时跑着 10 个容器如果没有任何限制某个容器里的进程写了个死循环for (;;) fork();或者疯狂申请内存会发生什么几十秒内整台机器内存耗尽其他 9 个容器陪葬宿主机 SSH 都连不上。CgroupsControl Groups控制组就是来解决这个问题的。它本质上是一个挂在内核里的资源账本你可以建一个组把一批进程归进去然后给这个组设置 CPU、内存、IO、PID 数量等上限。内核在调度、记账、回收的时候都会先查这笔账。类比一下宿舍楼里不光有隔断每层还装了独立水电表。不管你屋里怎么折腾电费封顶就是那么多超了直接跳闸绝不波及其他楼层。3.2 Cgroups v1/v2从各自为政到一棵大树搞清楚 Cgroups必须先知道它有 v1 和 v2 两代现在主流发行版正在全面切到 v2。v1老方案每个资源类型一个独立层级比如 CPU 树归 cpu 子系统管内存树归 memory 子系统管。一个进程可以同时挂在不同子系统的不同层级里结构非常绕时间久了经常出现找不到自己的进程在哪个组的混乱。v2新方案所有资源统一管理成一棵层级树进程只属于一个 cgroup配置直观得多。怎么看你系统用的是哪个很简单cat /sys/fs/cgroup/cgroup.controllers # 如果有这个文件说明是 v2 ls /sys/fs/cgroup/ # 看到 cpu,cpuacct,memory 等一大排目录是 v1Docker 从 20.10 开始对 cgroup v2 支持得很好了。如果你还在老内核上跑 Docker建议趁早规划升级早晚要迁。3.3 用echo向cgroupfs写入配置CPU和内存限额实操Cgroups 的设计很直白什么都用文件表达。/sys/fs/cgroup是一个虚拟文件系统你mkdir就是在创建 cgroup你用echo往文件里写内容就是在改配额。下面用手动方式模拟一次容器资源限制以 cgroup v2 为例# 1. 建一个控制组 sudo mkdir /sys/fs/cgroup/container-demo # 2. 限制 CPU最多只能用 0.5 核 # cpu.max 格式quota periodperiod 默认 100000 微秒100ms # 50000 / 100000 0.5 核 echo 50000 100000 | sudo tee /sys/fs/cgroup/container-demo/cpu.max # 3. 限制内存最多 256MB echo 268435456 | sudo tee /sys/fs/cgroup/container-demo/memory.max # 4. 把当前 shell带着它的全部子进程放进去 echo $$ | sudo tee /sys/fs/cgroup/container-demo/cgroup.procs放进去之后用yes /dev/null跑一个单核死循环再开另一个终端top看 CPUyes /dev/null 你会发现 CPU 使用率被死死压在 50% 左右再怎么跑也上不去。如果内存超限触发 OOM内核会直接干掉组里最肥的进程保证宿主机安全。这就是--cpus0.5、--memory256m在 Docker 里的底层真相。换句话说Docker 只是帮你把命令翻译成对 cgroupfs 的写入而已。3.4 Docker的--cpus、--memory都变成了哪些内核参数很多参数你天天用却不一定知道自己到底改了内核的什么。这里对应关系最常用的一张表Docker 启动参数cgroup v1 对应项cgroup v2 对应项说明--cpus1.5cpu.cfs_quota_us150000cpu.cfs_period_us100000cpu.max150000 1000001.5 核--cpu-shares512cpu.shares512cpu.weight换算关系不同相对权重不是硬上限--memory1gmemory.limit_in_bytes1073741824memory.max1073741824内存硬上限--memory-swap2gmemory.memsw.limit_in_bytes2147483648memory.swap.max内存Swap 总量--pids-limit100pids.max100pids.max100进程数量上限想亲眼看看 Docker 写了什么挑一个运行中的容器docker inspect container-id --format {{.HostConfig.NanoCpus}}NanoCpus 的单位是纳核1.5 核就是 1500000000 纳核除以 1e9 就是核数。Docker 会把你给的高层参数翻译成内核配置文件落到对应容器的 cgroup 目录里。这里有一个实操中很常见的判断陷阱在容器内部cat /sys/fs/cgroup/memory.max看到的可能是很大一个值甚至max这不代表限制没生效。因为在 cgroup v2 下容器看到的 mount 点可能是经过 Docker 改写的单子树视图只有当前容器这棵树根配额往往继承自理宿主机的父组。要看真实配额重点看memory.current和实际 OOM 行为别被视图骗了。提示cgroup v2 里还有一个memory.oom.group文件。设成 1 之后当一个进程触发 OOM内核会干掉整个 cgroup 的所有进程而不是只杀某一个。这对无状态服务集群很重要可以避免杀了一个进程其他兄弟进程还在跑状态已经不一致的尴尬。4. UnionFS镜像分层是如何搭出来的4.1 联合挂载思想多个目录叠加成一个视图Namespace 和 Cgroups 解决的是隔离与限额那镜像呢Docker 镜像能像积木一样一层层堆叠、互相共享靠的是联合文件系统UnionFS。联合文件系统的核心思想特别朴素把多个目录叠加在一起对外呈现成一个统一的目录。你可以把它想成 Photoshop 里的图层——最下面是底图往上叠一张张透明图层最终看到的画面是所有层的合成结果。Linux 上最著名的联合文件系统实现是 overlayfs现代 Docker 默认的存储驱动 overlay2 就是基于它的工程化实现。4.2 Docker镜像层与容器可写层的分工Docker 镜像就是一层层只读文件系统的叠加。每一层对应 Dockerfile 里的一条指令FROM ubuntu:22.04 # 基础层完整 ubuntu 文件系统 RUN apt-get update # 第 2 层在上一层之上生成的 /var/lib/apt 差异 COPY app /app # 第 3 层新增 /app 目录 CMD [/app/server] # 第 4 层元数据不产生文件内容每一层都是一个独立的 diff 集合只记录和上一层相比我新增/修改了哪些文件。真正运行容器时overlayfs 把这多层只读层串成 lowerdir再在上面加一层可写的 upperdir最后挂成一个 merged 视图。容器内看到的/就是这个 merged 目录。好处是显而易见同一台机器上10 个容器跑同一个 ubuntu 镜像它们共享底层所有只读层每个人只需要消费一份磁盘空间不需要复制 10 份。这也是为什么docker pull一个镜像往往比想象中快——公共层早就有了。4.3 写时复制与whiteout修改和删除文件时发生了什么镜像层是只读的那容器里改了文件怎么办这就是 overlayfs 的绝活——写时复制copy-on-write。当你容器里的程序要修改一个位于只读层的文件比如/etc/nginx/nginx.confoverlayfs 不会去动原来那个文件而是把这个文件从只读 lowerdir 复制一份到 upperdir在 upperdir 里修改这份副本从 merged 视角看文件内容已经变成了修改后的版本。原镜像层纹丝不动所以容器删了再重建改动全部消失但如果再起一个新容器镜像是干净的初始状态一点不受上一个容器影响。那删除文件呢更微妙。overlay 不能真的从只读层删除文件于是它用了一个叫whiteout的机制在 upperdir 里创建一个同名的特殊字符设备0/0 设备对 merged 视图来说这个文件就被遮掉了看起来就像删掉了一样。你可以亲手做一次实验彻底看清这个过程mkdir -p /tmp/lower /tmp/upper /tmp/work /tmp/merged echo hello /tmp/lower/hello.txt echo world /tmp/lower/world.txt sudo mount -t overlay overlay \ -o lowerdir/tmp/lower,upperdir/tmp/upper,workdir/tmp/work \ /tmp/merged cat /tmp/merged/hello.txt # 输出 hello rm /tmp/merged/hello.txt # 在 merged 视图里删除 ls -l /tmp/upper/ # 你会看到 hello.txt 变成一个字符设备 cat /tmp/merged/hello.txt # 已不存在这个实验做完你会瞬间理解为什么 Docker 镜像可以被大量容器共享也理解了为什么那些删不掉的文件改了不生效的配置背后大概率是镜像只读层在作祟。4.4 从AUFS到overlay2存储驱动的演进逻辑早期的 Docker 用的是 AUFS它是最早实现镜像分层思想的文件系统之一但 AUFS 一直没有被合入 Linux 主内核发行版适配成本高。后来 overlayfs 进了内核主线Docker 随之推出 overlay 驱动再演进到 overlay2。overlay2 相比 AUFS 的优势很明显对比项AUFSoverlay2内核主线未合入合入打开文件过多时 inode 消耗较高更低页缓存共享一般更好社区活跃度基本停滞活跃还有一个不可忽视的点RHEL 8 / Ubuntu 22.04 这些主流发行版的内核都原生支持 overlay2不用再装额外模块。所以现在新装 Docker默认就是 overlay2。想看看你的镜像到底分了几层可以用docker image inspect ubuntu:22.04 --format {{json .RootFS.Layers}}每一条 sha256 对应的就是一层。配合docker history你能看到每一层是由哪条 Dockerfile 指令产生的。如果你做了镜像瘦身优化你会发现把多个 RUN 合并成一条的本质其实就是减少层数、缩小层间 diff。5. 组合拳实操把 docker run 拆成四条命令5.1 准备一个最小rootfs前面三章分别讲了三大机制这一章把串起来——手动组装一次伪容器启动过程。目标不是复刻 Docker而是让你亲眼看见一个看似复杂的东西背后的步骤原来就这么几条。先准备一个最小 rootfs我直接用 busybox 镜像导出# 从 busybox 镜像创建容器并导出文件系统 mkdir -p /tmp/container-rootfs /tmp/write-layer /tmp/work-layer /tmp/final-root docker export $(docker create busybox) -o /tmp/rootfs.tar tar -xf /tmp/rootfs.tar -C /tmp/container-rootfs5.2 将rootfs作为只读底层叠加可写层Docker 镜像那一串只读层在我们这次手动实验里先简化成一层/tmp/container-rootfs。容器可写层是/tmp/write-layer。用 overlay 挂出容器的根sudo mount -t overlay overlay \ -o lowerdir/tmp/container-rootfs,upperdir/tmp/write-layer,workdir/tmp/work-layer \ /tmp/final-root这一步做完后/tmp/final-root里看到的就是一个完整的文件系统视图而且往里面写东西只会落到/tmp/write-layerrootfs 不动。5.3 用unshare创建隔离视角并进入容器根接下来新建隔离的 PID / Mount / UTS namespace然后chroot到 merged 根目录同时挂载隔离的/procsudo unshare --pid --fork --mount --uts --mount-proc \ chroot /tmp/final-root /bin/sh -c hostname manual-demo exec /bin/sh此刻你的 shell 已经相当接近容器内的体感主机名是manual-demops aux看不到宿主机进程文件系统是分层联合挂载出来的。5.4 用Cgroups给这个容器套上资源上限开一个新终端找到上面那个 shell 的宿主机 PID然后把它写进 cgroup 限制组# 找进程ps -ef | grep /bin/sh -c hostname manual-demo # 假设 PID 是 12345 sudo mkdir /sys/fs/cgroup/manual-demo echo 50000 100000 | sudo tee /sys/fs/cgroup/manual-demo/cpu.max echo 12345 | sudo tee /sys/fs/cgroup/manual-demo/cgroup.procs回到容器内跑yes /dev/null 再用top看CPU 被稳稳压在 50%。到这一步Namespace隔离视角、Cgroups资源限制、UnionFS分层文件系统三座大山全部到齐——你已经手动组装了一个微型容器。5.5 这套手写容器缺了什么以及为什么需要runc可别高兴太早这套手写容器离生产级的 Docker 还差很远。至少缺了这些Network namespace 没隔离手动 unshare 时没加--net容器网络和宿主机是打通的没有 seccomp / AppArmor 等安全防护没有 capabilities 收窄容器里 root 的权限接近宿主机 rootrootfs 里的/dev/pts、/sys等设备文件没有完整处理没有镜像下载、层管理、网络创建等外围能力。真正把docker run落到内核的就是runc它做的事和我上面这几条命令逻辑高度一致只不过每一步都做得更严谨、更安全、更完整。你如果直接读 runC 源码会发现里面就是多次clonesetns cgroup 写入 overlay 挂载的组合调用。这也是我建议每个搞容器的人做一次这个实验的原因你不亲手走一遍就很难理解 Docker 文档里那些参数一个个翻译成内核操作时到底是什么样。以后排查问题你脑子里会有完整的链路图而不是只会无脑重启容器。提示在宿主机上做 unshare 和 cgroup 实验务必用一台临时虚拟机或云主机操作需要 root 权限而且如果 cgroup 参数写得过于苛刻比如把内存上限调得很低又跑大程序有可能触发系统级 OOM。实验完记得rmdir /sys/fs/cgroup/manual-demo清理控制组umount /tmp/final-root卸载挂载点。6. 学完这次底层拆解排障思路会发生什么变化这几套机制学完最大的变化不是你多背了几个内核名词而是遇到问题时的推理方式完全不同了。以前遇到容器里端口起来但连不上我第一反应是上网搜命令现在会先问这个容器是不是 host 网络NFtables/iptables 规则在哪一层容器内进程绑定的是 0.0.0.0 还是 127.0.0.1遇到容器里内存一直涨但不爆会先去翻memory.current和 cgroup 层级看是不是父组配额太低遇到容器里改文件不生效会先确认那个文件是否来自镜像只读层whiteout 是不是打了标记。我的个人建议是学完这三块之后再去看两样东西一是runc的源码不长重点看libcontainer包这三大机制是怎么编排的二是 Kubernetes 里那个什么资源都不占的 pause 容器——它存在的意义就是当一个永远活着的 namespace 锚点业务容器挂掉重建后网络、PID 等命名空间还在。能看懂 pause 容器说明你对 Namespace 的理解已经到位了。最后顺手把一套安全习惯也刻进骨子里凡是能不加--privileged就不加凡是能收窄 capabilities 就收窄凡是能加 seccomp 就加。Namespace 隔离的是视野不是安全边界它只是看不见不代表进不来。把底层的东西吃得越透你在权限配置上就会越怂而这份怂恰恰是线上系统最需要的东西。