聊到 Docker,我经常发现很多人的卡点不在命令,而在概念。前阵子带一个新同事熟悉部署环境,他盯着 Dockerfile 看了半天,问了一句话:“镜像是不是像一个免安装的 ISO,跑起来就是一个虚拟机?” 这个问题我太熟悉了,因为它代表了大多数初学者对 Docker 核心概念与架构的第一印象,而这个印象恰好是错的。Docker 之所以重要,不是因为它能把程序“打包”,而是因为它重新定义了软件交付和运行的方式。这篇内容我不打算堆砌 docker run 的参数,而是把镜像、容器、仓库、daemon、分层文件系统、网络、存储和隔离这几条主线彻底打通。想系统理解 Docker,或者部署时总被奇怪问题卡住的读者,可以顺着这条线走一遍。
1. 先从“装系统”的痛点说起:容器到底解决了什么问题
1.1 虚拟机的三座大山:资源、时间、一致性
在容器出现之前,我们解决“环境不一致”最常用的手段是虚拟机。一个虚拟机相当于一台完整的电脑,里面有虚拟的 CPU、内存、磁盘和网卡,还要预装一整个操作系统。它的隔离效果确实好,但代价也很明显:每次部署都要从头配环境,启动要等操作系统引导完成,磁盘占用动辄几个 GB,且物理机上跑三台虚拟机,光系统本身就要吃掉大半内存。
我印象很深的一次经历,是早年在一台只有 8G 内存的服务器上部署三个服务。每个虚拟机分配 2G 内存,加上操作系统的常驻开销,真正留给业务进程的余量所剩无几。更难受的是,每次换环境都要重来一遍:装依赖、调配置、改路径,任何一步和文档不一致,服务就是起不来。这种“能在我机器上跑”的尴尬,本质上是交付物和运行环境绑定得太紧。
1.2 容器不是在模拟机器,而是在“约束进程”
容器换了一条完全不同的思路:它不虚拟一套硬件,也不启动一整个操作系统,而是直接复用宿主机的内核,把用户空间里的进程、文件系统、网络资源打包成一个个彼此隔离的运行环境。
打个比方,虚拟机像整房出租,你拥有独立的墙、水电和门锁;容器则像是在同一栋楼里合租,大家共用承重墙和公共管线,但每个房间有自己的房门、家具和规定。这个“合租”模式带来的直接好处就是启动快、占用小。一个容器本质上就是一个被 namespace 和 cgroup 包裹起来的进程,启动它不需要引导内核,所以只要几百毫秒就能起来,镜像体积也能控制在几十 MB 甚至几 MB。
这也是为什么容器适合做微服务和持续交付:进程粒度、秒级启动、环境一致性高,开发机上的容器和服务器上的容器共享同一份镜像。
1.3 一张表看清虚拟机与容器的差距
| 对比维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离层级 | 硬件级虚拟化,Guest OS 独立内核 | 内核级隔离,所有容器共享宿主机内核 |
| 启动速度 | 秒级到分钟级,需要完整引导 | 毫秒级到秒级,本质是启动进程 |
| 资源占用 | 每个 VM 都要完整系统开销 | 只多一层轻量运行时开销 |
| 镜像体积 | 通常数 GB | 通常几十 MB 到几百 MB |
| 跨平台能力 | 可运行不同操作系统的 Guest | 受宿主机内核类型限制 |
| 安全边界 | 强隔离,崩溃影响范围小 | 隔离共享内核,边界相对薄弱 |
这张表每一条展开都能讲很多,但最核心的一句话是:容器不是轻量级虚拟机,它是“加了隔离配置的进程”。理解了这一点,后面所有的架构设计就都有了主线:隔离是内核特性,资源限制是内核机制,而 Docker 只是把这些机制包装成了好用的用户接口。
2. 镜像不是安装包:镜像、容器、仓库之间的血缘关系
2.1 镜像是一堆只读层的堆叠,不是打完包就完事
很多人把镜像理解成“软件的安装包,拷到哪里都一样”,这个说法只对了一半。安装包通常是单个文件,而 Docker 镜像是一个分层结构。你可以用docker history查看一个镜像的构建记录,看到的每一行都对应 Dockerfile 里的一条指令,每一行都会在原有基础上增加一个只读层。
这些只读层会按照顺序堆叠起来,最终从外部看像一个完整的操作系统目录结构。镜像的不可变性在这里非常关键:层一旦生成就不会再被修改,后续的变更只能通过新增层来实现。这就引出了镜像的另外一个优点——复用。如果你本地已经拉过基础镜像,那么基于它构建的其他镜像只需要下载新增的层,这也是为什么同时跑几十个互相之间只差一层的小服务,磁盘占用依然可控的原因。
2.2 容器是镜像的动态实例,生命周期比想象中短
镜像和容器的关系,很多人用“类和对象”来类比,我觉得再贴切不过。镜像定义了文件系统的初始状态,容器则是这个状态的运行实例。当你执行docker run时,Docker 会在镜像的只读层之上创建一个可写层,然后在这个合并后的文件系统里启动进程。
这个可写层就是容器运行期间所有文件变更的落点。你往里写文件、装软件、改配置,都不会动到镜像本身。但要注意,这个可写层的生命周期和容器绑定:容器被删除,可写层一起消失。如果数据库跑在容器里,数据只是存在这个易失的可写层,那删容器等于删数据。这正是后面要讲数据卷的原因:可写层是临时的,卷才是持久的。
2.3 仓库与标签:镜像的“远程地址”和版本管理
镜像的流转靠仓库完成,类似代码托管平台之于代码库的关系。公共仓库有 Docker Hub,私有仓库可以自己搭。docker pull nginx:alpine这条命令里的nginx是镜像名,alpine是标签,两者组合表示一个具体版本。
标签的问题在于它是可变的。同一个标签可能被反复覆盖,今天拉到的内容和明天拉到的内容未必一致。所以生产环境里更严谨的做法是记录镜像的摘要值,也就是sha256:...形式的 digest。部署的时候可以通过 digest 精确锁定一个不可变的版本,避免“同一个 tag 却在两台机器上拉出不同镜像”的尴尬。我自己见过不止一次因为 tag 覆盖导致的线上事故,从那以后脚本里能锁定 digest 的地方绝不只写 tag。
2.4 容器状态机与常见认知误区
一个容器从创建到销毁,会经历几个状态:created、running、paused、exited。创建后还没启动是 created,启动后在跑是 running,暂停是 paused,进程退出后是 exited。很多人以为容器停了就和文件一样存着,其实容器由可写层和运行配置组成,docker stop之后可写层还在,但docker rm之后一切都没了。
还有一个高频误区是docker commit。有些初学者把运行中的容器直接 commit 成新镜像,确实能用,但它会把容器运行期间的临时文件、日志、历史命令残留一起塞进镜像,生成出来的层又大又脏,没法用 Dockerfile 复现。我的建议很直接:commit 只适合做现场抢救,绝不适合做正规交付。正规做法永远是把变更写进 Dockerfile,重新构建镜像,让镜像的每一层都清晰可控。
3. 一次 docker run 命令触发的完整架构链路
3.1 从客户端到 daemon:CLI 不是执行者,只是传话人
如果你只用docker ps、docker logs,可能感觉 Docker 就是一个简单命令。但真正执行一次docker run,后台会牵动一整条组件链。这一切的起点,是 Docker 的客户端与服务端分离设计。
docker这个命令行工具本身并不创建容器,它只是把命令转换成 API 请求,发送给本地的 Docker daemon(通常监听在/var/run/docker.sock)。daemon 才是真正的“项目经理”:它负责解析镜像、创建容器、分配网络、挂载卷、管理生命周期。所以你会看到 Docker 同时存在两个进程:一个是客户端,一个是dockerd后台服务。理解了这一点,你就能明白为什么换掉DOCKER_HOST环境变量就能操作远程主机的 Docker——因为客户端和 daemon 本来就是通过 HTTP 风格接口通信的。
3.2 containerd、shim 与 runC:为什么现代 Docker 不再是“一个大 daemon 包办一切”
早期 Docker 确实是 daemon 直接管理容器进程,但后来架构做了非常关键的一次拆分。现在的 Docker Engine 里,dockerd负责镜像构建、网络管理、API 编排等上层功能,真正和容器进程打交道的是containerd,而创建并运行容器的是更底层的runc。
先看一张我习惯画的进程链路图:
docker CLI └─> dockerd └─> containerd ├─ containerd-shim │ └─ runc -> 容器进程 └─ containerd-shim └─ runc -> 容器进程这个结构里最容易被忽略的是containerd-shim。它为每个容器进程建立一个独立的守护进程,负责接管容器的标准输入输出、退出状态和信号转发。它的存在带来一个非常实际的好处:即使 dockerd 本身崩溃或重启,shim 依然托管着正在运行的容器进程,容器不会跟着一起死掉。这在大规模生产环境里是保命的设计。
runc是真正的容器进程创建者,它根据 OCI 运行时规范,调用内核的 namespace、cgroup 等能力把容器进程拉起来。容器创建完成后,runc的使命基本结束,后续托管交给 shim。
3.3 OCI 标准:让上下游不必互相绑定
架构拆分背后还有一层更深的意义——标准化。Docker 后来把镜像格式和运行时规范捐给了开放容器倡议组织,也就是 OCI。现在只要符合 OCI 规范的镜像,就能跑在 runc、runc-based 的 containerd、以及其他容器运行时里。Kubernetes 能通过 CRI 协议对接不同的容器运行时(包括 containerd),靠的也是这套标准化思路。
所以我在讲 Docker 架构时总会提醒一句:现在的 Docker 只是容器生态里的一个发行版,它打包了 CLI、daemon、containerd、runc 这些组件,但它们各自都可以被替换或独立使用。理解了这条组件边界,再看服务器上那一堆 docker 相关进程,你就不会觉得它们是在重复干活了。每个组件只负责自己的那一层,出了问题也能快速定位是 API 层、运行时层还是内核层。
4. 分层文件系统与写时复制:镜像省空间的原理和翻车现场
4.1 overlay2 与镜像层:联合挂载是怎么把碎片拼成系统的
镜像层堆叠之后如何变成容器里能看到的完整文件系统?现代 Docker 默认使用 overlay2 存储驱动,它的思路是准备三个目录:只读的下层目录、可写的上层目录、以及合并视图目录。容器里的进程看到的是合并视图,但它对文件的读写其实发生在不同层级。
docker history能让你直观看到层的体积。以 nginx 镜像为例,你会看到每一层都有一个 SIZE 列,这些体积累加起来往往大于镜像压缩后的大小,因为共享层会在多个镜像间复用。我加了很多调试信息、临时文件再删掉,最终镜像体积却一点没变小,就是因为“删除”只是在新层里标记删除,原来层里的文件实体还在,直到所有依赖它的镜像都不再引用,空间才可能被回收。
4.2 写时复制:容器里改文件到底改在哪里
写时复制是容器文件系统的核心机制。当容器里的进程第一次修改某个位于只读层的文件时,overlay2 会把该文件完整地复制到容器的可写层,然后在可写层里进行修改。复制一次之后,后续读写都走可写层,不再影响下面的只读层。
这个机制的好处非常明显:多个容器基于同一个镜像运行,各自修改各自的可写层,互不干扰,而镜像层保持不变,内存和磁盘都能共享。但它也有一个容易被忽略的点:第一次写入小文件的开销等于复制整个文件的开销。如果一个文件特别大(比如几 GB 的模型文件、数据库文件),容器第一次触发写时复制时会有一段时间的延迟。所以高并发场景下,这类大文件最好直接通过数据卷挂载进来,别放在镜像层里。
4.3 Dockerfile 分层陷阱:缓存、脏层和“删不掉的体积”
分层机制给构建带来不少隐藏的坑,我踩过最典型的有三个。
第一个是缓存失效问题。Docker 构建时如果某一条指令的上下文变了,它之后的层缓存全部失效。所以 Dockerfile 里应该把“不容易变”的指令放前面,比如先复制go.mod、package.json这类依赖清单并执行依赖下载,再复制源码。否则每次改一行代码,整个依赖下载都要重来一遍,构建时间会从几秒暴涨到几分钟。
第二个是脏层问题。经典的错误写法是:
RUN apt-get update RUN apt-get install -y some-package RUN rm -rf /var/lib/apt/lists/*每一条 RUN 都会生成一个新层,最后那条rm只是在新层里删除文件,前面两层里下载的索引文件依然占用空间。正确处理是把它们合并成一个 RUN:一个指令做完下载、安装、清理,体积才能按住。这也是为什么大量优秀镜像里,你能看到一长串用&&连接的命令,末尾再统一清理。
第三个是构建上下文过大问题。docker build默认会把当前目录整个打包发送给 daemon,如果不写.dockerignore,几 GB 的 node_modules、临时目录都会成为构建开销,还可能把不该进镜像的文件带进去。.dockerignore和.gitignore一样,应该是每个项目的标配。
另外,现代构建里把多阶段构建用起来,能彻底摆脱“开发环境依赖留在运行时镜像”的尴尬。先用一个大而全的基础镜像编译,最后只把编译产物拷进一个精简镜像,这是压缩镜像体积最有效的手段之一。
5. 网络与存储:架构里最容易走错的两条岔路
5.1 五种网络模式怎么选,以及默认 bridge 的真实行为
容器的网络模式有五种:bridge、host、none、container、overlay。默认是 bridge,宿主机上会生成一个叫docker0的虚拟网桥,每个容器通过虚拟网卡接入这个网桥,再通过 NAT 转发到外部网络。
你执行docker run -p 8080:80时,Docker 会在宿主机上开启一个端口映射,外部请求先到宿主机的 8080 端口,经过 iptables 的 DNAT 规则转发到容器的 80 端口。这个机制在 Docker Desktop 上表现得比较“隐蔽”,因为桌面版在虚拟机里跑,端口的实际转发链路多了几层,所以偶尔会出现容器内部通、宿主机访问不了的情况,排查时要有这个预期。
host 模式让容器直接使用宿主机的网络命名空间,没有额外转发,网络性能和端口使用方式都和宿主机进程一样。但它牺牲了隔离性,端口冲突也由你自己负责。none 模式适合完全不需要网络的场景,或者你想自己定制网络堆栈的时候。container 模式则是和另一个容器共享网络命名空间,适合“网络代理”或者需要抓包调试的临时场景。
5.2 同一个用户自定义网桥下,容器怎么用名字互相访问
还有一个非常实用但常被忽略的知识点:默认的docker0网桥不支持容器间的 DNS 名字解析,容器之间要用 IP 访问,但 IP 会随容器重启而变,极其脆弱。解决办法是创建用户自定义网桥:
docker network create myapp-net docker run -d --network myapp-net --name web nginx docker run -d --network myapp-net --name api my-image在同一个用户自定义网桥上,容器可以直接用名字互相访问,比如在 api 容器里curl http://web就能通。Docker 自带的 DNS 服务会自动解析容器名。生产环境建议把同一套应用的前后端放进同一个自定义网络里,外部只需要暴露一个入口。这比把每个容器都映射到宿主机端口然后靠 IP 硬连要安全得多,也符合微服务架构里“内部网络只在内部可见”的原则。
5.3 数据卷、绑定挂载和 tmpfs:数据该放哪一层
容器数据有三条主要出路:绑定挂载、命名卷和 tmpfs。
绑定挂载直接把宿主机目录挂进容器,格式是-v /host/path:/container/path,适合开发环境放代码、放配置,能实现宿主机改了容器立刻生效。但绑定挂载与宿主机目录强耦合,跨主机迁移时要把整个目录一起搬走,灵活性差点。
命名卷是 Docker 管理的卷,存放在 Docker 数据目录下,用docker volume create或-v mydata:/container/path创建。它和宿主机目录解耦,备份迁移都有专门的命令,适合数据库这类需要持久化和保密数据的场景。tmpfs 是内存盘,数据不落盘,适合放临时文件或敏感缓存,进程退出即清空。
-v和--mount的区别也值得提一句:--mount语法更显式,能明确写出type=volume、source、target,不容易出现-v那种“目录不存在就自动创建成空目录并覆盖”的误伤。我的建议是脚本里优先用--mount,可读性和可维护性都更好。
5.4 卷备份和权限问题的现场处理
卷的备份其实很简单,借助一个临时容器就能完成:
docker run --rm -v mydata:/data -v $(pwd):/backup alpine \ tar czf /backup/mydata.tar.gz -C /data .这里利用了--rm一次性容器:它挂载了要备份的命名卷和宿主机当前目录,执行完打包命令就自动删除。恢复时反向解压即可。
权限问题是卷使用中最常见的现场事故:容器里的进程以非 root 用户运行,但挂载进来的宿主机目录属主是 root,进程没有写权限;或者反过来,容器以 root 写入文件,宿主机上的普通用户无法清理。解决思路是保证两边 UID 一致:要么在 Dockerfile 里指定USER为固定 UID,要么创建卷时先调整目录属主,要么在运行容器时用--user指定 UID。数据备份和权限这两件事,建议在写第一个挂载卷的时候就养好习惯,不然等数据量大了再改,迁移成本非常高。
6. 隔离的边界:Namespace 与 Cgroup 到底隔离了什么
6.1 Namespace 制造“视图隔离”,Cgroup 负责“资源配额”
容器隔离的两大支柱是 Linux 的 namespace 和 cgroup。namespace 决定了一个进程能“看见”什么,cgroup 决定了它最多能用多少资源。
namespace 有六种基本类型:PID 隔离进程视图,Network 隔离网络栈,Mount 隔离文件系统挂载点,UTS 隔离主机名,IPC 隔离进程间通信,User 隔离用户 ID。容器启动时,runtime 会为它创建一组新的 namespace,让容器里的进程以为自己是“单独的机器”:自己的 PID 1 就是主进程,自己的 hosts 文件叫某个名字,自己的网络接口和路由表独立存在。
cgroup 则负责资源上限,比如--memory限制内存、--cpus限制 CPU 时间、--device-read-bps限制磁盘读取速率。如果没有 cgroup 限制,一个失控进程可以把整个宿主机的 CPU 吃满,其他容器和服务全部遭殃。我在压测环境里见过一次内存泄漏的容器把宿主机打到 OOM,从那以后给核心服务配置资源和限制就成了我部署清单里的必选项。
6.2 共享内核带来的约束:容器能跑什么由宿主机决定
既然所有容器共享宿主机内核,那容器能跑什么就有了一条硬边界:宿主机内核不支持的东西,容器里也不可能凭空出现。比如一个依赖特定内核模块的应用,在普通容器里会加载失败,除非显式调整权限;一个需要修改系统级参数的场景,容器里的变更可能只对当前容器命名空间生效,也可能直接写不了。
举一个实际例子:容器里执行uname -r,看到的版本与宿主机一致,因为根本没有独立内核。再比如/proc/meminfo在某些实现里反映的是宿主机内存,而不是容器的 cgroup 限制值。很多人看到“进程里显示的内存和限制不符”就慌了,这其实是共享内核的常态,不是配置出错。
正因为共享内核,容器的隔离是“视图隔离”和“配额隔离”,不是“硬件隔离”。这也解释了为什么两个容器之间虽然彼此看不见进程,但宿主机上ps aux能看到所有容器进程,因为它们自始至终都在同一个内核上运行。
6.3 安全边界怎么把握:非 root 运行、只读根目录与能力裁剪
理解了隔离边界,就不难理解容器安全的基本思路:默认情况下容器内 root 并不是宿主机 root,但如果不做用户命名空间映射,这个 root 的权限范围依然远大于你的想象。官方安全基线里反复强调的一件事就是不要在容器里用 root 跑应用。
我自己在 Dockerfile 里已经养成固定写法:创建普通用户,USER指令切换到非 root 用户,运行参数里加--read-only让根文件系统只读,确需写数据的目录单独挂卷,再用--cap-drop ALL --cap-add裁剪内核能力。--privileged这个参数我基本只有调试真实内核问题的时候才用,生产容器里出现它基本可以视为安全告警。
这些约束听起来麻烦,但它们和容器“一次构建、到处运行”的初衷并不矛盾。镜像用好它的人,构建出来的产物体积小、可复现、权限清晰,生命周期里出的故障也少。下次再遇到部署问题,先别急着进容器里改东西,检查一下 namespace、资源限制、卷归属和用户权限,大概率比瞎试命令管用。