运维知识
悠悠
2026年6月29日

别再只会docker run了!这次我把Docker的"灵魂"扒给你看

说起来挺讽刺的,用了三年Docker,我到现在才敢说自己真正搞懂了它。

之前每次面试被问到“Docker镜像为什么能那么小”或者“容器删了数据还在不在”这种问题,要么就是瞎扯一通什么“分层存储”,要么就是顾左右而言他,心里虚得很。

直到有一次生产事故,让我彻底清醒了——那是个深夜,MySQL容器突然崩了,docker rm -f 之后数据全没了。当时整个人都傻了,心想这不对啊,明明数据库文件都在容器里,怎么会没了?

后来才知道,容器里的“可写层”跟容器同生共死,容器一没,它也就跟着消失了。

那次之后我痛定思痛,花了整整一周时间把Docker的核心原理从头到尾研究了一遍。今天就把我的学习成果分享出来,全是实打实的生产经验,没有半点水分。


先搞清楚一个灵魂拷问:镜像到底是个什么东西?

我们在服务器上敲 docker pull nginx 的时候,会看到控制台哗啦啦下载一堆东西,每一行后面都跟着个 “Layer” 或者 “Digest” 之类的字眼。

我当时就纳闷了:一个nginx镜像不是应该就一个压缩包吗?怎么搞出来这么多层?

后来查资料才知道,Docker镜像这玩意儿根本不是一个完整的大文件,它是由一堆“层”(Layer)叠加起来的。就像......你可以理解成是一块块乐高积木,每一块都是独立的,但拼在一起就能搭出一个完整的东西。

这种设计有个专门的名字,叫联合文件系统,英文是 Union File System,简称 UnionFS。

那它到底是怎么工作的呢?

简单来说,UnionFS 可以把多个不同的目录“叠”到一起,让它们看起来就像是一个目录。容器里的进程压根不知道自己其实是在一堆目录的合并视图里工作,它只会看到一个普普通通的文件系统。

这就好比你小时候有没有玩过那种透明叠加的动画本?每一页都是独立的画面,但快速翻动的时候,所有画面叠在一起就成了连贯的动画。UnionFS 就是这个原理,只不过它是“空间上叠加”而不是“时间上叠加”。


镜像的“千层饼”结构,一层一层剥开看

说完了基本概念,咱们来看看一个典型的Docker镜像到底长啥样。

通常来说,一个镜像会有这么几类层:

最底下是基础层,说白了就是一个精简过的操作系统。比如你 pull ubuntu,它其实就是从这个基础层开始的。这个层里包含了最最基本的系统工具、库文件、shell 之类的。没有它,后面的东西都跑不起来。

基础层之上是各种中间层。每你在 Dockerfile 里写一条指令,比如 RUN apt-get update 或者 COPY ./app /usr/src/app,就会在现有层的基础上再加一层。

注意,这里有个关键点:这些层全都是只读的

一旦生成,就改不了了。你要是想把某个文件改掉,对不起,只能在这个层的上面再加一层来“覆盖”它,原来的那个文件依然存在于下层,只是被“遮住”了而已。

最顶上还有一层可写层,但这个不是镜像的一部分,是容器启动之后才会有的。我待会儿会重点讲这个。

为了让大家有更直观的感受,我顺手在测试服务器上跑了个命令:

docker history ubuntu:latest

输出大概是这样的:

IMAGE          CREATED        CREATED BY                      SIZE      COMMENT
a6d02b8c3f9e   2 weeks ago    CMD ["/bin/bash"]               0B        shell: /bin/bash
<missing>      2 weeks ago    ADD file:xxxxxx in /            77.8MB    构建命令

可以看到,这个 ubuntu 镜像基本上就两个层,一个是最基础的 ADD 层(77.8MB),另一个是顶层的 CMD 层(0B,因为 CMD 本身不产生文件)。


写时复制:这才是Docker的灵魂所在

刚才说到,镜像的中间层都是只读的。那问题来了:如果我的容器运行过程中需要修改某个配置文件怎么办?

比如 nginx 容器默认的配置文件是 /etc/nginx/nginx.conf,但这个文件在镜像的只读层里。容器启动后,我想改一下 worker 进程数,Docker 难不成会把整个镜像层给改掉?

当然不会。这里就要引入一个核心机制——写时复制(Copy-on-Write,简称 CoW)。

它的原理说穿了很简单:

当容器要修改一个位于只读层的文件时,Docker 并不会真的去改那个只读层的原文件。它先把这文件复制一份到顶层的可写层,然后容器后续所有的读写操作都针对这个副本进行。

原文件呢?它还在下层好好躺着,只是不再“可见”了——被上层的副本给“遮挡”住了。

这个设计带来的好处太明显了:

第一,镜像复用率极高。

假设你服务器上跑了 5 个 nginx 容器、3 个 mysql 容器、2 个 redis 容器,它们全都基于同一个 ubuntu 基础层。那这个 ubuntu 的文件系统在磁盘上只存了一份,多出来的只是它们各自不同的部分。

我在测试机上实际跑了一下,基于同一个 alpine 镜像起了 10 个容器,看磁盘占用:

docker run -d --name test1 alpine sleep 3600
docker run -d --name test2 alpine sleep 3600
# ... 一共起10个

然后检查磁盘:

docker system df

输出显示,虽然有 10 个容器在运行,但镜像层占用的实际磁盘空间几乎可以忽略不计。这就是分层架构的威力。

第二,容器启动速度飞快。

因为不需要复制整个文件系统,启动容器时 Docker 只需要在上层叠一个薄薄的可写层就行,耗时通常是毫秒级的。你点一下按钮,容器就起来了,比眨眼还快。


Dockerfile 怎么写才合理?别让层数害了你

知道了镜像分层的原理,写 Dockerfile 的时候就得讲究点了。

每一条指令基本都会产生一个新的镜像层。比如你这样写:

FROM ubuntu:20.04

RUN apt-get update
RUN apt-get install -y nginx
RUN apt-get install -y vim
RUN apt-get install -y curl
RUN apt-get install -y git

好家伙,一口气 4 个 RUN 指令,产生了 4 个镜像层。但问题是,这些层都是不可变的啊!以后你想升级某个软件,或者换个版本,对不起,只能在后面追加新层,原来的删不掉。

正确的做法是合并同类项

FROM ubuntu:20.04

RUN apt-get update && \
    apt-get install -y nginx vim curl git && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

这样所有安装操作都压在一层里完成,镜像体积更小,分发起来也更快。

还有一个原则:把变化频繁的内容放在后面,不常变化的内容放前面

你想啊,镜像构建的时候是按顺序来的,每一层都会被缓存。如果你把 COPY ./app /app 这种代码复制放在前面,那每次代码改动都得重新构建后面所有的层,缓存全废了。

所以应该是这样:

FROM node:16-alpine

# 先把依赖锁定版本(不常变)
COPY package*.json ./
RUN npm ci --only=production

# 再把代码放进来(经常变)
COPY . .
CMD ["npm", "start"]

这样的顺序,依赖层可以被很好地缓存,只有代码改了才会触发重新构建。


数据持久化才是重头戏!容器删了数据还在吗?

好,说完了镜像,现在聊聊数据的事。

前面提到容器启动时会在镜像层之上添加一个可写层,所有容器内对文件系统的修改都写在这里。这个可写层是跟着容器走的——容器删了,它也就没了。

这是个大坑,很多人都在这儿栽过跟头。

我之前就见过有人把 MySQL 数据库直接跑在容器里,数据全存在 /var/lib/mysql。然后有一天服务异常,他一不做二不休 docker rm -f mysql-container,数据直接清零,GG。

那怎么让数据“活久一点”呢?

Docker 提供了三种主流方案:Volume(数据卷)Bind Mount(绑定挂载)、还有 tmpfs mount(内存挂载)。我重点说前两个,这是生产环境最常用的。


Docker Volume:官方亲儿子,数据安全有保障

Volume 是 Docker 官方最推荐的数据持久化方式。它由 Docker 统一管理,存储在宿主机的 /var/lib/docker/volumes/ 目录下(Linux 系统),和容器本身是分离的。

创建 Volume:

docker volume create my_data

查看一下:

docker volume ls

挂载到容器:

docker run -d -v my_data:/var/lib/mysql --name mysql-server mysql:8.0

或者用 --mount 写法,更清晰:

docker run -d \
  --mount source=my_data,target=/var/lib/mysql \
  --name mysql-server \
  mysql:8.0

这里 -v my_data:/var/lib/mysql 的意思是:把 my_data 这个 Volume 挂载到容器里的 /var/lib/mysql 路径。

从此以后,MySQL 写入的数据全都在 Volume 里,容器删了也不怕。

# 删除容器
docker rm -f mysql-server

# 重新起一个,还是挂同一个 Volume
docker run -d -v my_data:/var/lib/mysql --name mysql-server mysql:8.0

数据完整保留,开箱即用。

Volume 还有个好处是它和宿主机文件系统是隔离的,不容易误操作被破坏。Docker 自己也提供了一整套 CLI 来管理它:

docker volume inspect my_data    # 查看详情
docker volume rm my_data         # 删除卷
docker volume prune              # 清理无用卷

Bind Mount:灵活但有代价

Bind Mount 和 Volume 的本质区别在于:它直接把宿主机上的某个目录映射进容器,不走 Docker 的存储管理系统。

docker run -d -v /opt/mysql/data:/var/lib/mysql --name mysql-server mysql:8.0

这次 /opt/mysql/data 是宿主机上真实存在的路径,容器里看到的就是它。

这个方式的好处是直观,你在宿主机上直接能找到对应的目录,改配置、查日志都很方便。很多开发童鞋喜欢用这种挂载方式,尤其是本地调试的时候,挂个代码目录进去,改完保存容器里立刻就生效了。

但它的问题也很明显:

  • 路径是硬编码的,换台机器可能就没了
  • Docker 对这个目录没有控制权,没法做备份、迁移之类的操作
  • 权限管理也更复杂,需要考虑宿主机和容器用户的对应关系

所以生产环境里,我建议数据库、消息队列这些有状态的服务,老老实实用 Volume;本地开发调试倒是可以图个方便用 Bind Mount。


两种挂载方式怎么选?我用血泪教训给你总结

对比维度VolumeBind Mount
存储位置Docker 管理,路径在 /var/lib/docker/volumes/宿主机任意路径
管理方式Docker CLI 全套支持靠系统命令和手动操作
适用场景生产环境、数据库、需要迁移备份的场景本地开发、挂载配置文件、热更新代码
优点安全隔离、便于管理、支持驱动扩展直观方便、符合传统运维习惯
缺点路径隐蔽、不方便直接查看移植性差、依赖宿主机环境

这里有个我踩过的坑,必须提一下:

用 Bind Mount 挂载 MySQL 数据目录的时候,如果宿主机上那个目录不存在,Docker 会自动创建一个空目录而不是报错。你满怀期待地启动容器,结果数据库初始化在空目录上进行,等发现的时候数据全乱了。

Volume 就不存在这个问题,创建 Volume 的时候 Docker 会帮你处理。


tmpfs mount:数据只存在于内存中

最后简单提一下 tmpfs,这是把数据存在内存里的挂载方式。速度飞快,但容器一停数据就丢了。

docker run -d --tmpfs /run:rw,noexec,size=64m --name test-container alpine

适合存一些临时文件、session 数据之类的不需要持久化的东西。比如跑一些跑完就不要的临时计算任务,用 tmpfs 可以避免磁盘 IO 开销。


总结一下:记住这几点就够了

Docker 的镜像分层和持久化机制,其实就围绕两个核心问题:

一个是镜像怎么存:靠 UnionFS 把一堆只读层叠在一起,用写时复制(CoW)实现按需复制,既省空间又保证性能。写 Dockerfile 的时候注意合并命令、合理安排层顺序,能让你的镜像更轻量、构建更快。

一个是数据怎么活:容器的可写层跟着容器走,删容器就丢数据。想让数据活下来,要么用 Volume 交给 Docker 管理,要么用 Bind Mount 直接挂载宿主机目录。生产环境首选 Volume,本地开发可以图方便用 Bind Mount。


好了,关于 Docker 的核心原理和数据持久化,就说这么多。写这篇文章的目的就是想让大家不要再像我当年一样,只会 docker rundocker pull,遇到问题两眼一抹黑。

技术这东西吧,光会用是不够的,知道背后的原理才能用得更稳、出问题的时候也更有底气。

如果觉得这篇文章对你有帮助,欢迎转发给身边做运维或后端的朋友,大家一起来交流学习。

也可以关注我的公众号:耕云躬行录,第一时间收到更多实战技术分享。

个人博客:躬行笔记,有整理好的资料和脚本,有兴趣的可以去看看。

咱们下期见!

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码