欢迎光临
我们一直在努力

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

  • 构建基础镜像:将 Spring Boot、日志库、数据库驱动等所有微服务共用的依赖打包成一个 lib 目录,并构建为 Docker 基础镜像。这一层变化频率极低。
  • 构建业务镜像:每个微服务只打包自己的业务代码和少量私有依赖(如特定协议的客户端),形成 app.jar。这一层体积很小,且更新频繁。
  • 当所有微服务都基于同一个基础镜像构建时,基础镜像层在宿主机上只存储一份,每个微服务镜像只需额外存储自己那几十 MB 的业务代码层,从而极大地节省了磁盘空间和网络传输时间。

    3. 使用 Maven 拆分 JAR 包

    Maven 本身不直接支持将依赖拆分为两个 Jar,但我们可以通过 maven-dependency-plugin 和 maven-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 目录下会得到:

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

    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-jdbc、specific-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 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 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:

    version: '3.8'
    services:
    http-producer:
    image: httpproducer:layered
    ports:
    "8080:8080"
    networks:
    httpnet

    http-consumer:
    image: httpconsumer:layered
    ports:
    "8081:8081"
    networks:
    httpnet

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

    赞(0)
    未经允许不得转载:171主机测评 » Docker 镜像瘦身:使用 Maven 拆分 Jar 包
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址