Springboot多模块,多环境,docker分层镜像打包的最佳实践
docker镜像为什么要分层打包?以我自己的实际经验,springboot打fat包,然后以 openjdk:21 构建成镜像,镜像大小差不多得5-600mb起步。即使除去openjdk:21的基础层,光应用的jar包就有100mb+。
镜像上传到服务器的时候,网速一般的情况下,非常慢。还有另外一个原因就是每个镜像重复占用硬盘,对于devOps不停打包,硬盘空间很快就消耗完了。
从springboot2.3之后,springboot打包插件(spring-boot-maven-plugin)支持了docker分层打包,可以把springboot依赖,项目依赖单独领出来作为docker的层,可以为后续的镜像提供重用镜像层。这就意味着只有自己项目的代码的jar包(这部分内容非常小,10几到几百kb),才会触发上传和镜像空间占用。
docker的一大特色就是镜像的存储是分层的,参考下面这张官图
我们在Dockerfile中的每一个指令会对应到镜像的每一层,docker在更新镜像时,只会推送变更过的层,当它计算出来这一层的摘要和之前的版本一致时,会复用上一次打包镜像时的缓存,会极大的提高打包镜像以及镜像push/pull操作的速度。
分层jar
Spring Boot始终支持它的fat格式,jar包解压后的目录结构如下:
为三个主要部分:
classes是用于引导jar加载的类
Spring Boot应用程序类是在 BOOT-INF/classes
依赖是在 BOOT-INF/lib
2.3.0.M1之后springboot(spring-boot-maven-plugin)提供了layout一种称为的新类型LAYERED_JAR,默认会打开,此时会多出layers.idx和classpath.idx两个分层索引文件。
执行一下命令来查看当前jar包支持的分层:
java -Djarmode=layertools -jar target/demo-0.0.1-SNAPSHOT.jar list输出:
dependencies
spring-boot-loader
snapshot-dependencies
applicationdependencies 项目的jar依赖(变化小)
spring-boot-loader springboot的启动引导class(基本不变)
snapshot-dependencies 项目的快照jar依赖 (变化小)
application 项目自身的jar (变化大)
编写dockerfile的时候,从上到下copy分层,从上到下的变更可能性逐渐变大,因为一旦上层的数据变动,将会引起下层全部分层缓存失效,不能复用。
接下来使用命令看看分层后的目录结构(list命令只是查看支持什么分层,并没有进行分层):
java -Djarmode=layertools -jar demo-0.0.1-SNAPSHOT.jar extract结果如下:(由于没有快照包,所以没有snapshot-dependencies目录)
接下来制作一个dockerfile用于提取并复制每一层的文件。这是一个例子:
ROM openjdk:11 as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract
FROM openjdk:11
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/resources/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]最佳实践
以上说明了分层的基本原理和操作,接下来按照实际项目进行改造。
假设当前有如下多模块项目:

编写项目根pom
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.example</groupId>
<artifactId>docker-spring-build</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>dsb-api</module>
<module>dsb-boot</module>
</modules>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>编写启动模块的pom
<?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>org.example</groupId>
<artifactId>docker-spring-build</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>dsb-boot</artifactId>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>dsb-api</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<resources>
<!--设置包含哪些文件到“target/classes”目录下-->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/**</include>
</includes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<!--使用默认的替换展位符 ${}-->
<useDefaultDelimiters>true</useDefaultDelimiters>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
<!--支持自定义分层-->
<configuration>${project.basedir}/layers.xml</configuration>
</layers>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<!--排除 META-INFO/maven 文件夹-->
<configuration>
<archive>
<addMavenDescriptor>false</addMavenDescriptor>
</archive>
</configuration>
</plugin>
<plugin>
<!--打包完成之后如归档插件,就是把jar,配置文件等打成tar包-->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<executions>
<execution>
<id>assembly</id>
<goals>
<goal>single</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<descriptors>
<!--设置详细的打包规则文件-->
<descriptor>${project.basedir}/src/main/assembly/release.xml</descriptor>
</descriptors>
<appendAssemblyId>false</appendAssemblyId>
<outputDirectory>${project.basedir}/target</outputDirectory>
<finalName>${project.name}_${project.version}_${env.active}</finalName>
</configuration>
</plugin>
</plugins>
</build>
<!--支持多环境配置-->
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<env.active>dev</env.active>
</properties>
</profile>
<profile>
<id>test</id>
<properties>
<env.active>dev</env.active>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<env.active>prod</env.active>
</properties>
</profile>
</profiles>
</project>详细说明大家请看注释内容
release.xml
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.0 http://maven.apache.org/xsd/assembly-1.1.0.xsd">
<id>dist</id>
<formats>
<format>tar.gz</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>.</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>README*</include>
</includes>
</fileSet>
<fileSet>
<directory>target</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
<fileSet>
<directory>./src/main/bin</directory>
<outputDirectory>bin</outputDirectory>
<includes>
<include>**/*</include>
</includes>
<fileMode>0755</fileMode>
</fileSet>
<fileSet>
<directory>target/classes</directory>
<outputDirectory>/config</outputDirectory>
<includes>
<include>application.yml</include>
<include>application-*.yml</include>
<include>spy.properties</include>
<include>*.xml</include>
<include>templates/**/**.ftl</include>
<include>i18n/**/**.properties</include>
</includes>
</fileSet>
<fileSet>
<directory>target/classes</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>version.txt</include>
<include>applicationName.txt</include>
</includes>
</fileSet>
</fileSets>
</assembly>
自定义docker分层
dsb-boot目录下添加layers.xml分层文件
<layers xmlns="http://www.springframework.org/schema/boot/layers"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/boot/layers
https://www.springframework.org/schema/boot/layers/layers-3.2.xsd">
<application>
<into layer="spring-boot-loader">
<include>org/springframework/boot/loader/**</include>
</into>
<into layer="application" />
</application>
<dependencies>
<!--这里的顺序决定依赖处理的分层,从上到下处理-->
<!--先处理二方依赖-->
<into layer="second-snapshot-dependencies">
<!--规则是 groupId:artifactId:version -->
<include>org.example:*:*SNAPSHOT</include>
<exclude>org.example:dsb-api:*SNAPSHOT</exclude>
</into>
<into layer="second-dependencies">
<include>org.example:*:*</include>
<!--排除一方依赖,保留到下面的application-->
<exclude>org.example:dsb-api:*</exclude>
</into>
<into layer="application">
<includeModuleDependencies />
</into>
<!--三方快照包单独一层-->
<into layer="snapshot-dependencies">
<include>*:*:*SNAPSHOT</include>
</into>
<into layer="dependencies" />
</dependencies>
<layerOrder>
<layer>spring-boot-loader</layer>
<layer>dependencies</layer>
<!--三方快照修改频率较小-->
<layer>snapshot-dependencies</layer>
<layer>second-dependencies</layer>
<layer>second-snapshot-dependencies</layer>
<layer>application</layer>
</layerOrder>
</layers>编写Dockerfile
FROM openjdk:21 as builder
WORKDIR application
ARG TAR_FILE=target/*.tar.gz
ADD ${TAR_FILE} /application
RUN mv *.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM openjdk:21
WORKDIR application
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/dependencies/ ./
RUN mkdir ./bin
COPY --from=builder application/bin/** ./bin
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/second-dependencies/ ./
COPY --from=builder application/second-snapshot-dependencies/ ./
RUN mkdir ./config
COPY --from=builder application/config/ ./config
COPY --from=builder application/application/ ./
COPY --from=builder application/version.txt ./
ENTRYPOINT ["/application/bin/dockerStartup.sh"]dsb-boot添加启动脚本
src/main/bin/dockerStartup.sh
#!/bin/bash
current_path=`pwd`
# $0 代表当前执行脚本的位置(名称) 例如执行 /application/bin/dockerStartup.sh $0=/application/bin/dockerStartup.sh
# 执行 ./bin/dockerStartup.sh $0=./bin/dockerStartup.sh
# 获取脚本的绝对路径
case "`uname`" in
Linux)
bin_abs_path=$(readlink -f $(dirname $0))
;;
*)
bin_abs_path=`cd $(dirname $0); pwd`
;;
esac
base=${bin_abs_path}/..
export LANG=en_US.UTF-8
export BASE=$base
#环境变量设置
if [ -z "$JAVA_MEM_OPTS" ] ; then
JAVA_MEM_OPTS="-Xms512m -Xmx512m "
fi
if [ -z "$JAVA_GC_OPTS" ] ; then
JAVA_GC_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=250 -XX:+UseGCOverheadLimit -XX:+ExplicitGCInvokesConcurrent "
fi
if [ -z "$JAVA_OTHER_OPTS" ] ; then
JAVA_OTHER_OPTS="-Djava.awt.headless=true -Djava.net.preferIPv4Stack=true -Dfile.encoding=UTF-8 "
fi
if [ -z "$APP_NAME" ] ; then
APP_NAME="default-app"
fi
JAVA="java -server"
SERVER_NAME="-DappName=$APP_NAME"
echo "cd to $bin_abs_path for workaround relative path"
cd $base
#echo CLASSPATH :$CLASSPATH
exec $JAVA $SERVER_NAME $JAVA_MEM_OPTS $JAVA_GC_OPTS $JAVA_OTHER_OPTS $JAVA_DEBUG_OPT org.springframework.boot.loader.JarLauncherMaven打包
mvn clean package -Dmaven.test.skip=true打包镜像
cd dsb-boot
docker build --tag dsb:1.55 .分层效果
首先打包一次镜像,观察日志
cd dsb-boot
docker build --tag dsb:1.55 .dsb:1.55日志:
docker build --tag dsb:1.55 .
[+] Building 17.3s (21/21) FINISHED docker:orbstack
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 809B 0.0s
=> [internal] load metadata for docker.io/library/openjdk:21 15.7s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.2s
=> => transferring context: 17.03MB 0.2s
=> [stage-1 1/13] FROM docker.io/library/openjdk:21@sha256:af9de795d1f8d3b6172f6c55ca9ba1c5768baa11bb2dc8af7045c7db9d4c33ac 0.0s
=> CACHED [stage-1 2/13] WORKDIR application 0.0s
=> [builder 3/5] ADD target/*.tar.gz /application 0.1s
=> [builder 4/5] RUN mv *.jar app.jar 0.3s
=> [builder 5/5] RUN java -Djarmode=layertools -jar app.jar extract 0.6s
=> [stage-1 3/13] COPY --from=builder application/spring-boot-loader/ ./ 0.0s
=> [stage-1 4/13] COPY --from=builder application/dependencies/ ./ 0.0s
=> [stage-1 5/13] RUN mkdir ./bin 0.1s
=> [stage-1 6/13] COPY --from=builder application/bin/** ./bin 0.0s
=> [stage-1 7/13] COPY --from=builder application/snapshot-dependencies/ ./ 0.0s
=> [stage-1 8/13] COPY --from=builder application/second-dependencies/ ./ 0.0s
=> [stage-1 9/13] COPY --from=builder application/second-snapshot-dependencies/ ./ 0.0s
=> [stage-1 10/13] RUN mkdir ./config 0.1s
=> [stage-1 11/13] COPY --from=builder application/config/ ./config 0.0s
=> [stage-1 12/13] COPY --from=builder application/application/ ./ 0.0s
=> [stage-1 13/13] COPY --from=builder application/version.txt ./ 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:94fc88ee72602d085129974f2c98e2dcc8cc5bd041be787b8f1e66e9747710de 0.0s
=> => naming to docker.io/library/dsb:1.55 修改一些代码,重新打包
mvn clean package -Dmaven.test.skip=true
cd dsb-boot
docker build --tag dsb:1.56 .观察日志:
docker build --tag dsb:1.56 .
[+] Building 19.4s (21/21) FINISHED docker:orbstack
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 809B 0.0s
=> [internal] load metadata for docker.io/library/openjdk:21 18.2s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [stage-1 1/13] FROM docker.io/library/openjdk:21@sha256:af9de795d1f8d3b6172f6c55ca9ba1c5768baa11bb2dc8af7045c7db9d4c33ac 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 17.03MB 0.1s
=> CACHED [stage-1 2/13] WORKDIR application 0.0s
=> [builder 3/5] ADD target/*.tar.gz /application 0.1s
=> [builder 4/5] RUN mv *.jar app.jar 0.2s
=> [builder 5/5] RUN java -Djarmode=layertools -jar app.jar extract 0.5s
=> CACHED [stage-1 3/13] COPY --from=builder application/spring-boot-loader/ ./ 0.0s
=> CACHED [stage-1 4/13] COPY --from=builder application/dependencies/ ./ 0.0s
=> CACHED [stage-1 5/13] RUN mkdir ./bin 0.0s
=> CACHED [stage-1 6/13] COPY --from=builder application/bin/** ./bin 0.0s
=> CACHED [stage-1 7/13] COPY --from=builder application/snapshot-dependencies/ ./ 0.0s
=> CACHED [stage-1 8/13] COPY --from=builder application/second-dependencies/ ./ 0.0s
=> CACHED [stage-1 9/13] COPY --from=builder application/second-snapshot-dependencies/ ./ 0.0s
=> CACHED [stage-1 10/13] RUN mkdir ./config 0.0s
=> CACHED [stage-1 11/13] COPY --from=builder application/config/ ./config 0.0s
=> [stage-1 12/13] COPY --from=builder application/application/ ./ 0.0s
=> [stage-1 13/13] COPY --from=builder application/version.txt ./ 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:997ce0e663d31f104d719b65d5bf6ea8f56ff5552dd7a45f195de93b84967f0b 0.0s
=> => naming to docker.io/library/dsb:1.56步骤3-11均使用到了CACHED(缓存),使用缓存后可以减小硬盘占用空间合镜像的推送和拉取速度。