Docker 工程化实践(二):理解镜像分层与容器文件系统

Docker 工程化实践系列 · 第 2 篇

引言:几个"为什么"带你进入分层世界

在上一篇文章中,我们建立了 Docker 的核心概念------Dockerfile 描述构建过程,Image 是应用模板,Container 是运行实例。但在实际操作中,你可能已经注意到一些让人困惑的现象:

为什么 docker build 的输出中,每条指令都显示为一个独立的步骤?为什么第二次构建时有些步骤会显示 CACHED?为什么只改了一行代码,后面的构建步骤全部重新执行?为什么删除容器后容器里创建的文件会消失?为什么多个镜像可以共享同一个基础镜像而不需要重复占用磁盘空间?

这些问题的答案,都指向 Docker 最核心的一个设计:镜像采用分层结构,容器在镜像层之上叠加一个可写层。 理解镜像分层,是后续掌握 Build Cache、多阶段构建和镜像优化的基础。本文将从一次实际的构建过程出发,逐层拆解这个机制。

一、从一次构建理解镜像分层

先看一个简单的 Node.js Dockerfile:

dockerfile 复制代码
FROM node:22
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
CMD ["pnpm", "start"]

执行 docker build -t fastify-api:1.0 . 后,构建日志会把 Dockerfile 展示为多个构建步骤:

复制代码
[1/8] FROM node:22
[2/8] WORKDIR /app
[3/8] COPY package.json pnpm-lock.yaml ./
[4/8] RUN corepack enable
[5/8] RUN pnpm install --frozen-lockfile
[6/8] COPY . .
[7/8] RUN pnpm build
[8/8] CMD ["pnpm", "start"]

这些编号首先表示的是构建步骤,不应该简单理解为"每一步都严格对应一个文件系统层"。Docker 构建日志展示的是 Docker 按照 Dockerfile 从上到下处理的每一条指令,至于每条指令最终在镜像中留下了什么,取决于指令的类型。

对于 RUNCOPYADD 等会修改文件系统的指令,Docker 会记录相对于上一状态产生的文件变化,这些变化构成镜像的只读层。而 CMDENTRYPOINTEXPOSEENV 等指令则主要更新镜像的配置元数据,不会单独产生文件系统层。

FROM node:22 也不是凭空产生一个单独的"Node.js 环境层"。node:22 本身已经是一个由多个只读层组成的基础镜像,当前项目会在这些基础层之上继续叠加自己的文件变化。

因此,一个项目镜像可以简化理解为:

复制代码
┌──────────────────────────┐
│ RUN pnpm build            │ 构建产物
├──────────────────────────┤
│ COPY . .                  │ 项目代码
├──────────────────────────┤
│ RUN pnpm install          │ 项目依赖
├──────────────────────────┤
│ COPY package*.json        │ 依赖描述文件
├──────────────────────────┤
│ node:22 的多个基础镜像层   │ 系统与 Node.js
└──────────────────────────┘

镜像配置:
WORKDIR /app
CMD ["pnpm", "start"]

这些层在逻辑上叠加后,形成应用看到的完整文件系统。Docker 并不是每次都把所有层重新复制合并成一份新文件,而是由存储驱动(常见的如 overlay2)在运行时提供一个统一的文件系统视图------"叠加"是运行时动态完成的,不是物理上的文件合并。

二、构建步骤、文件系统层与镜像配置

理解镜像分层时,需要区分三个概念。

构建步骤。 Docker 根据 Dockerfile 从上到下处理的每一条指令。构建日志中的编号(如 [1/8][2/8])表示的就是构建步骤。并不是每个步骤都会产生新的文件系统层。

文件系统层。 某些指令会创建、修改或删除文件,例如 RUN pnpm installCOPY . .ADD archive.tar.gz /app/。Docker 会记录这些文件系统变化,并将结果作为可复用的只读层保存。这些只读层就是镜像分层的核心------后续的 Build Cache、多阶段构建和镜像优化都建立在它们之上。

镜像配置。 另一些指令主要描述镜像如何运行,例如 CMD ["pnpm", "start"]EXPOSE 3000ENV NODE_ENV=production。这些内容主要进入镜像配置,而不是生成明显的文件系统内容。

因此,更准确的理解不是"Dockerfile 一行 = 一个文件层",而是:

复制代码
Dockerfile
    ↓
多个构建步骤
    ↓
文件系统变化 + 镜像配置
    ↓
最终镜像

FROM 指令比较特殊------它的作用是选择一个已有镜像作为当前构建阶段的基础。node:22 本身已经包含多个只读层(系统文件、Node.js 运行时等),你的项目镜像是在这些基础层之上继续增加变化,而不是 FROM node:22 只生成一个叫"Node.js 环境层"的单层。

三、镜像分层带来的三个收益

Docker 采用分层结构不是偶然的设计选择,它带来了三个实实在在的工程收益。

1. 提高复用能力

假设有两个项目,项目 A 和项目 B 都使用了 FROM node:22 作为基础镜像,各自只在上层叠加了不同的业务代码。那么 node:22 的所有基础层在磁盘上只需要保存一份,两个镜像共享它们。Docker 不需要为每个项目单独保存一套完整的 Node.js 环境------这在你本地同时开发多个 Node.js 项目时尤其明显,基础镜像只需要下载一次。

2. 减少存储空间

同样的道理,本地如果同时存在 node:22fastify-api:1.0nestjs-api:1.0 三个镜像,它们共享 node:22 的基础层。磁盘上实际存储的不是"完整的三份系统",而是"一份基础层 + fastify-api 的增量层 + nestjs-api 的增量层"。频繁构建不同项目的镜像时,磁盘压力远比你想象的小------你看到的 docker images 中的 SIZE 是逻辑大小,实际物理占用因为有层共享而小得多。

3. 为构建缓存提供基础

这是分层设计最大的工程价值:如果 Docker 发现某个构建步骤的输入和上次构建时完全一样,就可以直接复用之前的结果,跳过实际的执行过程。比如修改了 src/user.ts 但没改 package.json,那么复制依赖文件及之前的所有步骤都可以直接复用缓存。这就是 Build Cache 的基础。

不过,层可以复用也意味着连锁反应:一旦某个构建步骤的输入发生变化,该步骤及之后依赖它的所有步骤通常都需要重新执行。 至于缓存具体如何判断命中与失效、Dockerfile 中指令的顺序如何影响缓存效率,这些将在下一篇专门展开。

四、镜像为什么是只读的

Docker 镜像设计为只读,有三个原因。第一,保证镜像稳定------如果运行中的容器可以直接修改镜像,那么容器 A 的修改就会影响到使用同一镜像的容器 B,环境一致性就无从谈起。第二,支持多个容器共享同一镜像------三个容器可以同时从一个镜像启动,互不干扰。第三,方便版本管理------每个版本的镜像都应该保持不可变,更像一个"不可修改的软件发布包"而不是一个"可以随意改动的文件夹"。

但应用运行过程中一定会产生变化------写日志、生成临时文件、修改配置。这些变化写在哪里?答案是在镜像层之上,Docker 会为每个容器单独创建一个可写层(Writable Layer)

启动容器时,Docker 的结构变为:

复制代码
Container

┌─────────────────────────┐
│ 可写层                   │  ← 容器独有,存储运行时变更
│ 日志 / 临时文件 / 配置    │
├─────────────────────────┤
│ 镜像层 1..N              │  ← 只读,所有容器共享
│ node:22 + 业务代码 + ... │
└─────────────────────────┘

容器停止但只要没有删除,可写层就还在------再次启动容器时之前的文件变更不会丢失。但容器一旦被删除,可写层也会被销毁,所有在容器内手动操作产生的文件都会消失。因此,容器可写层不是可靠的持久化位置。 需要持久化的数据(如数据库文件、用户上传的文件)应该保存到 Volume 或外部存储中,而不是依赖容器可写层。

五、Copy-on-Write 如何工作

Docker 使用了一种叫 Copy-on-Write(写时复制)的机制来处理容器对镜像文件的修改。具体实现由 Docker 使用的存储驱动负责------常见的 overlay2 驱动在容器首次修改只读层中的文件时,会先执行 copy-up 操作,把文件从只读层复制到可写层,然后再修改可写层中的那份副本。

举个例子:镜像中存在 /app/config.json,容器启动后执行 echo "new config" > /app/config.json。存储驱动会先把 config.json 从镜像层拷贝到可写层,然后在可写层中修改它。之后容器访问这个文件时,看到的是可写层中被修改过的版本------镜像层中的原始文件保持不变,其他容器也完全不受影响。对于只读不写的文件,存储驱动直接从镜像层提供数据,不做任何额外操作,几乎没有性能损耗。

六、停止容器和删除容器的区别

通过一个简单的实验就能理解容器可写层的生命周期。启动一个临时容器并创建文件:

bash 复制代码
docker run --name layer-demo -it node:22 sh
echo "hello docker" > /tmp/demo.txt
cat /tmp/demo.txt    # 输出:hello docker
exit

此时重新进入同一个容器:

bash 复制代码
docker start -ai layer-demo
cat /tmp/demo.txt    # 输出:hello docker,文件还在

文件还在,因为容器只是停止了,没有被删除------可写层仍然存在。现在删除容器并重新创建:

bash 复制代码
docker rm layer-demo
docker run --name layer-demo -it node:22 sh
cat /tmp/demo.txt    # No such file or directory

文件消失了。因为删除容器意味着删除容器的可写层,而 demo.txt 从一开始就只存在于可写层中。镜像层没有受到任何影响------新创建的容器基于同一个镜像,拥有一个全新的、空白的可写层。

七、为什么在后续层删除文件,镜像可能仍然很大

这是镜像优化中非常关键的一个认知。看下面这个 Dockerfile 片段:

dockerfile 复制代码
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

第一条 RUN 在一个层中写入软件包索引,第二条在新的层中安装软件,第三条又在更上层记录删除操作。最终容器看到的统一视图中,索引文件已经不存在;但它们曾经写入较低的只读层,而较低层不会被后续层真正修改------在后续的新层中删除前一层创建的文件,通常不能回收前一层已经占用的镜像空间。 镜像的大小是所有层的累积,后面的层"删除"文件只是在新的层中做了删除标记,历史层中的数据并不会真的消失。

更合理的写法是,让下载、安装和清理发生在同一个构建步骤中:

dockerfile 复制代码
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

这样临时文件不会作为该步骤的最终结果被保留。

但需要注意一个重要的补充:如果文件是在同一个 RUN 步骤中创建并删除 的,中间文件通常不会出现在最终该层的结果里。所以不是"删除文件一定不能减小镜像大小"------关键是删除动作和被删除的文件是否在同一个构建步骤中。此外,也不是所有 RUN 都应该盲目合并到一行------是否合并要同时考虑镜像大小、缓存粒度和可读性,这个权衡会在下一篇详细展开。

八、如何查看镜像历史和磁盘占用

Docker 提供了几个命令帮助我们观察镜像的分层结构和空间占用情况。

docker image history fastify-api:1.0 可以查看镜像的构建历史、每个历史条目对应的创建命令以及该条目的大小。它用于观察镜像历史和各构建条目的大小,是分析镜像结构的常用入口------但要注意它展示的是构建历史条目,不一定和文件系统层一一对应,某些元数据操作也可能出现在历史中。

docker image inspect fastify-api:1.0 可以查看镜像的完整配置和底层 JSON 信息,包括环境变量、启动命令、架构信息、各层的 digest 等。

docker system df 则可以查看 Docker daemon 管理的镜像、容器、构建缓存和数据卷各占用了多少磁盘空间,适合排查"Docker 占用磁盘越来越大"的问题。

九、总结

Docker 镜像不是一个完整的大文件,而是由多个只读层堆叠而成------RUNCOPYADD 等指令会记录文件系统变化并形成只读层,CMDEXPOSEENV 等指令主要更新镜像配置。FROM 选择的基础镜像本身已包含多个层,项目镜像是在这些基础层之上继续叠加自己的变化。

容器启动时,Docker 在镜像层之上创建一个独立的可写层,容器运行时产生的所有文件变更都记录在这个可写层中。当容器需要修改镜像中的文件时,存储驱动(如 overlay2)通过 Copy-on-Write 机制先把文件从镜像层复制到可写层再修改------镜像层本身保持不变,所有容器共享同一份只读的镜像数据。

分层设计带来了镜像复用、节省磁盘空间和加速构建三个工程收益。同时也解释了为什么删除容器后容器内手动创建的文件会消失(可写层随容器删除而销毁)、为什么在后续层中删除前一层创建的文件不能回收镜像空间(历史层的数据依然存在)。

最重要的工程启示是:Dockerfile 中指令的顺序会直接影响构建速度和镜像大小------因为分层结构使得某一层的变更会连锁导致后续所有层重新构建。 至于具体如何利用这个机制来设计高效的 Dockerfile,正是下一篇要深入的内容。

上一篇:Docker 工程化实践(一):Docker 基本原理与基础语法

下一篇:Docker 工程化实践(三):Docker Build Cache 与构建优化

相关推荐
Android系统攻城狮1 小时前
Linux PipeWire深度解析之pw_context_add_spa_lib调用流程与实战(二十九)
linux·运维·服务器·音频进阶·pipewire音频进阶
Dovis(誓平步青云)1 小时前
《如何在CentOS 7中添加Plex官方软件源:解决文件磁盘难管理难题》
linux·运维·服务器·后端·生成对抗网络·centos
草莓熊Lotso1 小时前
【Linux网络】深入理解Linux IO多路复用:select服务器完善、内核原理与poll实战
linux·运维·服务器·c语言·网络·c++
网安老伯2 小时前
网络安全基础要点知识介绍(非常详细),零基础入门到精通,看这一篇就够了
运维·前端·网络协议·web安全·网络安全·职场和发展
迷茫中的自我2 小时前
GitHub Actions自动化运维实战:从CI/CD到云原生部署
运维·自动化·github
XH华2 小时前
Linux系统第二章:常见的Linux指令(上)
linux·运维·服务器
nVisual2 小时前
01-环境监控集成方案
运维·服务器·开发语言·网络·数据库·数据中心布线·综合布线管理软件
北龙云海12 小时前
案例分享 | 面向公网业务系统境内访问白名单配置实操方案
运维·网络·安全·网络安全·告警·数据泄露·入侵攻击
FoldWinCard12 小时前
D5 Linux 网络及端口命令
linux·运维·服务器