优化Docker中的Spring Boot应用:单层,双层镜像方法

优化Docker中的Spring Boot应用:单层,双层镜像方法

Docker功能强大且易于使用。Docker允许开发人员研制的软件创建可移植的镜像。这些镜像可重复地部署。你可以很容易地从Docker中获得很多价值,但是要从Docker中获得最大的价值,需要理解一些概念。在进行持续集成和持续交付时,如何构建Docker镜像具有重要的作用。在本文中,我将重点介绍在进行迭代开发和部署时,如何采用更有效的方法为Spring Boot应用程序构建Docker镜像。为Spring Boot应用程序构建Docker镜像的标准方法有一些缺点,因此在这里我们要介绍如何做得更好。

Docker关键概念

Docker有四个关键概念: images, layers, Dockerfile 和 Docker cache 。简而言之,Dockerfile描述了如何构建Docker镜像。镜像由许多层组成。Dockerfile从基础镜像开始,并添加了其他层。当新内容添加到镜像时,将生成一个新层。所构建的每个层都被缓存,因此可以在后续构建中重复使用。当Docker构建运行时,它可以从缓存中获取重复使用任何已有层。这就减少了每次构建所需的时间和空间。任何已更改或以前尚未构建的内容都将根据需要进行构建。

Docker更新频率

镜像层内容很重要

镜像各层的重要性。Docker缓存中的现有层,只有当改镜像层内容没有变更时,才能被使用。在Docker构建期间更改的层越多,Docker需要执行更多的工作来重建镜像。镜像层顺序也很重要。如果某个图层的所有父图层均未更改,则该图层就能被重用。因此,最好把比较频繁更改的图层放在上面,以便对其更改会影响较少的子图层。

镜像层的顺序和内容很重要。当你把应用程序打包为Docker镜像时,最简单的方法是将整个应用程序放置到一个单独的镜像层中。但是,如果该应用程序包含大量静态库依赖,那么即使更改很少的代码,也需要重新构建整个镜像层。这就需要在Docker缓存中,花费大量构建时间和空间。

镜像层影响部署

部署Docker镜像时,镜像层也很重要。在部署Docker镜像之前,它们会被推送到Docker远程仓库。该仓库是所有部署镜像的源头,并且经常包含同一镜像的许多版本。Docker非常高效,每个层仅存储一次。但是,对于频繁部署且具有不断重建的大体积层的镜像,这就不行了。大体积层的镜像,即使内部只有很少的更改,也必须单独存储在仓库中并在网络中推送。因为需要移动并存储不变的内容,这就会增加部署时间,

Docker中的Spring Boot应用

使用uber-jar方法的Spring Boot应用程序本身就是独立的部署单元。该模型非常适合在虚拟机或构建包上进行部署,因为该应用程序可带来所需的一切。但是,这对Docker部署是一个缺点:Docker已经提供了打包依赖项的方法。将整个Spring Boot JAR放入Docker镜像是很常见的,但是,这会导致Docker镜像的应用程序层中的不变内容太多。

Java SpringBoot单层

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"]

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

让我们看一个具体的例子, Spring Pet Clinic

更深入地研究单层方法

单层方法使用Open Boot JDK基础镜像之上的Spring Boot JAR作为Docker层构建Docker镜像:

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

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

$ docker history springio/spring-petclinic
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
94b0366d5ba2        52 seconds ago      /bin/sh -c #(nop)  ENTRYPOINT ["java" "-Djav…   0B
213dff56a4bd        53 seconds ago      /bin/sh -c #(nop) COPY file:d3551559c2aa35af…   38.3MB
bc453a32748e        6 minutes ago       /bin/sh -c #(nop)  ARG JAR_FILE                 0B
7fe0bb0d8026        6 minutes ago       /bin/sh -c #(nop)  VOLUME [/tmp]                0B
cc2179b8f042        8 days ago          /bin/sh -c set -x  && apk add --no-cache   o…   97.4MB
<missing>           8 days ago          /bin/sh -c #(nop)  ENV JAVA_ALPINE_VERSION=8…   0B
<missing>           8 days ago          /bin/sh -c #(nop)  ENV JAVA_VERSION=8u151       0B
<missing>           8 days ago          /bin/sh -c #(nop)  ENV PATH=/usr/local/sbin:…   0B
<missing>           8 days ago          /bin/sh -c #(nop)  ENV JAVA_HOME=/usr/lib/jv…   0B
<missing>           8 days ago          /bin/sh -c {   echo '#!/bin/sh';   echo 'set…   87B
<missing>           8 days ago          /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B
<missing>           5 months ago        /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           5 months ago        /bin/sh -c #(nop) ADD file:093f0723fa46f6cdb…   4.15MB

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

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

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

镜像层生命周期

这是基于镜像层的基本考虑:内容的生命周期。镜像层的内容应具有相同的生命周期。Spring Boot应用程序的内容有两个不同的生命周期:不经常更改的依赖库和经常更改的应用程序类。

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

想象一个应用团队在Pet Clinic上进行迭代。团队每天更改和重新部署应用程序10次。这10个新层的成本为每天383 MB。如果使用更多实际大小,则每天最多可以达到2.5 GB或更多。最终将浪费大量的构建时间,部署时间和Docker仓库空间。

快速迭代的开发和交付是决定我们是继续使用简单的单层方法,还是采用更有效的替代方法。

优化Docker中的Spring Boot应用:双层镜像方法

拥抱Docker,进入双层

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

Java Spring Boot双层

双层方法加快了迭代开发的速度,并最大程度地缩短了部署时间。当然实际效率因应用程序而异,但是平均而言,这将使应用程序部署大小减少90%,同时相应地缩短了部署周期。

在这里,我们介绍一种使用Open Liberty中的新工具springBootUtility,为现有Spring Boot应用创建双层Docker镜像。目前有多种方法可以为Spring Boot应用创建多层Docker镜像,但是本文方法的重点是从现有应用创建双层镜像,而不是更改maven或gradle构建步骤。

双层方法

在双层方法中,我们构建这样的Docker镜像:以使Spring Boot应用的依赖存在于应用代码下方的一层中。通过将不经常更改的依赖推入一个单独的层,并且仅将应用类保留在顶层,这样迭代重建和重新部署就会更快。

为了做到这一点,我们需要一种将Spring Boot应用拆分为这些独立组件的工具,例如:springBootUtility。

springBootUtility

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

Docker多阶段构建

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

Docker镜像

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

Dockerfile示例

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"]

Dockerfile细节解读

使用Docker的多阶段构建和Open Liberty中的springBootUtility,Dockerfile就可以把Spring Boot应用拆分。

我们把基础镜像openjdk8-openj9命名为stagging镜像。

首先,我们安装unzip。

接下来,我们下载Open Liberty并进行一些配置。所有这些准备工作都需要使用Open Liberty工具。虽然它现在很简陋,但是我们将在Docker镜像18.0.0.2发布 时改进完善。

镜像具有所需的所有工具后,将程序JAR文件复制到stagging镜像中并进行拆分。

在/staging/myFatApp.jar基础上我们会创建一个精简后的应用,将其解压后可以进行进一步的优化。解压使应用程序直接从类文件中托管。如果类文件没有更改,后续重建可以重新使用应用层。

现在准备工作已经完成,然后我们就可以使用COPY命令,拷贝整个Liberty安装,依赖库和精简后的应用。在Dockerfile中,单独的COPY命令会生成单独的层。较大的库依赖层(34.2MB)和较小的应用层(1.01MB)将会是“双重层”的含义。

$ docker history openlibertyio/spring-petclinic
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
883ee6374f66        7 minutes ago       /bin/sh -c #(nop)  CMD ["/opt/ol/wlp/bin/ser…   0B
e3ba1351fc05        7 minutes ago       /bin/sh -c #(nop)  EXPOSE 9080                  0B
86c646de6626        7 minutes ago       /bin/sh -c #(nop) COPY dir:589967d5ae0ade9a5…   1.01MB
8f98ce0a6c10        7 minutes ago       /bin/sh -c #(nop) COPY dir:d764c6a82219ed564…   34.2MB
240306c081cd        7 minutes ago       /bin/sh -c #(nop) COPY dir:0b45938a62d056d88…   200MB
161006b94f8e        22 minutes ago      /bin/sh -c #(nop)  VOLUME [/tmp]                0B
f50ba84462ab        3 weeks ago         /bin/sh -c #(nop)  ENV PATH=/opt/java/openjd…   0B
<missing>           3 weeks ago         /bin/sh -c set -eux;     ARCH="$(dpkg --prin…   193MB
<missing>           3 weeks ago         /bin/sh -c #(nop)  ENV JAVA_VERSION=jdk8u162…   0B
<missing>           3 weeks ago         /bin/sh -c rm -rf /var/lib/apt/lists/* && ap…   16MB
<missing>           3 weeks ago         /bin/sh -c #(nop)  MAINTAINER Dinakar Gunigu…   0B
<missing>           2 months ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           2 months ago        /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           2 months ago        /bin/sh -c sed -i 's/^#\s*\(deb.*universe\)$…   2.76kB
<missing>           2 months ago        /bin/sh -c rm -rf /var/lib/apt/lists/*          0B
<missing>           2 months ago        /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           2 months ago        /bin/sh -c #(nop) ADD file:592c2540de1c70763…   113MB

现在,当进行应用更改时,仅需要更改应用层。

Dockerfile运行效果

你可以复制此Dockerfile,并运行它。

$ docker build --build-arg JAR_FILE=target/spring-petclinic-2.0.0.BUILD-SNAPSHOT.jar -t openlibertyio/spring-petclinic .

生成的Docker镜像如下所示:

注意:整个Docker镜像并不像单层方法那么小。因为基本镜像不是基于Alpine Linux的,Liberty的安装也不是最简化。我们也正在努力改善这一点。

未来的方向

到目前为止,我们对所构建的内容感到满意,但是,构建这些镜像的用户体验不是很好。我们将在接下来的几个月中继续努力。还将发布包含预配置的Open Liberty实例的Docker镜像。这将大大降低Dockerfile的复杂性。

我们还认识到,将这些双层构建集成到持续交付中,还有改进的空间。我们也有兴趣去解决在Docker中的Spring Boot应用体验。

最后,这种从应用中分离静态依赖的方法并不是Spring Boot应用独有的!使用Java EE或MicroProfile应用也可以获得类似的效率。这也是我们正在探索的另一个领域。

译文链接: https://dzone.com/articles/optimizing-spring-boot-application-for-docker
参考文献: GitHub - gclayburg/dockerPreparePlugin: Gradle plugin to generate docker layer-friendly directory for spring boot applications


原文地址: