🔥 本文专栏: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
架构演进
→ 服务拆分
→ 部署问题
→ 环境标准化
→ 操作系统隔离
这一整条逻辑链中自然推导出来的技术方案。
