Skip to content

Docker 分层构建与多阶段构建原理

为什么改了业务代码就要重装整个 node_modules?镜像为什么越来越大?Docker 的两大核心机制——层缓存和多阶段构建——解决的就是这两个问题。


核心概念辨析:分层构建 vs 多阶段构建

很多人把这两个概念混为一谈,但它们解决的问题完全不同:

概念解决什么问题机制
分层构建提升缓存命中,减少重复安装依赖每条 RUN/COPY/ADD 形成一层,输入不变则复用缓存
多阶段构建减少最终镜像体积,隔离构建工具和运行环境多个 FROM 各自独立,COPY --from 只拿产物
两者关系多阶段构建也会产生层,但它更进一步——把不同阶段的文件系统隔离开

一句话:

分层构建是 Docker 的缓存机制;多阶段构建是用多个构建阶段控制最终镜像里留下什么。

一份成熟的 Dockerfile 两者都用:既让依赖层优先缓存(分层构建),也用 python-deps / runtime 把编译产物和运行镜像拆开(多阶段构建)。


一、层缓存:为什么改一行代码要重跑整个构建

Docker 的每条 RUNCOPYADD 指令都会生成一个镜像层。构建时按顺序判断:

  1. 如果某一层的输入没变 → 复用缓存,秒过
  2. 如果某一层变了 → 它和它后面的所有层全部重新执行

反模式:大杂烩 Dockerfile

dockerfile
COPY requirements.txt /app/
RUN pip install -r requirements.txt

COPY . /app/           # ← 业务代码变更导致此层失效
RUN npm install         # ← 被迫重跑

只要任意业务代码改了,COPY . 层就变,后面的 npm install 重新跑。每次构建都在浪费时间重装不变的依赖。

正解:依赖与代码分离

dockerfile
# 先 COPY 依赖声明文件(变动少)
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY package.json .
RUN npm install

# 最后 COPY 业务代码(变动频繁)
COPY . .
层级内容变动频率缓存效果
requirements.txt + pip installPython 依赖几乎不变,长期有效
package.json + npm installNode 依赖几乎不变,长期有效
COPY . .业务代码每次更新,但影响最小

核心原则:把变动频率低的东西放前面,变动频率高的放最后。


二、多阶段构建:构建时需要的 ≠ 运行时需要的

问题场景

mysqlclient 编译时需要完整的编译工具链:

build-essential
pkg-config
default-libmysqlclient-dev

但服务运行时不需要编译器。如果全部塞在一个镜像里:

  • 镜像体积膨胀(编译器+头文件几百 MB)
  • 攻击面扩大(多一个工具多一个风险点)

先分清:分层缓存 ≠ 多阶段构建

很多人把"拷贝依赖文件放前面"等同于"多阶段构建",其实它们是两个独立机制:

只做了分层缓存(有,但不完整):

dockerfile
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-essentialpkg-configdefault-libmysqlclient-dev 这些编译 mysqlclient 用的工具会永远留在最终运行镜像里。

真正的多阶段构建(拆成 base / deps / runtime 三段):

dockerfile
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

结论一句话:分层缓存决定"重不重装",多阶段构建决定"带不带进镜像"。两者互补,不是一回事。

多阶段构建方案

dockerfile
# ====== 阶段 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 包、业务代码编译工具

完整实践:五层分离

dockerfile
# 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 多阶段构建中的派生语法

dockerfile
# 先定义一个公共基础阶段
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

关键点:

  1. FROM base AS node-deps = "以 base 阶段作为基础镜像,再开一个新阶段,名叫 node-deps"
  2. COPY --from=node-deps = "从 node-deps 阶段里拿产物,不是从宿主机拿"
  3. 隔离效果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-depsnode-deps 整个阶段不会进入最终镜像。只有 COPY --from 指定的内容会进入 runtime:

  • python-deps:/installruntime:/usr/local
  • node-deps:/app/node_modulesruntime:/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 复制到最终镜像的 → 留下

dockerfile
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-essentialpkg-configdefault-libmysqlclient-dev
  • apt 缓存
  • 临时构建目录

但它们不一定会立刻物理删除。 Docker/BuildKit 为了缓存复用,会把中间阶段的层保留在本机 build cache 里。

多阶段构建各阶段
        │
        │ COPY --from=xxx 的内容
        ▼
最终镜像:保留被复制的产物

未复制内容:
  → 不进入最终镜像
  → 但可能留在 Docker build cache
  → 等待复用或被 prune 清理

清理命令:

bash
docker builder prune     # 清理构建缓存
docker system prune      # 更彻底,含悬空镜像

在 CI 里:如果没有持久化 BuildKit cache,中间阶段随 runner 销毁;如果配了 Docker layer cache,可能被缓存到下次构建。


三、Playwright Chromium 的安置策略

Playwright 安装 Chromium 浏览器有两种选择:

放构建层(镜像更小)

dockerfile
FROM base AS python-deps
RUN playwright install chromium
# runtime 阶段需要 COPY 浏览器缓存目录
COPY --from=python-deps /root/.cache/ms-playwright /root/.cache/ms-playwright

优点:最终镜像不包含 playwright 安装过程 缺点:需要精确知道浏览器缓存路径,容易遗漏

放运行层(逻辑更清晰)

dockerfile
FROM base AS runtime
RUN playwright install chromium

优点:逻辑简单,Docker 自动处理路径 缺点:playwright install 在最终层执行,镜像稍大

推荐:先选择"清晰稳定"方案——放运行层。等对整个流程足够熟悉后,再优化到构建层以缩小镜像。


四、多阶段构建的四大收益

收益说明
CI 更快依赖层不容易失效,改业务代码不影响依赖缓存
镜像更小编译工具不进入最终镜像,减少 200-500MB
失败更清楚系统依赖、Python 依赖、Node 依赖、业务代码分层明确,构建失败快速定位
安全更好运行镜像少无关工具,攻击面更小

五、速查:依赖分层顺序

频繁改动 ↓
─────────────────
  系统运行依赖    ← 几乎不变
  Python 构建依赖  ← 很少变
  Python 包        ← requirements.txt 变时才变
  Node 包          ← package.json 变时才变
  业务代码         ← 每次 commit 都变
─────────────────

一句话总结:层缓存让你不重装没变的依赖,多阶段构建让你不带编译工具进生产。两者结合,又快又小又安全。

Released under the MIT License.