Container

Article

Spring Boot & Docker: comment rendre ça un peu plus green !

De quoi va-t’on parler ?

Le but est de dockeriser une application Spring Boot en l’adaptant pour réduire au maximum l’espace disque nécessaire entre les différentes versions.

La planète 🌍 et votre repository docker 🐋 vous dirons merci !

Contexte

Il est fini le temps où l’on déployait en entreprise de grosses applications monolithiques embarquant des millions de lignes de code et gérant tout, même le ☕.

La mouvance est maintenant vers les micro-services: de petits programmes dédiés à des domaines précis.
Avec ce mode de fonctionnement il est fréquent de créer de nouvelles applications, celles-ci tournant la plupart du temps dans des containers (Docker dans notre exemple).

Il est donc logique de se dire que les nombreux frameworks existants sont Docker ready et que tout est déjà optimisé …

Mais dans cet article je vais vous présenter le cas d’un framework bien connu en java: Spring Boot et nous verrons qu’en laissant la configuration par défaut nous ne ménageons pas l’espace de nos disques dur.

Note: Cet article suppose que vous connaissiez déjà les bases de Maven et de la construction d’images Docker, notamment la notion de couches (layers en anglais)

Allons-y !

Etape 1: Création du projet de test

Spring Boot propose un site web pour démarrer rapidement un projet: Spring Boot Initializr.

Pour l’exemple je l’ai configuré comme suit:

  • Maven Project
  • Language Java
  • Version 2.1.6
  • Dependencies:
    • Spring Web Starter pour exposer des services en HTTP
    • Spring Security pour gérer l’accès authentifié aux services HTTP
    • Et enfin Spring Boot DevTools pour aider le développeur à travers divers outils

Cette liste de dépendances est purement arbitraire et ne vise qu’à montrer l’optimisation proposée ici, elle est néanmoins représentative d’une application de base.

Ici j’ai utilisé Maven mais l’exemple est applicable à Gradle.

Le repo du projet généré est disponible sur github.

Etape 2: Compilation du projet

La compilation se lance avec la commande:

mvnw clean package

Et nous obtenons un beau fichier target/spring-boot-docker-0.0.1-SNAPSHOT.jar de 18Mo.

Il est possible de lancer le projet avec la commande suivante pour vérifier que tout démarre bien:

java -jar target/spring-boot-docker-0.0.1-SNAPSHOT.jar
Jusque là tout va bien 🙂

Etape 3: Dockerisation

Maintenant ajoutons notre fichier Dockerfile pour pouvoir déployer cette application dans un container:

# -----------
# Image de construction
# -----------
FROM maven:3-jdk-8-slim as builder

WORKDIR /build

ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository"

# Les 4 instructions ci-dessous permettent de profiter du cache docker local tant que le fichier pom.xml n'est pas modifié

# On ne copie que le fichier pom.xml dans un premier temps
COPY pom.xml ./
# Puis on lance "dependency:go-offline" pour que maven télécharge les dépendances
RUN mvn --batch-mode --fail-never dependency:go-offline
# On ajoute le reste du code source
ADD . .
# On lance le packaging réel
RUN mvn --batch-mode -Dmaven.test.skip=true package

# -----------
# Image du runtime
# -----------
FROM openjdk:8-jre-slim as runtime

WORKDIR /app
EXPOSE 8080

# Recopie du jar construit dans l'image du builder
COPY --from=builder /build/target/spring-boot-docker*.jar ./spring-boot-docker.jar

# Commande de lancement de l'application
ENTRYPOINT ["java", "-jar", "/app/spring-boot-docker.jar"]

Accès au fichier complet sur GitHub

Les lignes importantes sont directement commentées dans le fichier. J’utilise une méthode pour optimiser l’usage du cache Maven, mais ce n’est pas l’objet de cet article 😉.

Nous pouvons alors lancer la construction de l’image spring-boot-docker:

docker build . -t spring-boot-docker

Lançons le container pour vérifier son bon fonctionnement:

docker run -it --rm spring-boot-docker

Nous obtenons une sortie console qui se termine par quelque chose qui ressemble à ça:

Tomcat started on port(s): 8080 (http) with context path ''
Started SpringBootDockerApplication in 3.003 seconds (JVM running for 3.357)
Et voila tout fonctionne donc on a terminé ?

Alors “oui“, si l’on considère qu’on ne fera jamais évoluer notre application et qu’on ne reconstruira jamais de nouvelle version de notre container !

Mais si nous faisons évoluer l’application que va-t-il se produire ?

Etape 4: Seconde construction

Ne modifions rien et testons en relançant la construction de l’image:

docker build . -t spring-boot-docker

Voici la sortie console obtenue:

Step 1/12 : FROM maven:3-jdk-8-slim as builder
 ---> d0936e87627b
Step 2/12 : WORKDIR /build
 ---> Using cache
 ---> e50e79245cfd
Step 3/12 : ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository"
 ---> Using cache
 ---> 07809618ddfe
Step 4/12 : COPY pom.xml ./
 ---> Using cache
 ---> 92c470275efc
Step 5/12 : RUN mvn --batch-mode --fail-never dependency:go-offline
 ---> Using cache
 ---> 5a8019b74d4e
Step 6/12 : ADD . .
 ---> Using cache
 ---> fd62b242c58f
Step 7/12 : RUN mvn --batch-mode -Dmaven.test.skip=true package
 ---> Using cache
 ---> b36f77dd66ce
Step 8/12 : FROM openjdk:8-jre-slim as runtime
 ---> 54567f06e0e8
Step 9/12 : WORKDIR /app
 ---> Using cache
 ---> ed4807ecc931
Step 10/12 : EXPOSE 8080
 ---> Using cache
 ---> f3327e920fab
Step 11/12 : COPY --from=builder /build/target/spring-boot-docker*.jar ./spring-boot-docker.jar
 ---> Using cache
 ---> a36a23eec1b2
Step 12/12 : ENTRYPOINT ["java", "-jar", "/app/spring-boot-docker.jar"]
 ---> Using cache
 ---> 939e911e2566
Successfully built 939e911e2566
Successfully tagged spring-boot-docker:latest

Les `Using cache` montrent que rien n’est modifié depuis le dernier build et que Docker va réutiliser les mêmes couches donc aucun espace disque supplémentaire n’est requis si j’ai déjà la version précédente de l’image sur mon poste.

Si je décide de pousser cette image sur un repository docker, celle-ci ne nécessitera donc pas d’espace disque supplémentaire par rapport à la version précédente.

Maintenant testons en modifiant un fichier, par exemple le fichier src\main\resources\application.properties pour lui mettre ce contenu:

spring.application.name=Spring Boot Docker

Si je relance la construction de l’image docker j’obtiens ceci dans ma console:

Step 1/12 : FROM maven:3-jdk-8-slim as builder
 ---> d0936e87627b
Step 2/12 : WORKDIR /build
 ---> Using cache
 ---> e50e79245cfd
Step 3/12 : ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository"
 ---> Using cache
 ---> 07809618ddfe
Step 4/12 : COPY pom.xml ./
 ---> Using cache
 ---> 92c470275efc
Step 5/12 : RUN mvn --batch-mode --fail-never dependency:go-offline
 ---> Using cache
 ---> 5a8019b74d4e
Step 6/12 : ADD . .
 ---> 0f3409d9d571
Step 7/12 : RUN mvn --batch-mode -Dmaven.test.skip=true package
 ---> Running in aa68869b40d6
[INFO] -------------------< fr.gop.demo:spring-boot-docker >-------------------
[INFO] Building spring-boot-docker 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
Removing intermediate container aa68869b40d6
 ---> 7916b67e9b3d
Step 8/12 : FROM openjdk:8-jre-slim as runtime
 ---> 54567f06e0e8
Step 9/12 : WORKDIR /app
 ---> Using cache
 ---> ed4807ecc931
Step 10/12 : EXPOSE 8080
 ---> Using cache
 ---> f3327e920fab
Step 11/12 : COPY --from=builder /build/target/spring-boot-docker*.jar ./spring-boot-docker.jar
 ---> 4174c2499398
Step 12/12 : ENTRYPOINT ["java", "-jar", "/app/spring-boot-docker.jar"]
 ---> Running in 0820ee06c24a
Removing intermediate container 0820ee06c24a
 ---> 2712fa4a9cac
Successfully built 2712fa4a9cac
Successfully tagged spring-boot-docker:latest

On remarque que le cache n’est plus utilisé à partir de l’étape 6. C’est logique car la modification du fichier application.properties fait que la commande `ADD . .` n’ajoute plus exactement le même contenu, le cache est alors invalidé.

On remarque ensuite que le cache est de nouveau utilisé entre les étapes 8 et 10 et c’est normal car nous repartons d’une image déjà en cache (`openjdk:8-jre-slim`).

Par contre à l’étape 11 il n’est de nouveau plus utilisé. Logique ici aussi car le fichier spring-boot-docker-0.0.1-SNAPSHOT.jar résultant de l’étape 7 est différent de sa version précédente.

L’inconvénient ici vient du fichier jar qui fait 18Mo. Nous avons donc ces 18Mo d’espace disque utilisés alors que la modification ne porte que sur quelques octets d’un fichier.

Que se passe-t’il sur un plus gros projet quand ce fichier monte à plusieurs dizaines de méga octets au gré des librairies utilisées ?

Imaginez qu’à chaque construction d’image nous allons avoir 20 … 40 .. 70 méga octets d’occupation supplémentaire sur notre repository d’images docker, pas très green tout ça !

Etape 5: Optimisation

Afin d’optimiser tout ça nous allons éclater le fichier spring-boot-docker-0.0.1-SNAPSHOT.jar en plusieurs morceaux.

Partie Maven

Il faut savoir que c’est le plugin maven spring-boot-maven-plugin qui est responsable de sa construction.

Que fait-donc ce plugin ?

Et bien il fait, entre autre, deux choses qui nous intéressent:

  • il embarque les librairies à l’intérieur du jar (elles sont dans le dossier BOOT-INF/lib)
  • il configure la classe que java doit exécuter lorsque l’on lance la commande `java -jar xxxx.jar` (dans le fichier META_INF/MANIFEST.MF)

Je vous propose de reproduire ce comportement avec deux plugins bien connus dans l’écosystème Maven, j’ai nommé:

Nous allons tout d’abord configurer le premier afin de copier toutes les dépendances nécessaires à l’application dans le dossier target/dependencies, lors de la phase de packaging:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>3.1.1</version>
  <executions>
    <execution>
      <id>copy-dependencies</id>
      <phase>package</phase>
      <goals>
        <goal>copy-dependencies</goal>
      </goals>
      <configuration>
       <outputDirectory>${project.build.directory}/dependencies</outputDirectory>
        <includeScope>runtime</includeScope>
      </configuration>
    </execution>
  </executions>
</plugin>

Nous allons ensuite configurer le second pour qu’il valorise deux choses dans le fichier META-INF/MANIFEST.MF du jar généré :

  • la classe principale à exécuter
  • où trouver les dépendances du jar (ces dépendances ayant été copiées au bon endroit par le premier plugin)
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.1.2</version>
  <configuration>
    <archive>
      <manifest>
        <addClasspath>true</addClasspath>
        <classpathPrefix>dependencies</classpathPrefix>
        <mainClass>fr.gop.demo.springbootdocker.SpringBootDockerApplication</mainClass>
      </manifest>
    </archive>
  </configuration>
</plugin>

Au passage nous allons supprimer la configuration du plugin spring-boot-maven-plugin devenue obsolète.

Voici le fichier complet sur GitHub

Je peux maintenant relancer une compilation maven:

mvnw clean package

Et nous obtenons un beau fichier target/spring-boot-docker-0.0.1-SNAPSHOT.jar de 4Ko ainsi qu’un dossier target/dependencies de 17.7Mo

Il est toujours possible de lancer le projet avec la commande suivante pour vérifier que tout démarre bien:

java -jar target/spring-boot-docker-0.0.1-SNAPSHOT.jar

Je vous laisse aller voir le contenu du fichier META-INF/MANIFEST.MF du jar pour comprendre comment tout ça fonctionne.

Partie Docker

Si nous laissons le projet tel quel alors la construction docker ne fonctionne plus car le dossier des dépendances n’est pas copié dans l’image. Remédions à ce problème en ajoutant les deux lignes suivantes dans le fichier Dockerfile, juste avant ‘Recopie du jar construit dans l’image du builder‘:

# Recopie des dépendances depuis l'image du builder
COPY --from=builder /build/target/dependencies/* ./dependencies/

Voir la nouvelle version du fichier sur GitHub

Et … c’est tout 🙂

La preuve en lançant le container:

docker run -it --rm spring-boot-docker

Qu’est ce qui a changé alors ?

La subtilité ici a été de copier les dépendances avant de copier le jar du projet en lui-même.

Si nous partons du principe que la liste des dépendances est beaucoup moins souvent modifiée que le code applicatif, alors à chaque construction le contenu du dossier target/dependencies sera quasiment systématiquement identique à la version précédente et Docker pourra donc utiliser son cache.

L’ajout du jar applicatif, de taille moindre, dans un second temps permet ainsi de n’embarquer que les modifications nécessaires.

Tout ceci peut-être validé en refaisant la manipulation proposée plus haut à savoir:

  • construire l’image docker
  • modifier le fichier src\main\resources\application.properties
  • re-constuire l’image docker

Voici la sortie console de la deuxième construction:

Sending build context to Docker daemon  19.46kB
Step 1/13 : FROM maven:3-jdk-8-slim as builder
 ---> d0936e87627b
Step 2/13 : WORKDIR /build
 ---> Using cache
 ---> e50e79245cfd
Step 3/13 : ENV MAVEN_OPTS="-Dmaven.repo.local=/build/.m2/repository"
 ---> Using cache
 ---> 07809618ddfe
Step 4/13 : COPY pom.xml ./
 ---> Using cache
 ---> dcbc4e2fef28
Step 5/13 : RUN mvn --batch-mode --fail-never dependency:go-offline
 ---> Using cache
 ---> abdbee6b6204
Step 6/13 : ADD . .
 ---> ae0ef270ffb1
Step 7/13 : RUN mvn --batch-mode -Dmaven.test.skip=true package
 ---> Running in 8fbe9a156a7e
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------< fr.gop.demo:spring-boot-docker >-------------------
[INFO] Building spring-boot-docker 0.0.1-SNAPSHOT
[INFO] --------------------------------[ jar ]---------------------------------
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
Removing intermediate container 8fbe9a156a7e
 ---> 4658124be9a0
Step 8/13 : FROM openjdk:8-jre-slim as runtime
 ---> 54567f06e0e8
Step 9/13 : WORKDIR /app
 ---> Using cache
 ---> ed4807ecc931
Step 10/13 : EXPOSE 8080
 ---> Using cache
 ---> f3327e920fab
Step 11/13 : COPY --from=builder /build/target/dependencies/* ./dependencies/
 ---> Using cache
 ---> 4019b92d8117
Step 12/13 : COPY --from=builder /build/target/spring-boot-docker*.jar ./spring-boot-docker.jar
 ---> cd40570bd77a
Step 13/13 : ENTRYPOINT ["java", "-jar", "/app/spring-boot-docker.jar"]
 ---> Running in e2067b798cb6
Removing intermediate container e2067b798cb6
 ---> 560611933a90
Successfully built 560611933a90
Successfully tagged spring-boot-docker:latest

Ici on voit que le cache est bien utilisé à l’étape 1. On voit qu’à l’étape 12 il n’est plus utilisé et c’est normal avec la modification apportée au fichier application.properties.

Conclusion

J’applique cette solution depuis maintenant 3 ans sur les projets auxquels je contribue et je n’ai jamais rencontré de problème.

Avec cette optimisation il est possible de mettre en place un système de construction automatique se déclenchant à chaque commit et qui envoi chaque image dans un repository docker tout en diminuant l’usage du disque dur mais aussi l’usage du réseau ! Car chaque couche présente sur le repository est une couche en moins à envoyer. En prime vous gagnez en productivité en attendant moins longtemps la fin des constructions.

Quand je disais que ça allait être green !

PS: Pour une solution packagée, je vous invite à jeter un œil au projet Jib qui propose des plugins Maven et Gradle, ainsi qu’une api java, pour conteneuriser ses applications.

Ressources

Nouveau commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

*

Solve : *
21 + 5 =


Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.