Docker Namespace 原理:容器为什么能"以为自己是台独立的机器"

一、开篇引言

学启动控制那阵子,我在 [[7.30 控制启动过程]] 结尾写过一句话:“chroot 现在我也只是知道了它能切换根目录,但具体在 Docker 或容器场景里怎么用,我还不太清楚,后面学容器的时候再回来看这部分。” 没想到这么快就真到容器了。

这周云原生课讲 Docker,老师上来先抛了个问题:“你们觉得容器和虚拟机到底有什么区别?” 我第一反应是"虚拟机重、容器轻、容器启动快",但这只是表象。真正把我问住的是后面这两句:

  • 容器里明明跑着进程,为什么容器内 ps 看不到宿主机的进程?
  • 为什么容器里改主机名,宿主机一点反应都没有?

我当时脑子里只有一个模糊的答案:“因为容器是隔离的。” 但"隔离"到底是怎么实现的?是像虚拟机那样,每个容器里装一个完整系统吗?显然不是——所有容器共享宿主机同一个内核

老师没直接回答,而是扔给我一个词:Namespace

顺带记一下环境:Docker 装在我那台 CentOS 7 虚拟机上,照例先打了快照。后面因为 unshare 需要权限,还加过 sudo,坑不少,下面慢慢说。


二、Namespace 核心概述:不是"独立电脑",是"独立视野"

什么是 Linux Namespace

先想想 docker run -it centos:7 bash 之后到底发生了什么。你进到了一个 shell,里面有自己的一套 /、自己的进程列表、自己的网卡,看起来像一台独立的机器。

但事实是:容器里的进程就是跑在宿主机上的普通进程,它只是被"加了层滤镜",看不到自己外面的世界。

这层"滤镜"就是 Linux 内核的 Namespace(命名空间) 机制。它是内核提供的一种资源视图隔离手段:给不同进程组提供不同的"系统资源视图"。内核里的进程表、网络栈、挂载点、主机名这些资源,Namespace 会分别给它们"复制一份假象",让每个 namespace 里的进程都以为资源是自己独享的。

我自己的理解:Namespace 不是把进程真的关起来,而是改写了进程看世界的视角。就像戴着 VR 眼镜,你以为自己在火星,其实人还在客厅里。

Namespace 核心能力:资源视图隔离

"视图隔离"这个词我一开始没懂,后来想通了:同一个东西,不同的人看,看到的不一样。

比如宿主机上有个 PID 是 2000 的进程,在某个 PID namespace 里,它被"重新编号"成了 PID 1。同一个进程,换个视角,编号就不同了。

Namespace 和虚拟机隔离的区别

这是老师强调的重点,直接上对比表:

维度 虚拟机 容器
隔离层次 硬件级:虚拟出 CPU、内存、磁盘 内核级:共享宿主机内核
是否独占内核 每个 VM 有自己的 Guest OS 和内核 所有容器共享宿主机一个内核
隔离强度 强,连内核都隔离 弱,只是"视图"隔离
启动速度 分钟级,要引导整个系统 秒级,本质就是起一个进程
资源开销 大,每个 VM 都要占一份系统资源 小,多个容器共用内核

一句话记忆:虚拟机是"每人发一台电脑",容器是"好多人共用一台电脑,但每人开了个独立桌面"。所以容器才这么轻、这么快,但也注定不如虚拟机"硬隔离"。


三、Docker 六大核心 Namespace 详解

Docker 创建容器时,默认会给容器建立 6 种核心 namespace。其实内核里还有 cgroup、time 等 namespace,但课堂上重点讲这 6 个。

类型 隔离内容 直观表现
PID 进程隔离,独立的 PID 编号空间 容器内 PID=1,宿主机上是另一个号
NET 网络隔离,独立网络栈:网卡、IP、路由、端口 容器有自己的 eth0、ip addr
MNT 挂载隔离,独立挂载树 容器内 / 是镜像 rootfs,不是宿主机的 /
UTS 主机名隔离,独立主机名和域名 容器里改 hostname,宿主机无感
USER 用户隔离,独立 UID/GID 映射 容器内 root 可能是宿主机的普通用户
IPC 进程间通信隔离 隔离消息队列、共享内存、信号量

下面一个一个说,尽量配一个能直接上手的验证命令。

1. PID Namespace:进程隔离

最直观的一种。容器里 ps 只能看到自己 namespace 里的进程,PID 从 1 开始(1 一般是容器主进程)。宿主机上同一批进程是另外一套 PID。

# 容器内
ps -ef | head -3
#   PID   PPID  CMD
#     1     0   bash          ← 容器主进程,在容器内是 PID=1

# 宿主机上找到这个 bash 的真实 PID
docker inspect --format '{{.State.Pid}}' <容器名>
# 输出:2057   ← 同一个进程,在宿主机上真实 PID 是 2057

同一个进程,容器内看是 1,宿主机看是 2057,这就是 PID namespace 干的事。

2. NET Namespace:网络栈隔离

每个容器有一套独立的网络设备、IP 地址、路由表和防火墙规则。容器里 ip addr 看到的是自己的网卡(默认 bridge 模式是 eth0),宿主机的物理网卡它看不到。所以宿主机和容器可以同时监听 80 端口,互不冲突。

docker run -d nginx
docker exec -it <容器ID> ip addr
# 1: lo: <LOOPBACK,UP>...
# 2: eth0@if7: ... inet 172.17.0.2/16    ← 容器自己的 IP

3. MNT Namespace:挂载隔离

每个容器有自己的挂载树,容器里的 / 是镜像分层 + 可写层联合挂载出来的(就是 [[8.4 张炜炜 容器知识点问答]] 里讲的 Overlay2),不是宿主机的 /。所以你在容器里改挂载、甚至乱删,影响不到宿主机文件系统——至少挂载点这个维度是隔离的。

4. UTS Namespace:主机名隔离

容器可以有自己的 hostname,改了也不影响宿主机。这个验证最简单,跑一个容器改个名字就行:

docker run -it --hostname my-container centos:7 bash
hostname        # 容器内输出:my-container
# 退出容器后,宿主机 hostname 一点没变

5. USER Namespace:用户隔离

容器里你看着是 root(UID=0),但这个 root 只是"看起来像"。通过 USER namespace 的 UID/GID 映射,容器内的 UID 0 可能对应宿主机上一个普通用户(比如 UID 1000)。

好处很直接:就算容器被攻破,容器内的 root 在宿主机上也只是个普通用户,动不了宿主机的系统文件。这也是容器安全体系里很关键的一环。

6. IPC Namespace:进程间通信隔离

隔离 System V 的消息队列、共享内存、信号量。容器内进程的 IPC 资源互相可见,但看不到宿主机的 IPC 资源,宿主机也看不到容器里的。


四、Namespace 底层实现原理

三个核心系统调用:clone / unshare / setns

Namespace 的创建和切换全靠这三个系统调用:

系统调用 作用 典型场景
clone() 创建新进程时带上 namespace 标志位,可顺带创建新 namespace docker run 时 runc 创建容器进程
unshare() 让当前进程脱离原 namespace,创建新的 命令行工具 unshare
setns() 让当前进程加入一个已存在的 namespace docker execnsenter

关键点:namespace 是被"继承"的。 子进程默认和父进程共享同一个 namespace。要开新 namespace,必须在 clone 时带上标志位,比如 CLONE_NEWPIDCLONE_NEWNETCLONE_NEWNS(挂载)等。

对应到 Docker:docker run 时,底层 runc 用 clone 创建了一个带着一堆 CLONE_NEW* 标志的进程,这个进程就成了容器里的第一个进程(PID=1),之后它 fork 出来的子进程全部待在这个新 namespace 里。

进程和 Namespace 怎么绑定

每个 namespace 在 /proc/<PID>/ns/ 下都有一个软链接:

ls -l /proc/$$/ns/
# lrwxrwxrwx ... mnt -> 'mnt:[4026531840]'
# lrwxrwxrwx ... net -> 'net:[4026532009]'
# ...

括号里的数字是 namespace 的 inode 号两个进程某个 namespace 的 inode 号相同,就说明它们在这个维度共享同一个 namespace。 判断两个进程是不是"一家人",就看 ns inode 对不对得上。

父子 Namespace 的可见性

以 PID namespace 为例,有一条"单向可见"的规则:

  • 父 namespace 能看到子 namespace 里的所有进程(宿主机能看到容器里的进程)
  • 子 namespace 看不到父 namespace 里的进程(容器里看不到宿主机的其他进程)

原因是 PID 是"逐层重新编号"的:宿主机给容器进程编了 2057,容器内又给同一批进程编了一套从 1 开始的号,两套编号通过映射对应起来。

我一开始想找个生活化的比喻,什么"领导能看到你工位,你工位看不到领导办公室",越想越别扭。算了,直接记结论:父见子,子不见父。


五、Namespace 实操验证

方法一:看容器开了哪些 namespace

# 1. 起一个容器,拿到它在宿主机的 PID
docker run -d --name demo nginx
container_pid=$(docker inspect --format '{{.State.Pid}}' demo)

# 2. 看这个进程的所有 namespace
ls -l /proc/$container_pid/ns/

会看到一堆 ns:[402653xxxx],这些就是 Docker 给容器创建的独立 namespace。

方法二:用 unshare 手工"造"一个隔离环境

不用 Docker,光靠内核的 unshare 命令就能体验 namespace:

# 创建一个新的 PID + UTS namespace,并重新挂一个空的 /proc
sudo unshare --pid --uts --fork --mount-proc bash
# 进去之后:
ps -ef        # 只能看到自己这少数几个进程
hostname      # 还是宿主机的,因为只创建了 namespace 没改名字

踩坑记录:我第一次敲的是 unshare --pid bash,然后 ps -ef,结果进程列表跟宿主机一模一样,以为 unshare 没生效。查了半天才明白:PID namespace 其实生效了,但 /proc 还是宿主机那份ps 读的 /proc 没跟着切。加上 --mount-proc(在新 mount namespace 里重新挂载空的 /proc)就正常了。

后来老师一句话点醒我:namespace 管的是"视图",而 /proc 这个文件系统本身就是一种视图,它也得跟着切。另外 --pid 一般要配 --fork,否则 namespace 的行为会很怪;普通用户直接跑还容易报 Operation not permitted,得加 sudo。

方法三:对比容器内外的主机名和进程

docker run -it --hostname container-test centos:7 bash
# 容器内:
hostname          # container-test
ps -ef | wc -l    # 很少,只有容器自己的进程

# 宿主机另开一个终端:
hostname          # 还是原来的名字,没被容器影响

六、Namespace 的局限性:它只"隔离视野",不"限制用量"

学到这里,我一度以为容器隔离就是 Namespace 一己之力。但老师马上泼了盆冷水:Namespace 只解决"看不看得见",不解决"用不用得起"。

局限一:内核不隔离

所有容器共享宿主机内核,Namespace 只是"改视角",不是真的砌了一堵墙。如果内核有漏洞,攻击者有可能从容器里"逃逸"到宿主机——这就是常说的容器逃逸。虚拟机里每个 Guest OS 有独立内核,就没这个问题(除非 Hypervisor 本身出问题)。

局限二:部分资源没法隔离

Namespace 能隔离进程、网络、挂载、主机名、用户、IPC,但它管不了 CPU、内存这些硬件资源的使用量。一个容器可以疯狂 fork 把宿主机内存吃光,把整个系统拖垮,其他容器跟着遭殃。

局限三:光靠 Namespace 远远不够

所以 Docker 的隔离是"组合拳":

机制 管什么
Namespace 隔离"视图":进程、网络、挂载、主机名、用户、IPC
Cgroups 限制"用量":CPU、内存、磁盘 IO 上限
Capabilities 收窄容器内 root 的特权(容器内 root ≠ 有全部内核能力)
Seccomp 限制容器内能调用的系统调用
SELinux / AppArmor 强制访问控制,给容器打安全标签

记法:Namespace 管"看得见",Cgroups 管"用得着",Seccomp/Capabilities 管"碰不得"。


七、总结

把整个逻辑串一遍:

docker run 一个容器
  → runc 用 clone 带着一堆 CLONE_NEW* 标志创建容器进程
  → 进程落在全新的几个 namespace 里,从此"眼睛"被换掉
  → 看到的是:独立 PID 表、独立网络栈、独立挂载树、独立主机名……
  → 但它的 CPU / 内存用量没人管
  → 于是 Cgroups 上场:限制它最多能用多少
  → 内核还是共享的,所以 Capabilities + Seccomp + SELinux 继续加固

所以容器隔离的整体逻辑是:Namespace 造出"独立世界"的幻觉,Cgroups 控制"饭量",安全机制防止"越狱",三者配合才是一个能上生产的容器。

学这块之前我一直以为"容器 = 轻量虚拟机",现在才明白这俩隔离的本质完全不同:虚拟机隔离在硬件层,容器隔离在内核的软件层。这也是容器能秒级启动、资源开销小的原因,同时也是它隔离强度不如虚拟机的根源。

速记清单

容器共享宿主机内核,不是硬件隔离
Namespace 管"看得见",Cgroups 管"用得着"
6 大 Namespace:PID / NET / MNT / UTS / USER / IPC
进程和 namespace 靠 /proc/<PID>/ns/ 的 inode 号对上关系
clone 开新 namespace,setns 进已有 namespace,unshare 给自己换
父 namespace 能看到子,子看不到父

我踩过的坑合集

我的翻车经历 正确做法
unshare 没权限 普通用户跑 unshare --pidOperation not permitted 前面加 sudo
--pid 忘配 --fork PID namespace 行为很怪 unshare --pid --fork --mount-proc bash
unshare --pid 后 ps 没变 以为没生效,其实是 /proc 没隔离 --mount-proc 重新挂空的 /proc
容器内外 PID 对不上 docker inspect 里找容器 PID 一时找不到 {{.State.Pid}} 拿宿主机的真实 PID
Namespace 和 Cgroups 搞混 以为 Namespace 能限制内存 Namespace 只管视图,资源限制归 Cgroups
chroot 和 MNT namespace 混着记 以为容器隔离就是 chroot 容器是 MNT namespace + pivot_root,chroot 只是切 / 视角,范围小得多

最后说两句

写这篇的时候,我在虚拟机上反复 unsharedocker runls /proc/*/ns/,中间还因为 Operation not permitted 卡了一会儿,最后是靠 sudo + --fork --mount-proc 一套组合拳跑通的。

还没完全搞懂的地方:USER namespace 的 UID 映射细节我只能看懂流程,真要让我自己配一组映射还是配不来;setnsnsenter 怎么进别人的 namespace 也还停留在概念阶段。这两块下学期打算补上,顺便把 Cgroups 单独写一篇。

对了,回看 [[7.30 控制启动过程]] 里那句"chroot 在容器里怎么用还不清楚",现在算是有答案了——容器用的是 Mount namespace + pivot_root 这一整套,比 chroot 彻底得多:chroot 只是切了 / 的路径视角,MNT namespace 是把整个挂载树都换掉。果然学过的东西是会串起来的。


2026年8月 · 写于云原生课 Namespace 整理 · 虚拟机里 unshare 出来的隔离环境被我拆了又建、建了又拆

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐