Optimizing Docker Images for Spring Boot

4

Spring Boot creates fat jars as a result. It is very easy to run them in containers, just create an image that contains the appropriate Java version, copy the jar, start java => that’s basically it. Most likely you want to push this image to a Docker registry and here one disadvantage of this method becomes obvious: If you just change one line in one of your own Java classes, you create a layer in the Docker image, which contains not only your own stuff bat also all dependencies. So when you push an image after a small change, you are probably pushing between 30 and 60 MB, depending on your dependencies. This block post explains how you can optimize your Docker images so that you only push your stuff, which in the best case is only KB or a few MB.

The first step is to separate your own stuff and your dependencies. For that, you can use extra Gradle or Maven build steps. I will show how this is done using Gradle. The idea is that after Gradle has created the Spring Boot fat jar, unzip it, and copy the result to 2 separate directories: docker/lib for the external dependencies and docker/app for your very own stuff. Then create a Dockerfile including 2 lines: One line copies docker/lib to /app/BOOT-INF/lib/ in one Docker image layer and the second line copies docker/app/ to /app/ in another Docker image layer.

The Dockerfile looks then like:

FROM java:openjdk-8-jre-alpine
COPY docker/lib/ /app/BOOT-INF/lib/
COPY docker/app/ /app/
CMD java -cp /app/ org.springframework.boot.loader.JarLauncher
EXPOSE 8080

The Gradle code looks like:

task deleteDockerDir(type: Delete) {
    delete "${buildDir}/docker"
}

task unzipBoot(type: Copy) {
    def zipFile = file("${buildDir}/libs/" + project.name + '-' + project.version + '.jar')
    def outputDir = file("${buildDir}/docker/app")

    from zipTree(zipFile)
    into outputDir

    def copyDetails = []
    eachFile { copyDetails << it } doLast { copyDetails.each { FileCopyDetails details ->
            def target = new File(outputDir, details.path)
            if (target.exists()) {
                target.setLastModified(details.lastModified)
            }
        }
    }
}
unzipBoot.dependsOn deleteDockerDir

task moveBootExplodedLib() {
    doLast {
        ant.move(file: "${buildDir}/docker/app/BOOT-INF/lib", toFile: "${buildDir}/docker/lib")
    }
}
moveBootExplodedLib.dependsOn unzipBoot

task createDockerfile () {
    doLast {
        def dockerfile = new File("$buildDir/Dockerfile")
        dockerfile.write 'FROM java:openjdk-8-jre-alpine\n'
        dockerfile << 'COPY docker/lib/ /app/BOOT-INF/lib/\n'
        dockerfile << 'COPY docker/app/ /app/\n'
        dockerfile << 'CMD java -cp /app/ org.springframework.boot.loader.JarLauncher\n'
        dockerfile << 'EXPOSE 8080\n'
    }
}
createDockerfile.dependsOn moveBootExplodedLib

You find working example projects using this optimization in my Github repos:

4 Comments

  1. Gary Clayburg
    Gary Clayburg10-19-2017

    Nice post! I also saw deployment issues with Spring Boot fat jars. I ended up creating a gradle plugin that accomplishes this

    Just add this to your build.gradle:
    plugins {
    id “com.garyclayburg.dockerprepare” version “1.1.0”
    }
    This splits your spring boot jar or war into 3 docker layers.

    Full details here https://github.com/gclayburg/dockerPreparePlugin

    • Kai Tödter
      Kai Tödter10-19-2017

      thx for the info, will take a look at your plugin.

  2. Gary Clayburg
    Gary Clayburg10-19-2017

    BTW, I see you are using setLastModified(). Do you find this necessary? I haven’t seen issues where modification times affect the docker layer cache. It seems to be pretty smart about checksums.

    • Kai Tödter
      Kai Tödter10-19-2017

      yes, setLastModified was necessary, otherwise the files would have the current time stamp and the Docker layer would be different.

Leave a Reply