Transmode / gradle-docker

A Gradle plugin to build Docker images from the build script.
Apache License 2.0
647 stars 142 forks source link

Add support for skinny layers especially for Spring Boot applications #64

Open loverde opened 8 years ago

loverde commented 8 years ago

For Spring Boot applications, most builds don't change dependencies, so the only file that actually changes is the ${applicationName}.jar file. However, the Dockerfile produced by this plugin ADDs the entire distribution as a single Docker command. So even a 1 byte change will potentially cause a new 40+ MB layer to be created by docker.

If the Dockerfile instead separated the "semi-static" dependencies from the frequently changing files as separate ADD/COPY steps, then the size of the docker image layers created would be substantially reduced.

I've prototyped this locally outside of this plugin via:

task unzipDistWithoutBootJar(type: Copy, dependsOn: distZip) {
    from zipTree(file("${buildDir}/distributions/${applicationName}.zip"))
    into "${buildDir}/docker"
    exclude "**/${applicationName}.jar"
}

task unzipBootJar(type: Copy, dependsOn: [unzipDistWithoutBootJar]) {
        from zipTree("${buildDir}/distributions/${applicationName}.zip")
        into "${buildDir}/docker"
        includeEmptyDirs = false
        include "**/${applicationName}.jar"
        doLast {
                def file = new File("${buildDir}/docker/${applicationName}/lib/${applicationName}.jar")
                def dir = new File("${buildDir}/docker")
                file.renameTo(new File(dir, file.getName()))
        }
}

task createDockerfile(dependsOn: distZip) {
    doLast {
        new File("${buildDir}/docker/Dockerfile").text = """
FROM ubuntu
EXPOSE 8080

COPY ${applicationName} /${applicationName}/
COPY ${applicationName}.jar /${applicationName}/lib/${applicationName}.jar

ENTRYPOINT ["/${applicationName}/bin/${applicationName}"]
"""
    }
}

task distDocker2(type: Exec, dependsOn: [build, unzipDistWithoutBootJar, unzipBootJar, createDockerfile]) {
    workingDir "${buildDir}/docker"
    commandLine 'docker', 'build', '-t', "${project.group}/${applicationName}", '.'
}

Some posts I've seen mention that you also need to force the timestamp of the "semi-static" resources to be the same. However in my testing I didn't find that to be necessary. Changing just timestamps on the files still resulted in the semi-static layer being cached. Only changing the contents of a file or adding/removing files seemed to cause a new layer.

The end result is a substantial savings in docker image layer size. The key benefit, at least for my current use case, is that CI builds can now push new docker images on every build without causing undo burden on our docker repository storage.

I'll see if I can incorporate this into a fork, but wanted to get the issue out there for a future roadmap. My thought is to make it a configurable property, something like "skinnyLayers: true", and possibly also have some way to make the description of what is "semi-static" versus what is "dynamic" configurable.

felixbarny commented 8 years ago

+1

damianoneill commented 7 years ago

+1

gclayburg commented 7 years ago

I realize this is an old issue, but have you looked at recent work from @dsyer? https://github.com/dsyer/spring-boot-thin-launcher

dsyer commented 7 years ago

Actually, while the thin launcher certainly gives you new options, a regular spring boot fat jar is already very cleanly partitioned into "app" and "external" resources. It isn't very hard to use a Dockerfile to create an image with 2 layers from a normal fat jar (this wasn't the case in older versions).

gclayburg commented 6 years ago

I took some of these ideas and created a new gradle plugin for this:

plugins {
  id "com.garyclayburg.dockerprepare" version "1.0.1"
}

The plugin code is here: https://github.com/gclayburg/dockerPreparePlugin

Using this, you you can keep the extra layer logic out of your build.gradle file.

It also isn't dependent on any one gradle docker plugin like Transmode or bmuschko. Each of these should work fine to build a layer-friendly docker image, although it seems like the bmuschko plugin is being more actively developed.

What do you guys think?

liqweed commented 6 years ago

@gclayburg Awesome work, thank you!

I'm trying to test it out but I'm getting the following build problem: "dockerprepare cannot prepare jar file that is executable". I'm not

On another note: I have a code base with multiple projects, some of them are Spring Boot services. Each project has it's own unique dependencies but all share a common dependency on a 'common-service' project which in turn depends on Spring Boot etc. It would be great if there was a Docker layer for this 'common-service' (including common 3rd parties that don't change that often), a layer for the unique dependencies of each Spring Boot project and then a layer for the project's jar. I'm deploying on-prem so this could reduce the overall size considerably.

gclayburg commented 6 years ago

@liqweed for the first issue, add this to your build.gradle:

springBoot{
  executable = false
}

The jar/war file no longer needs to be executable when bundled into a docker image. Many zip/jar tools including gradle silently fail when they encounter the special spring boot executable jar format. The jar is still a complete jar file though.

Your second issue is an interesting one. This project certainly could be expanded to handle that case. Pull requests are certainly welcome!

liqweed commented 6 years ago

@gclayburg I probably should have mentioned I tested this on a Windows machine. The executable flag is off by default and explicitly setting it to false didn't do the trick. It probably has to do with jarfile.canExecute() behaving differently on different systems (in the javadoc: "On some platforms it may be possible to start the Java virtual machine with special privileges that allow it to execute files that are not marked executable." https://docs.oracle.com/javase/8/docs/api/java/nio/file/Files.html#isExecutable-java.nio.file.Path-). Your tests fail for that reason on my machine but pass successfully after removing the isExecutable validations (both for jars and for wars).

How does Gradle's silent failure in the face of executable jars manifest? Is it possible to workaround it?

gclayburg commented 6 years ago

good to know, @liqweed. I'll create an issue for that. The only reason that check is in there is to stop the copy from silently failing.