在Docker中Gradle构建速度慢,如何使用缓存加速Gradle构建?

23

我正在进行大学项目,需要同时运行多个Spring Boot应用程序。

我已经配置了Gradle Docker镜像的多阶段构建,并在OpenJDK:jre镜像中运行应用程序。

这是我的Dockerfile:

FROM gradle:5.3.0-jdk11-slim as builder
USER root
WORKDIR /usr/src/java-code
COPY . /usr/src/java-code/

RUN gradle bootJar

FROM openjdk:11-jre-slim
EXPOSE 8080
WORKDIR /usr/src/java-app
COPY --from=builder /usr/src/java-code/build/libs/*.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

我正在使用docker-compose构建和运行所有内容。其中一部分是docker-compose:

 website_server:
    build: website-server
    image: website-server:latest
    container_name: "website-server"
    ports:
      - "81:8080"

当然,第一次构建需要很长时间。Docker正在拉取所有依赖关系。我对此感到满意。

目前一切都正常,但是对代码进行的每个小修改都会导致一个应用程序需要约1分钟的构建时间。

构建日志的一部分:docker-compose up --build

Step 1/10 : FROM gradle:5.3.0-jdk11-slim as builder
 ---> 668e92a5b906
Step 2/10 : USER root
 ---> Using cache
 ---> dac9a962d8b6
Step 3/10 : WORKDIR /usr/src/java-code
 ---> Using cache
 ---> e3f4528347f1
Step 4/10 : COPY . /usr/src/java-code/
 ---> Using cache
 ---> 52b136a280a2
Step 5/10 : RUN gradle bootJar
 ---> Running in 88a5ac812ac8

Welcome to Gradle 5.3!

Here are the highlights of this release:
 - Feature variants AKA "optional dependencies"
 - Type-safe accessors in Kotlin precompiled script plugins
 - Gradle Module Metadata 1.0

For more details see https://docs.gradle.org/5.3/release-notes.html

Starting a Gradle Daemon (subsequent builds will be faster)
> Task :compileJava
> Task :processResources
> Task :classes
> Task :bootJar

BUILD SUCCESSFUL in 48s
3 actionable tasks: 3 executed
Removing intermediate container 88a5ac812ac8
 ---> 4f9beba838ed
Step 6/10 : FROM openjdk:11-jre-slim
 ---> 0e452dba629c
Step 7/10 : EXPOSE 8080
 ---> Using cache
 ---> d5519e55d690
Step 8/10 : WORKDIR /usr/src/java-app
 ---> Using cache
 ---> 196f1321db2c
Step 9/10 : COPY --from=builder /usr/src/java-code/build/libs/*.jar ./app.jar
 ---> d101eefa2487
Step 10/10 : ENTRYPOINT ["java", "-jar", "app.jar"]
 ---> Running in ad02f0497c8f
Removing intermediate container ad02f0497c8f
 ---> 0c63eeef8c8e
Successfully built 0c63eeef8c8e
Successfully tagged website-server:latest

每次都在 Starting a Gradle Daemon (subsequent builds will be faster) 后冻结。

我在考虑是否通过缓存Gradle依赖项来增加容量,但我不知道这是否是问题的核心。同时我也找不到好的例子。

有没有什么方法可以加快构建速度?


我并不是非常熟悉Java和Gradle,但它不是与本地开发中的行为相同吗?我的意思是如果您对代码进行了一些更改,您需要重新编译项目以将更改应用于运行时。 也许你的意思是Gradle重新编译整个项目而不仅仅是更改的部分? - Charlie
发布的Dockerfile可以正常工作,但问题在于速度。本地构建需要大约8秒钟,在Docker中需要1到1.5分钟。我想知道是否有一种方法可以加快docker构建速度。 - PAwel_Z
7个回答

41
每次构建Docker镜像时,Gradle都会下载所有插件和依赖项,因此构建需要很长时间。无法在镜像构建时挂载卷。但是可以引入新阶段,下载所有依赖项并缓存为Docker镜像层。请保留{{和}}占位符。
FROM gradle:5.6.4-jdk11 as cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME /home/gradle/cache_home
COPY build.gradle /home/gradle/java-code/
WORKDIR /home/gradle/java-code
RUN gradle clean build -i --stacktrace

FROM gradle:5.6.4-jdk11 as builder
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY . /usr/src/java-code/
WORKDIR /usr/src/java-code
RUN gradle bootJar -i --stacktrace

FROM openjdk:11-jre-slim
EXPOSE 8080
USER root
WORKDIR /usr/src/java-app
COPY --from=builder /usr/src/java-code/build/libs/*.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Gradle插件和依赖项缓存位于$GRADLE_USER_HOME/cachesGRADLE_USER_HOME必须设置为与/home/gradle/.gradle不同的其他内容。在父Gradle Docker映像中定义了/home/gradle/.gradle作为卷,并在每个映像层之后被删除。
在示例代码中,GRADLE_USER_HOME设置为/home/gradle/cache_home
builder阶段,复制Gradle缓存以避免重新下载依赖项:COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
仅当更改build.gradle时,才会重新构建cache阶段。更改Java类时,将重用带有所有依赖项的缓存图像层。
这些修改可以减少构建时间,但使用Jib by Google以构建Java应用程序的Docker映像的更干净的方法。有一个Jib Gradle插件,允许为Java应用程序构建容器映像,而无需手动创建Dockerfile。使用应用程序构建图像并运行容器类似于:
gradle clean build jib
docker-compose up

2
使用包含上下文中仅 build.gradle 的阶段的多阶段构建绝对是正确的方法。通过仅在 cache 中复制 build.gradle,您可以确保只有在 Gradle 构建文件未更改时才会下载依赖项(Docker 将重用缓存)。 - Pierre B.
1
@Evgeniy Khyst,当缓存阶段使用build任务时,它是如何工作的?对于Spring Boot,它还会调用bootJar,但是如果没有带有主类的源代码,则会导致下载依赖项后出现org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':bootJar' - Main class name has not been configured and it could not be resolved。谢谢。 - ssukienn
1
好的,实际上我在这里犯了一个错误。如果在booJar任务中设置了mainClassName,那么你就可以继续进行了。当有源代码时,任务会从中解析主类名,如果没有显式设置属性。此外,即使主类是com.xyz.Main.kt文件,如果使用kotlin,我仍然需要使用com.xyz.MainKt名称。 - ssukienn
3
@Saris 我也遇到了同样的问题,但是我通过在缓存阶段使用 RUN gradle clean build -i --stacktrace -x bootJar 来排除 bootJar,而不必担心指定 mainClassName - David Vail
@Evgeniy Khyst 的解决方案非常出色,谢谢! - Dmitry Senkovich

5
Docker将其镜像缓存在"层"中。您运行的每个命令都是一层。在给定层中检测到的每个更改都会使其后面的层无效。如果缓存无效,则必须从头开始构建无效的层,包括依赖项
我建议您分割构建步骤。有一个先前的层,仅将依赖规范复制到图像中,然后运行一个命令,该命令将导致Gradle下载依赖项。完成后,将源代码复制到刚才执行该操作的同一位置,并运行真正的构建。
这样,只有gradle文件更改时,先前的层才会失效。
我没有使用Java / Gradle进行此操作,但我已经按照this博客文章的指导,在Rust项目中遵循了相同的模式。

2

正如其他答案所述,Docker会将每个步骤缓存到一个层中。如果您可以将已下载的依赖项放入一个层中,则不必每次重新下载,假设依赖项未更改。

不幸的是,Gradle没有内置任务来执行此操作。但您仍然可以解决它。这是我所做的:

# Only copy dependency-related files
COPY build.gradle gradle.properties settings.gradle /app/

# Only download dependencies
# Eat the expected build failure since no source code has been copied yet
RUN gradle clean build --no-daemon > /dev/null 2>&1 || true

# Copy all files
COPY ./ /app/

# Do the actual build
RUN gradle clean build --no-daemon

此外,请确保您的.dockerignore文件至少包含以下项目,以便在构建图像时不将它们发送到docker构建上下文中:

.gradle/
bin/
build/
gradle/

2
我使用了一个稍微不同的想法。我在我的Jenkins上安排了每晚构建整个Gradle项目的计划: docker build -f Dockerfile.cache --tag=gradle-cache:latest .
# GRADLE BUILD CACHE
FROM gradle:6.7.1-jdk11

COPY build.gradle.kts /home/gradle/code/
COPY settings.gradle.kts /home/gradle/code/
COPY gradle.properties /home/gradle/code/
COPY ./src /home/gradle/code/src

WORKDIR /home/gradle/code

RUN gradle bootJar -i -s

我会从这个“缓存镜像”开始构建,以便能够利用 Gradle 的所有优点: docker build --tag=my-app:$version .
# GRADLE BUILD
FROM gradle-cache:latest as gradle

COPY build.gradle.kts /home/gradle/code/
COPY settings.gradle.kts /home/gradle/code/
COPY gradle.properties /home/gradle/code/

RUN rm -rf /home/gradle/code/src
COPY ./src /home/gradle/code/src

WORKDIR /home/gradle/code

RUN gradle bootJar -i -s

# SPRING BOOT
FROM openjdk:11.0.9.1-jre

COPY --from=gradle /home/gradle/code/build/libs/app.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-Xmx2G", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

每周左右记得修剪未使用的图片。

2
您可以尝试使用BuildKit(现在在最新的docker-compose 1.25中默认激活)。
请参阅Aboullaite Med的“使用BuildKit加速Java应用程序Docker镜像构建!”。
(这是针对Maven的,但相同的思路适用于Gradle)

让我们考虑以下Dockerfile:

FROM maven:3.6.1-jdk-11-slim AS build  
USER MYUSER  
RUN mvn clean package  

修改第二行总是会由于错误的依赖而使得Maven缓存失效,这暴露了低效缓存问题。BuildKit通过引入并发构建图求解器来解决这个限制,可以并行运行构建步骤并优化掉对最终结果没有影响的命令。此外,Buildkit只跟踪在重复构建调用之间对文件所做的更新,以优化对本地源文件的访问。因此,在工作开始之前无需等待本地文件被读取或上传。

问题并不与构建 Docker 镜像有关,而是与在 Dockerfile 中运行命令有关。我认为这是缓存问题。我已经尝试过缓存,但每次都会下载 Gradle 等等。我也尝试了不同的卷目标组合。 - Neel Kamath
@NeelKamath,“在Dockerfile中运行命令”是“构建Docker镜像”的一部分!而BuildKit则是为了缓存构建和加速Docker构建而设计的。不妨试试看。 - VonC
仅使用BuildKit无法解决此问题:通过在构建开始时复制整个上下文并使用RUN,BuildKit将始终在每次代码更改时重新构建所有内容(因为上下文已更改),但是结合@Evgeniy Khyst的答案,它可能朝着更好的结果迈进。 - Pierre B.
@PierreB。好的,所以任何解决方案都比我想象的要复杂。 - VonC

1

作为对其他人回答的补充,如果您的互联网连接速度较慢,因为每次下载依赖项,您可能需要设置Sonatype Nexus,以保留已经下载的依赖项。


-2

我对Docker内部不是很了解,但我认为问题在于每个新的docker build命令都会复制所有文件并构建它们(如果至少一个文件检测到更改)。 然后这很可能会更改几个JAR文件,第二步也需要运行。

我的建议是在终端上构建(在Docker之外),然后只构建应用程序镜像。

这甚至可以使用Gradle插件自动化:


那么在 Docker 中进行 Gradle 构建是错误的方式吗?其想法是您无需安装任何依赖项即可在您的环境中构建和运行代码。 - PAwel_Z
哦,我明白了!我不认为你在问题中提到过这一点。在这种情况下,看起来当前的解决方案是可以的...但需要时间。 另一个问题是,为什么你希望你的开发环境没有依赖项?它被称为开发环境,因为它将有开发相关的东西。 - Vetras
这是一个很好的观点。我应该更具体一些。所有在容器开发中使用Docker的原因是因为项目由大约10个人编辑。所以我认为不需要任何操作系统或SDK依赖关系会更好。但也许这有点过度设计了。 - PAwel_Z
根据我的经验(团队最多6/7个开发人员),每个人都有本地设置。通常,在每个存储库的根目录上都有一个自述文件,其中包含设置所需的步骤命令和所有内容。 我理解你的问题,但我认为docker不是解决这个问题的正确工具。 也许,尝试简化/最小化首先需要的设置,例如:通过重构代码、设置更好的默认值、使用命名约定、减少依赖项、更好的自述设置文档。 - Vetras
我强烈感觉在docker build命令中完全构建是一件非常正常的事情,以至于我不认为他们需要解释这一点。 - Novaterata

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接