Beaucoup d'entreprises aujourd'hui se tournent ou se sont tournées vers des Cloud Provider public ou privé pour y déployer son parc applicatif. Ce choix est souvent poussé par l'envie de se débarrasser de ces serveurs physiques, ne plus se préoccuper de la disponibilité des applications et au passage, faire des économies !
Il y a tout de même quelques règles de l'art à respecter afin de garantir l'efficience et la sécurité des conteneurs que nous déployons sur nos pods/nodes.
Nous ne parlerons pas ici d'image native, elles répondent à des besoins très spécifiques et limités. La compilation native est très bien pour le serverless, les Cloud Functions, mais n'apportent pas le niveau de performance que nous procure la JVM.
Le mauvaise exemple
Commençons tout de suite par ce qu'il ne faut PAS faire :
FROM eclipse-temurin:17-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
Pourquoi ? Pour les raisons suivantes :
- Mon image de base pèse à elle seule près de 200 Mo! Cela représente déjà une belle application Spring Boot.
- Mon image ne sera pas rootless.
- Elle embarque des librairies dont je n'ai pas besoin.
- La commande de lancement ne permet pas de bénéficier des optimisations de Spring effectuées à la compilation (layers).
Une piqûre de rappel est donc nécessaire. Une image docker n'est pas une VM ! J'ai trop souvent vu dans des Dockerfile l'installation de tool ou l'utilisation d'une image ENORME (~800Mo ça fait chère sur un Artifactory) embarquant ces différents tool (curl/wget/nano/vi etc).
Pour faire simple, une image docker, c'est notre application et uniquement celle-ci. Plus cette dernière est légère, plus vite l'application sera disponible et moins cela nous coûtera en termes de stockage (et c'est bon pour l'environnement) !
Le bon exemple
Allez, voyons comment améliorer tout ça, et là pas de miracle, on s'en réfère à la documentation officiel
FROM eclipse-temurin:17-jre as builder
WORKDIR application
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract
FROM eclipse-temurin:17-jre
WORKDIR application
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
L'idée générale ici est de découper notre création d'images en deux parties. La première dite "builder" va nous permettre de construire notre image final dénuée de tout ce qui est inutile !
Il va s'en dire qu'en modifiant un peu les images de bases utilisées, nous pouvons choisir notre JVM, ainsi qu'une image dite "distroless" afin que sont poids soit proche du poids de notre application. Fini le superflu, ici, vous pouvez oubliez les accès root (nous avons une image rootless), pas de curl/wget/nano ou autre il n'y a rien d'autre que votre JVM et votre application.
Nous bénéficions en plus de l'optimisation des layers Docker en utilisant les layers Spring, nous permettant de gagner du temps au rebuild de l'image et au temps de démarrage (c'est toujours ça de pris).
Il manque encore quelque chose...
En tant que développeur Java que nous sommes, nous avons une "contrainte" supplémentaire comparée à d'autres langages, la JVM !
Pour faire court, la JVM n'est pas magique, je vous épargne tous les détails, mais je vous mets des liens pour appuyer l'importance de ce que je vous présente.
Configuration de la Heap
Ce n'est pas une option ! Et on ne le fait pas au hasard non plus. Il existe un outil qui vous permet de calculer précisément vos options de JVM en fonction des ressources disponibles et du nombre de classes chargées par l'application.
Vous avez deux possibilités, soit vous clonez le repo git suivant :
Ou alors, vous faites comme moi et vous utilisez l'image docker :)
Attention, si vous allouez plus de mémoire que nécessaire, vous risquez d'avoir régulièrement un full GC et des temps de pauses considérables (plusieurs secondes). Et voilà ma transition pour le chapitre suivant !
Configuration du Garbage Collector
Le choix du GC n'est pas une option non plus !
À tous ceux qui pensent que le G1 est le GC par défaut depuis Java 11, vous avez tort. Pour s'en rendre compte, un petit tour du côté du code source de la hotpost :
Que nous apprennent ces deux liens ? Grosso modo, si vous avez settez moins de 2 Go de RAM et moins de 2 CPU sur votre pod/node, votre application utilise le SerialGC dans le cas où vous n'auriez pas précisez le GC à utiliser dans vos options de JVM.
Ce n'est pas une si mauvaise chose, car la documentation d'Oracle nous préconise bien le SerialGC sur du monothread, ce qui est normal, car les autres ont été pensés multithread. Il faut tout de même l'avoir en tête pour ne pas se faire surprendre par un comportement inattendu entre l'environnement de recette et l'environnement de production par exemple :)
Qui écrit encore des Dockerfile ?
Il faut maintenir tous ces Dockerfile. Cela peut vite devenir chronophage et prendre du temps s'il faut vérifier qu'il existe une nouvelle version de l'image de base ou un patch sur la JVM. Heureusement le plugin spring-boot est là pour nous !
Voici un exemple de son usage en condition réelle :
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<imageBuilder>paketobuildpacks/builder:full</imageBuilder>
<image>
<name>${ARTIFACTORY_DOCKER_REGISTRY}/${ARTIFACTORY_DOCKER_DIR}/gfr-referential:${ARTIFACTORY_DOCKER_TAG}</name>
<env>
<BP_JVM_VERSION>${java.version}</BP_JVM_VERSION>
<BPL_DEBUG_ENABLED>false</BPL_DEBUG_ENABLED>
<BPE_DELIM_JAVA_TOOL_OPTIONS xml:space="preserve"> </BPE_DELIM_JAVA_TOOL_OPTIONS>
<BPE_APPEND_JAVA_TOOL_OPTIONS> -Dfile.encoding=UTF-8</BPE_APPEND_JAVA_TOOL_OPTIONS>
</env>
<bindings>
<binding>${project.basedir}/bindings:/platform/bindings/ca-certificates</binding>
</bindings>
<publish>true</publish>
</image>
<layers>
<enabled>true</enabled>
</layers>
<docker>
<publishRegistry>
<username>${ARTIFACTORY_DIOD_USERNAME}</username>
<password>${ARTIFACTORY_DIOD_PASSWORD}</password>
</publishRegistry>
</docker>
</configuration>
</plugin>
Ce plugin nous permet de créer une image Docker Cloud Native sans avoir recours à un Dockerfile. Pas besoin non plus d'un Dockerfile pour copier un certificat et l'intégrer aux certificats connus par la JVM via la commande keytool, ici, c'est prévu par les bindings ;)
Par Cloud Native, on entend :
- Rootless
- Optimisation des layers
- Optimisation de la JVM
Le plugin s'appuie sur l'utilisation de buildpack et de paketo pour construire une image sécurisée, optimisée et toujours à jours (par défaut maintenue par paketo).
En utilisant ce plugin, vous n'avez pas besoin d'utiliser le calculateur des options de JVM, car il est inclus dans l'image final ! Vous aurez seulement besoin de spécifier le GC que votre application doit utiliser.
Optimiser l'application Spring Boot
Quelques optimisations existent pour gagner en temps de démarrage et réduire la consommation de ressources.
Ces optimisations sont préconisées dans la documentation Google Cloud Run notamment :
- spring-content-indexer
- Evite l'analyse des classes au démarrages, consommateur en terme d'I/O disque. Les beans sont indexés à la compilation
- spring.main.lazy-initialization=true
- Initialisation des beans uniquement lors du premier usage
- Utiliser WebFlux / programmation réactive (même avec l'arrivée de Loom)
CRaC !
Vous pouvez oublier l'indexation des beans à la compilation ainsi que l'initialisation différée des beans avec l'arrivée de CRaC (Coordinated Restore at Checkpoint).
CRaC est un projet de l'OpenJDK et va changer radicalement notre manière de déployer une application Java en mode JVM. En quelques mots, ce projet permet de démarrer une application Java en moins de 100 ms !
Là, vous pensez sûrement que ça n'est pas demain la veille que l'on aura ça. Détrompez-vous, vous l'avez peut-être déjà utilisé via les fonctions Lambda d'AWS, sous sa dénomination SnapStart !
L'autre bonne nouvelle, c'est que le framework Spring 6.1 est compatible avec CRaC, une démo est disponible ici :
https://github.com/sdeleuze/spring-boot-crac-demo
Ci-dessous, une liste de JDK embarquant déjà CRaC :
- Amazon Corretto
- IBM Semeru
- Azul Zulu
- Azul Platform Prime
Plusieurs conférences ont été données sur ce sujet dont une lors du dernier Devoxx France:
Quelques liens pour aller plus loin
Les liens ci-dessous approfondissent ce que nous avons vu ici et apportent des éléments supplémentaires pour parfaire votre maîtrise !
- Tour d'horizon des JDK
- Comprendre et choisir son Garbage Collector
- CRaC vs GraalVM
- Buildpack
- Paketo (plugin Spring)
- Solution Google
- Java on Kube
- Spring 6.1