4、镜像瘦身与存储成本:多阶段构建,选择最小基础镜像,清理无用层与构建缓存
镜像太大,其实是个挺头疼的事。
我记得刚带团队那会儿,有个同事把生产镜像打到了 1.8 个 G。拉一次镜像要五分钟,磁盘很快就满了,镜像仓库的账单也蹭蹭往上涨。老板问我怎么回事,我说「镜像里装了个编译器」。老板说「你疯了吗?」——嗯,他说得对。
这一章,我们就聊聊怎么给镜像「减肥」。说白了,就是三个方向:多阶段构建、选对基础镜像、清理无用层。这三板斧砍下去,镜像体积能缩 80% 以上。
4.1 多阶段构建:编译环境和运行环境分离
先问一个问题:你的应用需要编译器吗?
大部分情况是不需要的。你只是在构建阶段需要 gcc、maven、npm 这些工具,运行时只需要编译好的二进制文件或 jar 包。那为什么要把编译器也塞进最终镜像里?
多阶段构建就是解决这个问题的。它允许你在一个 Dockerfile 里写多个 FROM 语句,前面的阶段用来编译,后面的阶段只拷贝产物。
核心思路:第一阶段装工具、搞编译;第二阶段只拿结果,干干净净。
来看一个 Go 应用的例子:
# 第一阶段:编译
FROM golang:1.21 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.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
你看,第一阶段用的是 golang:1.21,这个镜像大概 800MB。但第二阶段只用了 alpine,不到 5MB。最终镜像就是 alpine 加上一个二进制文件,总共也就十几 MB。
个人经验:我在一个微服务项目里,用多阶段构建把 12 个服务的镜像从平均 600MB 降到了 25MB 左右。镜像仓库的存储费用直接砍了一半多。老板说「干得漂亮」,我说「是 Docker 的功劳」。
多阶段构建的另一个好处是:你可以在第一阶段做很多「脏活」——比如跑单元测试、做静态检查、生成文档。这些都不会影响最终镜像的大小。
4.2 选择最小基础镜像:Alpine 与 Distroless
基础镜像的选择,直接决定了你的镜像「底子」有多大。
我见过有人用 ubuntu:22.04 做基础镜像,啥也没装就 80MB。然后往上叠一层 Python,再叠一层依赖,最后 1.2GB。其实大部分功能根本用不上。
4.2.1 Alpine:轻量级首选
Alpine Linux 基于 musl libc 和 busybox,镜像只有 5MB 左右。它用 apk 包管理器,比 apt 轻得多。
FROM alpine:3.19
RUN apk add --no-cache python3 py3-pip
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python3", "app.py"]
这个镜像大概 100MB 出头。如果用 python:3.11-slim,大概 200MB。如果用 python:3.11,直接 900MB。差距就是这么明显。
注意:Alpine 用的是 musl libc,不是 glibc。有些 C 扩展库可能不兼容。我曾经在 Alpine 上跑一个依赖 psycopg2 的 Python 应用,编译报错折腾了半天。后来换了 slim 版本才搞定。所以,先测试兼容性,再决定用不用 Alpine。
4.2.2 Distroless:极致精简
Distroless 镜像由 Google 维护,里面只有你的应用和它的运行时依赖。没有 shell,没有包管理器,没有 ls、cat 这些命令。说白了,就是一个「能跑应用的最小环境」。
# 第一阶段:编译
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp .
# 第二阶段:使用 Distroless
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
这个镜像只有 10MB 左右。而且因为里面没有 shell,攻击者就算进了容器,也没法执行命令。安全性也提升了。
我的建议:生产环境优先考虑 Distroless。开发环境或调试阶段用 Alpine 或 slim 版本,因为你需要 shell 进去看日志、查问题。我曾经在生产环境用 Distroless,有一次出问题想进容器看日志,发现连 /bin/sh 都没有……嗯,后来我加了调试端口。
4.3 清理无用层与构建缓存
Docker 镜像是由一层层文件系统叠加而成的。每一层都会占用空间。如果你不注意,很容易产生「垃圾层」。
4.3.1 合并 RUN 指令
每一条 RUN 指令都会创建一个新层。如果你写多个 RUN,就会有多层。而且每一层都会保留中间文件。
❌ 不好的写法:
RUN apt-get update
RUN apt-get install -y python3
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
✅ 好的写法:
RUN apt-get update && \
apt-get install -y python3 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
为什么?因为第一条写法里,apt-get update 产生的缓存文件在第二层还存在,虽然你最后 rm 了,但前面那层已经固定了。说白了,删除操作只能「覆盖」不能「抹除」。
4.3.2 清理包管理器的缓存
apt、yum、apk、pip、npm 这些工具都会下载缓存文件。记得在同一个 RUN 里清理掉。
# apt
RUN apt-get update && apt-get install -y \
package1 \
package2 \
&& rm -rf /var/lib/apt/lists/*
# apk (Alpine)
RUN apk add --no-cache package1 package2
# pip
RUN pip install --no-cache-dir -r requirements.txt
# npm
RUN npm install --production && npm cache clean --force
关键点:所有清理操作必须在同一个 RUN 指令里完成。分开写就没用了。
4.3.3 利用 .dockerignore 避免不必要的上下文
构建上下文里如果有 node_modules、.git、日志文件等,都会被发送到 Docker daemon。虽然最终镜像里可能没有,但构建过程会变慢,而且可能产生缓存问题。
# .dockerignore
node_modules
.git
*.log
.env
dist
__pycache__
4.3.4 清理构建缓存
Docker 构建时会缓存每一层。如果经常构建,缓存会占用大量磁盘空间。
# 查看磁盘占用
docker system df
# 清理所有未使用的构建缓存
docker builder prune -a -f
# 清理所有未使用的镜像、容器、网络
docker system prune -a -f
我的习惯:在 CI/CD 流水线里,每次构建完都执行一次 docker builder prune -f。不然跑几次流水线,构建节点磁盘就满了。我曾经吃过这个亏,半夜被报警叫起来清磁盘……
4.4 实战:一个完整的镜像瘦身案例
我们来看一个 Node.js 应用的例子。从 1.2GB 瘦身到 120MB。
原始 Dockerfile:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
这个镜像 1.2GB。问题在哪?node:18 本身就 900MB,npm install 又下载了一堆 devDependencies,还有缓存没清理。
优化后的 Dockerfile:
# 第一阶段:安装依赖
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm install --production --no-cache
# 第二阶段:构建(如果有构建步骤)
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 第三阶段:运行
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/app.js"]
最终镜像 120MB。体积缩小了 90%。
总结一下瘦身清单:
- 多阶段构建:编译和运行分离
- 选最小基础镜像:Alpine 或 Distroless
- 合并 RUN 指令,清理缓存
- 只安装生产依赖
- 使用 .dockerignore
- 定期清理构建缓存
镜像瘦身这件事,说白了就是「只带够用的东西上飞机」。你不需要带整个工具箱,只需要带一把螺丝刀。镜像仓库是按存储收费的,每省下 1GB,都是实打实的钱。
嗯,下一章我们聊聊容器运行时的资源限制和成本控制。到时候见。