现在你大概是这种状态:
docker run/build/ps都会用,照着别人的docker-compose.yml也能把服务起起来。但只要稍微出点岔子------镜像怎么几个 G、改一行代码为什么要重新 build 半天、容器一删数据就没了、两个容器为什么localhost死活连不上------你就完全不知道里面发生了什么。这篇文章不教命令清单。读完你会拿到 5 个心智模型,以后遇到没背过的命令,能自己推理出「它会动哪一层、改什么状态」。
先说大多数 Docker 教程的问题
上来就是一张命令表:docker run 是跑容器,docker build 是构建镜像,docker ps 是看容器,docker exec -it 是进容器......
然后某天镜像构建慢得离谱,你去搜「docker build 加速」,一堆 --cache-from、多阶段构建、.dockerignore,完全不知道该用哪个,也不知道为什么。或者容器跑挂了,日志一闪而过,你 docker run 重开一个,发现昨天导进去的数据全没了。
根本原因和学 Git 时一模一样:你装的是命令列表,不是心智模型。
有了心智模型,遇到没见过的情况能自己推理;没有的话,每个新参数都是新的记忆负担。
如果你看过我上一篇《Git七天速通》,会发现这两件事的内核惊人地像:Git 的 commit 是一串只读快照,Docker 的镜像也是一串只读层。把那篇里「快照」的直觉搬过来,这篇会轻松很多。
第一个模型:镜像是「只读分层快照」,容器是最上面加的一层可写层
这是最值得先掰正的直觉。
很多人以为镜像就是个「安装包」或者「虚拟机的硬盘文件」------一整块东西。
不是的。
镜像是一摞只读的「层」(layer)叠起来的。 每一层是文件系统的一次增量改动,下层不可变,上层盖在下层之上。最终你看到的文件系统,是所有层从下往上叠加后的合并视图。
sql
┌─────────────────────────┐ ← 容器可写层(读写,容器独有)
镜像 ↑ ├─────────────────────────┤
(只读)├─ COPY 你的代码 │
├─ RUN 装依赖 │
├─ FROM openjdk:8(基础层)│
└─────────────────────────┘
那容器是什么?容器 = 镜像的那摞只读层 + 最上面新加的一层可写层。
镜像本身一个字节都不会被改。你在容器里所有的修改------新建文件、改配置、写日志------全发生在最上面那层薄薄的可写层里。这个机制叫写时复制(Copy-on-Write) :你要改一个来自下层的只读文件时,Docker 先把它复制一份到可写层,再让你改副本,下层原件纹丝不动。
这一条直接推出三个平时想不通的现象:
- 为什么同一个镜像能跑 100 个容器还不占 100 份空间:100 个容器共享同一摞只读层,各自只多了一层可写层。
- 为什么容器删掉,里面的改动就没了:可写层是跟着容器走的,容器一删,那层连同你的改动一起蒸发。下面讲数据卷时会回到这点。
- 为什么
docker commit能把容器存成新镜像:无非是把那层可写层「固化」成一个新的只读层贴上去。
验证你是否真的理解了
问自己:基于 nginx 镜像起一个容器,进去把首页 index.html 改了,然后再用 同一个 nginx 镜像 起第二个容器,第二个容器的首页是改过的还是原版的?
答案:原版的。你的修改在第一个容器的可写层里,镜像的只读层没动,第二个容器叠的是干净的只读层。
第二个模型:容器不是「轻量虚拟机」,是被「障眼法」隔离的普通进程
这是 Docker 最反直觉、也最关键的一点。
虚拟机是真的虚拟了一套硬件,上面跑一个完整的操作系统内核。容器完全不是这样。
容器里的进程,就是宿主机上一个普普通通的进程,和你的其他进程共用同一个 Linux 内核。 Docker 只是给它戴了两副「眼镜」,让它以为自己独占了一台机器:
- namespace(命名空间)------管「看得见什么」 :给进程一套被裁剪过的视图。PID namespace 让它以为自己是 1 号进程,看不到宿主机其他进程;NET namespace 给它一套独立的网卡和 IP;MNT namespace 给它独立的文件系统挂载视图。本质是障眼法,资源还是宿主机的,只是被遮住了。
- cgroup(控制组)------管「能用多少」 :限制这个进程能用多少 CPU、多少内存。你
docker run -m 512m限的内存,就是 cgroup 在干活。
把这两个模型合起来:容器 = 一个进程 + namespace(隔离视图)+ cgroup(限制资源)+ 一个独立的根文件系统(来自镜像)。 没有第二个内核,没有虚拟硬件。
这解释了为什么容器秒起秒停、为什么比虚拟机省那么多资源------它压根没启动一个操作系统,只是 fork 了个被圈起来的进程。
一个能当场验证的实验
在宿主机上跑一个容器,里面起个 sleep 9999。然后在宿主机 上 ps aux | grep sleep------你会直接看到这个进程,PID 是宿主机的真实 PID。同一个进程,在容器里看自己是 PID 1,在宿主机看是 PID 30421。同一个进程,两套视角,这就是 namespace。
容易踩的坑
正因为共用宿主内核,容器跑的镜像必须和宿主内核兼容。这也是为什么 Linux 镜像不能直接跑在没有 Linux 内核的环境里------Docker Desktop 在 Mac/Windows 上其实偷偷塞了一个轻量 Linux 虚拟机来提供内核,你的容器是跑在那个 VM 里的。理解了「容器靠宿主内核」,这件事就不再神秘。
第三个模型:构建镜像是「一层层叠加 + 缓存」,所以指令顺序决定快慢
回到第一个模型:镜像是一摞层。那这摞层是谁生成的?Dockerfile 里几乎每一条指令,都生成一个新层,盖在上一条的结果之上。
bash
FROM openjdk:8 # 第 1 层:基础镜像
COPY build.gradle . # 第 2 层
RUN ./gradlew dependencies # 第 3 层:把依赖拉下来
COPY src ./src # 第 4 层:拷源码
RUN ./gradlew build # 第 5 层:编译
关键机制是构建缓存 :Docker 逐条执行指令,如果某条指令和它依赖的上下文都没变,就直接复用上次缓存的那一层,跳过执行 。一旦某一层变了,它以及它之后的所有层缓存全部失效,必须重建。
现在「改一行代码 build 半天」的谜题解开了------如果你的 Dockerfile 是这样写的:
bash
COPY . . # 先把所有源码拷进来
RUN ./gradlew build # 再装依赖 + 编译
那你随便改一行业务代码 ,COPY . . 这层就变了,它后面的「装依赖 + 编译」全部缓存失效,依赖被迫重新拉一遍。慢就慢在这。
正确姿势是把不常变的放前面,常变的放后面:先单独拷依赖描述文件、装依赖(这层只要依赖不变就一直命中缓存),最后才拷源码、编译。这样改业务代码时,沉重的「装依赖」那层稳稳命中缓存,只重跑最后的编译。
心智模型一旦建立,.dockerignore、多阶段构建这些「优化技巧」你都能自己推理出来:前者是减少进入构建上下文的文件、避免无谓的缓存失效;后者是让最终镜像只保留产物层、把笨重的编译层丢掉。它们不是要背的招式,是这个模型的自然推论。
验证你是否真的理解了
问自己:RUN apt-get update 和 RUN apt-get install -y curl 分两条写,第二次构建时改了别的层导致它俩重跑,会有什么隐患?
答案:update 拉的是构建那一刻的软件源索引,可能被缓存到很旧;之后 install 命中的是过期索引,可能装到老版本甚至装不上。所以社区惯例是把它俩合进同一条 RUN (apt-get update && apt-get install -y curl),让它们绑定在同一层,要失效一起失效。
第四个模型:容器可写层是「即用即弃」的,数据要活下来必须挂卷
接着第一个模型往下推:容器的可写层跟着容器生死。容器一删,可写层连同里面所有改动一起消失。
所以「把数据写进容器」是个陷阱。 你在容器里跑的 MySQL,数据默认就写在那层可写层里------容器一重建,库就空了。这正是无数人「容器一删数据就没了」的根因:不是 Docker 弄丢了数据,是数据本来就存在一个设计上就会被丢弃的地方。
解法是把数据存到容器之外,主要两种:
- volume(数据卷) :由 Docker 管理的一块独立存储,挂载到容器内某个路径。容器删了,卷还在,下个容器挂上同一个卷就能接着用。生产持久化数据(数据库文件等)用它。
- bind mount(绑定挂载) :直接把宿主机的某个目录挂进容器。本地开发时把源码目录挂进去、改完即时生效,用的就是它。
心智模型:容器是即用即弃的「一次性进程」,凡是要比容器活得更久的东西,都得放到容器外的卷里。 把容器当成可以随时 kill 掉重建的东西来设计,是用好 Docker 的分水岭。
容易踩的坑
docker run -v /data 这种匿名卷 会偷偷在宿主机生成一个随机名字的卷,容器删了它还在、还占着磁盘。一台跑了很久的机器上 docker volume ls 经常一长串无名僵尸卷,全是这么攒出来的。要么用具名卷 (-v mydata:/data)方便管理,要么定期 docker volume prune 清理。
第五个模型:容器默认各自一套网络,靠虚拟网桥互通
最后一个高频困惑:两个容器,一个跑应用一个跑数据库,应用里写 localhost:3306 连数据库,死活连不上。
回到第二个模型------每个容器有自己的 NET namespace ,也就是自己独立的一套网卡、IP、端口空间。所以:
在容器里,localhost 指的是「这个容器自己」,不是宿主机,更不是别的容器。 应用容器里的 localhost:3306 是在找应用容器自己的 3306 端口,那儿当然没有数据库。
容器之间怎么通?Docker 默认建了一个叫 docker0 的虚拟网桥,每个容器用一根虚拟网线(veth pair)接上去,相当于插在同一台交换机上,彼此能通过各自的容器 IP 互相访问。
但 IP 是动态的,硬编码 IP 不现实。所以实践里的标准做法是:把要互通的容器放进同一个自定义网络,然后用容器名/服务名当主机名互相访问 ------Docker 内置了 DNS,会把服务名解析成对应容器的当前 IP。你在 docker-compose.yml 里写 mysql:3306 能连上,就是因为 compose 把所有服务放进了同一个网络,mysql 这个名字被自动解析了。
理解到这,docker-compose 也就祛魅了:它不是什么新魔法,只是帮你把「建一个共享网络 + 一组容器 + 各自的卷」这套动作用一个 yaml 声明出来、一键拉起。 我本地起整套分布式后端(一堆业务节点 + MySQL + ZooKeeper + Kafka),靠的就是这一个文件,而不是手敲十几条 docker run。
验证你是否真的理解了
问自己:docker run -p 8080:80 这个 -p 到底在干嘛?为什么不写它,宿主机浏览器就访问不到容器里的服务?
答案:容器在自己的网络 namespace 里,端口对外是隔离的。-p 8080:80 是在宿主机和容器之间打一个端口转发 ------把宿主机的 8080 映射到容器的 80。不写 -p,容器服务只在容器自己的网络里可达,宿主机这边没有入口。容器间互通靠共享网络,宿主机访问容器靠端口映射,这是两条不同的路。
把 5 个模型串起来
倒回去看,这 5 个模型其实是一条逻辑链:
- 镜像是只读分层快照,容器在最上面加一层可写层(写时复制)------理解了存储。
- 容器是被 namespace + cgroup 圈起来的普通进程,不是虚拟机------理解了隔离。
- 构建是逐层叠加 + 缓存,指令顺序决定快慢------理解了镜像怎么来的。
- 可写层即用即弃,持久数据必须挂卷------理解了数据去哪。
- 每个容器一套网络,靠虚拟网桥 + 服务名互通------理解了容器怎么连。
有了这条链,你再看那些命令和参数,就不再是孤立的咒语:-v 是在接第四个模型的卷,-p 和 --network 是在接第五个模型的网络,docker build 的缓存优化是在接第三个模型的层......它们全是这 5 个模型的具体操作入口。
这也是「不背命令」的真正含义:不是真的不用命令,而是命令变成了模型的自然推论,记不住也能现推。下次再遇到一个没见过的 Docker 报错或参数,先别急着搜------问问自己:这事儿动的是哪一层、哪套 namespace、哪个卷?大概率你自己就能推到答案。
这是「程序员基础」系列的第二篇。上一篇是《 Git七天速通》。如果这种学法对你有用,欢迎点个赞,加个关注,当然文中有什么不对的地方,也欢迎在评论区交流,后面会继续分享好用的基础。