【Docker入门系列】:从 Namespace 到 unshare:一文理解容器隔离机制与 PID、Mount 实战



💪 今日博客励志语录:
很多时候,真正困难的不是记住一个概念,而是找到它为什么会出现,以及它和此前知识之间的逻辑关系。
思维导图
Docker 容器隔离
│
├── Docker 与虚拟机的根本区别
│ │
│ ├── 虚拟机:虚拟硬件 + Guest Kernel
│ └── 容器:共享 Host Kernel
│
├── 容器为什么看起来像独立系统?
│ │
│ └── Linux Namespace
│ │
│ ├── PID Namespace
│ ├── Network Namespace
│ ├── Mount Namespace
│ ├── UTS Namespace
│ └── IPC Namespace
│
├── Docker 在其中扮演什么角色?
│ │
│ ├── 用户态负责组织与配置
│ └── Linux Kernel 真正落实隔离
│
├── Mount Namespace 实验前置
│ │
│ ├── dd
│ ├── mkfs
│ ├── mount
│ └── df
│
└── unshare
│
├── PID 隔离实验
└── Mount 隔离实验
引入
认识 Docker 时,很容易形成一个比较模糊的认识:
Docker
→ 给程序创建一个独立环境
→ 程序运行在这个环境里面
但是如果继续往下追问,就会遇到一个非常关键的问题:
Docker 自己不过也是运行在用户态的一套程序,它究竟凭什么能够让另一个进程看到独立的 PID、网络、文件系统以及主机名?
如果 Docker 自己维护一张:
容器 PID → 宿主机 PID
容器网卡 → 宿主机网卡
容器文件系统 → 宿主机文件系统
的映射表,然后容器进程每次进行系统调用时,都由 Docker 在中间重新翻译一次,那么 Docker 就变成了一个巨大的“模拟器”。
但实际上,容器并不是这样工作的。
真正实现隔离能力的是:
Linux Kernel
Docker 所做的事情,更像是:
确定容器需要怎样的运行环境
↓
调用 Linux 提供的 Namespace、cgroup、mount 等能力
↓
让 Linux Kernel 创建并维护对应的隔离环境
↓
在其中启动真正的业务进程
所以,理解 Docker 容器隔离的关键,不是先去背 Docker 命令,而是先理解:
Linux 内核究竟提供了哪些能力,使一个普通 Linux 进程能够看到一个相对独立的“世界”。
这就要从 Namespace 开始。
一、Docker 与虚拟机:隔离并不等于重新虚拟一台机器
1. 虚拟机:虚拟的是一台完整计算机
虚拟机的思路比较直接。
一台真实计算机可以抽象成:
应用程序
↓
操作系统
↓
CPU / 内存 / 磁盘 / 网卡
虚拟机则先虚拟出一套硬件:
vCPU
虚拟内存
虚拟磁盘
虚拟网卡
然后在这些虚拟硬件之上,再运行一个完整的 Guest OS:
App
↓
Guest Kernel
↓
Virtual Hardware
↓
Hypervisor
↓
Physical Hardware
因此,虚拟机中的进程首先由 Guest Kernel 管理。
例如线程调度可以粗略理解为:
Thread
↓
Guest Kernel Scheduler
↓
vCPU
↓
Hypervisor
↓
Physical CPU
也就是说:
虚拟机内部确实存在一套独立的操作系统内核。
这也是虚拟机相对较重的重要原因之一。
2. Docker:没有自己的 Guest Kernel
Docker 容器不是重新虚拟一套硬件,也不会为每个容器再启动一个完整的 Guest Kernel。
从当前学习模型来看,可以理解为:
Container Process
↓
Host Linux Kernel
↓
Physical Hardware
容器中的进程依然需要宿主机内核完成:
进程调度
系统调用
内存管理
文件系统访问
网络协议栈相关处理
所以这里需要建立第一个非常重要的认识:
容器中的进程,本质上仍然是宿主机 Linux 内核管理的普通进程。
Docker 的“轻量”也正来自这里:
虚拟机
→ 每个实例拥有 Guest Kernel
容器
→ 多个容器共享 Host Linux Kernel
但是马上又会产生一个新的问题:
既然大家共享的是同一个内核,为什么不同容器中的进程却感觉自己运行在不同系统中?
这就是 Namespace 要解决的问题。
二、Namespace:给进程戴上一副“VR 眼镜”
1. 先用 C++ namespace 建立一个直觉
对于 C++ 来说,我们对 namespace 并不陌生。
例如:
namespace A {
int value = 10;
}
namespace B {
int value = 20;
}
这里虽然都存在:
value
但是由于处于不同命名空间,因此:
A::value
B::value
并不会冲突。
Linux Namespace 与 C++ namespace 的底层机制完全不是一回事,但是二者可以帮助我们建立一种类似的直觉:
不同的 Namespace,可以让相同的“名字”或者资源标识在不同视角下拥有不同含义。
Linux Namespace 隔离的不是 C++ 变量名,而是:
进程
网络
挂载关系
主机名
IPC
……
这些操作系统资源的视图。
2. “VR 眼镜”模型
可以想象一个房间中有很多人。
真实房间
├── A
├── B
└── C
这个真实房间可以类比成:
宿主机 + 同一个 Linux Kernel
而不同容器中的进程,就像房间中的不同人。
现在每个人都戴上一副不同的 VR 眼镜:
A → VR A
B → VR B
C → VR C
虽然他们真实处在同一个物理房间中,但是:
A 看不到 B
B 看不到 C
每个人只看到 VR 为自己呈现的那部分世界
这就是 Namespace 最核心的思想:
底层仍然是同一个真实系统,但是内核给不同进程呈现不同的系统资源视图。
这里一定要注意:
VR 中展示的东西并不是重新凭空创造了一套物理硬件。
容器进程最终执行代码时:
普通机器指令
→ 仍然由真实 CPU 执行
系统调用
→ 仍然进入 Host Linux Kernel
改变的是:
内核按照当前进程所在的 Namespace 去解释它能够看到和操作的资源。
所以 Namespace 更像一个:
系统资源视图隔离器
而不是虚拟机中的:
完整硬件模拟 + 独立 Guest Kernel
三、真正完成“转换”的不是 Docker,而是 Linux Kernel
这里有一个很容易产生的疑问。
假设容器内部看到:
PID 20 → worker
但是宿主机看到同一个进程可能是:
PID 10019 → worker
那么容器中的程序执行:
kill(20, SIGTERM);
到底是谁把:
20
转换成宿主机视角下的:
10019
呢?
第一反应很容易认为:
Container Process
↓
Docker
↓
进行 PID 翻译
↓
Host Kernel
但实际上不是。
真正的过程更接近:
Container Process
↓
kill(20, SIGTERM)
↓
System Call
↓
Linux Kernel
↓
内核知道调用者属于哪个 PID Namespace
↓
在这个 Namespace 中解释 PID = 20
↓
找到真正对应的内核进程对象
也就是说:
Docker 不需要在每一次系统调用中扮演“翻译官”。
Docker 也不是编译器,更不会把容器程序重新翻译成另一份机器代码。
普通指令例如:
mov
add
cmp
jmp
仍然可以正常由 CPU 执行。
只有当程序通过系统调用访问操作系统资源时:
Process
↓
syscall
↓
Linux Kernel
Linux Kernel 才会根据这个进程所在的 Namespace,按照对应的隔离视图解释这次请求。
因此这里可以建立一个非常重要的认识:
Docker 负责组织隔离环境,Linux Kernel 负责真正实现和维护隔离语义。
四、Docker 到底在做什么:组织者,而不是隔离机制本身
从当前学习阶段,可以把 Docker / 容器运行时理解成一个用户态的“组织者”。
它会告诉 Linux:
我要创建一个进程
这个进程使用新的 PID Namespace
这个进程使用新的 Network Namespace
这个进程使用新的 Mount Namespace
这个进程受到指定 cgroup 约束
这个进程使用准备好的 rootfs
……
真正落实这些要求的仍然是:
Linux Kernel
因此关系可以理解为:
Docker / Container Runtime
↓
组织、配置、发起请求
↓
Linux Kernel
↓
Namespace / cgroup / mount ...
↓
创建并管理 Container Process
这里有一个非常关键的结论:
容器不是 Linux 内核中一种神秘的“特殊进程类型”。
从底层来看,一个容器更接近:
普通 Linux 进程
+
Namespace 隔离
+
cgroup 资源限制
+
rootfs
+
权限及网络等配置
Docker 把这些能力组合起来之后,才形成了我们平时看到的:
Container
五、Namespace 并不是只有一种
前面一直说“Namespace”,但这里需要进一步拆开。
Linux 并不是存在一个 Namespace,把所有资源全部塞进去。
而是:
不同类型的 Namespace 分别负责不同类型的资源隔离。
当前比较重要的有:
PID Namespace
→ 隔离进程编号和进程可见性
Network Namespace
→ 隔离网卡、IP、路由、端口空间等网络资源
Mount Namespace
→ 隔离文件系统挂载关系视图
UTS Namespace
→ 隔离 hostname 等主机标识
IPC Namespace
→ 隔离 IPC 相关资源
因此 Docker 构造一个完整容器视图,本质上是把多个 Namespace 组合起来:
Container Process
│
├── PID Namespace
├── Network Namespace
├── Mount Namespace
├── UTS Namespace
└── IPC Namespace
如果继续沿用“VR 眼镜”的模型,那么可以理解为:
PID Namespace
→ VR 里能看到哪些“人”
Network Namespace
→ VR 里能看到哪些“网卡、IP、路由”
Mount Namespace
→ VR 里看到怎样的“文件系统挂载布局”
UTS Namespace
→ VR 里认为自己的 hostname 是什么
多个隔离视图组合起来之后,进程才会感觉:
“这里似乎就是一个属于我的独立系统。”
六、PID Namespace:同一个进程,不同视角下拥有不同 PID
1. PID Namespace 隔离的是什么
PID Namespace 最直观。
假设宿主机中存在:
PID 3568 → nginx
当 nginx 位于一个新的 PID Namespace 中时,容器内部可能看到:
PID 1 → nginx
注意:
这不是两个 nginx,也不是复制了一份进程。
而是同一个 Linux 内核进程,在不同 PID Namespace 中拥有不同的 PID 表示:
nginx
│
┌─────────┴─────────┐
↓ ↓
Host PID Namespace Child PID Namespace
PID 3568 PID 1
因此 PID Namespace 隔离的不只是一个数字,还包括:
进程编号
+
进程可见性
2. 为什么宿主机能看到容器进程,而容器看不到宿主机进程
PID Namespace 具有层级关系。
可以先理解为:
Host PID Namespace
↓
Child PID Namespace
↓
Grandchild PID Namespace
外层 Namespace 可以看到内层 Namespace 中的进程。
但是内层 Namespace 看不到外层中的那些进程。
所以:
Host
→ 可以看到 Container Process
Container
→ 看不到 Host 中无关的外层进程
这正是“隔离视图”的体现。
它不是双方完全对称地互相看不到,而是:
内层看不到外层,外层能够观察内层。
3. PID 0 与 PID 1
这里还需要澄清一个容易混淆的点。
PID 0 并不代表“整个操作系统”。
Linux 中:
PID 0
→ 内核中的特殊任务,通常与 idle / swapper 相关
PID 1
→ 某个 PID Namespace 中的第一个进程
在宿主机的初始 PID Namespace 中,PID 1 通常是系统初始化进程,例如 systemd。
而在一个新的 PID Namespace 中:
bash
完全可以成为:
PID 1
这并不意味着 bash 变成了操作系统,而只是说明:
它是这个 PID Namespace 中的第一个进程。
七、Network Namespace:隔离一套独立网络视图
除了 PID,容器还需要网络隔离。
Network Namespace 可以给进程提供一套相对独立的网络环境,其中包括:
网络接口
IP 地址
路由表
邻居表
端口空间
……
例如:
Container A
eth0 → 172.17.0.2
nginx → listen 80
Container B
eth0 → 172.17.0.3
nginx → listen 80
两个容器都可以拥有:
eth0
也都可以监听:
80
而不会直接发生冲突。
原因就在于:
端口属于某一套网络栈,而不同 Network Namespace 拥有不同的网络栈视图。
这里的 eth0 也通常不是真实物理网卡,而是通过 Linux 虚拟网络机制构造出来的虚拟接口。
当前阶段先建立这样一个认识即可:
PID Namespace 隔离“进程世界”,Network Namespace 隔离“网络世界”。
八、Mount Namespace 前置:先补齐几个 Linux 基础命令
在真正验证 Mount Namespace 之前,需要先认识几个 Linux 命令:
dd
mkfs
mount
df
这些命令并不是 Docker 专属命令。
在这里引入它们,是因为我们需要亲手准备一个文件系统,并观察:
同一个挂载点,在两个 Mount Namespace 中为什么能够呈现不同内容。
九、dd:准备一片原始存储空间
1. dd 的基本作用
dd 可以先理解为:
从输入源按块读取数据,再按块写入输出目标。
基本形式:
dd if=输入源 of=输出目标 bs=块大小 count=块数
其中:
if
→ input file,输入源
of
→ output file,输出目标
bs
→ block size,每个块大小
count
→ 复制多少个块
例如:
dd if=/dev/zero of=/tmp/mount-lab.img bs=1M count=64
表示:
/dev/zero
↓
每次读取 1 MiB
↓
一共读取 64 次
↓
写入 /tmp/mount-lab.img
最终得到一个大约:
64 MiB
的文件。
2. /dev/zero 是什么
/dev/zero 并不是一个普通文件。
它是 Linux 中的特殊字符设备。
特点是:
读取 /dev/zero
↓
内核持续返回字节 0
它并不是磁盘中真的提前保存了一大堆 0。
而是:
程序读取它时,由 Linux Kernel 动态提供连续的 0。
因此它特别适合用来生成指定大小的空白测试文件。
3. skip 与 seek
dd 还可以控制输入和输出的偏移。
例如:
skip
→ 输入源先跳过多少个块再开始读取
seek
→ 输出目标先跳过多少个块再开始写入
因此从整体上,可以把 dd 理解为:
输入源
↓
按照块读取
↓
可以控制输入偏移
↓
读取指定数量的块
↓
可以控制输出偏移
↓
写入输出目标
十、mkfs:把原始空间组织成文件系统
通过 dd 得到:
mount-lab.img
以后,它目前只是一片原始空间。
我们还需要:
mkfs.ext4 -F /tmp/mount-lab.img
将它按照 ext4 文件系统格式进行初始化。
mkfs 可以理解为:
make filesystem
核心作用就是:
把一片可按块读写的原始空间,按照某种文件系统规则组织起来。
1. ext4 文件系统的大致组织
假设在一片存储空间上建立 ext4:
原始空间
↓
mkfs.ext4
↓
ext4 File System
↓
多个 Block Group
每个 Block Group 中主要会涉及:
Block Bitmap
→ 哪些数据块已经使用
Inode Bitmap
→ 哪些 inode 已经使用
Inode Table
→ 保存 inode
Data Blocks
→ 保存真正的数据
除此之外还有:
Superblock
→ 描述整个文件系统
GDT
→ Group Descriptor Table
→ 描述各个 Block Group 的位置、状态等信息
这里要注意:
GDT 不是描述每一个具体数据块,而是描述各个 Block Group。
同时,也不要死记成“每一个 Block Group 前面都一定完整保存一份 Superblock 和 GDT”。
ext4 会根据具体布局保存主信息以及必要的备份。
2. 文件系统不一定只能建立在一个磁盘分区上
最常见的情况当然是:
磁盘分区
↓
mkfs.ext4
↓
ext4
但文件系统实际上需要的是:
一片能够按块读写的存储空间。
因此还可以建立在:
整个磁盘
逻辑卷
镜像文件
上。
我们当前实验中的:
/tmp/mount-lab.img
就是一个普通镜像文件。
经过:
mkfs.ext4
之后,这个普通文件内部就按照 ext4 文件系统格式进行了组织。
因此:
dd
→ 准备一片原始空间
mkfs
→ 给这片空间建立文件系统格式
十一、mount:不是复制,而是把文件系统接入 Linux 目录树
1. Linux 为什么需要挂载
Windows 中经常看到:
C:
D:
E:
而 Linux 采用的是一棵统一目录树:
/
├── home
├── usr
├── var
├── mnt
└── tmp
现在我们已经有:
mount-lab.img
并且其中已经存在 ext4 文件系统。
但是这个文件系统目前还没有成为 Linux 目录树的一部分。
于是就需要:
mount
2. 什么叫“挂载点”
假设准备:
/mnt/mount-lab
作为目录。
然后将镜像中的 ext4 挂载到这个目录:
mount -o loop /tmp/mount-lab.img /mnt/mount-lab
逻辑上就是:
mount-lab.img 中的 ext4
↓
mount
↓
/mnt/mount-lab
这里:
/mnt/mount-lab
就叫:
Mount Point
也就是挂载点。
它的含义可以理解成:
Linux 目录树进入另一个文件系统的入口。
3. 挂载绝对不是“把镜像文件复制到目录里”
这是理解 mount 最容易产生的误区。
挂载后不是:
/mnt/mount-lab/mount-lab.img
这种关系。
而是:
访问 /mnt/mount-lab
↓
VFS 发现这里存在挂载关系
↓
切换到 mount-lab.img 中 ext4 的根目录
↓
继续进行路径解析
所以挂载更像:
建立了一条从挂载点到另一个文件系统根目录的跳转关系。
4. 用“指针”理解挂载
如果借助 C/C++ 中指针的思想,可以形成一个直觉:
挂载前:
/mnt/mount-lab
→ 原文件系统中的普通目录
挂载后:
/mnt/mount-lab
→ 被挂载文件系统的根目录
但这里一定要注意:
这只是类比。
Linux 并不是把原目录 inode 中的指针数组真的改成了另一个文件系统的地址。
实际是 VFS 在路径解析过程中发现:
这里是 Mount Point
于是跳转到另一个文件系统。
所以原目录中的数据:
没有被删除
没有被覆盖
没有被复制
只是暂时被挂载进来的文件系统遮住了。
一旦:
umount /mnt/mount-lab
原目录中的内容还会重新出现。
5. 目录本身是什么
这里还可以联系文件系统本身的知识。
在 Linux 中:
目录本质上也是一种特殊文件。
普通文件大致可以理解为:
inode
↓
记录属性和数据块位置
↓
Data Blocks
↓
真正文件内容
目录则是:
目录 inode
↓
目录 Data Blocks
↓
保存“名字 → inode 编号”的目录项
例如:
a.txt → inode 100
b.txt → inode 200
subdir → inode 300
所以路径:
/home/test/a.txt
本质上是 Linux 逐层完成:
/
↓
找到 home 对应 inode
↓
home 中找到 test 对应 inode
↓
test 中找到 a.txt 对应 inode
而 mount 特殊的地方就在于:
当路径解析走到某个 Mount Point 时,VFS 会从原文件系统切换到被挂载文件系统继续解析。
6. U 盘为什么也需要挂载
U 盘是理解挂载非常好的例子。
插入 U 盘后,Linux 可能识别到:
/dev/sdb
→ 整个 U 盘
/dev/sdb1
→ U 盘中的某个分区
假设 /dev/sdb1 中存在 FAT32 / exFAT / ext4 文件系统。
此时 Linux 只是:
识别到了一个设备
它还没有自然变成 Linux 目录树中的一部分。
于是需要选择:
/media/usb
作为挂载点:
/dev/sdb1 中的文件系统
↓
mount
↓
/media/usb
此后:
ls /media/usb
看到的才是 U 盘中的文件。
桌面版 Linux 插入 U 盘后经常不需要手动执行 mount,原因并不是 Linux 不需要挂载,而是:
桌面环境自动替用户完成了挂载。
十二、df:查看已经挂载文件系统的空间使用情况
认识 mount 以后,df 就很好理解了。
最常见:
df -h
核心作用:
查看已经挂载的文件系统的磁盘空间使用情况。
常见输出包括:
Filesystem
→ 文件系统所在设备
Size
→ 总容量
Used
→ 已使用
Avail
→ 可用空间
Use%
→ 使用率
Mounted on
→ 挂载点
所以这一组命令可以串成:
dd
→ 准备原始空间
mkfs
→ 建立文件系统格式
mount
→ 接入 Linux 目录树
df
→ 查看已挂载文件系统的空间情况
十三、unshare:直接体验 Linux Namespace
前面已经知道:
Docker 的隔离并不是 Docker 自己从 0 实现,而是利用 Linux Kernel 提供的 Namespace。
那么我们怎样不借助 Docker,直接体验 Linux Namespace 呢?
一个非常直接的工具就是:
unshare
可以先把 unshare 理解成:
让一个进程不再和原来的进程共享某一类 Namespace,从而创建一个新的隔离视图。
基本形式:
unshare [Namespace 选项] [程序]
其中:
Namespace 选项
→ 决定隔离什么
程序
→ 决定谁在这个隔离视图中运行
例如:
sudo unshare --uts bash
表示:
创建新的 UTS Namespace
↓
在其中运行 bash
再例如:
sudo unshare --mount bash
表示:
创建新的 Mount Namespace
↓
在其中运行 bash
所以可以把 unshare 记成:
前面的参数决定“隔离什么”,最后面的程序决定“谁运行在隔离环境里”。
十四、为什么经常让 unshare 启动一个 bash
这里可以联系 Linux Shell 的进程模型。
我们平时登录 Linux 后,本身就存在一个 bash。
用户输入:
ls
对于这种外部命令,bash 大致会:
读取并解析命令
↓
根据 PATH 找到可执行文件
↓
创建子进程
↓
exec 替换为目标程序
当然:
cd
export
这类 Shell Builtin 通常直接由 bash 自己完成,不一定创建子进程。
如果我们执行:
unshare --mount bash
就相当于:
原来的 Host bash
↓
启动 unshare
↓
创建新的 Mount Namespace
↓
运行一个新的 bash
此后我们在这个新 bash 里面继续执行:
ls
mount
ps
touch
……
这些由 bash 创建出来的子进程,默认会继承父进程所在的 Namespace。
于是:
这个 bash 就成为了我们操作隔离视图的交互入口。
十五、为什么 PID Namespace 经常需要 --fork
这一点非常关键。
对于很多 Namespace,例如:
UTS
Network
Mount
IPC
当前进程调用 unshare 后,本身就可以切换到新的 Namespace。
但是 PID Namespace 比较特殊。
执行:
unshare --pid ...
以后:
当前调用
unshare的进程自己不会直接进入新的 PID Namespace。
新的 PID Namespace 主要作用于:
它之后创建出来的子进程
所以才会经常看到:
unshare --pid --fork bash
逻辑可以理解为:
unshare
↓
创建新的 PID Namespace
↓
当前 unshare 进程仍在外层
↓
--fork 创建子进程
↓
子进程进入新的 PID Namespace
↓
exec 成 bash
于是:
Host 看:
bash → PID 2785736
新 PID Namespace 看:
bash → PID 1
这也是为什么 --fork 对 PID Namespace 特别重要。
十六、PID Namespace 实战
本次实验使用:
sudo unshare --pid --fork --mount --mount-proc bash
其中:
--pid
→ 创建新的 PID Namespace
--fork
→ 创建一个真正进入新 PID Namespace 的子进程
--mount
→ 创建新的 Mount Namespace
--mount-proc
→ 在新的挂载视图中重新挂载 /proc
→ 让 ps 等程序能够正确观察新的 PID 视图
bash
→ 在隔离环境中运行新的 bash
进入之后:
ps -ef
可以看到类似:
UID PID PPID CMD
root 1 0 bash
root 8 1 ps -ef
这里:
bash → PID 1
说明这个 bash 正是新 PID Namespace 中的第一个进程。
然后再开一个新的 SSH 会话,在宿主机中执行:
ps -ef | grep bash
本次实验中能够看到类似:
root 2785735 2785734 unshare ...
root 2785736 2785735 bash
这里最关键的是:
Host PID 2785736
和隔离环境中的:
PID 1
其实是:
同一个 bash 进程在不同 PID Namespace 中的不同编号。
因此实验直接验证:
同一个 bash
Host PID Namespace
→ PID 2785736
New PID Namespace
→ PID 1
而且新 PID Namespace 中执行 ps 时,看不到宿主机外层的大量进程,也进一步验证了:
PID Namespace 隔离的不只是 PID 数字,同时还隔离进程可见性。
十七、Mount Namespace:隔离的是“挂载关系视图”
认识完 PID Namespace,再看 Mount Namespace 就容易很多。
这里最容易产生的疑问是:
unshare --mount bash
为什么这里只有:
--mount
却没有告诉 unshare:
到底挂到哪个目录?
原因是:
unshare --mount中的--mount并不是执行一次具体的文件系统挂载。
它做的是:
创建一个新的 Mount Namespace
也就是创建一套独立的:
挂载关系视图
真正指定:
把谁挂到哪里
的仍然是后面的:
mount source target
因此二者必须区分:
unshare --mount
→ 创建独立挂载视图
mount ...
→ 在这个独立视图中增加一条具体挂载关系
十八、Mount Namespace 实战:同一个目录,不同视图
1. 创建镜像文件
首先:
dd if=/dev/zero of=/tmp/mount-lab.img bs=1M count=64
此时:
/dev/zero
↓
dd
↓
64 MiB 空白镜像文件
2. 格式化为 ext4
接着:
mkfs.ext4 -F /tmp/mount-lab.img
输出中可以看到:
Creating filesystem ...
Allocating group tables
Writing inode tables
Creating journal
Writing superblocks
说明这 64 MiB 空间已经被按照 ext4 文件系统格式进行了组织。
3. 准备挂载点
创建:
sudo mkdir -p /mnt/mount-lab
这里:
/mnt
只是 Linux 约定俗成用于临时挂载的目录。
真正由我们创建的是:
/mnt/mount-lab
挂载点并不要求必须位于 /mnt。
本质上,只要是合适的现有目录,都可以作为 Mount Point。
4. 创建独立 Mount Namespace
执行:
sudo unshare --mount bash
此时得到:
新的 bash
↓
新的 Mount Namespace
注意:
当前只是创建了独立的“挂载关系视图”,此时还没有把我们的镜像真正挂载到
/mnt/mount-lab。
5. 在隔离视图中完成挂载
进入新 bash 后执行:
mount -o loop /tmp/mount-lab.img /mnt/mount-lab
这里:
/tmp/mount-lab.img
→ 被挂载的文件系统镜像
/mnt/mount-lab
→ 挂载点
-o loop 可以先理解为:
让 Linux 通过 loop 机制把普通镜像文件作为可挂载的块设备来使用。
接着:
ls /mnt/mount-lab
可以看到:
lost+found
说明:
/mnt/mount-lab
在这个 Mount Namespace 中,已经成为镜像中 ext4 文件系统的入口。
然后创建:
touch /mnt/mount-lab/hello.txt
再次查看:
ls /mnt/mount-lab
得到:
hello.txt
lost+found
实验现象如下:

6. 再开一个宿主机终端进行对比
此时不要退出隔离 bash。
重新建立一个 SSH 会话,在宿主机原来的 Mount Namespace 中执行:
ls /mnt/mount-lab
结果:
什么都没有
实验结果如下:

现在,同一个路径:
/mnt/mount-lab
在两个 Namespace 中却拥有完全不同的意义:
New Mount Namespace:
/mnt/mount-lab
↓
mount-lab.img 中的 ext4
↓
hello.txt
lost+found
而宿主机:
Host Mount Namespace:
/mnt/mount-lab
↓
原文件系统中的普通空目录
这就是 Mount Namespace 的隔离效果。
这里一定要抓住:
被隔离的不是
/mnt/mount-lab这个目录对象本身,而是“这个目录上到底挂载了什么”的视图。
也就是说:
隔离 bash 认为:
/mnt/mount-lab 上挂着 ext4
Host bash 认为:
/mnt/mount-lab 上什么都没有挂
因此双方看到不同内容。
十九、PID Namespace 与 Mount Namespace 的关键差异
经过两次实验,可以把二者放在一起比较。
PID Namespace
PID Namespace 比较特殊:
创建新的 PID Namespace
↓
当前 unshare 进程自己不能直接变成里面的进程
↓
需要创建后续子进程
↓
子进程进入新 PID Namespace
所以经常使用:
unshare --pid --fork bash
可以理解成:
先创建 PID 隔离视图,再创建一个子进程,让这个子进程运行在新的 PID 视图中。
Mount Namespace
Mount Namespace 则可以:
unshare --mount bash
先让新的 bash 处于独立的挂载视图中。
然后:
mount ...
在这套独立视图里建立新的挂载关系。
所以它的思路是:
先进入独立 Mount Namespace
↓
再在这个 Namespace 中执行 mount
↓
只改变当前 Namespace 的挂载关系
因此可以总结:
PID Namespace
→ 新 Namespace 主要作用于后续创建的子进程
→ 常配合 --fork
Mount Namespace
→ 当前执行链可以直接拥有独立挂载视图
→ 再在其中执行 mount
二十、从 unshare 再回头理解 Docker
做到这里,再回头看 Docker,整个逻辑就非常清晰了。
Docker 并不是:
自己实现一个假的 PID
自己实现一个假的网络栈
自己实现一个假的文件系统
然后每次替容器进程翻译
真正的关系是:
Docker / Container Runtime
↓
利用 Linux Kernel 提供的能力
↓
PID Namespace
Network Namespace
Mount Namespace
UTS Namespace
IPC Namespace
cgroup
rootfs
……
↓
创建并组织出一个相对独立的运行环境
↓
在其中运行普通 Linux Process
这些进程最终仍然:
由 Host Kernel 调度
由 Host Kernel 管理内存
通过 Host Kernel 执行系统调用
使用真实 CPU / 内存 / 磁盘 / 网络
但是:
它看到的 PID
它看到的网络
它看到的挂载关系
它看到的 hostname
已经被 Namespace 隔离。
所以可以形成这一阶段最重要的一条心智模型:
虚拟机
→ 虚拟硬件
→ Guest Kernel
→ Guest Process
Docker
→ Host Kernel
→ Namespace / cgroup 等内核能力
→ 被隔离和约束的普通 Linux Process
如果继续沿用此前的“VR 眼镜”类比:
虚拟机更像真的重新造了一套房子;容器则仍然处于同一个真实房子中,只是 Linux Kernel 给不同进程戴上不同的 VR 眼镜,让它们看到不同的世界。
而 Docker 的作用,就是:
告诉 Linux Kernel:这副“VR 眼镜”应该怎样配置,然后把业务进程放进这个由内核维护的隔离世界中运行。
二十一、最后总结:这一阶段到底建立了哪些认知
这一阶段最重要的不是记住几个命令,而是建立下面这条完整逻辑链:
Docker 不拥有独立 Guest Kernel
↓
容器进程仍然由 Host Linux Kernel 管理
↓
但容器之间为什么能够隔离?
↓
Linux Namespace
↓
不同 Namespace 隔离不同资源视图
↓
PID Namespace
→ 隔离进程编号与可见性
Network Namespace
→ 隔离网络栈视图
Mount Namespace
→ 隔离挂载关系视图
↓
Docker 不是隔离机制的底层实现者
↓
Docker / Runtime 负责组织配置
↓
Linux Kernel 真正创建并维护隔离
然后通过 unshare,我们进一步验证:
Linux 本身就拥有 Namespace 能力
其中:
unshare --pid --fork ...
→ 验证 PID Namespace
unshare --mount ...
→ 验证 Mount Namespace
最后结合:
dd
mkfs
mount
我们亲眼观察到了:
同一个 /mnt/mount-lab
隔离 Mount Namespace
→ 看到 ext4 镜像中的 hello.txt / lost+found
Host Mount Namespace
→ 仍然看到原来的空目录
这时再理解“容器隔离”,它就不再是一个抽象概念了。
所谓容器,本质上就是 Linux 内核利用 Namespace 等机制,为普通进程构造出一个受隔离、受约束的运行环境;Docker 则负责把这些底层能力组织起来,让我们能够方便地创建、运行和管理这样的进程。
下一步继续学习 Docker 时,就可以很自然地过渡到另一个关键问题:
Namespace
→ 解决“我能看到什么”
cgroup
→ 解决“我能使用多少”
也就是从:
视图隔离
继续走向:
资源限制

更多推荐



所有评论(0)