Docker 镜像瘦身:使用 Maven 拆分 Jar 包

1. 引言

在微服务架构中,每个服务通常都会被打包成一个独立的 Docker 镜像。然而,随着业务发展,依赖库(如 Spring Boot、日志框架、数据库驱动等)的体积越来越大,导致每个微服务镜像动辄几百 MB。这不仅占用了大量磁盘空间,还拖慢了 CI/CD 流水线的构建和部署速度。

一个常见的优化思路是:将微服务共有的依赖(如 Spring Boot、Netty、Jackson 等)提取出来,构建一个基础镜像(Base Image),而每个微服务只打包自己独有的业务代码和少量私有依赖。 这样,基础镜像只需构建一次,后续微服务镜像的体积将大幅缩减。

本文将详细介绍如何在 Docker 环境下,利用 Maven 插件将 Java 应用的 JAR 包拆分为"公共依赖 Jar"和"微服务业务 Jar",并构建出高效的分层 Docker 镜像。最后,我们通过一个 HTTP 生产者-消费者示例,直观对比传统 Fat Jar 与分层镜像的大小差异。

2. 核心原理:Docker 镜像分层与依赖分离

Docker 镜像是分层构建的,每一层都是只读的。当多个镜像共享同一层时,该层只需存储一份。我们的优化策略正是基于这一特性:

  1. 构建基础镜像 :将 Spring Boot、日志库、数据库驱动等所有微服务共用的依赖打包成一个 lib 目录,并构建为 Docker 基础镜像。这一层变化频率极低。
  2. 构建业务镜像 :每个微服务只打包自己的业务代码和少量私有依赖(如特定协议的客户端),形成 app.jar。这一层体积很小,且更新频繁。

当所有微服务都基于同一个基础镜像构建时,基础镜像层在宿主机上只存储一份,每个微服务镜像只需额外存储自己那几十 MB 的业务代码层,从而极大地节省了磁盘空间和网络传输时间。

3. 使用 Maven 拆分 JAR 包

Maven 本身不直接支持将依赖拆分为两个 Jar,但我们可以通过 maven-dependency-pluginmaven-jar-plugin 的组合来实现。

3.1 项目结构假设

假设我们有一个标准的 Spring Boot 微服务项目 user-service,其 pom.xml 中引入了大量公共依赖。

3.2 第一步:提取公共依赖

pom.xml 中配置 maven-dependency-plugin,将项目所有依赖(包括传递依赖)复制到一个临时目录 lib 中。

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>3.6.1</version>
    <executions>
        <execution>
            <id>copy-dependencies</id>
            <phase>package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <!-- 输出目录 -->
                <outputDirectory>${project.build.directory}/lib</outputDirectory>
                <!-- 排除测试依赖 -->
                <includeScope>compile</includeScope>
                <!-- 排除项目自身输出的 jar -->
                <excludeArtifactIds>${project.artifactId}</excludeArtifactIds>
            </configuration>
        </execution>
    </executions>
</plugin>

3.3 第二步:打包业务代码(不包含依赖)

配置 maven-jar-plugin,在打包时不包含任何依赖,只打包项目自身的 class 文件和资源。

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.3.0</version>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
                <!-- 指定 classpath 前缀,指向 lib 目录 -->
                <classpathPrefix>lib/</classpathPrefix>
                <mainClass>com.example.UserServiceApplication</mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

关键点<classpathPrefix>lib/</classpathPrefix> 告诉 JVM,启动时所需的依赖 JAR 包位于当前目录下的 lib/ 子目录中。

3.4 第三步:构建产物

执行 mvn clean package 后,在 target 目录下会得到:

  • user-service-1.0.0.jar瘦 Jar,只包含业务代码。
  • lib/ 目录:包含所有公共依赖 JAR 包。

4. 构建 Docker 镜像

4.1 构建基础镜像(Base Image)

首先,我们需要一个包含所有公共依赖的基础镜像。这个镜像可以基于一个通用的 JDK 镜像构建。

Dockerfile.base:

dockerfile 复制代码
FROM eclipse-temurin:17-jre-alpine

# 创建一个目录存放公共依赖
WORKDIR /app

# 将公共依赖复制到镜像中
# 注意:这里我们假设有一个包含所有依赖的 lib 目录
COPY lib/ ./lib/

# 基础镜像不需要入口,它只是提供依赖层

构建命令:

bash 复制代码
# 假设你有一个专门的项目来生成 lib 目录,或者从某个构建产物中获取
# 这里简化处理,直接在当前目录构建
docker build -t my-java-base:1.0 -f Dockerfile.base .

更优实践 :创建一个独立的 Maven 项目 common-dependencies,其 pom.xml 中声明所有公共依赖,然后通过 maven-dependency-plugin 将依赖复制出来,再构建基础镜像。这样基础镜像的构建与业务服务解耦。

4.2 构建微服务业务镜像

每个微服务的 Dockerfile 只需基于基础镜像,并添加自己那几十 KB 的业务 Jar 包。

Dockerfile:

dockerfile 复制代码
# 基于公共依赖基础镜像
FROM my-java-base:1.0

WORKDIR /app

# 只复制业务 Jar 包
COPY target/user-service-1.0.0.jar app.jar

# 启动命令,classpath 指向 lib/ 目录下的依赖和当前 jar
ENTRYPOINT ["java", "-jar", "app.jar"]

构建命令:

bash 复制代码
docker build -t user-service:1.0 .

5. 效果对比与验证

5.1 镜像大小对比

方案 镜像大小 说明
传统 Fat Jar ~250 MB 每个微服务都包含完整的 Spring Boot 和依赖
分层镜像(首次构建) ~250 MB (Base) + ~20 MB (App) 基础镜像首次构建时较大
分层镜像(后续服务) ~20 MB 后续微服务只需构建应用层,基础镜像可复用

5.2 验证方法

使用 docker images 命令查看镜像大小:

bash 复制代码
# 查看基础镜像
docker images my-java-base
# REPOSITORY      TAG       IMAGE ID       CREATED          SIZE
# my-java-base    1.0       abc123def456   10 minutes ago   230MB

# 查看业务镜像
docker images user-service
# REPOSITORY      TAG       IMAGE ID       CREATED          SIZE
# user-service    1.0       ghi789jkl012   5 minutes ago    250MB (实际只新增了20MB)

注意:docker images 命令显示的 SIZE 是镜像的虚拟大小 ,包含了所有共享层。实际占用的磁盘空间 是各层去重后的总和。你可以通过 docker system df 命令查看实际磁盘占用。

6. 进阶优化与注意事项

6.1 使用 Spring Boot Layertools

Spring Boot 2.3+ 提供了内置的 Layertools,可以更精细地拆分镜像层(如将依赖、Spring Boot 框架、业务代码分层)。如果你的项目已经使用了 Spring Boot,这通常是更简单、更官方的选择。

dockerfile 复制代码
# 使用 layertools 提取分层
FROM eclipse-temurin:17-jre-alpine as builder
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder dependencies/ ./
COPY --from=builder spring-boot-loader/ ./
COPY --from=builder snapshot-dependencies/ ./
COPY --from=builder application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

6.2 依赖版本管理

所有微服务必须使用完全一致的公共依赖版本,否则基础镜像中的依赖可能与业务代码不兼容。建议通过 Maven BOM (Bill of Materials)或父 POM 统一管理版本。

6.3 私有依赖处理

如果某个微服务有自己特有的依赖(如 oracle-jdbcspecific-protocol),这些依赖不应放入基础镜像,而应打包到该微服务的业务 JAR 中,或者通过 maven-dependency-plugin<exclude> 标签排除。

7. 总结

通过 Maven 插件将 Java 应用的公共依赖与业务代码分离,并构建分层 Docker 镜像,是一种非常有效的镜像优化策略。它利用了 Docker 镜像层的分层共享机制,显著减少了磁盘占用和网络传输成本,尤其适合拥有大量微服务的场景。

  • 核心工具maven-dependency-plugin + maven-jar-plugin
  • 关键配置<classpathPrefix>lib/</classpathPrefix>
  • 最佳实践:统一管理依赖版本,或直接使用 Spring Boot Layertools。

希望本文能帮助你构建更轻量、更高效的微服务 Docker 镜像。

8. HTTP 生产者与消费者示例:镜像大小对比

为了更直观地展示分层镜像的优势,我们以一个基于 HTTP 通信的消息生产者和消费者微服务为例,对比传统 fat jar 与分层镜像的大小差异。

8.1 项目结构

假设我们有两个微服务:http-producer(HTTP 消息生产者)和 http-consumer(HTTP 消息消费者)。它们都依赖 Spring Boot、Spring Web、Jackson 等公共库。生产者通过 HTTP POST 发送消息,消费者通过 HTTP GET 拉取消息。

复制代码
http-demo/
├── pom.xml                  # 父 POM,统一管理依赖版本
├── http-producer/
│   └── pom.xml              # 生产者模块
└── http-consumer/
    └── pom.xml              # 消费者模块

8.2 生产者代码(http-producer)

pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.example</groupId>
        <artifactId>http-demo</artifactId>
        <version>1.0.0</version>
    </parent>
    <artifactId>http-producer</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

HttpProducerApplication.java

java 复制代码
package com.example.producer;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;

import java.util.concurrent.ConcurrentLinkedQueue;

@SpringBootApplication
@RestController
public class HttpProducerApplication {

    private final ConcurrentLinkedQueue<String> messageQueue = new ConcurrentLinkedQueue<>();
    private final RestTemplate restTemplate = new RestTemplate();
    private static final String CONSUMER_URL = "http://http-consumer:8081/receive";

    @PostMapping("/send")
    public String send(@RequestParam String message) {
        messageQueue.add(message);
        // 通过 HTTP 将消息转发给消费者
        try {
            restTemplate.postForEntity(CONSUMER_URL + "?message=" + message, null, String.class);
            return "Sent and forwarded: " + message;
        } catch (Exception e) {
            return "Sent to queue (consumer unavailable): " + message;
        }
    }

    @GetMapping("/queue/size")
    public int queueSize() {
        return messageQueue.size();
    }

    public static void main(String[] args) {
        SpringApplication.run(HttpProducerApplication.class, args);
    }
}

application.properties

properties 复制代码
server.port=8080
spring.application.name=http-producer

8.3 消费者代码(http-consumer)

pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.example</groupId>
        <artifactId>http-demo</artifactId>
        <version>1.0.0</version>
    </parent>
    <artifactId>http-consumer</artifactId>
    <packaging>jar</packaging>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

HttpConsumerApplication.java

java 复制代码
package com.example.consumer;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

import java.util.concurrent.CopyOnWriteArrayList;

@SpringBootApplication
@RestController
public class HttpConsumerApplication {

    private final CopyOnWriteArrayList<String> receivedMessages = new CopyOnWriteArrayList<>();

    @GetMapping("/receive")
    public String receive(@RequestParam String message) {
        receivedMessages.add(message);
        System.out.println("Received: " + message);
        return "OK";
    }

    @GetMapping("/messages")
    public List<String> getMessages() {
        return new ArrayList<>(receivedMessages);
    }

    @GetMapping("/count")
    public int count() {
        return receivedMessages.size();
    }

    public static void main(String[] args) {
        SpringApplication.run(HttpConsumerApplication.class, args);
    }
}

application.properties

properties 复制代码
server.port=8081
spring.application.name=http-consumer

8.4 父 POM(统一依赖管理)

pom.xml(项目根目录):

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>http-demo</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>http-producer</module>
        <module>http-consumer</module>
    </modules>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-dependency-plugin</artifactId>
                    <version>3.6.1</version>
                    <executions>
                        <execution>
                            <id>copy-dependencies</id>
                            <phase>package</phase>
                            <goals>
                                <goal>copy-dependencies</goal>
                            </goals>
                            <configuration>
                                <outputDirectory>${project.build.directory}/lib</outputDirectory>
                                <includeScope>compile</includeScope>
                                <excludeArtifactIds>${project.artifactId}</excludeArtifactIds>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-jar-plugin</artifactId>
                    <version>3.3.0</version>
                    <configuration>
                        <archive>
                            <manifest>
                                <addClasspath>true</addClasspath>
                                <classpathPrefix>lib/</classpathPrefix>
                            </manifest>
                        </archive>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

8.5 构建与镜像大小对比

方案一:传统 Fat Jar(每个服务独立打包)

Dockerfile.fat

dockerfile 复制代码
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
bash 复制代码
# 构建生产者 Fat Jar
cd http-producer
mvn clean package -DskipTests
docker build -t http-producer:fat -f Dockerfile.fat .

# 构建消费者 Fat Jar
cd ../http-consumer
mvn clean package -DskipTests
docker build -t http-consumer:fat -f Dockerfile.fat .
方案二:分层镜像(共享基础镜像)

Dockerfile.base

dockerfile 复制代码
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/lib/ ./lib/
bash 复制代码
# 第一步:构建公共依赖基础镜像
# 在 http-demo 根目录执行,提取所有公共依赖
mvn dependency:copy-dependencies -DoutputDirectory=target/lib -DincludeScope=compile
docker build -t http-base:1.0 -f Dockerfile.base .

# 第二步:构建业务镜像
# 构建生产者业务镜像
cd http-producer
mvn clean package -DskipTests
docker build -t http-producer:layered -f Dockerfile.layered .

# 构建消费者业务镜像
cd ../http-consumer
mvn clean package -DskipTests
docker build -t http-consumer:layered -f Dockerfile.layered .

Dockerfile.layered

dockerfile 复制代码
FROM http-base:1.0
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

8.6 大小对比结果

方案 镜像 大小 说明
传统 Fat Jar http-producer:fat ~250 MB 每个服务都包含完整的 Spring Boot 依赖
传统 Fat Jar http-consumer:fat ~250 MB 同上,两个服务共占用约 500 MB
分层镜像 http-base:1.0 ~230 MB 公共依赖基础镜像,只需构建一次
分层镜像 http-producer:layered ~20 MB 仅包含生产者业务代码
分层镜像 http-consumer:layered ~20 MB 仅包含消费者业务代码

磁盘实际占用

  • 传统方案 :250 MB + 250 MB = 500 MB
  • 分层方案 :230 MB(基础镜像,共享) + 20 MB + 20 MB = 270 MB

节省了约 46% 的磁盘空间。随着微服务数量增加,节省效果更加显著。

8.7 运行与测试

使用 Docker Compose 启动两个服务:

docker-compose.yml

yaml 复制代码
version: '3.8'
services:
  http-producer:
    image: http-producer:layered
    ports:
      - "8080:8080"
    networks:
      - http-net

  http-consumer:
    image: http-consumer:layered
    ports:
      - "8081:8081"
    networks:
      - http-net

networks:
  http-net:
    driver: bridge
bash 复制代码
docker-compose up -d

# 发送消息
curl -X POST "http://localhost:8080/send?message=HelloDocker"

# 查看消费者收到的消息
curl http://localhost:8081/messages
# 输出: ["HelloDocker"]

# 查看消息数量
curl http://localhost:8081/count
# 输出: 1

8.8 完整代码仓库结构

复制代码
http-demo/
├── pom.xml                          # 父 POM,统一依赖管理
├── Dockerfile.base                  # 基础镜像 Dockerfile
├── docker-compose.yml               # 编排文件
├── http-producer/
│   ├── pom.xml
│   ├── Dockerfile.fat               # 传统 Fat Jar 方式
│   ├── Dockerfile.layered           # 分层镜像方式
│   └── src/main/
│       ├── java/com/example/producer/
│       │   └── HttpProducerApplication.java
│       └── resources/
│           └── application.properties
└── http-consumer/
    ├── pom.xml
    ├── Dockerfile.fat
    ├── Dockerfile.layered
    └── src/main/
        ├── java/com/example/consumer/
        │   └── HttpConsumerApplication.java
        └── resources/
            └── application.properties

通过这个完整的 HTTP 生产者-消费者示例,你可以实际运行并验证分层镜像在磁盘占用上的巨大优势。生产者通过 HTTP POST 发送消息,消费者通过 HTTP GET 接收并存储消息,两个服务共享同一个基础镜像,显著减少了磁盘占用。

相关推荐
码智社1 小时前
Maven 零基础完整教程
java·开发语言·maven
半夜修仙1 小时前
Spring集成邮箱功能
java·开发语言·spring boot·spring·rabbitmq·github·maven
曾阿伦1 小时前
Maven 构建工程化指南
java·maven
潘正翔11 小时前
docker核心概念
linux·运维·服务器·docker·容器·centos·运维开发
Java小白笔记14 小时前
Docker 安装配置完全指南:MacOS 、Windows、Linux环境下的安装、配置与验证
linux·macos·docker
copyer_xyf19 小时前
Docker 入门与实战:容器化 React + NestJS 项目
docker
刘马想放假20 小时前
Docker 从入门到进阶:一篇写给工程师的系统化实战指南
运维·docker·容器
Wang's Blog20 小时前
Go-Zero进阶实战2: 使用 Docker Compose 编排与部署微服务
docker·微服务·golang
zjun302121 小时前
如何搭建TRAE IDE 连接到容器开发环境
docker·容器·ascend·trae