docker要解决的问题是:如何在成千上万个厨房里(服务器),以毫秒级的速度,做出一模一样的菜(应用环境)。
- Docker 本质上不是虚拟化,而是**"特殊的进程"** 。它之所以能跑起来且不干扰宿主机,全靠 Linux 内核的两个核心特性:
- Namespaces(命名空间) :负责隔离。让进程以为自己独享系统(看不见别人)。
- Cgroups(控制组) :负责限制。给进程戴上镣铐(CPU、内存不能超支)。
第一部分:镜像分层 ------ "千层蛋糕"与"透明胶带"
1. 什么是镜像分层?
在传统虚拟机时代,你要部署一个 App,得先装个几 GB 的操作系统,慢得像蜗牛。
Docker 说:"太蠢了!咱们用千层蛋糕的思路。"
假设你要做一个"Python Web 应用"镜像:
- 底层 (Base Image):铺一层 Ubuntu 系统(这是地基)。
- 中间层 (Layer 2):撒一层 Python 依赖包(这是馅料)。
- 顶层 (Layer 3) :放你的代码
app.py(这是樱桃)。
关键点来了:
Docker 有个超能力叫**"写时复制" (Copy-on-Write, CoW)** 。
当你运行这个镜像生成容器时,Docker 不会真的去复制那几百兆的底层蛋糕。它只是在最上面盖了一张**"透明保鲜膜"**(可写层)。
- 读操作:如果没有修改,直接读下面的蛋糕。
- 写操作:一旦你要改文件,Docker 就把改动写在最上面的"保鲜膜"上。
原理
上面说的还是比较高层一些,再进一步讲:分层存储和写时复制是通过联合文件系统(UnionFS) 实现的,在现代 Linux 上具体表现为 OverlayFS。
深入底层:OverlayFS 与 分层原理
Docker 的镜像不是一个大文件,而是一堆目录的叠加。
- 在 Linux 内核(3.18+)中,
overlayfs驱动负责把这些目录"骗"过应用程序,让它们看起来像是一个完整的文件系统。
1. 核心四层架构
当你启动一个容器时,内核会把四个目录挂载到一起:
| 层级名称 | 对应目录 | 权限 | 作用 |
|---|---|---|---|
| LowerDir | /var/lib/docker/overlay2/<id>/diff |
只读 | 存放镜像层。比如 Ubuntu 基础镜像的文件都在这里。可以有多个 LowerDir 层层叠加。 |
| UpperDir | /var/lib/docker/overlay2/<container-id>/diff |
读写 | 容器的真实存储。所有修改、新增文件都存这儿。删容器时这层就没了。 |
| WorkDir | /var/lib/docker/overlay2/<container-id>/work |
私有 | 用于处理"重命名"等复杂操作的临时中转站,保证原子性。 |
| MergedDir | /var/lib/docker/overlay2/<id>/merged |
视图 | 最终呈现给容器的文件系统。这是 Lower 和 Upper 合并后的"虚像"。 |
2. 代码级落地:手动实现一个"Docker"
别用 docker run,我们用原生的 Linux 命令来模拟 Docker 是怎么把层叠起来的。
假设你有两个文件夹:
-
lower:里面有个文件hello.txt,内容是 "World"。 -
upper:空的。1. 创建目录结构
mkdir -p lower upper work merged
2. 准备数据
echo "Hello World" > lower/hello.txt
3. 【核心操作】使用 mount 命令进行 Overlay 挂载
这就是 Docker 守护进程在后台偷偷干的事!
sudo mount -t overlay overlay
-o lowerdir=lower,upperdir=upper,workdir=work
merged4. 验证"分层"效果
ls merged
输出: hello.txt (看起来像是 merged 里的,其实是映射的)
cat merged/hello.txt
输出: Hello World
原理揭秘 :此时,merged 目录并没有真正占用磁盘空间存 hello.txt,它只是把 lower 目录映射过来了。
深入底层:写时复制的具体流程
当问到了"如何自动触发",在内核层面,这不是代码逻辑,而是文件系统驱动的中断处理。
场景 A:读取文件(零拷贝)
当你在容器里执行 cat hello.txt:
- 内核收到请求,去
MergedDir找。 - OverlayFS 驱动发现
UpperDir(可写层)没有这个文件。 - 它直接返回
LowerDir(只读层)里的文件句柄。 - 结果:直接读底层的磁盘块,速度极快,不消耗额外空间。
场景 B:修改文件(写时复制 COW)
当你在容器里执行 echo "Docker" > hello.txt:
- 拦截 :内核发现这是一个写操作。
- 检查 :OverlayFS 驱动检查
UpperDir,发现里面没有hello.txt。 - 复制 :驱动立刻把
LowerDir里的hello.txt复制 一份到UpperDir。 - 写入 :所有的修改操作,实际上是在对
UpperDir里的副本进行的。 - 遮蔽 :从此以后,
MergedDir再看到hello.txt,展示的就是UpperDir里的版本。底层的原版被"挡住"了。
场景 C:删除文件(白盲遮挡)
这是最骚的操作。当你执行 rm hello.txt:
- 内核不能在
LowerDir删文件(那是只读的镜像)。 - 它会在
UpperDir里创建一个特殊的字符设备文件 (Whiteout file),通常以.wh.开头。 - 效果 :OverlayFS 驱动看到这个特殊标记,就会告诉应用:"这文件没了",尽管它在
LowerDir里还活得好好的。
2. 代码落地:为什么顺序很重要?
很多新手写 Dockerfile 就像乱搭积木,导致构建巨慢。
原理:Docker 是从上往下检查缓存的。如果上一层变了,下面所有的层都要重新构建。
错误写法(把变动频繁的放在前面):
# 每次你改一行代码,这一层就失效了
COPY . .
# 导致这层即使没变,也得重新跑一遍 pip install,浪费生命!
RUN pip install -r requirements.txt
正确写法(利用缓存,像剥洋葱一样从不变到万变):
FROM python:3.9-slim
# 第一层:系统依赖(几乎不变)
RUN apt-get update && apt-get install -y gcc
# 第二层:只复制依赖文件(变动频率低)
COPY requirements.txt .
# 第三层:安装依赖(耗时最长,但利用了缓存)
# 只要 requirements.txt 不改,这步直接秒过!
RUN pip install -r requirements.txt
# 第四层:复制代码(天天变)
COPY . .
CMD ["python", "app.py"]
效果 :你改了 app.py,Docker 发现前三层没变,直接复用缓存,几秒钟就构建完了。
第二部分:容器网络 ------ "虚拟网线"与"隐形门神"
容器之间怎么说话?容器怎么上网?这背后是 Linux 内核的三个狠角色在配合:Namespace、Veth Pair、Bridge。(注意:容器网络不是虚拟出来的交换机,而是 Linux 内核的网络栈隔离)
1. 隔离:Namespace( namespace 是啥?)
每个容器启动时,Docker 都会给它发一个**"独立房间"** (Network Namespace)。
在这个房间里,容器以为自己拥有全世界:它有独立的 IP、网卡、路由表。它根本不知道宿主机和其他容器的存在。
-
宿主机 :有
eth0,lo。 -
容器 :刚创建时,除了
lo(回环接口)什么都没有,是个瞎子。1. 创建一个新的网络命名空间,名字叫 my-container
sudo ip netns add my-container
2. 查看它里面的网卡(此时只有 lo)
sudo ip netns exec my-container ip addr
输出: 1: lo: <LOOPBACK> ...
3. 此时,这个 namespace 里的进程完全看不到宿主机的 eth0
2. 连接:Veth Pair(虚拟网线)
既然容器在独立的房间里,它怎么跟外面联系?
为了让隔离的容器能上网,内核提供了一根管状设备:Veth Pair 。
它有两个端点,像一根管子:数据包从 A 进,必然从 B 出。
- 一头 (eth0):插进容器的Namespace房间里。
- 一头 :插在宿主机的Namespace里,连到交换机(docker0 网桥)上。
这样,数据包就能顺着网线流出来了。
# 1. 创建一对虚拟网线:veth-host (宿主机端) 和 veth-container (容器端)
sudo ip link add veth-host type veth peer name veth-container
# 2. 把 veth-container 这一头扔进刚才创建的 my-container 命名空间
sudo ip link set veth-container netns my-container
# 3. 在容器内部配置 IP 和启动网卡
sudo ip netns exec my-container ip addr add 172.17.0.2/16 dev veth-container
sudo ip netns exec my-container ip link set veth-container up
sudo ip netns exec my-container ip link set lo up
# 4. 在宿主机启动另一头,并连到 docker0 网桥(模拟)
sudo ip link set veth-host up
sudo brctl addif docker0 veth-host
3. 转发:Bridge & Iptables(隐形门神)
数据出了容器,到了宿主机的 docker0 网桥(相当于一个大路由器)。
这时候,Iptables (Linux 的防火墙)登场了,它像个精明的门神,负责翻译和指路。
Docker 启动时会自动写入几条 NAT 规则:
- POSTROUTING (SNAT) :当数据包从
docker0网桥流向物理网卡eth0时,把源 IP(容器 IP)伪装成宿主机的 IP。 - PREROUTING (DNAT) :当你访问
宿主机IP:8080时,Iptables 根据端口映射规则,把目标 IP 修改为容器IP:80,然后转发过去。
场景
-
场景一:容器访问外网 (SNAT)
- 容器说:"我要访问百度!"
- 门神(Iptables)一把抓住数据包,把源地址从容器的内网 IP (
172.17.0.2) 改成宿主机的公网 IP。 - 百度回信给宿主机,门神再查小本本,把信转交给容器。
-
场景二:外部访问容器 (DNAT / 端口映射)
- 你在浏览器访问
宿主机IP:8080。 - 门神一看:"哟,8080 端口?这是映射给 Nginx 容器的!"
- 门神瞬间修改目标地址,把数据包扔给
172.17.0.3:80(Nginx 容器)。
- 你在浏览器访问
实战演练:手写网络配置
别光听理论,我们来看看怎么用代码玩转这些网络模式。
1. 默认的 Bridge 模式(自带 DNS 的自定义网络)
默认的 docker0 网络不支持通过容器名互访。生产环境一定要创建自定义网络。
# 1. 创建一个专属局域网
docker network create my-app-network
# 2. 启动 Redis,连入局域网
docker run -d --name redis-server --network my-app-network redis
# 3. 启动 Python 应用,连入同一个网络
# 注意:这里可以直接 ping 通 'redis-server',不需要记 IP!
docker run -it --rm --name python-app --network my-app-network alpine ping redis-server
底层发生了什么? Docker 内置的 DNS 服务器自动把 redis-server 解析成了它的内网 IP。
2. Host 模式(性能狂魔)
如果你嫌 NAT 转发(门神检查)太慢,想要极致的网络性能,可以让容器共享宿主机的网络栈 。
这就好比容器不住单间了,直接住进了宿主机的豪宅里,共用一个大门口。
# 容器没有独立 IP,直接占用宿主机的 80 端口
docker run -d --net=host nginx
代价:端口容易冲突,隔离性差。一般用于高性能网关或监控探针。
3. Container 模式(Sidecar 模式)
这是 Kubernetes 里的经典玩法。让一个新容器共享另一个容器的网络(IP 和端口都一样)。
比如:业务容器 + 日志收集容器。
# 1. 启动主容器
docker run -d --name main-app my-app
# 2. 启动日志容器,共享 main-app 的网络
# 它们通过 localhost 就能互相访问,就像在一个进程里一样
docker run -d --name log-agent --net=container:main-app fluentd
| 核心概念 | 通俗比喻 | 技术术语 | 作用 |
|---|---|---|---|
| 镜像分层 | 千层蛋糕 | UnionFS / Overlay2 | 节省空间,秒级分发,复用缓存。 |
| 容器运行 | 蛋糕上加保鲜膜 | Copy-on-Write | 隔离修改,保护原镜像不被破坏。 |
| 网络隔离 | 独立房间 | Network Namespace | 让容器以为自己是独立的电脑。 |
| 网络连接 | 虚拟网线 | Veth Pair | 连通容器与宿主机的通道。 |
| 流量转发 | 智能门神 | Iptables / NAT | 负责地址转换,让内外网互通。 |
终极总结:Docker 的本质
Docker 并没有什么魔法,它只是一个高明的组装工:
- 利用 UnionFS (Overlay2) :把一堆只读目录和一个读写目录,通过内核挂载,拼成一个看起来完整的目录给进程用。写时复制是内核驱动在截获写操作时自动完成的。
- 利用 Namespaces:给进程造个假象,让它以为自己有独立的 PID、网络和挂载点。
- 利用 Cgroups:给进程套上枷锁,防止它吃光内存 CPU。
- 利用 Veth Pair + Iptables:给隔离的进程拉根网线,并配置路由规则让它能上网。
所谓的"容器",就是带着这些镣铐和面具运行的普通 Linux 进程而已。