Docker cache gradle dependencies
Asked Answered
N

5

53

I'm trying to deploy our java web application to aws elastic beanstalk using docker, the idea is to be able to run the container locally for development and testing and eventually push it up to production using git.

I've created a base image that has tomcat8 and java8 installed, the image that performs the gradle builds inherit from this base image, speeding up build process.

All works well, except for the fact that the inheriting application container that gets built using docker doesn't seem to cache the gradle dependencies, it downloads it every time, including gradlew. We build our web application using the following command:

./gradlew war

Is there some way that i can cache the files in ~/.gradle this would speed my build up dramatically.

This isn't so much of an issue on beanstalk but is a big problem for devs trying to build and run locally as this does take a lot of time, as you can imagine.

The base image dockerfile:

FROM phusion/baseimage
EXPOSE 8080
RUN apt-get update
RUN add-apt-repository ppa:webupd8team/java
RUN apt-get update
RUN echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | sudo /usr/bin/debconf-set-selections
RUN apt-get -y install oracle-java8-installer
RUN java -version
ENV TOMCAT_VERSION 8.0.9
RUN wget --quiet --no-cookies http://archive.apache.org/dist/tomcat/tomcat-8/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz -O /tmp/catalina.tar.gz
# Unpack
RUN tar xzf /tmp/catalina.tar.gz -C /opt
RUN mv /opt/apache-tomcat-${TOMCAT_VERSION} /opt/tomcat
RUN ln -s /opt/tomcat/logs /var/log/tomcat
RUN rm /tmp/catalina.tar.gz
# Remove unneeded apps
RUN rm -rf /opt/tomcat/webapps/examples
RUN rm -rf /opt/tomcat/webapps/docs
RUN rm -rf /opt/tomcat/webapps/ROOT
ENV CATALINA_HOME /opt/tomcat
ENV PATH $PATH:$CATALINA_HOME/bin
ENV CATALINA_OPTS $PARAM1
# Start Tomcat
CMD ["/opt/tomcat/bin/catalina.sh", "run"]

The application dockerfile:

FROM <tag name here for base image>
RUN mkdir ~/.gradle
# run some extra stuff here to add things to gradle.properties file
# Add project Source
ADD . /var/app/myapp
# Compile and Deploy Application, this is what is downloading gradlew and all the maven dependencies every time, if only there was a way to take the changes it makes to ~/.gradle and persist it as a cache layer
RUN cd /var/app/myapp/ && ./gradlew war
RUN mv /var/app/myapp/build/libs/myapp.war /opt/tomcat/webapps/ROOT.war
# Start Tomcat
CMD ["/opt/tomcat/bin/catalina.sh", "run"]
Nought answered 16/9, 2014 at 16:29 Comment(3)
Can you share the Dockerfile for both the base and the application images?Jacobson
i've added the docker file contents to the descriptionNought
do you need the project sources and the build artifacts (myapp.war) in one image?Umbra
T
38

I faced this issue. As you might agree, it is a best practice to download dependencies alone as a separate step while building the docker image. It becomes little tricky with gradle, since there is no direct support for downloading just dependencies.

Option 1 : Using docker-gradle Docker image


We can use pre-built gradle docker image to build the application. This ensures that it's not a local system build but a build done on a clean docker image.

docker volume create --name gradle-cache
docker run --rm -v gradle-cache:/home/gradle/.gradle -v "$PWD":/home/gradle/project -w /home/gradle/project gradle:4.7.0-jdk8-alpine gradle build
ls -ltrh ./build/libs
  • gradle cache is loaded here as a volume. So subsequent builds will reuse the downloaded dependencies.
  • After this, we could have a Dockerfile to take this artifact and generate application specific image to run the application.
  • This way, the builder image is not required. Application build flow and Application run flow is separated out.
  • Since the gradle-cache volume is mounted, we could reuse the downloaded dependencies across different gradle projects.

Option 2 : Multi-stage build


----- Dockerfile -----

FROM openjdk:8 AS TEMP_BUILD_IMAGE
ENV APP_HOME=/usr/app/
WORKDIR $APP_HOME
COPY build.gradle settings.gradle gradlew $APP_HOME
COPY gradle $APP_HOME/gradle
RUN ./gradlew build || return 0 
COPY . .
RUN ./gradlew build

FROM openjdk:8
ENV ARTIFACT_NAME=your-application.jar
ENV APP_HOME=/usr/app/
WORKDIR $APP_HOME
COPY --from=TEMP_BUILD_IMAGE $APP_HOME/build/libs/$ARTIFACT_NAME .
EXPOSE 8080
CMD ["java","-jar",$ARTIFACT_NAME]

In the above Dockerfile

  • First we try to copy the project's gradle files alone, like build.gradle, gradlew etc.,
  • Then we copy the gradle directory itself
  • And then we try to run the build. At this point, there is no other source code files exists in the directory. So build will fail. But before that it will download the dependencies. 
  • Since we expect the build to fail, I have tried a simple technique to return 0 and allow the docker to continue execution
  • this will speed up the subsequent build flows, since all the dependencies are downloaded and docker cached this layer. Comparatively, Volume mounting the gradle cache directory is still the best approach.
  • The above example also showcases multi-stage docker image building, which avoid multiple docker build files.
Tineid answered 22/5, 2018 at 12:3 Comment(5)
With respect to Option 1, this can lead to failures due to Gradle file locks. "Gradle processes will hold locks if they are uncontended (to gain performance). Contention is announced through inter-process communication, which does not work when the processes are isolated in Docker containers." See https://mcmap.net/q/340893/-any-drawbacks-of-sharing-the-gradle-user-home-with-many-developersShaynashayne
Why in Option 2 you don't you gradle image?Lillith
for me gradlew build fails with /usr/bin/env: ‘sh\r’: No such file or directoryDurwin
@DanielJeney Try changing the line endings on the gradlew file. I faced the same issue on my windows and changing the file to LF worked.Fawnia
Option #2 is the right solution as it behaves as if you're copying a lock-file (yarn.lock, requirements.txt) and then installing dependencies. This is very intuitive and I'll definitely adopt this method. Thank you! I'd even make it better with ./gradlew build 2>/dev/null || true to ignore errors since we know it's coming and true instead of return 0, in my opinion it's more intuitive, but a matter of opinion I guess.Johnette
P
17

I

Add resolveDependencies task in build.gradle:

task resolveDependencies {
    doLast {
        project.rootProject.allprojects.each { subProject ->
            subProject.buildscript.configurations.each { configuration ->
                configuration.resolve()
            }
            subProject.configurations.each { configuration ->
                configuration.resolve()
            }
        }
    }
}

and update Dockerfile:

ADD build.gradle /opt/app/
WORKDIR /opt/app
RUN gradle resolveDependencies

ADD . .

RUN gradle build -x test --parallel && \
    touch build/libs/api.jar

II

Bellow is what I do now:

build.gradle

ext {
    speed = project.hasProperty('speed') ? project.getProperty('speed') : false
    offlineCompile = new File("$buildDir/output/lib")
}

dependencies {
    if (speed) {
        compile fileTree(dir: offlineCompile, include: '*.jar')
    } else {
        // ...dependencies
    }
}

task downloadRepos(type: Copy) {
    from configurations.all
    into offlineCompile
}

Dockerfile

ADD build.gradle /opt/app/
WORKDIR /opt/app

RUN gradle downloadRepos

ADD . /opt/app
RUN gradle build -Pspeed=true
Phlebotomy answered 25/5, 2016 at 15:53 Comment(3)
Since Gradle 3.3, some of the configuration may raise IllegalStateException which will fail the build. That could be checked with Configuration#isCanBeResolved(), in Gradle 3.4. ref: docs.gradle.org/3.4/release-notes.htmlRachaba
Option 2 worked well for me. I've created example to try it out (in case if it would be helpful): github.com/yb172/experiments/tree/master/java-multistageTapley
Option 1 should check if the configuration can actually be resolved in the loop: if (configuration.canBeResolved) configuration.resolve()Greyback
U
10

You might want to consider splitting your application image to two images: one for building the myapp.war and the other for running your application. That way, you can use docker volumes during the actual build and bind the host's ~/.gradle folder into the container performing the build. Instead of only one step to run your application, you would have more steps, though. Example:

builder image

FROM <tag name here for base image including all build time dependencies>

# Add project Source
# -> you can use a project specific gradle.properties in your project root
# in order to override global/user gradle.properties
ADD . /var/app/myapp

RUN mkdir -p /root/.gradle
ENV HOME /root
# declare shared volume path
VOLUME /root/.gradle
WORKDIR /var/app/myapp/ 

# Compile only
CMD ["./gradlew", "war"]

application image

FROM <tag name here for application base image>

ADD ./ROOT.war /opt/tomcat/webapps/ROOT.war

# Start Tomcat
CMD ["/opt/tomcat/bin/catalina.sh", "run"]

How to use in your project root, assuming the builder Dockerfile is located there and the application Dockerfile is located at the webapp subfolder (or any other path you prefer):

$ docker build -t builder .
$ docker run --name=build-result -v ~/.gradle/:/root/.gradle/ builder
$ docker cp build-result:/var/app/myapp/myapp.war webapp/ROOT.war
$ cd webapp
$ docker build -t application .
$ docker run -d -P application

I haven't tested the shown code, but I hope you get the idea. The example might even be improved by using data volumes for the .gradle/ cache, see the Docker user guide for details.

Umbra answered 6/10, 2014 at 10:10 Comment(1)
Thanks. Simply persisting the gradle folder between builds made a significant speed increase to later builds. -v /opt/myapp-docker-gradle-cache:/root/.gradleImpenetrability
M
1

The current version of Docker supports mounting a "cache" and it's local to the Docker environment (so it's not shared with your OS which is both good and bad; good in that there's nothing about your system in the build process, bad in that you have to download again)

This code is from my Spring Docker Swarm integration rework

FROM gradle:7.4-jdk17 AS builder
WORKDIR /w
COPY ./ /w
RUN --mount=type=cache,target=/home/gradle/.gradle/caches gradle build --no-daemon -x test

FROM openjdk:17-jdk as extractor
WORKDIR /w
COPY bin/extract.sh /w/extract.sh
COPY --from=builder /w/*/build/libs/*.jar /w/
RUN sh ./extract.sh

FROM openjdk:17-jdk as sample-service
WORKDIR /w
COPY --from=extractor /w/sample-service/* /w/
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=80", "org.springframework.boot.loader.JarLauncher"]
HEALTHCHECK --interval=5s --start-period=60s \
    CMD curl -sfo /dev/null http://localhost:8080/actuator/health
USER 5000
EXPOSE 8080

What this does is from my current folder which is a multi-module gradle build I run the build. extractor stage unbundles the JAR file using extract.sh script below.

Then assembles the relevant component

The relevant contents of extract.sh

#!/bin/sh
set -e
set -x

# Remove support projects that won't be a Spring Boot
# rm buildSrc.jar
# rm gateway-common-*.jar

for jar in *.jar
do
  DIR=$(basename $jar -0.0.1-SNAPSHOT.jar)
  mkdir $DIR
  java -Djarmode=layertools -jar $jar extract --destination $DIR
done
Matthia answered 16/8, 2022 at 10:30 Comment(0)
C
-1

try changing the gradle user home directory

RUN mkdir -p /opt/gradle/.gradle
ENV GRADLE_USER_HOME=/opt/gradle/.gradle

Cirilo answered 7/11, 2016 at 20:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.