Dockerfile 最佳实践:减少层数、合并 RUN 指令、利用构建缓存

镜像构建慢,说白了就是层数太多、缓存没用好。我刚开始用 Docker 那会儿,一个 Java 应用镜像动不动就 1.5GB,构建一次要 8 分钟。后来我花了整整一周专门优化 Dockerfile,愣是把构建时间压到了 45 秒,镜像体积也砍掉了 70%。今天就把这些实战经验拆开揉碎了讲给你听。

为什么层数越少,构建越快?

Docker 镜像是由只读层堆叠起来的。每一条 RUN、COPY、ADD 指令都会生成一个新层。构建时,Docker 会逐层检查缓存——只要某一层变了,后面所有层都得重新构建。

我见过最夸张的 Dockerfile,光 RUN apt-get install 就拆了 12 条,每条装一个包。结果每次改代码,前面 12 层缓存全失效,白白浪费 3 分钟。你想想看,这 12 层合并成 1 层,构建速度能快多少?

核心原则:能用 1 层搞定的,绝不用 2 层。但也不是越少越好——要兼顾缓存命中率和可读性。

合并 RUN 指令:从 8 层到 1 层

先看一个反面教材。这是我早期写的 Dockerfile:

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

6 条 RUN,6 层。而且 apt-get update 和 apt-get clean 分开写,中间如果缓存失效,update 那层会重新执行,但 clean 那层可能还是旧的——最后镜像里残留了一堆没清理的缓存文件。

正确的做法是用 && 串联,或者用 ; 分行:

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

6 条变 1 条,6 层变 1 层。构建时间从 2 分钟降到 20 秒。我在项目中遇到过类似场景,当时把 30 多条 RUN 合并成 5 条,镜像体积直接少了 200MB。

小技巧:合并时注意顺序——把最稳定的操作放前面,最可能变的放后面。比如先装系统包,再装语言依赖,最后复制代码。

利用构建缓存:让 Docker 记住你的每一步

Docker 的构建缓存机制其实很简单:每执行一条指令,Docker 都会计算一个缓存键。如果缓存键匹配,就直接复用之前的层,跳过执行。

缓存键由什么决定?

  • 指令本身:比如 RUN apt-get update
  • 上下文文件:COPY 和 ADD 会检查源文件的内容哈希
  • 父层:父层变了,子层缓存自动失效

我见过最坑的写法是把 COPY . . 放在 Dockerfile 最前面。每次改一行代码,整个构建缓存全废了。正确的做法是把「经常变」的放后面,「几乎不变」的放前面。

来看一个优化后的 Dockerfile 结构:

# 第 1 层:基础镜像(几乎不变)
FROM node:18-alpine

# 第 2 层:系统依赖(偶尔变)
RUN apk add --no-cache curl git

# 第 3 层:包管理文件(代码不变就不变)
COPY package.json package-lock.json ./
RUN npm ci --only=production

# 第 4 层:应用代码(经常变)
COPY . .
CMD ["node", "app.js"]

这样设计后,90% 的构建都只命中第 4 层缓存。改代码时,前面 3 层直接复用,构建时间从 2 分钟降到 5 秒。

注意:npm ci 比 npm install 更适合构建场景。它严格依赖 lock 文件,不会因为网络波动或版本漂移导致缓存失效。我曾经因为 npm install 的缓存问题排查了整整一天,最后发现是 lock 文件没提交。

层数、缓存、体积的三角关系

这三者不是独立的。减少层数通常能提升缓存命中率,但过度合并也可能让缓存粒度变粗。我一般遵循这个原则:

场景 推荐做法 原因
系统包安装 合并为 1 条 RUN 包管理操作有状态依赖,分开写容易出问题
依赖安装 拆成 2-3 层 系统包、语言依赖、应用依赖分开,缓存粒度更细
代码复制 先复制稳定文件,再复制代码 最大化缓存命中率
清理操作 和安装合并 避免中间层残留临时文件

嗯,这里要注意:不是所有场景都适合无脑合并。比如 RUN npm test 这种测试步骤,我建议单独放一层。这样构建时如果测试失败,缓存不会污染前面的层,调试起来也方便。

实战:一个完整的优化案例

这是我优化过的一个 Python 项目的 Dockerfile。优化前 18 层,构建 4 分 20 秒;优化后 6 层,构建 45 秒。

优化前(部分):

FROM python:3.11-slim
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
RUN pip install -e .
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*

优化后:

FROM python:3.11-slim

# 系统依赖 + 清理,合并为 1 层
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc libpq-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 先复制 requirements,利用缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 最后复制代码
COPY . .
RUN pip install --no-cache-dir -e .

你看,核心变化就三点:

  1. 系统包安装和清理合并
  2. 先复制 requirements.txt,再复制代码
  3. 加了 --no-cache-dir 和 --no-install-recommends,减少无用文件

优化后,每次改代码只需要重新构建最后两层,前面两层直接命中缓存。构建时间从 4 分 20 秒降到 45 秒,镜像体积也从 1.2GB 降到 480MB。

一句话总结:把不变的放前面,把变的放后面,把能合并的合并,把能清理的清理。镜像构建飞起来,靠的就是这三板斧。

本章知识体系

下面这张图帮你理清 Dockerfile 优化的核心逻辑:

Dockerfile 优化核心逻辑图 Dockerfile 优化核心逻辑 减少层数 合并 RUN 指令 利用构建缓存 每层只做一件事 避免无关操作混在一层 用 && 串联命令 安装 + 清理合并为 1 层 稳定文件先复制 package.json 在前,代码在后 构建速度提升 5-10 倍 镜像体积减少 50%-70% 核心思路:把不变的放前面,把变的放后面,把能合并的合并,把能清理的清理

这张图把三个核心原则和具体做法串起来了。你照着这个逻辑去优化 Dockerfile,构建速度想不快都难。