docker

关注公众号 jb51net

关闭
首页 > 网站技巧 > 服务器 > 云和虚拟化 > docker > Docker  Maven拆分Jar 包

Docker 镜像瘦身使用 Maven 拆分 Jar 包的思路详解

作者:chexus

本文将详细介绍如何在 Docker 环境下,利用 Maven 插件将 Java 应用的 JAR 包拆分为"公共依赖 Jar"和"微服务业务 Jar",并构建出高效的分层 Docker 镜像,感兴趣的朋友跟随小编一起看看吧

在使用 Docker 镜像时,通常我们会将应用程序打包成一个 JAR 文件,并通过 Docker 镜像来运行这个 JAR。然而,有时候 JAR 文件可能会非常大,这不仅会占用更多的磁盘空间,还会增加镜像的构建时间。为了减少镜像的大小,我们可以考虑使用 Maven 来拆分 JAR 包,只包含运行时所需的最小化依赖,而不是将整个项目打包成一个单一的 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 中。

<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 文件和资源。

<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 目录下会得到:

4. 构建 Docker 镜像

4.1 构建基础镜像(Base Image)

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

Dockerfile.base:

FROM eclipse-temurin:17-jre-alpine
# 创建一个目录存放公共依赖
WORKDIR /app
# 将公共依赖复制到镜像中
# 注意:这里我们假设有一个包含所有依赖的 lib 目录
COPY lib/ ./lib/
# 基础镜像不需要入口,它只是提供依赖层

构建命令:

# 假设你有一个专门的项目来生成 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:

# 基于公共依赖基础镜像
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"]

构建命令:

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 命令查看镜像大小:

# 查看基础镜像
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,这通常是更简单、更官方的选择。

# 使用 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 镜像层的分层共享机制,显著减少了磁盘占用和网络传输成本,尤其适合拥有大量微服务的场景。

希望本文能帮助你构建更轻量、更高效的微服务 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 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

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

server.port=8080
spring.application.name=http-producer

8.3 消费者代码(http-consumer)

pom.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

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

server.port=8081
spring.application.name=http-consumer

8.4 父 POM(统一依赖管理)

pom.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

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
# 构建生产者 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

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/lib/ ./lib/
# 第一步:构建公共依赖基础镜像
# 在 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

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

8.6 大小对比结果

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

磁盘实际占用

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

8.7 运行与测试

使用 Docker Compose 启动两个服务:

docker-compose.yml

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
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 接收并存储消息,两个服务共享同一个基础镜像,显著减少了磁盘占用。

到此这篇关于Docker 镜像瘦身使用 Maven 拆分 Jar 包的思路详解的文章就介绍到这了,更多相关Docker Maven拆分Jar 包内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文