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 builder、AS test。这样在 COPY --from=builder 时一目了然。
4.2 减少层数:每一层都是「债」
Docker 镜像是由一层层只读文件系统叠加而成的。每一条 RUN、COPY、ADD 指令都会产生一个新层。层数多了有什么问题?
- 构建变慢:每一层都要计算哈希、缓存、传输。
- 镜像变大:即使你删除了文件,只要它出现在某一层,它依然存在(因为下层是只读的)。
- 推送变慢:层数越多,推送时需要对比的哈希就越多。
怎么减少层数?我个人的习惯是:
- 合并 RUN 指令:把连续的
RUN apt-get update、RUN apt-get install、RUN rm -rf /var/lib/apt/lists/*合并成一条。 - 使用链式操作:用
&&连接多个命令,用\换行保持可读性。 - 清理临时文件:在同一个 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
很多人忽略了这个文件。没有 .dockerignore,COPY . . 会把项目里的 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 install 和 COPY . 放在同一个 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 的时候,不妨试试这些方法,你会回来感谢我的。