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