📌 原创 / 后端技术 / Docker | ⏱️ 阅读约 12 分钟| 👁️ 硬核实战

标签: Docker Java 镜像瘦身 Spring Boot DevOps 多阶段构建


🔥 文章亮点速览

维度数据
最终体积298 MB
原始体积~1200 MB
压缩率约 75% 🚀
代码侵入0(不改业务代码)
改造手段仅优化 Dockerfile + 构建配置
适用场景Spring Boot / 传统 Java 应用 / 微服务

🎯 一句话总结: 多阶段构建抛弃构建工具链 → Alpine 精简 OS → Jlink 按需裁剪 JRE,三步走稳扎稳打。


📖 阅读导航


一、为什么 Java 镜像总是这么大?

几千行的祖传代码、十几个 Spring 依赖、还有那些"不敢删"的历史 Jar,打出来的 Docker 镜像动不动就 1G+。拉取慢、部署慢、CI 跑一轮能去喝杯咖啡。

很多团队第一次给 Java 应用做容器化,Dockerfile 大概长这样:

FROM openjdk:8
COPY target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

看起来没毛病,但 openjdk:8 这个基础镜像本身就有 500MB+,再加上:

  • ❌ Maven 构建产生的中间产物、源码、.class 文件
  • ❌ 全量依赖 Jar 包,很多根本没被引用
  • ❌ 测试依赖(JUnit、Mockito 等)也被一起打进去了
  • ❌ 构建工具链(Maven、Gradle)残留在镜像里

于是镜像轻松突破 1G。下面是本次改造前后的真实数据对比:

📊 改造前后体积对比

阶段镜像方案体积降幅
改造前openjdk:8 + 全量 Jar 单阶段打包1200 MB
改造后Alpine + Jlink + 多阶段构建298 MB↓ 75.2%

二、多阶段构建

2.1 为什么需要多阶段?

一个 Java 镜像的生命周期里,真正在运行时需要的只有:

  • ✅ 一个精简的 JRE
  • ✅ 你应用本身的 Jar 包

而构建阶段需要的 Maven、源码、测试代码、构建缓存,运行时一个都不需要。但很多 Dockerfile 把这些全部留在了最终镜像里。

2.2 改造前的 Dockerfile(反例)

FROM maven:3.8-openjdk-8
WORKDIR /build
COPY . /build
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/app.jar"]

最终镜像里残留了哪些垃圾?

残留内容估算体积
Maven 本体 + 本地仓库(~/.m2~200MB+
全部源码、测试代码、.class 中间产物~50MB+
target/ 目录下所有构建中间文件~30MB+

2.3 改造后:多阶段构建

# ============================================
# 第一阶段:构建(builder)
# 作用:利用 Maven 编译打包,产物最终会被精确拷贝到运行阶段
# ============================================
FROM maven:3.8-openjdk-8 AS builder
WORKDIR /build

# 先拷 pom,利用 Docker 层缓存加速依赖下载
COPY pom.xml .
RUN mvn dependency:go-offline -B

# 再拷源码,构建
COPY src ./src
RUN mvn clean package -DskipTests \
    && mv target/app.jar /build/app.jar

# ============================================
# 第二阶段:运行(runtime)
# 作用:只保留运行时必需品,所有构建环境全部丢弃
# ============================================
FROM openjdk:8-jre-slim
WORKDIR /app
COPY --from=builder /build/app.jar /app/app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]

🔑 关键点说明

优化点作用
AS builder给构建阶段命名,第二阶段用 --from=builder 精确拷贝
COPY pom.xml 单独先行利用 Docker 层缓存,依赖不变时跳过耗时的下载
mvn dependency:go-offline预下载所有依赖,加速后续构建
第二阶段只 COPY jar源码、Maven、.m2 全部被丢弃

第一招收益: 镜像立刻从 1200 MB → 约 480 MB,砍掉了整个 Maven 工具链和源码。


三、换用 Alpine 基础镜像

3.1 为什么 openjdk:8-jre-slim 还不够小?

openjdk:8-jre-slim 基于 Debian,去掉了一些冗余包,但底层仍带了大量 GNU 工具链、glibc 等,镜像仍 200MB+。对于"只跑一个 Java 进程"的容器来说,这些其实都不是必需的。

3.2 改用 Alpine + OpenJDK

Alpine Linux 是一个面向安全的轻量级 Linux 发行版,基础镜像只有 ~5MB,自带 musl libc 和 busybox,足够跑大多数 Java 应用。

把第二阶段的基础镜像换成:

# 方案一:传统 openjdk alpine
FROM openjdk:8-jre-alpine

# 方案二(推荐):官方维护的 Temurin Alpine 版,更安全更活跃
FROM eclipse-temurin:8-jre-alpine

3.3 ⚠️ 需要注意的坑

切换到 Alpine 不是"改一行就完事",有几个常见坑:

🔴 坑 1:glibc vs musl libc

Alpine 用的是 musl libc,部分依赖 native 库的应用会报错。常见场景:

  • 用了 netty-tcnativenetty-transport-native-epoll 等 native 包
  • 用了 SQLite JDBCRocksDB 等 JNI 库
  • 用了 tibco、一些老旧的 JDK 工具

解决方案:

  1. 优先使用 pure Java 实现的依赖
  2. 必要时安装 gcompat 提供部分 glibc 兼容:
RUN apk add --no-cache gcompat
  1. 实在不行,退回 eclipse-temurin:8-jre-jammy(Ubuntu 基础,比 slim 更小)

🔴 坑 2:时区与字体缺失

很多 Java 应用会用到时区和字体(验证码、报表导出),Alpine 默认不带:

RUN apk add --no-cache tzdata ttf-dejavu \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone

🔴 坑 3:DNS 解析行为差异

musl 的 DNS 解析与 glibc 不完全一致,偶尔会出现"在 Debian 上能解析、在 Alpine 上解析不到"的情况,建议显式配置:

RUN echo "hosts: files dns" > /etc/nsswitch.conf

第二招收益: 镜像体积从 480 MB → 约 380 MB


四、Jlink 定制精简 JRE + 清理冗余依赖

这一招是压轴的,也是收益最大的一步。

4.1 用 Jlink 裁剪一个"只含必要模块"的 JRE

从 JDK 9 开始,官方提供了 jlink 工具,可以根据应用实际用到的模块,裁剪出一个极小的定制 JRE

一个只跑 Spring Boot 的应用,定制 JRE 往往只有 40~60MB,对比官方 jre-alpine 的 ~170MB 又能省一大半。

💡 注意: jlink 要求应用是模块化的(module-info.java)或使用自动模块。对于非模块化的传统 Spring Boot 应用,可以用 jdeps 分析依赖。

构建阶段调用 jlink(完整 Dockerfile)
# ============================================
# 第一阶段:构建应用 Jar
# ============================================
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests \
    && mv target/app.jar /build/app.jar

# ============================================
# 第二阶段:用 jlink 裁剪 JRE
# ============================================
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
WORKDIR /jre
COPY --from=builder /build/app.jar /jre/app.jar

# 用 jdeps 自动分析应用用到的 JDK 模块
RUN jdeps --ignore-missing-deps \
          --print-module-deps \
          /jre/app.jar > /jre/modules.txt

# 生成定制 JRE(最高压缩、去调试、去文档)
RUN jlink --add-modules $(cat /jre/modules.txt) \
          --strip-debug \
          --no-header-files \
          --no-man-pages \
          --compress=2 \
          --output /jre/custom-jre

# ============================================
# 第三阶段:运行(纯 Alpine + 定制 JRE)
# ============================================
FROM alpine:3.18
WORKDIR /app
COPY --from=jre-builder /jre/custom-jre /opt/jre
COPY --from=builder /build/app.jar /app/app.jar
ENV PATH=/opt/jre/bin:$PATH
EXPOSE 8080
CMD ["java", "-jar", "/app/app.jar"]

🔑 jlink 关键参数说明

参数作用预计收益
--strip-debug去掉调试符号体积立省 30%+
--no-header-files / --no-man-pages不打包头文件和 man 页~5MB
--compress=2最高压缩等级再压缩 20~30%
jdeps --print-module-deps自动分析 JDK 模块,避免漏裁防运行时报错

💡 如果你用的是 JDK 8(无 jlink): 可以退一步用 eclipse-temurin:8-jre-alpine,并在 Maven 端用 maven-dependency-plugin 做依赖分析(见下文)。


4.2 清理 Maven 端的冗余依赖

Jlink 只裁剪 JDK,业务 Jar 里的冗余依赖需要从源头治。三步走:

第一步:分析未使用依赖
mvn dependency:analyze

输出会告诉你:

输出类型含义处理
Used undeclared dependencies用了但没声明⚠️ 补声明
Unused declared dependencies声明了但没用到重点清理
第二步:排除测试依赖进入生产包

确保 scope=test 的依赖不会被打进 fat jar:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <scope>test</scope>
</dependency>
第三步:用 spring-boot-maven-plugin 排除冗余

Spring Boot 项目可以这样配置,去掉无用的 spring-boot-devtools、文档、元数据等:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-devtools</artifactId>
            </exclude>
        </excludes>
        <layers>
            <enabled>true</enabled>
        </layers>
    </configuration>
</plugin>

💡 开启 layers 后,Spring Boot 会把依赖和应用代码分层,配合 Docker 多阶段构建可以把"依赖层"单独缓存,CI 速度还能再上一个台阶。

第三招收益: jlink + 依赖清理组合拳打完之后,最终镜像 298MB,比改造前省了 902MB


五、最终对比:三招叠加的效果

📊 每一步体积变化明细

阶段方案体积阶段降幅累计降幅
原始镜像openjdk:8 + 全量单阶段打包1200 MB
+ 第一招多阶段构建(抛弃 Maven/源码)480 MB↓ 720 MB↓ 60%
+ 第二招换 Alpine 基础镜像380 MB↓ 100 MB↓ 68%
+ 第三招Jlink 定制 JRE + 依赖清理298 MB↓ 82 MB↓ 75%

📉 阶梯降幅示意

1200 ┤████████████████████████████████████████████████████ 原始
     │
 480 ┤███████████████████ + 多阶段构建
     │
 380 ┤██████████████     + Alpine
     │
 298 ┤█████              + Jlink + 依赖清理
     │
     └───────────────────────────────────────────────────
       0         200        400        600       1200 MB

🎁 最终 Dockerfile 完整版(JDK 17 + Spring Boot 生产级)

# ==================================================================
# Java 17 + Spring Boot 生产级 Dockerfile
# 特性:三阶段构建 · Jlink 裁剪 JRE · Alpine · 时区/字体/DNS 均已处理
# ==================================================================

# --------------------------【阶段 1:构建应用】--------------------------
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests \
    && mv target/app.jar /build/app.jar

# --------------------------【阶段 2:用 jlink 裁剪 JRE】--------------------------
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
WORKDIR /jre
COPY --from=builder /build/app.jar /jre/app.jar
RUN jdeps --ignore-missing-deps --print-module-deps /jre/app.jar > /jre/modules.txt \
    && jlink --add-modules $(cat /jre/modules.txt) \
             --strip-debug --no-header-files --no-man-pages \
             --compress=2 --output /jre/custom-jre

# --------------------------【阶段 3:运行(最终镜像)】--------------------------
FROM alpine:3.18

# 安装运行时依赖:时区数据 + 字体 + DNS 配置
RUN apk add --no-cache tzdata ttf-dejavu \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone \
    && echo "hosts: files dns" > /etc/nsswitch.conf

WORKDIR /app
COPY --from=jre-builder /jre/custom-jre /opt/jre
COPY --from=builder /build/app.jar /app/app.jar

# 环境变量:JRE 路径 + JVM 生产级参数
ENV PATH=/opt/jre/bin:$PATH \
    JAVA_OPTS="-XX:+UseG1GC -XX:MaxRAMPercentage=75.0 -XX:+HeapDumpOnOutOfMemoryError"

EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]

六、避坑清单(先收藏⭐)

把踩过的坑整理成清单,落地时逐项对照:

#坑点现象解决方案
1musl libc 兼容性native 库(netty native、RocksDB)启动报错优先 pure Java 依赖 / 装 gcompat / 退回 Ubuntu base
2JDK 8 没有 jlink无法裁剪 JREeclipse-temurin:8-jre-alpine,收益少一截但仍比 openjdk:8
3时区/字体/locale 缺失验证码乱码、报表导出失败、日志时间不对apk add tzdata ttf-dejavu 并设置时区
4DNS 解析偶发失败musl 对多 A 记录解析有差异echo "hosts: files dns" > /etc/nsswitch.conf
5jlink 漏模块运行时 ClassNotFound(反射调用的模块未被 jdeps 识别)手动补 --add-modules java.naming,java.management,...
6CI 缓存失效每次都重下载依赖,构建极慢COPY pom.xmlmvn dependency:go-offline 单独前置,命中 Docker 层缓存
7基础镜像选型openjdk:8 已停维,有安全风险生产优先选 eclipse-temurin 官方维护镜像

七、总结:三招的本质

回头看,这三招其实对应着镜像瘦身的三条主线:

招数本质单步收益
多阶段构建隔离构建产物与运行产物-720MB
Alpine 基础镜像用更小的 OS 内核与用户空间-100MB
Jlink + 依赖清理只带真正需要的 JDK 模块和业务 Jar-82MB

💎 一句话原则

运行时容器里不该出现的,一律不要带进去。

这套思路不止适用于 Java,对 Go、Node、Python 的镜像同样有效——

  • 多阶段构建切掉构建工具链
  • 换最小可用基础镜像
  • 按需打包运行时依赖

把这个原则记住,你的镜像永远小而美。


🔧 改造完成后,建议用 dive 工具逐层分析镜像,定位还能再砍的冗余文件:

dive <your-image>:tag

八、附:多语言通用 Docker 模板

不止 Java,其他语言的镜像优化思路完全一致。以下模板可以直接拿去改:


8.1 Go 语言通用模板(纯静态二进制 → 极致压缩)

# ==================================================================
# Go 通用生产级 Dockerfile
# 特性:多阶段 · 纯静态编译 · 关闭 CGO · 非 root 用户运行
# ==================================================================

# --------------------------【构建阶段:编译环境】--------------------------
FROM golang:1.24-alpine AS builder

ENV TZ=Asia/Shanghai
WORKDIR /build

# 先拷贝依赖描述,利用 Docker 缓存
COPY go.mod go.sum ./
RUN go mod download

# 拷贝全部源码
COPY . .

# 静态编译:关闭 CGO + 去调试符号 (-s -w)
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -o /app/main ./cmd/main.go


# --------------------------【最终运行阶段:只保留产物】--------------------------
# 纯静态二进制可用 scratch(0 字节空镜像);需要 shell 调试则用 alpine
FROM alpine:3.20

ENV TZ=Asia/Shanghai
WORKDIR /app

# 仅装运行时必须依赖,立刻清理 apk 缓存
RUN apk --no-cache add ca-certificates tzdata \
    && rm -rf /var/cache/apk/*

# 只拷贝编译好的二进制文件
COPY --from=builder /app/main ./main

# 非 root 用户运行(安全加固)
RUN addgroup -g 1001 appgroup \
    && adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuser

EXPOSE 8080
ENTRYPOINT ["./main"]

8.2 Python 语言模板(最容易膨胀,重点参考)

# ==================================================================
# Python 生产级 Dockerfile
# 特性:多阶段 · slim 基础镜像 · 无 pip 缓存 · 非 root
# ==================================================================

# --------------------------【构建阶段:安装依赖】--------------------------
FROM python:3.12-slim AS builder

WORKDIR /build

# 先装 requirements,利用缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .


# --------------------------【运行阶段:复制 site-packages + 业务代码】--------------------------
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

# 从构建阶段复制已装好的 python 库
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /build /app

# 非 root 用户运行
RUN groupadd -r appuser \
    && useradd -r -g appuser appuser
USER appuser

EXPOSE 8000
CMD ["python", "main.py"]

8.3 NodeJS 前端模板(构建 → Nginx 托管)

# ==================================================================
# NodeJS 前端生产级 Dockerfile
# 特性:多阶段 · npm ci 精确安装 · Nginx Alpine 托管
# ==================================================================

# --------------------------【构建阶段:npm build】--------------------------
FROM node:22-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci                  # ci 比 install 更稳定,适合 CI
COPY . .
RUN npm run build


# --------------------------【运行阶段:Nginx 轻量镜像】--------------------------
FROM nginx:1.27-alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

九、配套工具与最佳实践

9.1 🚫 配套 .dockerignore(非常关键!)

放在项目根目录,与 Dockerfile 同级。减少构建上下文,避免把本地垃圾打进镜像:

# ================== Git ==================
.git
.gitignore

# ================== 本地编译产物 ==================
node_modules
dist
build
*.pyc
__pycache__

# ================== 本地 IDE 文件 ==================
.vscode
.idea

# ================== Docker 自身 ==================
Dockerfile
.dockerignore

# ================== 日志、缓存、环境变量 ==================
*.log
tmp
cache
.env
.env.local

9.2 📋 镜像体积优化 7 条军规

#要点说明
1多阶段构建builder 阶段做编译,最终镜像只拷贝运行产物,编译器/源码全部丢弃
2基础镜像优先级scratch > alpine > slim > 完整版镜像
3RUN 指令合并 + 清缓存每条 RUN 生成一层;安装包后立刻删缓存:
apk: --no-cache
apt: apt update && apt install xxx && rm -rf /var/lib/apt/lists/*
pip/npm: --no-cache-dir
4Go 去调试符号增加编译参数 -ldflags="-s -w" 去除调试符号,缩小二进制体积
5.dockerignore 必用不要把本地 node_modules、编译产物传入 Docker 构建上下文
6非 root 用户运行安全加固,生产环境不要用 root
7不装调试工具vim/curl 等不要进最终镜像;需要调试用 builder 阶段或临时 debug 容器

9.3 🔍 查看镜像层大小命令

# 分析镜像每层占用大小,精准定位哪里体积膨胀
docker history --human your-image-name

📝 结语

镜像瘦身从来不是"炫技",而是实打实的工程收益:

  • 拉取速度提升 4 倍 → 部署更快
  • 💰 镜像仓库存储成本降低 75%
  • 🛡️ 攻击面更小(镜像里少了几千个没用的文件和工具)
  • 🚀 CI/CD 效率翻倍

三招记不住?保存这张表就够了:

┌────────────────────────────────────────────┐
│          Docker 镜像瘦身三件套              │
├────────────────────────────────────────────┤
│  ① 多阶段构建  →  抛弃构建工具链            │
│  ② Alpine     →  换最小 OS 基础镜像         │
│  ③ Jlink/裁剪  →  只带运行时必需品          │
├────────────────────────────────────────────┤
│  原则:运行时不该出现的,一律别带进去        │
└────────────────────────────────────────────┘

💬 如果觉得有用,欢迎点赞 👍 · 收藏 ⭐ · 关注 📬,后续持续输出后端硬核实战内容。

有问题或补充,欢迎评论区留言交流 ~

Logo

一站式 AI 云服务平台

更多推荐