如何更优雅的构建spring boot应用的镜像

背景

在云原生的趋势下,越来越多的程序开始跑在容器上,而这就绕不开构建镜像这个操作,而镜像的大小又会关系到这个镜像的拉取时间、网络开销、磁盘占用等问题。因此如何能够更好的构建镜像就变得越来越重要了。

优化过程

基础镜像

这里选用ibm-semeru-runtimes:open-17.0.3_7-jre-focalibm-semeru-runtimes:open-17.0.3_7-jdk-focal分别作为运行时和编译时的基础镜像。

这里都以gradle项目为例。

最初方式

Dockerfile:

1
2
3
4
5
FROM ibm-semeru-runtimes:open-17.0.3_7-jdk-focal

ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

shell:

1
2
3
cd /path/to/project
./gradlew build
docker build --build-arg JAR_FILE=build/libs/your-spring-boot-version.jar -t myorg/myapp .

这种方式其实是将编译过程放在了外面,就会显得不那么优雅。因此我们决定将其放入image build 环节。

先编译

Dockerfile:

1
2
3
4
5
6
FROM ibm-semeru-runtimes:open-17.0.3_7-jdk-focal
VOLUME /tmp
COPY . /
RUN ./gradlew build && /
cp build/libs/your-application-version.jar /app.jar # && 是为了减少RUN指令,从而减少layer
ENTRYPOINT ["java","-jar","/app.jar"]

shell:

1
docker build -t myorg/myapp .

此时你会发现这个image里包含了很多不必要的文件,都是在编译过程中生成的。这我们应该怎么优化呢,此时就要用到Docker的多阶段编译(Multi-Stage Build)功能了。

img

Multi-Stage Build

Dockerfile:

1
2
3
4
5
6
7
8
FROM ibm-semeru-runtimes:open-17.0.3_7-jdk-focal as builder
VOLUME /tmp
COPY . /
RUN ./gradlew build

FROM ibm-semeru-runtimes:open-17.0.3_7-jre-focal
COPY --from=builder /build/libs/your-application-version.jar /app.jar
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} ${JAVA_TOOL_OPTIONS} -jar app.jar ${0} ${@}"]

shell:

1
docker build -t myorg/myapp .

咋一看目前的dockerfile已经足够完美了,似乎已经没有什么可以优化的了。其实不然,目前的image其实还是很大的,docker构建镜像是通过layer来进行叠加,从而达到能够复用已存在的模块来减少新版本镜像的改动部分大小,从而减少网络传输。

那layers是怎么生成的呢,其实很简单就是根据你Dockerfile里的一行行指令,每个指令都对应一个layer:

img

spring bootbuild产生的jar包其实是一个**fat jar**,里面除了你自己应用的代码其他绝大部分都是可以复用的,因此我们可以对其进行分解处理。

Spring Boot Layer Index

当然这个有个前提条件,需要spring boot 版本大于 2.3.0

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM ibm-semeru-runtimes:open-17.0.3_7-jdk-focal as builder
WORKDIR /workspace/app
COPY . .
RUN ./gradlew build
RUN mkdir extracted && \
cd /workspace/app/build/libs && \
java -Djarmode=layertools -jar app.jar extract --destination /workspace/app/extracted

FROM ibm-semeru-runtimes:open-17.0.3_7-jre-focal
VOLUME /tmp
ARG EXTRACTED=/workspace/app/extracted
COPY --from=builder ${EXTRACTED}/dependencies/ ./
COPY --from=builder ${EXTRACTED}/spring-boot-loader/ ./
COPY --from=builder ${EXTRACTED}/snapshot-dependencies/ ./
COPY --from=builder ${EXTRACTED}/application/ ./
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} ${JAVA_TOOL_OPTIONS} org.springframework.boot.loader.JarLauncher ${0} ${@}"]

shell:

1
docker build -t myorg/myapp .

Cache Build Dependencies

目前的dockerfile你会发现,每次进行build时,都会先下载gradle,然后下载相关依赖包,会占用极大的带宽以及消耗大量时间,那这些数据是否可以共享呢,答案是当然可以了。

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# syntax=docker/dockerfile:experimental
FROM ibm-semeru-runtimes:open-17.0.3_7-jdk-focal as builder
WORKDIR /workspace/app
COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew build
RUN mkdir extracted && \
cd /workspace/app/build/libs && \
java -Djarmode=layertools -jar app.jar extract --destination /workspace/app/extracted

FROM ibm-semeru-runtimes:open-17.0.3_7-jre-focal
VOLUME /tmp
ARG EXTRACTED=/workspace/app/extracted
COPY --from=builder ${EXTRACTED}/dependencies/ ./
COPY --from=builder ${EXTRACTED}/spring-boot-loader/ ./
COPY --from=builder ${EXTRACTED}/snapshot-dependencies/ ./
COPY --from=builder ${EXTRACTED}/application/ ./
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} ${JAVA_TOOL_OPTIONS} org.springframework.boot.loader.JarLauncher ${0} ${@}"]

shell:

1
docker build -t myorg/myapp .

到目前为止对于image的优化,已经基本完成了,大家可以通过以下命令来观察每个image的各个layer的大小已经images之间的关系。

1
docker history myorg/myapp