Docker 学习
刚开始用 Docker 时,我最困惑的其实不是 docker run 的参数,而是那句常见的话:“Docker 比虚拟机轻。” 轻在哪里?它省掉了什么?既然容器里看上去也有 /bin、/usr 和一个独立 IP,它和一台虚拟机的能力边界又在哪里?
后来一点点拆开看,我把 Docker 暂时理解成三个层次的产品设计:借 Linux 内核做进程隔离与资源控制;借 Linux 网络把进程接到网络里;借分层文件系统分发应用,并把数据从容器生命周期中抽出来。Docker 并没有重新发明这些内核能力,但把它们包装成了镜像、容器、网络、卷这些可交付的对象。
先和虚拟机划一条线:Docker 没有自己的 Linux kernel
虚拟机通常先由 hypervisor 虚拟一组硬件,再在其中启动一个客体操作系统和它自己的 kernel。Docker 容器则是宿主机上的一组普通进程:它有独立视图和资源约束,却仍通过宿主机 kernel 执行系统调用。
虚拟机:应用 → Guest OS → Guest kernel → 虚拟硬件 → Host kernel
容器: 应用 → 容器用户空间 ────────────────→ Host kernel
少启动一个操作系统和 kernel,意味着镜像更小、进程启动更快、同一台机器能放下更多工作负载。可这也不是免费的“轻量虚拟机”:容器共享内核,不能随意携带另一个 Linux kernel;内核漏洞、特权配置和过宽的 capability 也会让隔离边界变弱。
这也解释了我当时容易忽略的一点:在 Linux 主机上运行 Linux 容器,Docker 可以直接调用 Linux 内核的能力;在 macOS 或 Windows 上运行 Linux 容器,通常仍需要先有一台 Linux VM 提供那个 kernel。Docker 镜像带来的主要是用户空间和应用依赖,不是一整个操作系统。
1. namespace 与 cgroup:让一组进程“像在自己的机器里”
Docker 启动容器时,核心工作不是把程序变成某种新二进制,而是给它安排 namespace、cgroup、根文件系统与安全限制。namespace 负责隔离“它看见什么”,cgroup 负责约束“它最多用多少”。

上图更适合被看成一张“能力来源图”:Docker 和不同运行时的具体实现会演进,但容器隔离最终仍要落到 Linux kernel 提供的 namespace、cgroup、capability、SELinux/AppArmor、Netfilter 等机制上。图中的 libcontainer、LXC 等名称带有较早时期的实现背景;今天的 Docker 运行链路常会经过 containerd、runc,不过这条从运行时到内核能力的主线没有变化。
| 机制 | 直观问题 | 例子 |
|---|---|---|
| PID namespace | 我能看见哪些进程? | 容器内的 PID 1,在宿主机上其实是另一个 PID |
| Mount namespace | 我能看见哪些挂载? | 容器有自己的 /、/proc 与 volume 挂载点 |
| Network namespace | 我有哪些网卡、路由和端口? | 两个容器都可监听各自的 8080 |
| UTS / IPC / User namespace | 我叫什么、能与谁 IPC、我是谁? | 独立 hostname;容器内 UID 可映射为宿主机非特权 UID |
| cgroup | 我能用多少资源? | --memory、--cpus、PIDs 和 I/O 控制 |
所以一个容器并不是真的“拥有了一台机器”,而是一组被内核赋予独立视图的进程。cgroup 也不只是限制 CPU 和内存:它会记账、参与调度,并在内存超过上限时触发回收甚至 OOM。设置资源限制是在安排故障域和资源竞争,不等于保证应用永远有稳定性能。
这正是 Docker 的能力边界:它很适合把 Linux 进程及其依赖做成可重复运行的交付物;但对不同内核、需要完整硬件虚拟化,或需要极强安全隔离的场景,虚拟机或 Kata、gVisor 一类额外隔离层仍有价值。
2. bridge 与 veth:给每个容器接上一小段网线
刚开始看到容器 IP 时,我以为 Docker “虚拟出了一张网卡”。更贴切的理解是:它利用 Linux network namespace、veth pair 和 bridge,把容器网络接回宿主机。veth 是成对出现的虚拟网线,两端分别在容器和宿主机网络命名空间里;Linux bridge 则像一台二层交换机。

图中的 eth0 位于各自的容器 network namespace,另一端 veth1、veth2 留在宿主机,并被接入 docker0。因此两个容器虽然都显示为一张 eth0,但实际上是两组不同的接口和协议栈;docker0 再把它们放到同一个二层广播域中。
container netns host netns
eth0 ─ veth pair ─ vethXXX ─ docker0 bridge ─ host eth0 ─ LAN / Internet
默认 bridge 网络通常会为容器分配私网地址和默认路由。出站流量经宿主机 NAT/masquerade 离开;-p 8080:80 这类端口发布则把到达宿主机 8080 的流量,用 NAT/防火墙规则导向容器 80。容器内监听 80 和宿主机监听 80 因而不是同一件事。
如果还想再往下追一层,可以看下面这张 Linux bridge 的转发示意图。网卡被加入 bridge 后,bridge 会像交换机一样学习/查找转发表:目的端在同一 bridge 上时,数据帧从另一个端口送出;需要交给本机协议栈处理时,再向上进入 IP/TCP 等处理路径。这也是为什么排查单机 Docker 网络时,除了看容器 IP,还要看 veth、bridge、路由和防火墙规则。

还有一张图帮助我分清当时常混淆的 tap 与 veth。tap 是用户空间可以通过字符设备读写的虚拟网络接口,常见于虚拟机网络;veth 则没有一个“用户态读写端”,它的一端发出的数据直接成为另一端收到的数据。Docker 的 bridge 模式主要利用的是后者,因而能自然地把容器 network namespace 接到宿主机 bridge 上。

这套设计的好处是开发时足够直接:创建网络、把容器接进去、按名称互访、按需发布端口。用户自建 bridge 网络还会提供更好的隔离和容器间 DNS。它的边界也很明确:bridge 主要解决单机容器通信;跨多主机的地址分配、服务发现、网络策略和故障处理,需要 overlay 或 Kubernetes CNI 这样的更高层网络方案。
3. 镜像层、Copy-on-Write 与 volume:程序可复制,数据不要跟着漂
Docker 镜像不是一个完整磁盘镜像,而是一组只读的文件变更层。比如基础系统一层、安装依赖一层、复制应用代码一层;多个镜像可以共享相同的底层。创建容器时,Docker 在这些只读层之上加一个可写层;OverlayFS 将它们合并成容器里看到的一个根目录。
container rootfs(merged view)
├─ writable layer:容器运行时新写入或修改的文件
└─ read-only image layers:app → dependencies → base image
这就是 copy-on-write 的直觉:未修改的文件继续复用镜像层;第一次修改底层文件时,先复制到容器可写层,再在上层修改。它让镜像分发、构建缓存与启动复用变得高效,也是 Docker “构建一次,到处运行”体验的一部分。
但我也需要记住它的反面:容器可写层会随着容器删除而消失,而且写入已有镜像文件可能触发 copy-up。数据库、上传文件、需要长期保存或大量写入的数据,不应寄希望于这个可写层。
这就是 volume 的位置。volume 由 Docker 管理,独立于容器生命周期;bind mount 则把宿主机的指定目录直接挂进去。两者都把数据路径从镜像层和容器可写层中分离出来:
| 需求 | 更合适的选择 |
|---|---|
| 数据库数据、需要持久化的状态 | volume |
| 开发时挂入本地源码或配置 | bind mount |
| 容器运行的临时缓存 | 容器可写层或 tmpfs |
对我来说,这也是 Docker 产品设计最重要的取舍之一:镜像负责不可变交付,容器负责运行中的进程,volume 负责可变数据。把三者混在一起,容器会很快变成难以复制、难以升级的“小服务器”。
Docker 给我的不是“更小的虚拟机”
回头看,Docker 的产品价值不只是把 namespace、cgroup、bridge 和 OverlayFS 拼起来,而是给开发与运维约定了一套共同语言:镜像描述运行环境,容器描述一次运行,network 描述连接关系,volume 描述数据边界。于是应用能更容易在笔记本、测试环境和服务器间移动。
不过这套抽象没有抹去底层差异。排查容器问题时,最终仍会回到 Linux 进程、路由/NAT、文件系统和权限;知道这三条线,才不会把 Docker 当成一个无所不能的黑盒。
References
Notion 相关零散笔记
- Docker
- 虚拟化
- Filesystem
- Storage
- Network
- NAT