镜像分层原理与体积瘦身

镜像分层原理与体积瘦身

写在前面

上一篇讲了 Dockerfile 的写法,但没回答一个根本问题:为什么镜像是一层一层的?这些层是怎么来的?为什么 docker pull 同一个镜像的不同 tag 不会重复下载? 这一篇会从 UnionFS 入手,讲清楚分层的本质。

然后是实战——拿公司一个 1.8GB 的 Java Spring Boot 服务镜像,逐步瘦身到 180MB,过程中记录每一步的体积变化和踩坑点。

一、镜像到底是什么

1.1 一个直观但不严谨的比喻

如果用 Git 来类比:

每一层都记录了"相对上一层的修改"。从第一层 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 文件做完整解析,讲清楚多服务编排的每一个细节。

参考资料