4、Dockerfile编写技巧:多阶段构建、减少层数、优化镜像大小

说实话,Dockerfile 写得好不好,直接决定了你镜像的「身材」和构建速度。我见过不少团队,镜像动辄 1GB 起步,部署一次等半天。其实很多体积都是可以砍掉的。今天我就把压箱底的三板斧掏出来:多阶段构建、减少层数、优化镜像大小。嗯,咱们一个一个说。

4.1 多阶段构建:把「厨房」和「餐桌」分开

多阶段构建是 Docker 17.05 之后引入的特性。说白了,就是允许你在一个 Dockerfile 里写多个 FROM 语句。每个 FROM 都是一个独立的构建阶段,只有最后一个阶段会被保留下来。

为什么要这么干?你想想看,编译代码需要 gcc、make、各种开发库,但运行时只需要一个二进制文件。把编译环境和运行环境混在一起,就像把厨房的油烟味带到餐桌上一样,没必要。

核心思想:第一阶段负责编译,第二阶段只拷贝编译产物。最终镜像里只有运行时依赖,没有编译工具。

举个例子,一个 Go 应用的 Dockerfile:

# 第一阶段:编译
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .

# 第二阶段:运行
FROM alpine:3.18
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/myapp /usr/local/bin/myapp
EXPOSE 8080
CMD ["myapp"]

你看,第一阶段用 golang:1.20 这个 800MB 的大镜像来编译,第二阶段只用了 alpine:3.18 这个 5MB 的小镜像。最终镜像大小只有 15MB 左右。我在项目中遇到过,一个同事把 Go 应用直接打包在 golang 镜像里,镜像体积 900MB,部署了 5 个副本,光拉镜像就花了 3 分钟。改成多阶段构建后,镜像缩到 18MB,部署时间降到 10 秒。

小技巧:给每个阶段起个有意义的名字,比如 AS builderAS test。这样在 COPY --from=builder 时一目了然。

4.2 减少层数:每一层都是「债」

Docker 镜像是由一层层只读文件系统叠加而成的。每一条 RUNCOPYADD 指令都会产生一个新层。层数多了有什么问题?

  • 构建变慢:每一层都要计算哈希、缓存、传输。
  • 镜像变大:即使你删除了文件,只要它出现在某一层,它依然存在(因为下层是只读的)。
  • 推送变慢:层数越多,推送时需要对比的哈希就越多。

怎么减少层数?我个人的习惯是:

  1. 合并 RUN 指令:把连续的 RUN apt-get updateRUN apt-get installRUN rm -rf /var/lib/apt/lists/* 合并成一条。
  2. 使用链式操作:&& 连接多个命令,用 \ 换行保持可读性。
  3. 清理临时文件:在同一个 RUN 里下载、安装、清理,确保中间文件不留在层里。

来看一个对比:

写法 层数 镜像大小
分开写(坏) 4 层 350MB
合并写(好) 1 层 280MB

坏写法:

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

好写法:

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

注意:合并 RUN 指令不是越多越好。如果某条命令经常变化(比如 COPY . .),把它单独放一层,可以利用缓存。我曾经因为过度合并,导致每次改代码都要重新安装所有依赖,构建时间从 30 秒变成了 5 分钟。嗯,缓存策略要灵活。

4.3 优化镜像大小:能省则省

镜像大小直接影响部署速度、存储成本和网络带宽。我见过最夸张的一个 Java 镜像,2.5GB,里面居然还带着 JDK 的源码和文档。优化镜像大小,说白了就是「断舍离」。

4.3.1 选择合适的基础镜像

基础镜像的选择,决定了镜像的「底子」。

  • Alpine:5MB 左右,基于 musl libc,适合静态编译的 Go、Rust 应用。
  • Slim:比如 python:3.11-slim,基于 Debian 但去掉了不必要的包,适合 Python、Node.js。
  • Distroless:Google 出品,只包含应用和运行时依赖,没有 shell、没有包管理器,安全性高。

我个人习惯:能用 Alpine 就用 Alpine,但要注意 musl libc 的兼容性问题。比如 Python 的某些 C 扩展可能依赖 glibc,这时候用 Slim 更稳妥。

4.3.2 清理不必要的文件

在同一个 RUN 指令里,下载、安装、清理一条龙:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        libssl-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

注意 --no-install-recommends 这个参数,它告诉 apt 不要安装推荐的包,能省下不少体积。

4.3.3 利用 .dockerignore

很多人忽略了这个文件。没有 .dockerignoreCOPY . . 会把项目里的 node_modules.git__pycache__ 都打包进去。我曾经接手过一个项目,镜像里居然包含了 200MB 的 .git 历史记录。

一个典型的 .dockerignore

.git
node_modules
__pycache__
*.md
.env
.gitignore
Dockerfile
.dockerignore

4.3.4 使用 --link 优化 COPY 缓存

Docker 24.0 之后,COPY --link 可以把文件拷贝到独立层,不依赖前一层的内容。这样即使前一层缓存失效,COPY --link 的缓存依然有效。适合拷贝那些不常变化的文件,比如 package.json

COPY --link package.json package-lock.json ./
RUN npm ci --only=production
COPY --link . .

避坑指南:我曾经在构建 Node.js 镜像时,把 npm installCOPY . 放在同一个 RUN 里,结果每次改代码都要重新安装依赖。后来改成先拷贝 package.json,安装依赖,再拷贝源码,构建时间从 3 分钟降到了 20 秒。记住:把「不常变」的放在前面,「常变」的放在后面。

4.4 综合示例:一个生产级的 Dockerfile

把上面这些技巧串起来,写一个生产级的 Dockerfile:

# 第一阶段:安装依赖
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
    npm cache clean --force

# 第二阶段:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 第三阶段:运行
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

这个 Dockerfile 做到了:

  • 多阶段构建,最终镜像只有运行时依赖
  • 依赖安装和源码拷贝分离,充分利用缓存
  • 使用 Alpine 基础镜像,体积小
  • 使用非 root 用户运行,安全性高
  • 清理了 npm 缓存,不留垃圾

最终镜像大小?我实测过,一个中等规模的 Nest.js 应用,从 1.2GB 降到了 180MB。部署速度提升了一个数量级。

好了,关于 Dockerfile 的编写技巧,今天就聊到这里。记住三个关键词:多阶段、少层数、小体积。下次写 Dockerfile 的时候,不妨试试这些方法,你会回来感谢我的。