优化Spring Boot应用的Docker打包速度

18-09-25 banq
              

重点介绍如何在进行迭代开发和部署时采用更快速方法为Spring Boot应用构建Docker镜像?也就是提高构建镜像的速度,降低等待时间。

Docker概念

Docker有四个关键概念:镜像,图层,Dockerfile和Docker缓存。

1. Dockerfile描述了如何构建镜像。

2. 镜像由许多层组成。

3. Dockerfile以基本镜像开始并添加其他层。

4. 将新内容添加到镜像时会生成新图层。

5. 构建的每个层都是缓存的,因此可以在后续构建中重复使用。

6. 当Docker构建运行时,它可以使用缓存中的任何现有层,这减少了每次构建所需的总时间和空间,任何已更改或之前未构建的内容都将会再次重新构建。

图层内容

这是层发挥重要作用的地方。仅当该层的内容未更改时,才能使用Docker缓存中的现有层。Docker构建过程中如果更改的层越多,Docker重建镜像所需的工作就越多。

图层顺序也很重要。只有在所有父图层都保持不变的情况下才能重复使用图层,最好在后面放置更频繁更改的图层,以便对它们进行更改会影响更少的子图层。

层的顺序和内容很重要。将应用程序打包为Docker镜像时,最简单的方法是将整个应用程序推送到单个层。但是,如果在更改最少量的代码时该应用程序包含许多静态库依赖项,则需要重建整个层。这最终会在Docker缓存中浪费大量的构建时间和空间。

图层影响部署

部署Docker镜像时,图层也很重要。在部署Docker镜像之前,它们将被推送到远程Docker存储库,此存储库充当所有部署镜像的源,并且通常包含同一镜像的许多版本。

Docker非常高效,只存储一层,但是,对于经常部署且具有不断重建大图层的镜像,这种高效率无用了。大型图层,即使其内部的变化很小,也必须单独存储在存储库中并在网络中推送,这会对部署时间产生负面影响,因为需要为不变化的部分移动和存储重复的位bit。

Docker中的Spring Boot应用

使用超级jar打包方法的Spring Boot应用程序是一个独立的部署单元。这种模型非常适合在虚拟机或构建包上进行部署,因为应用程序可以随身携带所需的一切。

但是,这对于Docker部署却是一个缺点:Docker已经提供了打包依赖关系的方法。将整个Spring Boot JAR放入Docker镜像是很常见的。但是,这会导致Docker镜像的应用程序层中有太多不变的位bit。

Spring社区正在讨论如何在运行Spring Boot应用程序时减少部署大小和时间,特别是在Docker中。

这最终是在简单性和效率之间进行权衡。为Spring Boot应用程序构建Docker镜像的最常用方法是我称之为“单层”方法。这在技术上并不正确,因为实际上Dockerfile创建了多个层,但它足以满足本次讨论的目的。

单层方法

我们来看看单层方法。单层方法快速,直接,易于理解和使用。Docker的Spring Boot指南列出了单层Dockerfile来构建Docker镜像:

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]
<p>

最终结果是一个正常运行的Docker镜像,其运行方式与你期望运行Spring Boot应用程序的方式完全相同。但是,它受到分层效率问题的困扰,因为它基于整个应用程序JAR,随着应用程序源的更改,整个Spring Boot JAR都会重建,下一次构建Docker镜像时,将重建整个应用程序层,包括所有未更改的库依赖项。

更深入地了解单层方法

单层方法使用Spring Boot JAR构建Docker镜像,基于Open JDK基础镜像之上的Docker层如下所示:

$ docker images
REPOSITORY                    TAG         IMAGE ID            CREATED             SIZE
springio/spring-petclinic     latest      94b0366d5ba2        16 seconds ago 
<p>

生成的Docker镜像为140 MB。你可以使用该docker history命令检查图层。你可以看到Spring Boot应用程序JAR已复制到大小为38.3 MB的映像中。

$ docker history springio/spring-petclinic
<p>

下次构建Docker镜像时,将重新创建整个38 MB层,因为JAR文件已重新打包。

在此示例中,应用程序大小相对较小,仅基于 spring-boot-starter-web 其他依赖项,例如 spring-actuator。在现实世界中,这些大小通常要大一些,因为它们不仅包括Spring Boot库,还包括其他第三方库。根据我的经验,实际的Spring Boot应用程序的大小范围可以从50 MB到250 MB(如果不是更大)。

仔细观察应用程序,应用程序代码中只有372 KB的应用程序JAR。剩余的38 MB是库依赖项。这意味着只有0.1%的层实际上在变化。其余99.9%未变。

层生命周期

这说明了分层的基本考虑:内容的生命周期。图层和内容应该具有相同的生命周期。Spring Boot应用程序的内容有两个不同的生命周期:不经常更改的库依赖项和频繁更改的应用程序类。

每次由于应用程序代码更改而重建层时,还包括不变的二进制文件。在应用程序代码不断变化和重新部署的快速应用程序开发环境中,这种附加成本会变得非常昂贵。

想象一下,一个应用团队在Pet Clinic上进行迭代。团队每天更改和重新部署应用程序10次。这10个新层的成本将为每天383 MB。使用更多真实尺寸,每天最多可达2.5 GB或更多。这最终会浪费构建时间,部署时间和Docker存储库空间。

快速、渐进的开发和交付是在权衡变得重要的时候,必须继续使用简单的单层方法或采用更有效的替代方法。

拥抱Docker,Go Dual Layer

在简单和效率之间的权衡中,我觉得正确的选择是“双层”方法。(更多层可能,但太多层可能是有害的,并且违反了 Docker最佳实践)。在双层方法中,我们构建Docker镜像,以便Spring Boot应用程序的库依赖项存在于应用程序代码下面的层中。这样,层遵循内容的不同生命周期。通过将不经常更改的库依赖关系推送到单独的层并仅将应用程序类保留在顶层,迭代重建和重新部署将更快。


双层方法可加速迭代开发,并最大限度地缩短部署时间。结果因应用程序而异,但平均而言,这会将应用程序部署大小减少90%,同时相应减少部署周期时间。

我们需要一种方法将Spring Boot应用程序拆分为这些单独的组件:

springBootUtility是Open Liberty中的一个新工具,它将Spring Boot应用程序分为两部分:库依赖项,例如Spring Boot启动器和其他第三方库,以及应用程序代码。库依赖项放在库高速缓存中,应用程序代码用于构建精简应用程序。瘦应用程序包含一个文件,该文件引用类路径上所需的库。然后可以将此瘦应用程序部署到Open Liberty,它将从库高速缓存生成完整的类路径。

构建此双层镜像的Dockerfile使用多阶段构建。多阶段构建允许单个Dockerfile创建多个镜像,其中一个镜像的内容可以复制到另一个镜像,丢弃临时内容。这使您可以大幅减小最终镜像的大小,而无需涉及多个Docker文件。我们使用此函数在Docker构建过程中拆分Spring Boot应用程序。

Docker镜像使用Open JDK与Open J9和Open Liberty。Open JDK为开源Java技术提供了坚实的基础。Open J9比Open JDK附带的默认Java虚拟机带来了一些性能改进。Open Liberty是一个多编程模型运行时,支持Java EE,MicroProfile和Spring。这允许开发团队使用具有一致运行时堆栈的各种编程模型。

Dockerfile:

FROM adoptopenjdk/openjdk8-openj9 as staging

ARG JAR_FILE
ENV SPRING_BOOT_VERSION 2.0

# Install unzip; needed to unzip Open Liberty
RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip \
    && rm -rf /var/lib/apt/lists/*

# Install Open Liberty
ENV LIBERTY_SHA 4170e609e1e4189e75a57bcc0e65a972e9c9ef6e
ENV LIBERTY_URL https://public.dhe.ibm.com/ibmdl/export/pub/software/openliberty/runtime/release/2018-06-19_0502/openliberty-18.0.0.2.zip

RUN curl -sL "$LIBERTY_URL" -o /tmp/wlp.zip \
   && echo "$LIBERTY_SHA  /tmp/wlp.zip" > /tmp/wlp.zip.sha1 \
   && sha1sum -c /tmp/wlp.zip.sha1 \
   && mkdir /opt/ol \
   && unzip -q /tmp/wlp.zip -d /opt/ol \
   && rm /tmp/wlp.zip \
   && rm /tmp/wlp.zip.sha1 \
   && mkdir -p /opt/ol/wlp/usr/servers/springServer/ \
   && echo spring.boot.version="$SPRING_BOOT_VERSION" > /opt/ol/wlp/usr/servers/springServer/bootstrap.properties \
   && echo \
'<?xml version="1.0" encoding="UTF-8"?> \
<server description="Spring Boot Server"> \
  <featureManager> \
    <feature>jsp-2.3</feature> \
    <feature>transportSecurity-1.0</feature> \
    <feature>websocket-1.1</feature> \
    <feature>springBoot-${spring.boot.version}</feature> \
  </featureManager> \
  <httpEndpoint id="defaultHttpEndpoint" host="*" httpPort="9080" httpsPort="9443" /> \
  <include location="appconfig.xml"/> \
</server>' > /opt/ol/wlp/usr/servers/springServer/server.xml \
   && /opt/ol/wlp/bin/server start springServer \
   && /opt/ol/wlp/bin/server stop springServer \
   && echo \
'<?xml version="1.0" encoding="UTF-8"?> \
<server description="Spring Boot application config"> \
  <springBootApplication location="app" name="Spring Boot application" /> \
</server>' > /opt/ol/wlp/usr/servers/springServer/appconfig.xml

# Stage the fat JAR
COPY ${JAR_FILE} /staging/myFatApp.jar

# Thin the fat application; stage the thin app output and the library cache
RUN /opt/ol/wlp/bin/springBootUtility thin \
 --sourceAppPath=/staging/myFatApp.jar \
 --targetThinAppPath=/staging/myThinApp.jar \
 --targetLibCachePath=/staging/lib.index.cache

# unzip thin app to avoid cache changes for new JAR
RUN mkdir /staging/myThinApp \
   && unzip -q /staging/myThinApp.jar -d /staging/myThinApp

# Final stage, only copying the liberty installation (includes primed caches)
# and the lib.index.cache and thin application
FROM adoptopenjdk/openjdk8-openj9

VOLUME /tmp

# Create the individual layers
COPY --from=staging /opt/ol/wlp /opt/ol/wlp
COPY --from=staging /staging/lib.index.cache /opt/ol/wlp/usr/shared/resources/lib.index.cache
COPY --from=staging /staging/myThinApp /opt/ol/wlp/usr/servers/springServer/apps/app

# Start the app on port 9080
EXPOSE 9080
CMD ["/opt/ol/wlp/bin/server", "run", "springServer"]
<p>

使用Docker的多阶段构建和springBootUtilityOpen Liberty,Dockerfile分割Spring Boot应用程序:

我们从一个临时镜像开始。首先,我们安装unzip。接下来,我们在某些配置中下载Open Liberty和stage。所有这些准备工作都需要准备好Open Liberty工具。我们知道它非常难看,这是我们在不久的将来发布Liberty 18.0.0.2 Docker镜像时会改进的事情之一。

一旦镜像具有所需的所有工具,JAR文件就会被复制到暂存镜像中并进行拆分。在下面创建瘦应用程序之后/staging/myFatApp.jar,将采取进一步的优化步骤来解压缩它。此解压缩导致应用程序直接从类文件托管。如果类文件未更改,这允许后续重建重用应用程序层。

现在,分段工作已经完成,我们重新开始,以便我们可以复制最终的Liberty安装,依赖库和瘦应用程序。Dockerfile中的单独COPY命令生成单独的层。较大的库依赖层(34.2MB)和较小的应用层(1.01MB)是'双层'的含义。

$ docker history openlibertyio / spring-petclinic

现在,当进行应用程序更改时,只需要更改应用程序层。

Optimizing Spring Boot apps for Docker - OpenLiber

[该贴被banq于2018-09-25 15:05修改过]