Skip to content

镜像(Image)vs 容器(Container):只读层 vs 可写层

在 Docker 的体系中,“镜像”与“容器”是两个最基本、也最容易被混淆的概念。许多人认为容器是镜像的“运行实例”,这种说法没错,但过于笼统。要真正理解两者的关系,我们必须深入文件系统的结构,看清 Docker 如何通过分层机制实现高效、可复用的容器化运行时。

核心结论只有一句:

镜像是由多个只读层组成的静态文件系统快照;容器是在这些只读层之上,叠加一个可写层的运行时实体。

一、镜像:分层的只读快照

Docker 镜像不是单个文件,而是由一系列只读层(read-only layers) 构成的堆栈。每一层对应 Dockerfile 中的一条指令,并且遵循“内容寻址”原则——每一层的 ID 由其内容的哈希值决定,内容不变,层就不变。

例如,一个典型的 Node.js 应用镜像可能包含以下层次:

  1. FROM node:18-alpine
    → 基础镜像层,包含 Alpine Linux 和 Node.js 运行时。
  2. COPY package.json .
    → 新增一层,仅包含 package.json 文件。
  3. RUN npm ci
    → 执行安装命令,生成 node_modules 目录,形成新的一层。
  4. COPY . .
    → 将应用代码复制进来,形成最终的应用层。

这些层通过联合文件系统(如 overlay2自底向上叠加,形成一个统一的文件系统视图。由于每一层都是只读的,它们可以被多个镜像或容器安全地共享。

例如,所有基于 node:18-alpine 的镜像,都可以共享同一个基础层,无需重复存储和下载。


二、容器:在只读层之上添加可写层

当你执行 docker run 时,Docker 并不会修改镜像本身。相反,它会在镜像的所有只读层之上,创建一个全新的可写层(writable layer),也称为“容器层(container layer)”。

这个可写层是容器独有的,它的生命周期与容器绑定:容器启动时创建,容器删除时销毁。

所有在容器运行时发生的文件变更——例如:

  • 写入日志文件:echo "request received" >> /var/log/app.log
  • 创建临时文件:touch /tmp/session.txt
  • 修改配置:sed -i 's/debug=false/debug=true/' config.ini

——都会被记录在这个可写层中。底层的镜像层保持不变。

这种设计带来了几个关键优势:

  1. 轻量启动:不需要复制整个镜像,只需创建一个空的可写层。
  2. 资源复用:多个容器可以共享同一个镜像的只读层,极大节省磁盘空间。
  3. 一致性保障:镜像一旦构建完成,就不可变,确保每次运行的环境一致。

三、联合文件系统(UnionFS)如何工作

Docker 默认使用的存储驱动(如 overlay2)实现了联合挂载(union mount),它将多个文件系统层合并为一个逻辑视图。

overlay2 为例,它通过三个目录实现:

  • lowerdir:指向镜像的只读层(可以是多层)。
  • upperdir:容器的可写层,记录所有修改。
  • merged:对外暴露的统一视图,进程看到的根文件系统。

当容器内进程读取一个文件时:

  • 如果文件位于只读层,直接从 lowerdir 返回。
  • 如果文件在可写层被修改过,返回 upperdir 中的副本。
  • 如果文件被删除,upperdir 会记录一个“whiteout”文件,表示该文件已被移除。

当进程写入一个文件时:

  • 如果文件原本在只读层,Docker 采用“写时复制”(Copy-on-Write, CoW)机制:先将文件从只读层复制到可写层,再进行修改。
  • 如果是新文件,则直接在可写层创建。

这种机制确保了只读层的完整性,同时实现了运行时的灵活性。


四、docker run 到底发生了什么?

执行 docker run myapp:latest 时,Docker 引擎会完成以下关键步骤:

  1. 解析镜像:加载 myapp:latest 的所有只读层,确定其分层结构。
  2. 创建容器元数据:生成容器 ID、配置网络、挂载卷、设置环境变量。
  3. 创建可写层:在存储驱动(如 overlay2)中为该容器创建 upperdir
  4. 联合挂载文件系统:将镜像的只读层(lowerdir)与容器的可写层(upperdir)合并,形成 merged 视图。
  5. 启动进程:在独立的命名空间和 cgroup 中,运行镜像中定义的命令(如 npm start)。

至此,容器启动完成。你看到的文件系统,是镜像层与可写层的联合视图;你运行的进程,被限制在特定的资源和网络环境中。


五、理解分层结构的实践意义

1. 为什么修改容器内的文件,退出后还能看到?

因为修改发生在可写层,只要容器未被删除,可写层就一直存在。

2. 为什么 docker rm 后数据丢失?

docker rm 会删除容器及其可写层。如果未使用 volumebind mount,所有运行时产生的数据将永久丢失。

3. 为什么镜像构建要遵循“高变更频率在后”原则?

因为 Docker 的层缓存机制是“一旦某一层变化,其后的所有层都失效”。所以应将不常变的内容(如基础依赖)放在前面,将经常变化的应用代码放在最后。

4. 如何将容器的修改保存为新镜像?

使用 docker commit 命令,它会将容器的可写层“固化”为一个新的只读镜像层。

bash
docker commit <container-id> myapp:modified

但这种做法违背了不可变基础设施原则,推荐通过 Dockerfile 重新构建。


六、总结

  • 镜像 = 只读层的堆栈,是静态的、可复用的文件系统快照。
  • 容器 = 镜像 + 可写层,是动态的、可修改的运行时实例。
  • docker run 的本质,是启动一个进程,并为其挂载一个由只读镜像层和独立可写层组成的联合文件系统。

理解这一分层模型,是掌握 Docker 构建优化、数据管理、镜像瘦身和 CI/CD 自动化的前提。当你开始思考“哪一层应该缓存”“哪些文件不该进入镜像”“如何最小化可写层开销”时,你已经超越了“会用 Docker”的层面,真正进入了“掌控 Docker”的领域。