mercredi 28 septembre 2022

Vous saviez pour OpenJDK et Docker™?

Jusqu'à la version 11 de la JDK, il était possible d'utiliser une image "fine" pour empaqueter les applications Java: openjdk:11-jre-slim. Mais depuis, ça se complique car il n'existe plus d'image officielle OpenJDK uniquement centrée sur la JRE.
La conséquence majeure: on se retrouve alors avec une image d'environ 400 MiB pour enrober une API dont le JAR n'en fait que 70; pas cool. On dirait un rapport Charge Utile / Masse Totale digne d'une fusée Arianne. Toute cela parce que:

  • l'image embarque l'ensemble du JDK; incluant le compilateur ainsi qu'un tas d'autres trucs inutiles au runtime
  • le concept de JRE n'existe pas au niveau de l'OpenJDK, c'est un "détail" laissé à la discretion des distributeurs du JDK

J'ai retenu deux solutions[1] à ce problème :

  1. la plus simple: changer d'image de base pour passer sur celle d'une distribution livrant une version JRE (comme docker.io/library/eclipse-temurin:18-jre-alpine par exemple ~170 MiB, ou une autre)
  2. la plus fun: utiliser jdeps et jlink pour fabriquer un JRE custom sur base de l'OpenJDK

On explorera ici la deuxième solution : non pas parce que c'est la plus facile, mais parce que c'est la plus compliquée 🙂 (… et parce que la première, vous la connaissiez sûrement déjà)

mise à jour du 29/03/2024:
Un projet d'exemple est disponible ici montrant un exemple d'usage production ready.

Ce qui va suivre sera illustré avec l'OpenJDK 18, version courante de Java au moment de l'écritre de ces lignes, mais cela s'applique à tous les JDK > 11 (testé sur 17 [2]).
L'application embarquée est une API ReST motorisée avec Spring Framework par l'intermédiaire de Spring Boot.

1. Un peu de contexte

Jusqu'à la JDK 11, j'ai pris l'habitude de construire les images Docker™ d'applications Java à l'aide de JIB et de son plugin pour Maven.
Les avantages pincipaux que j'ai retenus de cette technique sont :

  • Une intégration parfaite au processus de build (il existe un plugin pour Gradle bien entendu)
  • Ne pas avoir besoin de Docker™ pour construire une image, très pratique quand la CI qui s'occupe de construire l'image est un container, plus besoin de D-in-D

L'inconvénient majeur (ou le prix à payer, c'est selon) est l'absence total de contrôle sur l'image produite. En même temps: ça marche! alors que demander de plus. (Règle n°42: If it works, don't fix it!)

C'est devenu un problème en migrant sur OpenJDK 17. Quand remplacer openjdk:11-jre-slim par openjdk:17-jre-slim a échoué car l'image n'existe pas. Et que se rabattre sur l'image openjdk:17-slim faisait alors passer l'image de l'application d'environ 200 MiB à plus de 400, pour le même binaire 😯.
Après quelques recherches sur Internet, la première recommandation est de supprimer dans l'image produite: le fichier src.zip (~50 MiB) ainsi que le répertoire jmods (~75 MiB) pour gagner environ … 125 MiB (c'est bien, vous suivez 🙂), mais 285 MiB ça fait encore trop chère payer.
Et encore eu-t-il fallu pouvoir le faire avec le JIB Maven plugin (spoiler alert: ça sent le sapin pour ce dernier).

2. Reprendre le contrôle

Alors, oui: il est possible de repartir d'une image plus fine en optant pour une distribution alternative: l'Eclipse Temurin (nouveau nom du projet Adopt OpenJDK confié à la fondation Eclipse) pouvant faire l'affaire. C'était sans compter sur le fait que je suis têtu et préfère boire la bière au plus prêt du fut.
Dit autrement: je n'ai rien contre les distributions de la JDK, j'ai juste envie de m'en tenir à la version la plus proche de l'upstream.

À force de creuser dans l'Internet, une timide solution se fit entendre: « Et pourquoi pas utiliser jdeps et jlink pour assembler un JRE aux petits oignons ? »
L'idée ici est alors de se passer du confort offert jusqu'à présent par JIB pour se retrousser les manches et utiliser les build stages du Dockerfile. Et BIM! :


FROM docker.io/library/maven:3.8-openjdk-18 AS builder
WORKDIR /usr/local/src/app
COPY . .
RUN mvn -DskipTest -DskipITs clean package \
 && mvn dependency:build-classpath \
    -DincludeScope=compile \
    -Dmdep.outputFile="./target/class.path" \
 && jdeps -classpath "$(cat "./target/class.path")" \
    --multi-release 18 \
    --ignore-missing-deps \
    --print-module-deps \
    "target/${MAVEN_ARTIFACT}-${MAVEN_VERSION}.jar" > "./target/jre.modules" \
 && jlink --strip-debug \
  --output "target/jre" \
  --add-modules "$(cat "./target/jre.modules")"

FROM debian:11-slim
COPY --from=builder \
  /usr/local/src/app/target/jre/ \
  /usr/local/lib/jre/
COPY --from=builder \
  /usr/local/src/app/target/${MAVEN_ARTIFACT}-${MAVEN_VERSION}.jar \
  /usr/local/share/app/${MAVEN_ARTIFACT}-${MAVEN_VERSION}.jar
ENV JAVA_HOME /usr/local/lib/jre/
ENV PATH $JAVA_HOME/bin:$PATH
ENV LANG C.UTF-8
EXPOSE 8080
CMD java -jar "/usr/local/share/app/${MAVEN_ARTIFACT}-${MAVEN_VERSION}.jar"

Comment ça marche ?

En substance:

  1. l'image de build basée sur maven:3.8-openjdk-18 construit le binaire sans exécuter tests (ces derniers ont déjà tourné avec succès durant une phase précédente, sans quoi on n'en serait pas là)
  2. on demande à maven la liste exhaustive des dépendances de notre binaire, le classpath
  3. on donne ce classpath à manger à jdeps pour qu'il nous donne la liste des modules nécessaires et suffisants pour faire tourner l'appli
  4. on demande enfin a jlink de nous faire un joli JRE ne contenant que les modules mentionés par jdeps (attention: --strip-debug est obligatoire car supprime toutes les métadonnées de debug laissées dans les lib*.so du runtime)
  5. on passe ensuite à la construction de l'image finale en partant d'une debian slim (on verra pourquoi juste après)
  6. on y copie le JRE confectionné avec amour juste avant
  7. on y copie le JAR de l'appli (notre précieux)
  8. on fini avec la commande de lancement classique : java -jar mon.jar

On se retrouve alors avec une image d'une taille "raisonable" d'environ 200 MiB comme avant.

Et pourquoi Debian ?

Il est parfois recommandé de faire patienter le démarrage d'une appli tant que le service dont elle a besoin n'est pas envore prêt. Par exemple: attendre que la BDD soit prête à accecpter les connections avant de faire démarrer l'appli. Appli qui pourrait échouer sinon, croyant que la base est H.S.
On pourra alors pimper le Dockerfile en employant wait-for-it de la manière suivante :


...
FROM debian:11-slim
RUN apt-get update \
 && apt-get install -y wait-for-it \
 && apt-get clean autoremove \
 && rm -rf /var/lib/apt/lists/*
...
ENV DB_HOST undefined_DB_HOST
ENV DB_PORT undefined_DB_PORT
CMD wait-for-it --strict --host=${DB_HOST} --port=${DB_PORT} \
  -- java -jar "/usr/local/share/app/${MAVEN_ARTIFACT}-${MAVEN_VERSION}.jar"

Et donc, retour de Docker-ception ?

Alors, oui … et non: pour assembler une image Docker sans Docker™, gràce à L'Open Container Initiative, il existe des alternatives telles que: Podman ou encore Buildah. Étant sous Fedora, j'utilise Podman, mais j'ai déjà utilisé Buildah avec un CI/CD GitLab notamment et ça fait le café.

3. Conclusion

Si j'ai beaucoup apprécié le confort de JIB, j'accorde à présent plus de poids à la maîtrise du Dockerfile avec tout le lot de responsabilités que cela implique. Pour rester concis, je n'ai pas repris l'ensemble des paramètres de JVM qu'il est de bon ton de bichonner pour se sauver la vie en cas de tempête en prod (gc log, mémoire, certificats, …) mais ce qu'il faut comprendre c'est que maintenant qu'on se débrouille tout seul, … faut se débrouiller tout seul (et avec StackOverFlow 🤫).

Cela reste un avis très personnel, les 2 solutions ont chacune leurs avantages et inconvénient et elles sont pour moi impossible à départager objectivement sans un minimum de contexte.

Et vous ? vous faites comment ?

[1]
Parmis les solutions écartées, il y avait :
  • ne rien faire, parce que la taille de l'image prends de la place dans les registries, parce que son transfert prend du temps et de la bande passante et parce que ça se paye au final … en € et en KWh
  • créer sa propre image Java de base, parce qu'après il faut la maintenir et que c'est chiant, on le fait jamais jusqu'à la prochaine faille de sécurité …
[2]
Les tests avec l'OpenJDK 17 ont été pénible à cause d'un bug dans jdeps fixé en 18 et plus ou moins backporté en fonction de la distribution. Il conviendra alors de :
  • passer sur une distrib maven utilisant un JDK plus récent: fix jdeps backporté en 17.0.4 le plus souvent (sauf pour l'OpenJDK, j'y reviens après)
  • vérifier que l'image de base possède l'utilitaire strip fournit par le paquet binutils notamment utilisé avec l'option --strip-debug
Exemple avec l'image Maven 3.8 basée sur amazoncorreto 17
FROM docker.io/library/maven:3.8-amazoncorreto-17 AS builder
WORKDIR /usr/local/src/app
COPY . .
RUN yum install -y binutils \
 && yum clean all \
 && mvn -DskipTest -DskipITs clean package \
...

De ce fait, on n'est plus sur l'OpenJDK 100% pure jus mais l'OpenJDK ne receverra jamais de mise à jour car la version courante est 18, 17 étant désormais dépréciée. Maintenant que le train des release est lancé, il ne s'arrête plus, donc autant passer directement à 18. Ce qui fit fait.

Aucun commentaire:

Enregistrer un commentaire