镜像分层原理与体积瘦身
镜像分层原理与体积瘦身
写在前面
上一篇讲了 Dockerfile 的写法,但没回答一个根本问题:为什么镜像是一层一层的?这些层是怎么来的?为什么 docker pull 同一个镜像的不同 tag 不会重复下载? 这一篇会从 UnionFS 入手,讲清楚分层的本质。
然后是实战——拿公司一个 1.8GB 的 Java Spring Boot 服务镜像,逐步瘦身到 180MB,过程中记录每一步的体积变化和踩坑点。
一、镜像到底是什么
1.1 一个直观但不严谨的比喻
如果用 Git 来类比:
- 镜像(Image) ≈ 一个 Git 仓库
- 层(Layer) ≈ 一个 commit
- 容器(Container) ≈ 这个仓库 checkout 出来的工作区 + 你的未提交修改
每一层都记录了"相对上一层的修改"。从第一层 base 镜像开始,每一条 Dockerfile 指令产生一个新层。
1.2 严格的定义:UnionFS
Docker 用的是 UnionFS(联合文件系统),核心思想:把多个目录挂载到同一个挂载点,呈现为单一文件系统。
常见的 UnionFS 实现:
| 实现 | 内核模块 | 代表系统 |
|---|---|---|
| AUFS | aufs | 早期 Docker 默认 |
| OverlayFS | overlay / overlay2 | 现代 Linux 默认 |
| Btrfs | btrfs | 部分云厂商 |
| ZFS | zfs | 部分云厂商 |
现代 Linux(内核 4.0+)默认用 overlay2。
1.3 用一张图看懂
假设有这样一个 Dockerfile:
FROM ubuntu:22.04 # Layer 0: ~70MB
RUN apt-get update && \ # Layer 1: ~120MB
apt-get install -y python3
COPY app.py /app/ # Layer 2: ~4KB
最终文件系统视图:
┌─────────────────────────────┐
│ Layer 2 (COPY) │ app.py
├─────────────────────────────┤
│ Layer 1 (RUN apt-get) │ python3, package lists
├─────────────────────────────┤
│ Layer 0 (ubuntu:22.04) │ base system
└─────────────────────────────┘
UnionFS 视图
所有的层都是只读的。容器运行时,会在最上面加一层可写层(也叫容器层),所有写操作都发生在这里。
┌─────────────────────────────┐
│ Container Layer (writable) │ 容器运行时修改
├─────────────────────────────┤
│ Layer 2 (COPY) │ app.py
├─────────────────────────────┤
│ Layer 1 (RUN apt-get) │ python3
├─────────────────────────────┤
│ Layer 0 (ubuntu:22.04) │ base system
└─────────────────────────────┘
容器删除 = 丢弃可写层。 这就是为什么容器是临时的。
1.4 看一眼真实镜像的分层
# 查看镜像的分层信息
docker history myapp:1.0
IMAGE CREATED CREATED BY SIZE COMMENT
abc123 2 minutes ago CMD ["node" "dist/main.js"] 0B buildkit.dockerfile.v0
def456 2 minutes ago EXPOSE map[3000/tcp:{}] 0B buildkit.dockerfile.v0
ghi789 2 minutes ago USER nodejs 0B buildkit.dockerfile.v0
jkl012 2 minutes ago COPY dir:... /app/dist # ... 8MB buildkit.dockerfile.v0
mno345 3 minutes ago COPY dir:... /app/node_modules # ... 145MB buildkit.dockerfile.v0
pqr678 4 minutes ago COPY 2 files... 0B buildkit.dockerfile.v0
stu901 5 minutes ago RUN npm ci --omit=dev # buildkit 43MB buildkit.dockerfile.v0
vwx234 6 minutes ago COPY 2 files... 0B buildkit.dockerfile.v0
yza567 8 minutes ago WORKDIR /app 0B buildkit.dockerfile.v0
bcd890 8 minutes ago /bin/sh -c #(nop) CMD ["node"] 0B
efg123 2 hours ago bash 950MB <--- 哇 950MB!
最后一行的 950MB 就是 base 镜像的大小。这就是为什么选对基础镜像很重要。
二、分层带来的好处
2.1 拉取加速
假设我本地已经有 node:20-alpine(130MB),现在要拉 myapp:1.0,基于同一个 base:
docker pull myapp:1.0
# 实际下载量:只下载 myapp 自己的层,base 层从本地复用
如果你的公司有 50 个微服务都基于 node:20-alpine,那 base 镜像只需要拉一次。整个集群的镜像总下载量会显著降低。
2.2 构建缓存
这就是上一篇讲的:COPY package.json 那一层如果没变,后面 npm install 也不会重跑。
2.3 共享运行时
同一台宿主机上跑了多个容器,它们共享 base 镜像的层,只在内存里缓存一次。50 个基于 node:20-alpine 的容器不会消耗 50 倍的磁盘。
三、分层带来的坑
3.1 坑 1:删除文件不会减小镜像
FROM node:20-alpine
RUN npm install some-huge-package # 增加 50MB
RUN rm -rf /app/node_modules/some-huge-package # 看似删了
结果:镜像反而比不删多一层。因为 Docker 镜像是累加的,后一层删除的只是标记为"白空"的文件,但前一层的数据还在镜像里。
正确做法:在同一个 RUN 里装,装完立即清掉无用文件:
RUN npm install some-huge-package && \
npm cache clean --force && \
rm -rf /tmp/* /root/.npm
3.2 坑 2:多文件复制不分开
# 任何文件变化都会让所有文件重新被复制
COPY . /app/
改进:对不会变的文件单独 COPY,经常变的文件最后 COPY:
COPY package.json package-lock.json /app/
RUN cd /app && npm ci
COPY src/ /app/src/ # 源码变了也不影响 npm install 那层
3.3 坑 3:大文件没单独处理
有些项目里有几个 GB 的测试数据、训练样本、dump 文件,这些不该进镜像:
# .dockerignore
test_data/
*.dump
*.sql.gz
samples/
四、实战:Java Spring Boot 服务从 1.8GB 到 180MB
4.1 原始 Dockerfile
公司一个订单服务的 Dockerfile(我没写过,接手时就是这样):
FROM openjdk:11
WORKDIR /app
COPY target/order-service.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
镜像大小:1.83GB。一个 Spring Boot 应用,1.8GB,主要是 JDK 11 全家桶。
4.2 第一步:换基础镜像
FROM openjdk:11-jre-slim
只用了 JRE,体积从 1.83GB → 680MB。
openjdk:11-jre-slim 是 debian + JRE,删掉了 JDK 工具链、源码、文档。大部分生产场景够用。
4.3 第二步:用 Alpine
FROM openjdk:11-jre-alpine
openjdk:11-jre-alpine 基于 alpine + JRE,体积从 680MB → 220MB。
但我在这里遇到了一个问题:公司用的 Redis 客户端库在 Alpine 上跑不起来,报 No such file or directory,原因是 musl libc 和 glibc 的差异,某个 JNI 库找不到动态链接库。
解决方案:换成 eclipse-temurin:11-jre-alpine(AdoptOpenJDK 后继项目),镜像更新更及时,库的兼容性更好。最终 210MB。
4.4 第三步:多阶段构建
# 构建阶段
FROM maven:3.9-eclipse-temurin-11 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline # 预下载依赖
COPY src/ ./src/
RUN mvn package -DskipTests
# 运行阶段 - 只带 JAR
FROM eclipse-temurin:11-jre-alpine
WORKDIR /app
COPY --from=builder /build/target/order-service.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
体积:205MB。多阶段构建把 Maven(几百 MB)排除了,但 JRE 还是 200MB。
4.5 第四步:exploded JAR + 自定义 JRE
Spring Boot 的 JAR 实际上是一个 fat jar,里面带了所有依赖。Java 11 引入了 jlink,可以根据模块依赖构建一个最小化的 JRE。
# 构建阶段
FROM maven:3.9-eclipse-temurin-11 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ ./src/
RUN mvn package -DskipTests
# 解压 fat jar
RUN mkdir -p /build/extracted && \
cd /build/extracted && \
unzip -q /build/target/order-service.jar
# 用 jdeps 分析依赖模块
RUN jdeps \
--ignore-missing-deps \
--print-module-deps \
/build/extracted/BOOT-INF/lib/*.jar > /tmp/modules.txt
# 用 jlink 构建最小 JRE
RUN jlink \
--add-modules $(cat /tmp/modules.txt) \
--strip-debug \
--no-man-pages \
--compress=2 \
--output /build/custom-jre
# 运行阶段 - 自定义 JRE
FROM alpine:3.19
RUN apk add --no-cache libc6-compat
COPY --from=builder /build/custom-jre /opt/jre
COPY --from=builder /build/extracted /app
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["/opt/jre/bin/java", "-jar", "/app/BOOT-INF/lib/spring-boot-loader.jar", "org.springframework.boot.loader.JarLauncher"]
体积:180MB。
但 180MB 里有 120MB 是 custom-jre。还能再压吗?
4.6 第五步:distroless
Google 的 distroless 镜像只带运行时需要的最小系统,不包含 shell、包管理器、库:
# 构建阶段
FROM maven:3.9-eclipse-temurin-11 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ ./src/
RUN mvn package -DskipTests
# 运行阶段
FROM gcr.io/distroless/java11-debian12:nonroot
COPY --from=builder /build/target/order-service.jar /app/app.jar
WORKDIR /app
EXPOSE 8080
USER nonroot
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
体积:185MB。看起来跟第四步差不多。
但 distroless 真正的价值不是体积,是安全——没有 shell、没有 apt、没有 curl,被入侵后黑客能做的事非常少,大大减少了攻击面。我们后来把所有对外服务都换成了 distroless。
4.7 最终方案对比
| 方案 | 体积 | 安全性 | 调试难度 |
|---|---|---|---|
| openjdk:11 (原始) | 1.83GB | 差 | 低 |
| openjdk:11-jre-slim | 680MB | 中 | 低 |
| eclipse-temurin:11-jre-alpine | 210MB | 中 | 低 |
| 多阶段 + Alpine + jlink | 180MB | 中 | 低 |
| 多阶段 + distroless | 185MB | 高 | 高(无 shell) |
我们最终选了多阶段 + distroless,看重安全收益而非体积。
五、镜像分析工具
5.1 dive - 逐层分析镜像
dive 是最好的镜像分析工具,可以看到每一层的文件变化、空间占用、效率评分:
# 安装
brew install dive
# 分析镜像
dive myapp:1.0
界面分两栏:左边是当前层,右边是上一层,可以点击切换。底部还会给一个 efficiency 评分(可优化空间)。
5.2 docker history
前面演示过,简单粗暴看每一层的命令和大小。
5.3 crane - Google 出的镜像分析工具
# 查看镜像元数据
crane manifest myregistry/myapp:1.0
# 拷贝镜像(registry 之间)
crane copy source.io/myapp:1.0 target.io/myapp:1.0
# 删除镜像 tag
crane delete myregistry/myapp:1.0
六、本篇小结
| 主题 | 关键点 |
|---|---|
| 镜像本质 | 由只读层叠加而成,容器运行时加可写层 |
| UnionFS | 多目录挂载到同一挂载点 |
| 分层好处 | 拉取加速、构建缓存、运行时共享 |
| 分层坑 | 删除不会减小、单 COPY 不分离、大文件不忽略 |
| 体积优化 | 换基础镜像 → 多阶段 → 自定义 JRE → distroless |
| 安全优化 | distroless 比 alpine 更安全 |
下一篇讲 docker-compose 编排实战,会拿公司一个测试平台的 compose 文件做完整解析,讲清楚多服务编排的每一个细节。