Docker 分层构建与多阶段构建原理
为什么改了业务代码就要重装整个 node_modules?镜像为什么越来越大?Docker 的两大核心机制——层缓存和多阶段构建——解决的就是这两个问题。
核心概念辨析:分层构建 vs 多阶段构建
很多人把这两个概念混为一谈,但它们解决的问题完全不同:
| 概念 | 解决什么问题 | 机制 |
|---|---|---|
| 分层构建 | 提升缓存命中,减少重复安装依赖 | 每条 RUN/COPY/ADD 形成一层,输入不变则复用缓存 |
| 多阶段构建 | 减少最终镜像体积,隔离构建工具和运行环境 | 多个 FROM 各自独立,COPY --from 只拿产物 |
| 两者关系 | 多阶段构建也会产生层,但它更进一步——把不同阶段的文件系统隔离开 | — |
一句话:
分层构建是 Docker 的缓存机制;多阶段构建是用多个构建阶段控制最终镜像里留下什么。
一份成熟的 Dockerfile 两者都用:既让依赖层优先缓存(分层构建),也用 python-deps / runtime 把编译产物和运行镜像拆开(多阶段构建)。
一、层缓存:为什么改一行代码要重跑整个构建
Docker 的每条 RUN、COPY、ADD 指令都会生成一个镜像层。构建时按顺序判断:
- 如果某一层的输入没变 → 复用缓存,秒过
- 如果某一层变了 → 它和它后面的所有层全部重新执行
反模式:大杂烩 Dockerfile
COPY requirements.txt /app/
RUN pip install -r requirements.txt
COPY . /app/ # ← 业务代码变更导致此层失效
RUN npm install # ← 被迫重跑
只要任意业务代码改了,COPY . 层就变,后面的 npm install 重新跑。每次构建都在浪费时间重装不变的依赖。
正解:依赖与代码分离
# 先 COPY 依赖声明文件(变动少)
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY package.json .
RUN npm install
# 最后 COPY 业务代码(变动频繁)
COPY . .
| 层级 | 内容 | 变动频率 | 缓存效果 |
|---|---|---|---|
| requirements.txt + pip install | Python 依赖 | 低 | 几乎不变,长期有效 |
| package.json + npm install | Node 依赖 | 低 | 几乎不变,长期有效 |
| COPY . . | 业务代码 | 高 | 每次更新,但影响最小 |
核心原则:把变动频率低的东西放前面,变动频率高的放最后。
二、多阶段构建:构建时需要的 ≠ 运行时需要的
问题场景
mysqlclient 编译时需要完整的编译工具链:
build-essential
pkg-config
default-libmysqlclient-dev
但服务运行时不需要编译器。如果全部塞在一个镜像里:
- 镜像体积膨胀(编译器+头文件几百 MB)
- 攻击面扩大(多一个工具多一个风险点)
先分清:分层缓存 ≠ 多阶段构建
很多人把"拷贝依赖文件放前面"等同于"多阶段构建",其实它们是两个独立机制:
只做了分层缓存(有,但不完整):
FROM python:3.10-slim-bullseye
# ...
COPY requirements.txt /app/ # ← 依赖文件放前面
RUN pip install -r requirements.txt
COPY . /app/ # ← 业务代码放后面
| 能力 | 是否具备 |
|---|---|
| 缓存 Python 依赖(代码变不重装 pip) | ✅ 是 |
多阶段构建(FROM ... AS + COPY --from) | ❌ 不是 |
| 把编译工具(build-essential 等)排除出最终镜像 | ❌ 不能 |
这种 Dockerfile 做对了依赖与代码分离,但 build-essential、pkg-config、default-libmysqlclient-dev 这些编译 mysqlclient 用的工具会永远留在最终运行镜像里。
真正的多阶段构建(拆成 base / deps / runtime 三段):
FROM python:3.10-slim-bullseye AS base
FROM base AS python-deps # 编译阶段——装编译器,pip install
FROM base AS runtime # 运行阶段——只拿产物,不带编译工具
COPY --from=python-deps /install /usr/local
结论一句话:分层缓存决定"重不重装",多阶段构建决定"带不带进镜像"。两者互补,不是一回事。
多阶段构建方案
# ====== 阶段 1:构建期 ======
FROM python:3.10-slim AS python-deps
RUN apt-get update && apt-get install -y \
build-essential pkg-config default-libmysqlclient-dev
RUN pip install --prefix=/install -r requirements.txt
# ====== 阶段 2:运行期 ======
FROM python:3.10-slim AS runtime
# 只复制安装结果,不带编译工具
COPY --from=python-deps /install /usr/local
COPY . /app/
CMD ["python", "app.py"]
| 阶段 | 职责 | 包含 | 不包含 |
|---|---|---|---|
| python-deps | 编译安装 | 编译器、头文件、pip | 业务代码 |
| runtime | 运行服务 | Python 包、业务代码 | 编译工具 |
完整实践:五层分离
# 1. 系统基础层
FROM python:3.10-slim AS base
RUN apt-get update && apt-get install -y curl ca-certificates
# 2. Python 依赖构建层
FROM base AS python-deps
RUN apt-get install -y build-essential pkg-config default-libmysqlclient-dev
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
# 3. Node 依赖构建层
FROM base AS node-deps
RUN apt-get install -y nodejs npm
COPY package.json .
RUN npm install
# 4. 最终运行层
FROM base AS runtime
# 系统运行时依赖(不含编译工具)
RUN apt-get install -y wkhtmltopdf xvfb chromium
# 从构建层复制产物
COPY --from=python-deps /install /usr/local
COPY --from=node-deps /app/node_modules /app/node_modules
# 最后才复制业务代码
COPY . /app/
CMD ["python", "app.py"]
FROM base AS node-deps 语法解释
FROM base AS node-deps 是 Docker 多阶段构建中的派生语法:
# 先定义一个公共基础阶段
FROM python:3.10-slim-bullseye AS base
# 公共配置:ENV、WORKDIR 等
# 从 base 派生,专门安装 Python 依赖
FROM base AS python-deps
# 安装 Python 依赖
# 从 base 派生,专门安装 Node 依赖
FROM base AS node-deps
# 安装 Node 依赖
# 从 base 派生,最终运行镜像
FROM base AS runtime
COPY --from=python-deps /install /usr/local
COPY --from=node-deps /app/node_modules /app/node_modules
关键点:
FROM base AS node-deps= "以base阶段作为基础镜像,再开一个新阶段,名叫node-deps"COPY --from=node-deps= "从node-deps阶段里拿产物,不是从宿主机拿"- 隔离效果:
node-deps里安装 Node 依赖时的中间环境(npm 缓存、devDependencies 等)不会进入最终镜像——最终镜像只拿它产出的node_modules
这条语法的本质是:一个 Dockerfile,多个独立的构建环境,互相之间只传递最终产物。
逻辑视角 —— 阶段与数据流:
┌──────────────────────────────┐
│ FROM python AS base │
│ ENV / WORKDIR │
└──────────────┬───────────────┘
│
┌────────────────┼────────────────┐
│ │ │
v v v
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ python-deps │ │ node-deps │ │ runtime │
│ │ │ │ │ │
│ install gcc │ │ install node │ │ install libs │
│ pip install │ │ npm install │ │ tini / cmd │
│ │ │ │ │ │
│ /install │ │ node_modules │ │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ COPY --from │ COPY --from │
│ │ │
v v v
└──────────────┬──┴─────────────────┘
│
v
┌────────────────────┐
│ 最终 runtime 镜像 │
│ │
│ /usr/local │ ← Python 依赖
│ /app/node_modules │ ← Node 依赖
│ /app │ ← 应用代码
│ gunicorn CMD │
└────────────────────┘
物理视角 —— 哪些层在最终镜像里,哪些只在 build cache:
build cache 里可能存在
────────────────────────────────────────
base layers
│
├── python-deps layers
│ ├── build-essential
│ ├── pkg-config
│ ├── mysql dev headers
│ └── /install
│
├── node-deps layers
│ ├── nodejs
│ └── /app/node_modules
│
└── runtime layers ← 最终镜像只引用这一条链
├── runtime apt libs
├── COPY /install from python-deps
├── COPY node_modules from node-deps
├── playwright chromium
└── COPY app source
关键点:python-deps 和 node-deps 整个阶段不会进入最终镜像。只有 COPY --from 指定的内容会进入 runtime:
python-deps:/install→runtime:/usr/localnode-deps:/app/node_modules→runtime:/app/node_modules
缓存失效规则:阶段内 + 跨阶段
阶段在物理层面就是一条命名的 layer 链。缓存失效分两种情况:
1. 同一阶段内:前面层失效 → 后面层全部失效
同一阶段内:
Layer A ← 输入未变,可复用
Layer B ← 输入变了,重新构建
Layer C ← 必须重建(基于新的 Layer B 快照)
原因很简单:Layer C 是基于新的 Layer B 文件系统快照继续构建的,旧的 Layer C 不能直接接上去。
2. 跨阶段:COPY --from 引用的内容变了 → 引用层及后续失效
python-deps 阶段里的 /install 内容变了
→ runtime 里 COPY --from=python-deps 层失效
→ runtime 里这一层后面的所有层也失效
完整的关系图:
base
│
┌─────────┼─────────┐
│ │ │
v v v
python-deps node-deps runtime
│ │ │
│ │ ├── COPY from python-deps ← 跨阶段依赖
│ │ ├── COPY from node-deps ← 跨阶段依赖
│ │ └── COPY app
│ │
│ └── npm install
│
└── pip install
一句话:
阶段就是一条可命名的构建链;缓存按链条顺序判断;跨阶段通过 COPY --from 建立依赖关系。
阶段是真实存在的构建单元
"阶段"首先是 Dockerfile 语法的逻辑概念,但它会真实映射到 Docker/BuildKit 的物理构建结果上。
FROM base AS python-deps ← 命名阶段(逻辑名)
RUN apt-get install gcc ← 生成真实缓存层
RUN pip install ... ← 生成真实缓存层
FROM base AS runtime ← 最后一个阶段就是最终镜像的"根"
COPY --from=python-deps /install /usr/local ← 只这一行让产物进入最终镜像
物理上看:
build 阶段的层:
├─ python 基础层
├─ 安装 gcc 的层
└─ pip install 的层
runtime 阶段的层(最终镜像):
├─ python 基础层
└─ COPY /install 的层
build 阶段的 gcc 等层 → 不属于最终镜像引用链
→ 但可能留在 Docker build cache
核心结论:
| 概念 | 是什么 |
|---|---|
| 阶段 | 逻辑构建单元 + 独立的文件系统快照链 |
阶段名(如 python-deps) | 逻辑引用名,供 COPY --from 使用 |
| 阶段里的 RUN/COPY | 真实存在的缓存对象(layer/blob/snapshot) |
| 最终镜像 | 最后一个 FROM 阶段 + 显式 COPY 进来的内容 |
| 未被引用的阶段层 | 构建缓存,可复用(下次构建更快),也可被 prune 清理 |
一句话:阶段不是纯注释,是真实参与构建的文件系统快照。最终镜像不会引用所有阶段,只引用最后一个阶段及 COPY --from 的内容。
三层视角总结:
| 层面 | 阶段的表现 |
|---|---|
| Dockerfile 逻辑层 | base / python-deps / node-deps / runtime 这些阶段名——构建图里的节点名称 |
| BuildKit 物理层 | 对应阶段产生的真实 snapshots / cache records / layers |
| 最终镜像层 | 只保留 runtime 镜像引用到的 layers;不保留"阶段"这个概念本身 |
可以这么类比:阶段名是构建图的节点名,阶段内容是真实的文件系统快照链,最终镜像是构建图导出的结果。
BuildKit 构建图(DAG)示意
BuildKit 不是简单的一条线,而是在构建一个有向无环图(DAG)。每个阶段产生各自的快照链,跨阶段通过 COPY --from 建立引用边:
┌──────────────────────┐
│ python:3.10-slim │
│ base image layers │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ base snapshot │
│ ENV + WORKDIR │
└──────────┬───────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ python-deps snap │ │ node-deps snap │ │ runtime snap │
│ apt build deps │ │ install node │ │ apt runtime libs │
│ cache record P1 │ │ cache record N1 │ │ cache record R1 │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ python-deps snap │ │ node-deps snap │ │ runtime snap │
│ pip install │ │ npm install │ │ COPY /install │◄──────┐
│ cache record P2 │ │ cache record N2 │ │ from python-deps │ │
└────────┬─────────┘ └────────┬─────────┘ │ cache record R2 │ │
│ │ └────────┬─────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────────┐ │
│ └────────────►│ runtime snap │ │
│ │ COPY node_modules│ │
│ │ from node-deps │ │
│ │ cache record R3 │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ runtime snap │ │
│ │ COPY . /app │ │
│ │ cache record R4 │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ final image │ │
│ │ image manifest │ │
└───────────────────────────────────┴──────────────────┘ │
P2 留在 build cache,可复用 │
│
P2 的 /install 被 R2 引用 ─────────────────────────────────┘
简化版:
base
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
P1 apt N1 node R1 libs
│ │ │
▼ ▼ ▼
P2 pip N2 npm R2 copy from P2
│
▼
R3 copy from N2
│
▼
R4 copy app
│
▼
final image
含义:
- P2 =
python-deps最终快照 → N2 =node-deps最终快照 → R4 =runtime最终快照 - 最终导出镜像只导出 R4 这条链
- P1/P2/N1/N2 留在 BuildKit cache,供下次构建复用
COPY --from=python-deps= 从 P2 快照读取文件COPY --from=node-deps= 从 N2 快照读取文件
中间阶段产物的去向
多阶段构建里的"中间产物"分两类:
1. 被 COPY --from 复制到最终镜像的 → 留下
COPY --from=python-deps /install /usr/local
COPY --from=node-deps /app/node_modules /app/node_modules
最终镜像里会有 Python 包(/usr/local/...)和 Node 包(/app/node_modules)。
2. 没有被复制的 → 不进入最终镜像
python-deps 阶段里的这些永远不会出现在 runtime 镜像中:
build-essential、pkg-config、default-libmysqlclient-devapt缓存- 临时构建目录
但它们不一定会立刻物理删除。 Docker/BuildKit 为了缓存复用,会把中间阶段的层保留在本机 build cache 里。
多阶段构建各阶段
│
│ COPY --from=xxx 的内容
▼
最终镜像:保留被复制的产物
未复制内容:
→ 不进入最终镜像
→ 但可能留在 Docker build cache
→ 等待复用或被 prune 清理
清理命令:
docker builder prune # 清理构建缓存
docker system prune # 更彻底,含悬空镜像
在 CI 里:如果没有持久化 BuildKit cache,中间阶段随 runner 销毁;如果配了 Docker layer cache,可能被缓存到下次构建。
三、Playwright Chromium 的安置策略
Playwright 安装 Chromium 浏览器有两种选择:
放构建层(镜像更小)
FROM base AS python-deps
RUN playwright install chromium
# runtime 阶段需要 COPY 浏览器缓存目录
COPY --from=python-deps /root/.cache/ms-playwright /root/.cache/ms-playwright
优点:最终镜像不包含 playwright 安装过程 缺点:需要精确知道浏览器缓存路径,容易遗漏
放运行层(逻辑更清晰)
FROM base AS runtime
RUN playwright install chromium
优点:逻辑简单,Docker 自动处理路径 缺点:playwright install 在最终层执行,镜像稍大
推荐:先选择"清晰稳定"方案——放运行层。等对整个流程足够熟悉后,再优化到构建层以缩小镜像。
四、多阶段构建的四大收益
| 收益 | 说明 |
|---|---|
| CI 更快 | 依赖层不容易失效,改业务代码不影响依赖缓存 |
| 镜像更小 | 编译工具不进入最终镜像,减少 200-500MB |
| 失败更清楚 | 系统依赖、Python 依赖、Node 依赖、业务代码分层明确,构建失败快速定位 |
| 安全更好 | 运行镜像少无关工具,攻击面更小 |
五、速查:依赖分层顺序
频繁改动 ↓
─────────────────
系统运行依赖 ← 几乎不变
Python 构建依赖 ← 很少变
Python 包 ← requirements.txt 变时才变
Node 包 ← package.json 变时才变
业务代码 ← 每次 commit 都变
─────────────────
一句话总结:层缓存让你不重装没变的依赖,多阶段构建让你不带编译工具进生产。两者结合,又快又小又安全。