Dockerfile 最佳实践:减少层数
聊到 Docker 镜像优化,减少层数几乎是我每次做 CI/CD 流水线都会强调的第一件事。我记得刚接手一个微服务项目时,镜像居然有 1.2GB,部署到生产集群慢得要命。后来一查,光 RUN 指令就写了十几个,每行都生成一层。说白了,层数太多不仅占空间,还会拖慢构建和拉取速度。
你可能会想:多几层而已,能有多大影响?嗯,我们来看一组真实数据。
先搞懂:层数为什么是“敌人”
Docker 镜像由只读层堆叠而成。每一层只保存与上一层的差异。层数越多,镜像体积越大,构建缓存命中率越低,拉取时的解压开销也越大。我在项目中遇到过最极端的例子——一个基础镜像有 57 层,构建时长是优化后的 4 倍。
层数膨胀的三大代价:
- 体积失控——每层即使只删一个文件,上层仍会保留它(除非显式清理)
- 缓存失效——某一层变更,后面所有层缓存全部无效
- 传输慢——更多层意味着更多 HTTP Range 请求,pull 效率骤降
官方文档建议尽量把相关操作合并到同一个 RUN 里,但我发现很多人只记住了“合并”,却忽略了另一个关键点——每一层只放最终需要的东西。
核心实践一:合并 RUN 指令
最直接的做法是把多个 apt-get install、pip install、npm install 用 && 串起来。我之前见过这样的 Dockerfile:
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN apt-get install -y vim
RUN rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get install -y nodejs
猜猜多少层?8 层。而且 apt 缓存没删干净,镜像里多出了几百兆的 .deb 包。优化后应该这样:
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
git \
vim \
ca-certificates \
gnupg && \
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
同一层执行更新、安装、清理三步。最终只增加 1 层,体积削减 40%。
小技巧:使用 --no-install-recommends 避免安装额外依赖,减少无用文件。
核心实践二:善用多阶段构建
多阶段构建是我最常用的层数控制手段。第一阶段装编译工具和依赖,第二阶段只拷贝产物,最终镜像里没有 gcc、cmake 这些庞然大物。你想象一下:一个 Go 应用,用基础镜像 golang:1.22 编译后能到 800MB;换成多阶段,最终用 alpine:3.18 只放二进制,体积直接降到 10MB 以下。
举个例子:
# 第一阶段:构建
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app .
# 第二阶段:运行
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/app /app/app
EXPOSE 8080
CMD ["/app/app"]
这里最终镜像只有 2 层(一层 alpine 基础,一层 COPY),再加上一个 CMD 元数据层。golang 镜像的十几层完全被隔离在第一阶段,不会污染最终产物。
注意:COPY --from=builder 不消耗 RUN 层,但 COPY 本身会生成一层。如果从第一阶段拷贝多个文件,建议尽量合并到一个 COPY 指令里。
核心实践三:控制 COPY / ADD 的顺序与粒度
为什么说顺序重要?因为 Docker 构建缓存是按层匹配的。如果你把经常变化的代码放在前面,后面依赖没变的层也会被迫重跑。我习惯的做法是:先拷依赖文件,再拷源码。
# 坏顺序:每次改源码都重装依赖
COPY . .
RUN npm install
# 好顺序:依赖缓存住
COPY package.json package-lock.json ./
RUN npm install
COPY . .
这里 package.json 层不常变,npm install 层直接命中缓存,开发效率翻倍。
另一个坑是 COPY . . 把整个上下文拷进去,里面可能有 .git、node_modules、测试数据等大量无用文件。每个文件都成为层的一部分,且后续删除只会增加新层,原文件仍占用空间。解决办法是写 .dockerignore:
.git
node_modules
*.log
__pycache__
.vscode
这样 COPY 层里就不会塞进好几万个小文件了。
核心实践四:在 RUN 层内主动清理
很多人忘了:删除操作不减少层体积,只增加新层。比如:
RUN apt-get install -y some-package
RUN rm -rf /var/cache/apt
第一层装有 some-package 并留下了 /var/cache/apt。第二层虽然删了缓存,但第一层的数据依旧存在,最终镜像体积是两层之和。正确姿势:
RUN apt-get install -y some-package && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
这样所有操作在一个层完成,没有遗留垃圾。同样适用于 pip、npm、yum 等包管理器。
我的经验公式:每个 RUN 只做“安装 + 配置 + 清理”闭环,保证该层产物是干净的。
层数优化全流程:一张图说清
层数与体积对照表(实测)
下面是我之前对比测试的一组数据,镜像为 Python 3.11 + Flask 应用:
| 优化方式 | 层数 | 镜像体积 | 构建时间(含缓存) |
|---|---|---|---|
| 原始 Dockerfile(10个RUN) | 12 层 | 1.1 GB | 5 分 20 秒 |
| 合并 RUN 指令 | 5 层 | 780 MB | 3 分 10 秒 |
| 合并 + apt 清理 | 5 层 | 520 MB | 3 分 05 秒 |
| 多阶段构建(slim 基础镜像) | 3 层 | 128 MB | 2 分 40 秒 |
可以看到,仅仅合并 RUN 就减少了一半层数,再加上多阶段构建,体积直接缩到十分之一。
避坑清单(我踩过的)
- 不要用
--no-cache滥用——虽然能强制重新构建,但会浪费之前的缓存,只有调试时才用。 - 不要把
RUN和COPY顺序搞反——我曾经把一个经常变动的配置文件放在COPY最前面,结果每次构建都要重新跑后面所有层,白白浪费 3 分钟。 - 小心
ADD自动解压——ADD会把 tar.gz 自动解压,无形中增加一层,能用COPY就尽量用COPY。 - 清理 .cache 目录——Python 的
pip install默认会在/root/.cache/pip留下缓存,记得在同一个 RUN 里删掉:rm -rf /root/.cache/pip。
我有一个习惯:写 Dockerfile 时先用 docker history 查看当前的层列表,确认每层的大小和内容,然后逐一优化。你可以试试:
docker history --no-trunc my-image:latest | head -20
看到哪层体积异常大,就找到对应的 Dockerfile 行,用合并、清理或多阶段重构。
总结一下
减少层数不是硬性规定“必须小于 5 层”,而是每层都要有意义。我今天讲的方法——合并 RUN、多阶段构建、控制 COPY 粒度、层内清理——都是我在实际项目里反复验证过,能稳定减少 50%~90% 体积的做法。
下次你写 Dockerfile 时,可以多问自己一句:这一层产出的文件,真的是最终镜像需要的吗?如果不是,就换个姿势写。