🔥 本文专栏:Docker
🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录:很多时候,真正困难的不是记住一个概念,而是找到它为什么会出现,以及它和此前知识之间的逻辑关系。


思维导图

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
→ 解决“我能使用多少”

也就是从:

视图隔离

继续走向:

资源限制

在这里插入图片描述

Logo

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

更多推荐