Kleine JARs mit Spring Boot und Gradle

2 Kommentare

Bei Spring Boot denkt man oft an große *.jar’s und langsame Builds, aber das muss nicht zwangsweise sein!

Einführung

Am Dienstag ging die #JCON2018 in Düsseldorf los. In der Keynote sprach Adam Bien über Java EE und MicroProfile. Eine Demo gab es auch: ein „Hello World“-Service deployt in Payara, das ganze als Container in einer lokalen OpenShift-Installation. Das war schon beeindruckend. Besonders betonte Adam wie schnell alles lief. Der Maven-Build dauerte gefühlt nicht länger als eine Sekunde. Am Ende purzelte ein kleines *.war mit 34KB Größe raus. Das Docker-Image war auch schnell gebaut, schließlich hat sich nur der dünne Layer mit dem *.war verändert. Jetzt musste nur noch das OpenShift den Container neustarten und die Demo konnte weitergehen.

Am Ende der Keynote wurde Adam gebeten MicroProfile mit Spring Boot zu vergleichen. Und Spring Boot kam dabei nicht gut weg :).
Ein Argument war die Größe des Artefakts, das am Ende entsteht. Ein „Hello-World“-Service mit Spring Boot ist schon stolze 16MB groß. Üblicherweise baut man mit Spring Boot ein *.jar, das alle Abhängigkeiten beinhaltet. Bei MicroProfile liegen die (meisten) Abhängigkeiten im Application Server und müssen nicht mit dem *.war ausgeliefert werden.
Auch mit Spring Boot kann man ein *.jar ohne Abhängigkeiten bauen und diese später beim Ausführen der Anwendung in den Classpath aufnehmen. Das probiere ich hier jetzt aus.

Hello World

Mein „Hello World“-Service mit Spring Boot ist sehr simple:

@RestController
@SpringBootApplication
public class HelloWorld {
    public static void main(String... args) {
        SpringApplication.run(HelloWorld.class);
    }
 
    @GetMapping("/hello")
    public String hello() {
        return "hello world";
    }
}

Und hier die Konfiguration für den Build mit Gradle:

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
 
buildscript {
    repositories {
        mavenCentral()
    }
 
    dependencies {
        classpath 'org.springframework.boot:spring-boot-gradle-plugin:2.0.3.RELEASE'
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    compile 'org.springframework.boot:spring-boot-starter-web:2.0.3.RELEASE'
}

Mit gradle assemble baue ich das Artefakt. Hier sieht man die 16MB

$ gradle.exe clean assemble
> Task :clean
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :bootJar
> Task :jar SKIPPED
> Task :assemble

BUILD SUCCESSFUL

$ ll build/libs/spring-boot-hello-world.jar
-rwxrwxrwx 1 user user 16M Oct 11 21:14 build/libs/spring-boot-hello-world.jar

Als letzten Schritt erstelle ich das Docker-Image mit folgendem Dockerfile:

FROM openjdk:8
COPY build/libs/spring-boot-hello-world.jar /app.jar
CMD [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app.jar" ]

Der Build hat im Augenblick ein paar unschöne Eigenschaften:

  1. Bei jeder Änderung am Code muss das komplette *.jar mit den Abhängigkeiten gebaut werden
  2. Da sich das *.jar jedes Mal ändert, muss auch der entsprechende Layer im Docker-Image neu erstellt werden.

Der neue Build

Jetzt verändere ich den Build, sodass die Abhängigkeiten nicht mehr im Artefakt selbst abgelegt werden, sondern in einem separaten Verzeichnis. Diese kopiere ich später in das Docker-Image und nehme sie beim Starten der Anwendung in den Classpath auf. Darum kümmert sich normalerweise der Classloader, den das Spring Boot Plugin für Gradle ins Artefakt kopiert. Da ich diesen Classloader nicht mehr brauche, nehme ich das Plugin aus dem Build raus.

Meine build.gradle sieht jetzt so aus:

apply plugin: 'java'
 
group 'de.melnichuk'
version '1.0-SNAPSHOT'
 
sourceCompatibility = 1.8
 
repositories {
    mavenCentral()
}
 
dependencies {
    compileOnly 'org.springframework.boot:spring-boot-starter-web:2.0.3.RELEASE'
    // compile 'commons-io:commons-io:2.6'
}
 
task collectCompileOnlyDependencies (type: Copy) {
    from project.configurations.compileOnly
    into "$buildDir.absolutePath/jars"
}
 
assemble.dependsOn('collectCompileOnlyDependencies')

Ich baue erneut mit gradle assemble das Artefakt, und siehe da: 1,3KB. Ordentlich abgespeckt!

$ gradle.exe clean assemble
> Task :collectCompileOnlyDependencies UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE

BUILD SUCCESSFUL

$ ll build/libs/spring-boot-hello-world.jar
-rwxrwxrwx 1 user user 1.3K Oct 11 21:22 build/libs/spring-boot-hello-world.jar

Dir ist bestimmt auch das UP-TO-DATE hinter collectCompileOnlyDependencies aufgefallen. Das kommt daher, dass ich den Build mehrmals hintereinander ausgeführt habe. Gradle merkt, dass sich an den Abhängigkeiten im Scope compileOnly nichts geändert hat und überspringt den Execution-Teil von diesem Task. Das ist ein schöner Nebeneffekt, wenn collectCompileOnlyDependencies von org.gradle.api.tasks.Copy ableitet.

Jetzt noch das Dockerfile anpassen, damit die Abhängigkeiten ins Docker-Image aufgenommen werden. Ich mache es mir im Rahmen des Experiments einfach und lege alle meine *.jar’s in einem Verzeichnis ab:

FROM openjdk:8
COPY build/jars/*.jar /jars/
COPY build/libs/spring-boot-hello-world.jar /jars/

CMD [ "sh", "-c", "java $JAVA_OPTS -cp '/jars/*' -Djava.security.egd=file:/dev/./urandom de.melnichuk.helloworld.HelloWorld" ]

Wenn ich docker build laufen lasse, sehe ich an den Hash-Werten, dass der Layer mit Abhängigkeiten sich nicht ändert. An der Dauer des Builds merke ich es auch ;).

Zwischenstand

Der Gradle-Build ist schneller, weil ein kleineres Artefakt erstellt wird und Abhängigkeiten nur dann kopiert werden, wenn sie sich wirklich ändern. Der Docker-Build ist schneller, weil ein kleinerer Layer erstellt werden muss.

Ich könnte mir ein Base-Image mit Spring Boot Abhängigkeiten erstellen und meine Docker-Images darauf aufbauen. Das würde sich positiv auf die Größer der Docker-Registry auswirken.

Die Größe des Artefakts bei einer Spring Boot Anwendung sollte also kein Argument gegen Spring Boot sein, schon gar nicht, wenn das Auslieferungsformat ein Docker-Image ist.

Yevgeniy Melnichuk

Yevgeniy unterstützt seit 2009 im Namen von codecentric AG die Kunden dabei ihre Ideen in Software zu gießen. Dabei ist die Software bevorzugt verteilt, gut getestet und natürlich Open Source.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.