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 只做“安装 + 配置 + 清理”闭环,保证该层产物是干净的。

层数优化全流程:一张图说清

镜像层数优化流程图 原始 Dockerfile RUN apt-get install (生成第1层) RUN npm install (生成第2层) ……更多 RUN 优化策略 ① 合并 RUN 指令 ② 多阶段构建 ③ 顺序优化 + .dockerignore ④ 层内主动清理 优化后镜像 层数:2~3 层 体积:减少 40%~90% 构建缓存:高效 拉取速度:快 核心思想:每一层只保留最终需要的文件,删除操作与安装操作同层完成

层数与体积对照表(实测)

下面是我之前对比测试的一组数据,镜像为 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 时,可以多问自己一句:这一层产出的文件,真的是最终镜像需要的吗?如果不是,就换个姿势写。