【Docker入门系列】:从架构演进到容器化:一文建立 Docker、虚拟化与 Namespace 的底层心智模型

🔥 本文专栏:Docker

🌸作者主页:努力努力再努力wz

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


思维导图

text 复制代码
单机单进程架构
        ↓
单台机器存在资源上限
        ↓
垂直扩容
        ↓
硬件存在上限 + 扩容粒度过粗
        ↓
水平扩容
        ↓
复制完整服务实例到多个节点
        ↓
整体吞吐量提升
        ↓
仍然无法针对单个模块精准扩容
        ↓
微服务架构
        ↓
将单体应用中的模块拆成独立服务进程
        ↓
独立部署 / 独立扩容
        ↓
跨进程调用产生 RPC 需求
        ↓
服务实例数量增多
        ↓
直接在 Linux 上裸跑进程虽然可行
        ↓
但不同节点运行环境、依赖版本可能不同
        ↓
程序部署开始依赖大量人工环境配置
        ↓
产生"程序 + 运行环境一起交付"的需求
        ↓
虚拟化技术
        │
        ├── 虚拟机:虚拟硬件 + 完整 Guest OS
        │
        └── JVM:抽象程序执行平台
        ↓
容器化
        ↓
不重新虚拟完整硬件和 Guest Kernel
        ↓
共享 Host Linux Kernel
        ↓
给进程提供隔离后的运行环境视图
        ↓
Docker Container
        ↓
Namespace:隔离"能看到什么"
Cgroups:限制"能使用多少"
Image:封装"程序 + 用户态运行环境"

引入

在开始学习 Docker 之前,如果直接从:

text 复制代码
Image
Container
Dockerfile
Namespace
Cgroups

这些概念切入,很容易出现一个问题:

每一个名词似乎都能够理解,但是却不知道 Docker 为什么要这样设计。

因此,理解 Docker 最合适的方式并不是一上来记命令,而是先回答一个更根本的问题:

Docker 为什么会出现?

要回答这个问题,需要先把视角拉回到一个应用系统最基本的部署方式。

假设我们现在实现的是一个聊天服务器系统,其内部可能包含:

text 复制代码
ChatServer
├── 登录模块
├── 好友管理模块
├── 消息模块
├── 群聊模块
└── 后台管理模块

随着业务规模逐渐扩大,整个系统会经历:

text 复制代码
单机单进程
→ 垂直扩容
→ 水平扩容
→ 微服务
→ 大量独立服务进程
→ 部署和运行环境管理问题
→ Docker

本文就沿着这条链路逐步展开,先建立 Docker 最底层的心智模型。


一、从单机单进程架构开始

1. 单体应用的基本模型

在最简单的情况下,可以将整个聊天服务器编译为一个完整的可执行程序:

text 复制代码
ChatServer
├── Login Module
├── Friend Module
├── Message Module
└── Admin Module

部署以后:

text 复制代码
物理节点
│
├── CPU
├── 内存
├── 磁盘
├── 网卡
│
└── Linux
      ↓
   ChatServer 进程

此时所有模块都位于:

text 复制代码
同一个进程地址空间

中。

因此模块之间可以直接:

text 复制代码
函数调用
对象方法调用
共享进程内部的数据结构

例如消息模块需要调用好友模块中的某个接口时,本质上可以直接完成一次普通的本地函数调用。

这种方式在系统规模较小时并没有问题。


2. 单台机器最终存在硬件上限

随着业务量提高,不同模块产生的负载并不相同。

例如:

text 复制代码
消息模块
→ 大量网络数据收发
→ 消息解析
→ 序列化 / 反序列化
→ 消息路由
→ 高频业务处理

登录模块
→ 大量连接建立
→ 用户校验
→ Redis / MySQL 查询
→ 在线状态维护

后台管理模块
→ 管理员偶尔操作
→ 请求频率通常较低

因此虽然它们都属于同一个程序:

text 复制代码
ChatServer

但是对于底层硬件资源的需求却并不一致。

当消息模块或者登录模块逐渐成为性能瓶颈以后,一个最直接的解决方案就是:

提升当前节点的硬件配置。

也就是所谓的:

text 复制代码
垂直扩容(Scale Up)

例如:

text 复制代码
8 核 CPU   → 32 核 CPU
16GB 内存  → 128GB 内存
普通磁盘   → 更高性能磁盘
普通网卡   → 更高带宽网卡

但是这里会逐渐出现两个问题。

首先:

text 复制代码
单台物理机器的硬件存在上限

CPU、内存、磁盘以及网络能力都不可能无限提升。

其次:

text 复制代码
真正需要扩容的可能只有 Message Module

但是由于所有模块被打包在同一个 ChatServer 进程中,因此无法做到:

text 复制代码
只给 Message Module 提供更多计算资源

只能整体升级整个节点。

因此:

单体应用的扩容粒度非常粗。


二、从垂直扩容过渡到水平扩容

1. 水平扩容:增加节点数量

既然单台机器的能力存在上限,那么另一个自然思路就是:

不再继续把一台机器做得越来越强,而是增加机器数量。

原来:

text 复制代码
Node-1
└── ChatServer

现在:

text 复制代码
                 Load Balancer
                      │
        ┌─────────────┼─────────────┐
        ↓             ↓             ↓
      Node-1        Node-2        Node-3
        │             │             │
   ChatServer     ChatServer     ChatServer

这就是:

text 复制代码
水平扩容(Scale Out)

负载均衡器负责将请求分发到不同节点:

text 复制代码
Request-1 → Node-1
Request-2 → Node-2
Request-3 → Node-3

因此这里提高的是:

整个系统的总体处理能力。

但是单个节点上的 ChatServer 本身并没有发生变化。


2. 水平扩容仍然复制的是完整单体应用

这里需要注意:

text 复制代码
Node-1
└── ChatServer
    ├── Login
    ├── Friend
    ├── Message
    └── Admin

Node-2
└── ChatServer
    ├── Login
    ├── Friend
    ├── Message
    └── Admin

即使真正高负载的只有:

text 复制代码
Message Module

我们仍然只能:

text 复制代码
复制整个 ChatServer

而不能只增加 Message Module。

同样,如果后台管理模块只修改了一行代码,由于它仍然属于 ChatServer 的一部分,通常仍然需要:

text 复制代码
重新编译整个 ChatServer
        ↓
重新发布所有相关服务实例

所以水平扩容虽然突破了单节点上限,但是:

并没有解决单体应用内部模块之间的部署耦合和扩容粒度问题。


3. 多节点还会带来状态问题

假设多个 ChatServer 节点各自维护本地状态:

text 复制代码
Node-1
└── 用户 A 的连接对象

Node-2
└── 用户 B 的连接对象

此时 Node-1 中的进程地址空间无法直接访问 Node-2 中的连接对象。

类似地,如果每个节点再分别维护自己的 MySQL 或 Redis 数据:

text 复制代码
Node-1 → Redis-1
Node-2 → Redis-2

就还需要额外解决复杂的数据同步和一致性问题。

因此实际系统中,通常会将很多有状态组件进一步独立出来:

text 复制代码
            多个业务节点
                 │
        ┌────────┴────────┐
        ↓                 ↓
      Redis              MySQL

也就是说:

应用服务尽可能无状态化,而状态交给独立的数据存储系统管理。

这为后面的分布式系统和微服务继续做了铺垫。


三、微服务:把"整个应用扩容"变成"某个服务独立扩容"

1. 从模块拆分到独立服务

原来的单体应用:

text 复制代码
ChatServer
├── Login Module
├── Friend Module
├── Message Module
└── Admin Module

进一步拆分以后:

text 复制代码
LoginService
FriendService
MessageService
AdminService

这些服务可以分别形成独立的程序,并作为独立进程运行。

例如:

text 复制代码
Node-A
├── LoginService
└── FriendService

Node-B
├── MessageService
├── MessageService
└── MessageService

Node-C
└── AdminService

这里有一个很重要的认识:

微服务强调的是服务之间可以独立开发、独立部署和独立扩容,并不是要求"一台机器只能运行一个服务"。

一台机器完全可以同时运行多个服务进程。


2. 微服务解决了扩容粒度问题

例如系统中:

text 复制代码
LoginService   × 2
FriendService  × 2
MessageService × 10
AdminService   × 1

当 MessageService 压力继续增加时,只需要:

text 复制代码
MessageService × 10
        ↓
MessageService × 20

并不需要把 LoginService、FriendService 和 AdminService 一起复制。

这样:

原来以"整个应用"为单位的扩容,变成了以"单个服务"为单位的细粒度扩容。

同时,不同服务还可以部署到不同规格的节点上。

例如:

text 复制代码
CPU 密集型服务
→ 更强的 CPU

大量网络 I/O 服务
→ 更高带宽的网络环境

低频后台服务
→ 较低规格节点

四、微服务为什么又会自然引出 RPC

微服务把原本位于同一个进程中的模块拆成了多个独立进程。

在单体应用中:

cpp 复制代码
UserService userService;
userService.Login(...);

本质上就是普通函数调用。

原因是:

text 复制代码
调用方
+
UserService 对象

位于同一个进程地址空间中。

但是拆成:

text 复制代码
Process-A:ChatService
Process-B:UserService

以后,Process-A 无法直接访问 Process-B 中的对象。

因为:

text 复制代码
不同进程
→ 独立虚拟地址空间

如果两个进程还位于不同机器上,则更不可能直接进行本地函数调用。

于是一次远程服务调用,需要变成:

text 复制代码
调用 UserService::Login()
        ↓
确定服务名称 + 方法名称
        ↓
封装请求参数
        ↓
序列化
        ↓
通过网络发送
        ↓
UserService 接收请求
        ↓
反序列化
        ↓
真正调用 Login()
        ↓
得到返回值
        ↓
序列化响应
        ↓
网络返回
        ↓
调用方得到结果

RPC 框架的核心意义就在于:

封装跨进程、跨机器的网络通信细节,让远程服务调用尽可能表现得像本地函数调用。

不过 RPC 解决的是:

text 复制代码
服务之间如何调用

而 Docker 后面解决的是另一个问题:

text 复制代码
这些大量独立服务应该如何稳定部署和运行

因此这里需要重新回到 Docker 主线。


五、没有 Docker,微服务照样可以运行

1. 最原始的方式:直接运行 Linux 进程

假设现在已经拆出了:

text 复制代码
LoginService
MessageService
FriendService
AdminService

即使完全没有 Docker,也可以直接在 Linux 中:

bash 复制代码
./login_server
./message_server
./friend_server
./admin_server

此时:

text 复制代码
Host Linux
├── login_server
├── message_server
├── friend_server
└── admin_server

这些都是普通 Linux 进程。

因此:

Docker 不是微服务能够运行的前提。

没有 Docker,微服务一样可以部署。

那么真正的问题就是:

既然普通 Linux 进程已经可以运行,为什么还需要 Docker?


六、Docker 出现的第一个核心问题:运行环境依赖

1. 一个程序并不是只有一个可执行文件

对于一个 C++ 服务来说,最终运行时可能依赖:

text 复制代码
server
├── libstdc++.so
├── glibc
├── libssl.so
├── protobuf
├── libmysqlclient.so
├── Redis Client Library
├── 配置文件
└── 其他用户态依赖

假设开发环境中:

text 复制代码
Ubuntu A
GCC 13
OpenSSL 3.x
protobuf A
libmysqlclient A

但是真正部署到另一台陌生节点以后:

text 复制代码
Ubuntu B
OpenSSL 1.x
protobuf B
没有 libmysqlclient

就可能出现:

text 复制代码
开发环境运行正常
        ↓
复制到生产环境
        ↓
动态库不存在 / ABI 不兼容 / 版本不匹配
        ↓
程序无法启动

2. 编译、链接和运行时加载需要区分

这里有一个容易混淆的点。

对于源码:

text 复制代码
源文件
↓
编译
↓
链接
↓
可执行程序

当可执行程序已经生成以后:

程序启动时不是重新进行一次普通的链接过程。

如果程序采用动态链接,启动时会由动态链接器/加载器查找程序所依赖的共享库。

例如:

text 复制代码
server
↓
启动
↓
寻找 libstdc++.so
寻找 libssl.so
寻找 libmysqlclient.so
↓
缺失或者版本不兼容
↓
启动失败

因此这里真正的问题是:

一个应用能否运行,不仅取决于可执行程序本身,还取决于它所在节点的用户态运行环境。


3. 没有 Docker 时只能人工解决环境差异

传统部署可能变成:

text 复制代码
拿到一台新服务器
        ↓
安装程序
        ↓
检查依赖
        ↓
缺 OpenSSL?
→ 安装

缺 MySQL Client Library?
→ 安装

protobuf 版本不匹配?
→ 升级 / 降级

配置文件不同?
→ 手动配置

服务数量少时还能够处理。

但是如果:

text 复制代码
几十个服务
×
几十台机器
×
不同依赖版本

部署成本会迅速增加。

于是产生一个非常自然的需求:

能不能把应用程序以及它需要的用户态运行环境一起交付,而不是每换一台机器就重新配置一遍?

这就是理解 Docker 的第一个核心切入点。


七、程序和运行环境一起交付

我们希望原来的:

text 复制代码
应用程序
↓
依赖宿主机提前配置好环境

变成:

text 复制代码
应用程序
+
动态库
+
配置文件
+
必要用户态工具
+
用户态文件系统
↓
统一封装

这样部署的时候,不再需要让宿主机专门为每个应用反复:

text 复制代码
安装依赖
调整版本
修改配置

这里可以形成一个非常重要的思想:

从"把程序部署到环境中",逐渐转变为"把程序和它需要的环境一起带过去"。

这也是后面理解 Docker Image 的基础。

但是在真正认识 Docker 容器之前,还需要先理解另一个概念:

text 复制代码
虚拟化

因为 Docker 和虚拟机都在尝试解决:

如何给上层程序提供一个相对独立、稳定的运行环境?

只不过两者采取的方式完全不同。


八、什么是虚拟化

1. 从"真实资源"到"虚拟视图"

一台真实物理主机可能拥有:

text 复制代码
CPU
内存
磁盘
网卡

而虚拟化技术的核心思想之一,就是:

在真实资源之上增加一层抽象,让上层看到一套独立的虚拟资源。

这个思想其实和虚拟内存有一定相似之处。

例如进程看到的是:

text 复制代码
自己的虚拟地址空间

而不是直接操作真实物理内存地址。

不同进程即使都访问:

text 复制代码
0x1000

也可能通过各自的页表映射到完全不同的物理页。

因此:

text 复制代码
虚拟内存
→ 给进程提供独立的内存地址视图

而虚拟机则进一步把这种思想扩大到了:

text 复制代码
整台计算机

九、虚拟机:虚拟一台完整计算机

1. 虚拟机不仅仅是"模拟一个操作系统"

传统虚拟机可以粗略理解成:

text 复制代码
真实物理硬件
        ↓
Hypervisor
        ↓
虚拟 CPU / 虚拟内存 / 虚拟磁盘 / 虚拟网卡
        ↓
Guest OS
        ↓
Guest Kernel
        ↓
Guest Process

也就是说,虚拟机首先提供的是:

text 复制代码
一套虚拟硬件

然后在这套虚拟硬件之上真正运行:

text 复制代码
完整 Guest OS

所以虚拟机中的 Linux 内核并不是"假的内核"。

它是一套真正运行起来的 Guest Kernel,只不过:

它所看到和管理的是虚拟硬件。


2. Guest 进程由 Guest Kernel 管理

假设虚拟机中运行:

bash 复制代码
./server

那么 Guest Linux 仍然会按照正常操作系统逻辑管理这个进程:

text 复制代码
server
↓
Guest task_struct
↓
Guest Scheduler
↓
vCPU

也就是说,在 Guest Linux 内部仍然存在:

text 复制代码
进程管理
线程调度
虚拟内存
文件系统
网络协议栈
...

这些完整的操作系统行为。

这一点与 Docker 后面会形成非常明显的对比。


十、vCPU 与真实 CPU 的关系

1. vCPU 不是真的又制造出了 CPU

假设物理主机拥有:

text 复制代码
8 个真实 CPU 核心

现在创建:

text 复制代码
VM-A:4 vCPU
VM-B:4 vCPU
VM-C:4 vCPU

逻辑上:

text 复制代码
12 个 vCPU

并不意味着物理机器突然拥有了 12 个真实核心。

vCPU 可以先简单理解为:

Guest OS 所看到的一份逻辑 CPU 资源。

最终真正执行机器指令的仍然只能是:

text 复制代码
物理 CPU

大致关系是:

text 复制代码
VM-A vCPU
VM-B vCPU
VM-C vCPU
     ↓
虚拟化层 / Host 调度机制
     ↓
真实物理 CPU

2. 不是"一个虚拟机永久独占几个真实核心"

这里不能简单理解成:

text 复制代码
VM-A 永久使用 CPU0 ~ CPU3
VM-B 永久使用 CPU4 ~ CPU7

虽然某些场景确实可以做 CPU 绑定,但是这并不是理解虚拟化时的默认模型。

更常见的认知应该是:

text 复制代码
多个 vCPU
↓
由底层统一调度
↓
分时使用真实物理 CPU

这和操作系统调度多个线程有一定相似性:

text 复制代码
很多线程
↓
Scheduler
↓
有限 CPU 核心

对应到虚拟化:

text 复制代码
很多 vCPU
↓
虚拟化层 / Host Scheduler
↓
有限真实 CPU 核心

十一、虚拟化为什么能够提高资源利用率

假设原来一台 16 核机器只运行一个服务:

text 复制代码
Service-A
平均只使用 2 ~ 4 核

那么大量 CPU 时间可能长期处于空闲状态。

引入虚拟化以后:

text 复制代码
VM-A
VM-B
VM-C
VM-D
  ↓
共享同一套真实硬件

多个虚拟机并不是彼此协商:

text 复制代码
"你现在用 CPU0,我等会儿再用"

而是由更底层的虚拟化层或者宿主调度机制统一协调。

于是:

text 复制代码
VM-A 忙
VM-B 闲
VM-C 闲

时,可以让真实 CPU 更多地服务于当前真正有执行需求的工作负载。

所以所谓:

提高资源利用率

并不是简单地把真实 CPU 机械切成几块,而是:

让多个隔离的工作负载能够共享同一套物理资源,通过统一调度减少硬件长期闲置。


十二、Hypervisor 的两种典型类型

1. Type 1:裸机型虚拟化

Type 1 可以粗略理解为:

text 复制代码
Guest OS / VM
      ↓
Hypervisor
      ↓
真实物理硬件

Hypervisor 直接位于真实硬件之上。

因此中间不需要先存在一个普通桌面 Host OS。


2. Type 2:宿主型虚拟化

另一种则是:

text 复制代码
Guest OS / VM
      ↓
虚拟化软件
      ↓
Host OS
      ↓
真实物理硬件

例如在 Windows 中安装桌面虚拟化软件,再运行 Ubuntu VM,可以粗略理解成:

text 复制代码
Ubuntu VM
↓
虚拟化软件
↓
Windows
↓
真实硬件

因此此前容易产生:

"虚拟机是不是宿主机中的一个进程?"

这种直觉,更多来源于 Type 2 模型。

在很多具体实现中,一台虚拟机在 Host 侧确实会通过进程、线程等执行实体承载,但是不能简单归纳为:

text 复制代码
虚拟机 = 一个普通进程

因为这个执行实体内部承载的是:

text 复制代码
虚拟 CPU 状态
虚拟设备
虚拟内存
完整 Guest OS
...

十三、JVM 也是虚拟机,但它虚拟的不是一整台计算机

学习"虚拟机"时,还会遇到:

text 复制代码
JVM
Java Virtual Machine

但是 JVM 和 VMware/KVM 这一类系统虚拟机并不是一个层面的东西。

传统系统虚拟机主要做:

text 复制代码
虚拟硬件
↓
运行完整 Guest OS

而 JVM 更像是:

虚拟出一个 Java 程序能够运行的抽象计算机。

Java 程序:

text 复制代码
Java Source
↓
javac
↓
Java Bytecode
↓
JVM
↓
解释执行 / JIT
↓
真实机器指令
↓
CPU

Java 字节码并不是某个具体 CPU 可以直接执行的 x86 或 ARM 指令。

JVM 在这里很像一个:

text 复制代码
"翻译官 + 运行时平台"

它负责将统一的 Java 字节码落实到不同的真实平台上。

因此:

text 复制代码
Windows + x86
Linux + x86
Linux + ARM

只要分别存在对应 JVM,上层 Java 程序就可以面对一套相对统一的执行模型。

当然 JVM 并不只是"翻译指令",还包括:

text 复制代码
类加载
JVM Heap
JVM Stack
垃圾回收
异常处理
JIT
线程运行
...

因此可以形成一个简单对比:

text 复制代码
传统虚拟机
→ 虚拟一台完整计算机

JVM
→ 虚拟一个程序执行平台

这里理解到这个程度就足够了,不需要继续深入虚拟机实现细节。


十四、从虚拟化过渡到容器化

1. 虚拟机的思路比较"重"

假设我们只是想运行:

text 复制代码
LoginService
MessageService

如果分别为两个服务创建完整虚拟机:

text 复制代码
VM-A
├── Guest Linux Kernel
├── 用户态系统环境
└── LoginService

VM-B
├── Guest Linux Kernel
├── 用户态系统环境
└── MessageService

会发现:

每个虚拟机都需要自己运行一套完整 Guest OS。

也就是说,为了隔离两个应用,我们连:

text 复制代码
操作系统内核

都复制了一套。

这就是为什么传统虚拟机通常比较重。


2. 容器化采取另一种思路

Docker Container 不再:

text 复制代码
虚拟一套完整硬件
+
启动一套新的 Guest Kernel

而是直接:

text 复制代码
共享宿主机硬件
+
共享 Host Linux Kernel

然后:

给不同进程提供彼此隔离的运行环境。

整体结构可以理解为:

text 复制代码
真实物理硬件
        ↓
Host Linux Kernel
        ↓
┌───────────────┬───────────────┐
│ Container-A   │ Container-B   │
│               │               │
│ LoginService  │ MessageService│
└───────────────┴───────────────┘

这里最关键的认识是:

容器中的进程最终仍然是宿主机 Linux 内核直接管理的进程。


十五、Docker 容器本质上仍然是 Linux 进程

假设不用 Docker:

text 复制代码
Host Linux
├── login_server
└── message_server

使用 Docker:

text 复制代码
Host Linux Kernel
│
├── Container-A
│   └── login_server
│
└── Container-B
    └── message_server

从 Host Kernel 的角度看:

text 复制代码
login_server
message_server

最终仍然属于 Linux 进程。

它们仍然需要:

text 复制代码
系统调用
↓
Host Kernel
↓
真实硬件

例如:

text 复制代码
read()
write()
socket()
mmap()

最终都还是进入同一个 Host Linux Kernel。

因此:

Docker 并没有创造一种全新的"容器进程类型"。

容器中的进程和普通 Linux 进程最大的差异,主要在于:

text 复制代码
它能看到什么
它能访问什么
它能使用多少资源

被做了额外隔离和限制。


十六、到底什么叫"独立运行环境"

这是理解 Docker 最关键的一步。

一个普通 Linux 进程运行以后,它所面对的"外部世界"包括:

text 复制代码
文件系统
动态库
配置文件
其他进程
网络设备
IP
端口
Hostname
IPC 资源
CPU
内存
...

这些东西合在一起,就可以粗略理解为:

进程的运行环境。

例如一个普通宿主机进程执行:

bash 复制代码
ls /

看到的是宿主机自己的:

text 复制代码
/
├── bin
├── etc
├── home
├── usr
├── var
└── ...

它加载动态库时,也会访问宿主机对应的文件系统环境。

Docker 的核心思路不是重新创造一台机器,而是:

虽然这个进程还是 Host Kernel 管理的,但让它看到一个属于自己的"世界"。


十七、"视图"是理解容器隔离的关键

1. 不重新复制一个完整物理世界

这里可以借用一个已经比较熟悉的概念:

cpp 复制代码
std::string_view

string_view 并不会为了自己重新深拷贝一整份字符串缓冲区。

它更重要的是:

text 复制代码
提供一段数据的观察视图

Linux Namespace 和 string_view 的实现完全不是一回事,但是二者可以帮助建立一个共同的抽象思想:

不一定重新制造完整底层实体,也可以让上层获得不同的观察范围。

容器也是如此。


2. 文件系统视图

假设宿主机真实文件系统:

text 复制代码
Host /
├── home
├── usr
├── etc
├── var
└── ...

Docker 可以为容器准备一套 rootfs:

text 复制代码
rootfs/
├── bin
├── lib
├── usr
├── etc
└── app
    └── server

然后让容器里的进程看到:

text 复制代码
/
├── bin
├── lib
├── usr
├── etc
└── app

也就是说:

容器进程认为这套 rootfs 就是自己的根文件系统 /

这里不能简单理解成:

text 复制代码
Docker 建一个目录
然后要求进程自觉不要出去

而是 Linux 内核真的通过相应机制改变了这个进程看到的挂载视图。


3. rootfs 仍然真实存放在宿主机磁盘上

容器中看到:

text 复制代码
/app/server
/lib/libxxx.so
/etc/xxx.conf

并不意味着 Docker 又创造了一块独立的真实磁盘。

这些文件最终仍然存放在:

text 复制代码
宿主机真实磁盘

之上。

区别在于:

容器中的进程只被提供了特定的文件系统视图。

因此:

text 复制代码
容器看到自己的 /

和:

text 复制代码
宿主机真正的 /

可以完全不同。


十八、Namespace:Linux 如何制造不同的"世界"

1. 从 C++ namespace 进行类比

C++ 中:

cpp 复制代码
namespace A {
    int value;
}

namespace B {
    int value;
}

虽然都存在:

text 复制代码
value

但是:

text 复制代码
A::value
B::value

属于不同命名空间,因此不会发生符号冲突。

Linux Namespace 和 C++ Namespace 并不是同一种机制,但是二者在思想上都存在:

text 复制代码
划分不同的作用范围

这一共同点。


2. Linux Namespace 隔离的是系统资源视图

Linux Namespace 更准确的定义可以先理解为:

Linux 内核控制一个进程能够看到哪一套系统资源视图的机制。

例如:

text 复制代码
Container-A
→ Namespace-A

Container-B
→ Namespace-B

内核根据进程所属 Namespace,让它们看到不同的资源。


3. PID Namespace:隔离进程视图

例如:

text 复制代码
Container-A
├── PID 1
├── PID 2
└── PID 3

Container-B
├── PID 1
└── PID 2

两个容器内部都可以看到:

text 复制代码
PID = 1

因为它们位于不同的 PID Namespace。

这并不是两台真实机器中真的各自存在一套独立 CPU,而是:

同一个 Host Kernel 给不同进程提供了不同的进程编号和进程可见性视图。


4. Mount Namespace:隔离文件系统挂载视图

Mount Namespace 主要负责:

text 复制代码
一个进程看到哪些挂载点
它眼中的 / 是什么

因此:

text 复制代码
Container-A
→ 看到自己的 rootfs

Container-B
→ 看到另一套 rootfs

这正是前面"容器拥有自己的文件系统视图"的重要基础。


5. Network Namespace:隔离网络视图

不同容器还可以看到自己的:

text 复制代码
网卡
IP 地址
路由表
端口空间

例如:

text 复制代码
Container-A
eth0 → 172.x.x.2

Container-B
eth0 → 172.x.x.3

虽然它们最终仍然通过宿主机真实网络设备完成通信,但是各自看到的是不同的网络环境。


6. 其他 Namespace

除了 PID、Mount、Network,还存在例如:

text 复制代码
UTS Namespace
→ Hostname 等系统标识视图

IPC Namespace
→ IPC 资源隔离

User Namespace
→ UID / GID 等身份映射

当前学习 Docker 时,不需要立刻钻进每一种 Namespace 的实现。

现在最重要的是建立:

Namespace 解决的是"一个进程能看到什么"。


十九、Docker 并不是把所有东西都"虚拟化"

这里要重新区分:

text 复制代码
文件系统视图
进程视图
网络视图

和:

text 复制代码
CPU / 内存资源

Namespace 更偏向解决:

text 复制代码
"你看到什么"

但是 CPU 和内存还涉及:

text 复制代码
"你最多能够使用多少"

因此 Docker 后面还需要另一套 Linux 内核机制:

text 复制代码
Cgroups

可以先建立:

text 复制代码
Namespace
→ 隔离资源视图

Cgroups
→ 限制、统计和控制资源使用

例如:

text 复制代码
Container-A
最多使用 2 CPU
最多使用 1GB 内存

这些已经属于后续 Docker 底层机制的内容,本文暂时不继续深入。


二十、虚拟机与 Docker 的核心区别

现在可以把二者放在同一个模型中。

1. 虚拟机

text 复制代码
Application
↓
Guest User Space
↓
Guest Kernel
↓
Virtual Hardware
↓
Hypervisor
↓
Physical Hardware

每个 VM 都有:

text 复制代码
自己的 Guest Kernel

Guest 进程由:

text 复制代码
Guest Kernel

管理。


2. Docker Container

text 复制代码
Container Application
↓
Container User Space / rootfs
↓
Host Linux Kernel
↓
Physical Hardware

多个容器:

text 复制代码
Container-A ─┐
Container-B ─┼→ Host Linux Kernel
Container-C ─┘

共同使用宿主机内核。

因此:

text 复制代码
虚拟机
→ 虚拟整台计算机
→ 虚拟硬件
→ 启动完整 Guest OS

Docker
→ 不重新虚拟完整硬件
→ 不启动独立 Guest Kernel
→ 共享 Host Kernel
→ 隔离应用运行环境

这就是为什么容器通常:

text 复制代码
启动更快
额外资源开销更小
部署密度更高

二十一、什么是容器化

理解了前面的模型以后,"容器化"这个概念就不再抽象。

所谓容器化,可以先理解为:

将应用程序以及运行所需要的用户态环境封装起来,并利用操作系统提供的隔离和资源控制机制,使其作为一个相对独立的运行单元运行。

其中:

text 复制代码
应用程序
动态库
配置文件
用户态文件
rootfs

可以作为应用运行环境的一部分被统一交付。

而:

text 复制代码
Host Kernel

仍然由不同容器共享。

所以容器化并不是:

text 复制代码
再创建一台小型虚拟机

而是:

text 复制代码
宿主机上的普通进程
+
独立用户态运行环境
+
Namespace 隔离
+
Cgroups 资源控制

二十二、虚拟化与容器化为什么能够提高资源利用率

对于传统"一台服务一台机器"的部署:

text 复制代码
Physical Server
└── Service-A

如果 Service-A 大量时间只使用少量 CPU 和内存,那么剩余硬件资源可能长期处于闲置。

虚拟化可以:

text 复制代码
Physical Server
├── VM-A
├── VM-B
├── VM-C
└── VM-D

让多个隔离工作负载共享同一套真实物理资源。

而容器进一步减少了:

text 复制代码
每个实例都启动一套 Guest OS

的额外开销。

因此同样的机器上,通常能够承载更多应用实例。

这里需要注意:

提高资源利用率并不是凭空产生更多 CPU 或内存。

而是:

text 复制代码
减少资源闲置
+
减少重复运行环境本身带来的额外开销
+
通过统一资源调度让硬件承载更多工作负载

二十三、环境标准化:Docker 更重要的价值之一

Docker 对工程开发非常重要的一点,就是:

text 复制代码
环境标准化

原来的问题是:

text 复制代码
开发机
→ 环境 A

测试机
→ 环境 B

生产机
→ 环境 C

即使:

text 复制代码
代码完全相同

也可能因为:

text 复制代码
动态库版本不同
配置不同
系统工具不同
用户态环境不同

导致行为不同。

容器化希望:

text 复制代码
Application
+
Dependencies
+
Configuration
+
User-space Environment
        ↓
统一封装
        ↓
开发 / 测试 / 生产
尽可能使用同一套交付环境

因此:

Docker 不是让应用完全不再依赖任何环境,而是把应用原本对宿主机用户态环境的依赖,收敛到一个标准化、可复制的容器运行环境中。

同时仍然需要注意:

text 复制代码
容器最终仍然依赖:
CPU 架构
Host Kernel
容器运行时

所以不能说:

text 复制代码
Docker 以后程序彻底没有环境依赖

更准确的是:

Docker 大幅降低了应用对"每台宿主机具体用户态环境"的耦合。


二十四、Docker、虚拟机和 JVM 的统一认识

到这里,可以从"到底虚拟/抽象了哪一层"重新看三者。

text 复制代码
应用程序
↓
用户态运行环境
↓
操作系统内核
↓
硬件

传统虚拟机:

text 复制代码
主要从硬件层进行抽象
↓
提供虚拟硬件
↓
上面运行完整 Guest OS

Docker:

text 复制代码
不重新虚拟完整硬件
↓
共享 Host Kernel
↓
隔离用户态运行环境和系统资源视图

JVM:

text 复制代码
在应用执行层提供抽象
↓
Java Bytecode
↓
JVM
↓
真实 OS / CPU

因此:

text 复制代码
传统 VM
→ 虚拟一台机器

Docker Container
→ 隔离一个应用运行环境

JVM
→ 抽象一个程序执行平台

它们背后都有一个共同思想:

通过增加抽象层,屏蔽底层一部分真实环境差异,给上层提供更加统一的运行视图。


二十五、当前阶段对于 Docker 的最终心智模型

经过前面的推导,现在可以把 Docker 的基本认知收敛成下面这条链路。

text 复制代码
大型单体应用
↓
不同模块负载不同
↓
单机垂直扩容粒度过粗
↓
水平扩容复制整个单体应用
↓
仍然无法独立扩容单个模块
↓
微服务拆分
↓
模块变成独立服务进程
↓
服务可以独立部署和扩容
↓
大量服务需要部署到不同节点
↓
陌生节点中的动态库 / 配置 / 用户态环境可能不同
↓
人工配置环境成本越来越高
↓
希望:
"程序 + 所需运行环境"一起交付
↓
Docker

然后从底层看:

text 复制代码
Docker Container
=
宿主机上的 Linux 进程
+
独立用户态运行环境
+
隔离后的系统资源视图
+
资源限制

其中:

text 复制代码
Namespace
→ 负责解决"进程能看到什么"

Cgroups
→ 负责解决"进程能使用多少资源"

Image
→ 负责封装"程序以及它需要的用户态运行环境"

而所有容器最终仍然:

text 复制代码
共享 Host Linux Kernel
↓
由 Host Kernel 管理进程
↓
使用真实 CPU / 内存 / 磁盘 / 网卡

所以对于 Docker,当前最重要的一句话就是:

容器不是一台小型虚拟机,而是宿主机上的 Linux 进程,只不过这个进程被 Linux 内核赋予了一套隔离后的运行环境视图,并配套了资源控制与标准化环境封装。


从这里继续学习 Docker

建立当前心智模型以后,后续 Docker 的很多概念就可以顺着自然展开:

text 复制代码
Docker 为什么能够隔离进程?
↓
Namespace

Docker 如何限制 CPU / 内存?
↓
Cgroups

Docker 如何把程序和环境一起交付?
↓
Image

Image 如何真正运行起来?
↓
Container

Image 是如何构建的?
↓
Dockerfile

容器文件系统为什么有层级?
↓
UnionFS / OverlayFS

容器如何访问网络?
↓
Network Namespace
Bridge
veth

容器删除以后数据怎么办?
↓
Volume

多个容器如何统一启动?
↓
Docker Compose

容器数量进一步扩大以后如何统一管理?
↓
Container Orchestration
↓
Kubernetes

因此当前阶段没有必要直接跳到容器编排。

更加合理的学习顺序应该是:

text 复制代码
Docker 基本模型
↓
Image / Container
↓
Namespace
↓
Cgroups
↓
文件系统
↓
网络
↓
Volume
↓
Docker Compose
↓
容器编排

到这里,Docker 已经不再只是一个需要死记命令的工具,而是能够从:

text 复制代码
架构演进
→ 服务拆分
→ 部署问题
→ 环境标准化
→ 操作系统隔离

这一整条逻辑链中自然推导出来的技术方案。

相关推荐
wuyk5551 小时前
13.堆排序:基于完全二叉树的高效排序算法一、什么是堆排序?
开发语言·算法·排序算法
runningshark1 小时前
Lecture: The ‘Why & How‘ Principle: Moving Beyond Simple Statements
开发语言·前端·javascript
光电笑映1 小时前
Linux 线程编程:从进程、分页到线程控制与封装
linux·运维·服务器·c++
qinzechen1 小时前
本周科技行业热点汇总·2026第36周(2026年8月31日-9月6日)
c++·科技·算法
Capricorn19881 小时前
跨端同步与记忆锁定排障:OpenClaw 2.0 Active Memory 云端劫持危机,知芽 Notebook Skill 单元记忆架构解析
大数据·论文阅读·人工智能·笔记·架构·论文笔记
吴佳浩 Alben1 小时前
多智能体系统的通信风暴与死锁治理:生产级降级与容灾方案
人工智能·docker·ai·容器·架构
学逆向的1 小时前
PE——RVA与FOA的转换
开发语言·网络安全·pe
白狐_7981 小时前
408 数据结构|红黑树 vs AVL:高频考点、易错判断与可能出题方式
数据结构·算法
zz-zjx1 小时前
Python实用转换模板
开发语言·前端·python