10 bonnes pratiques pour créer un conteneur Java avec Docker
24 août 2022
0 minutes de lectureNote de la rédaction
Cet article a été publié pour la première fois le 18 février 2021, puis mis à jour pour refléter les informations les plus récentes.
Vous souhaitez créer une application Java et l’exécuter dans une image Docker ? Je vais vous aider !
Cette fiche récapitulative présente les bonnes pratiques pour créer un conteneur Java prêt pour la production. Dans l’exemple de conteneur Java que nous allons créer en suivant ces recommandations, nous nous concentrerons sur l’optimisation et la sécurisation de votre application Java. Vous pouvez utiliser cette fiche comme guide pour créer un conteneur Java avec Docker, mais aussi pour examiner le code d’autres personnes. Dans les deux cas, il est important de vous concentrer sur l’optimisation et la sécurisation de l’image Docker que vous souhaitez déployer en production.

Dix bonnes pratiques
Utilisez des balises d’image Docker de base explicites et déterministes (« latest » n’est pas une version !)
N’installez dans l’image du conteneur Java que ce dont vous avez besoin en production
Détectez et corrigez les vulnérabilités de sécurité dans votre image Docker Java
Utilisez des builds multi-étapes pour réduire davantage la taille de l’image de production
N’exécutez pas les applications Java en tant que root
Gérez correctement les événements pour arrêter une application Java en toute sécurité
Arrêtez proprement les applications Java
Évitez les fichiers inutiles dans vos images de conteneur Java à l’aide de
.dockerignoreVérifiez que Java prend en charge les conteneurs
Méfiez-vous des outils de génération automatique de conteneurs Docker
La sécurité des conteneurs, pensée pour les développeurs
Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.
Comment NE PAS créer une image de conteneur Java
Commençons par un Dockerfile simple pour une application Java créée avec Maven. Dans de nombreux articles, on trouve des exemples similaires pour créer un conteneur Java, comme celui-ci :
La plupart des articles de blog se contentent des instructions de base suivantes dans un Dockerfile pour créer des images Docker Java :
Copiez ce contenu dans un fichier nommé Dockerfile, puis créez et exécutez l’image.
C’est simple et ça fonctionne. Pourtant, cette image comporte de nombreuses erreurs ! Il ne suffit pas de savoir utiliser Maven correctement : il faut absolument éviter de créer des conteneurs Java comme dans l’exemple ci-dessus.
Améliorons ce Dockerfile et optimisons-le étape par étape afin d’obtenir une image Docker efficace et sécurisée pour votre application Java.
1. Utilisez des balises d’image Docker de base explicites et déterministes
Pour créer une image de conteneur Java avec Maven, il semble évident de partir de l’image Maven : il suffit de la récupérer sur Dockerhub et de se lancer. Mais savez-vous vraiment ce que vous récupérez avec cette image de base ? Vous pouvez référencer une image Docker à l’aide de sa balise. Si vous n’en indiquez aucune, c’est la balise implicite latest qui sera utilisée.
Cette fonctionnalité peut sembler intéressante, mais cette stratégie consistant à utiliser l’image Maven par défaut présente plusieurs problèmes potentiels :
Cette fonctionnalité peut sembler intéressante, mais cette stratégie consistant à utiliser l’image Maven par défaut présente plusieurs problèmes potentiels :
Vos builds Docker ne sont pas idempotents
Cela signifie que le résultat peut être complètement différent lors d’une nouvelle compilation. Les images les plus récentes aujourd’hui peuvent différer de celles qui seront les plus récentes demain ou la semaine prochaine. Les versions de Maven et du JDK sur lesquelles vous basez la création de votre image peuvent être mises à niveau. Le bytecode de votre application sera alors différent, ce qui risque d’entraîner des résultats inattendus. Lors de la recréation d’une image, nous souhaitons obtenir un comportement reproductible et déterministe.
L’image Docker Maven est basée sur une image de système d’exploitation complète
De nombreux binaires supplémentaires se retrouvent alors dans votre image de production finale. Beaucoup ne sont pas nécessaires à l’exécution de votre application. Leur présence dans l’image du conteneur Java présente toutefois plusieurs inconvénients :
Une image plus volumineuse entraîne des téléchargements et des reconstructions plus longs.
Les binaires supplémentaires peuvent introduire des vulnérabilités de sécurité. Tout binaire vulnérable représente un risque de sécurité que vous ne souhaitez pas ajouter à votre système.
L’image maven:latest est assez volumineuse et repose régulièrement sur des versions de Maven et d’OpenJDK qui changent en quelques mois, quelques semaines, voire quelques jours.
Voici comment limiter ces problèmes :
Utilisez l’image de base la plus légère possible qui répond à vos besoins. Demandez-vous si vous avez besoin d’un système d’exploitation complet, avec tous les binaires supplémentaires, pour exécuter votre programme. Si ce n’est pas le cas, une image plus légère comme
debian-slimou mêmealpinepeut tout aussi bien faire l’affaire qu’une image Debian complète.Choisissez vos images avec précision. En utilisant une version précise de l’image, vous pouvez déjà contrôler et prévoir certains comportements. Si vous utilisez l’image
maven:3.6.3-jdk-11-slim, vous savez que vous utilisez JDK 11 et Maven 3.6.3. Le cycle de mise à jour de six mois du JDK n’aura plus d’incidence sur le comportement du conteneur Java. Pour être encore plus précis, vous pouvez utiliser le hachageSHA256de l’image. En indiquant ce hachage, vous aurez exactement la même image de base à chaque reconstruction.
Mettons à jour notre Dockerfile en tenant compte de ces recommandations :
Pour en savoir plus sur le choix d’images de conteneur sécurisées, consultez notre guide sur la sécurité des conteneurs.
2. N’installez dans l’image du conteneur Java que ce dont vous avez besoin en production
La commande suivante crée votre programme Java, avec toutes ses dépendances, dans le conteneur. Le code source et votre système de build font donc partie du conteneur Java de production.
Java est un langage compilé. Nous n’avons donc besoin que de l’artefact produit par votre environnement de build, et non du code lui-même. Cela signifie également que l’environnement de build ne doit pas faire partie de votre conteneur de production.
Pour exécuter une image Java, nous n’avons pas non plus besoin d’un JDK complet : un JRE suffit. En résumé, si le fichier JAR est exécutable, nous devons créer une image contenant un JRE et l’artefact Java compilé.
Créez votre programme dans votre pipeline CI avec Maven (ou Gradle), puis copiez le fichier JAR dans votre image de production, comme dans le Dockerfile mis à jour ci-dessous :
3. Détectez et corrigez les vulnérabilités de sécurité dans votre image Docker de conteneur Java
Nous utilisons déjà une petite image pour notre conteneur Java Docker. Cependant, je ne sais pas si les binaires de cette image de base comportent des problèmes. Testons notre image Docker à l’aide de la Snyk CLI. Vous pouvez créer gratuitement un compte Snyk pour suivre les étapes.
Vous pouvez installer la CLI avec npm, brew ou scoop, ou télécharger le dernier binaire depuis GitHub. Dans cet exemple, nous allons utiliser npm pour l’installation, puis nous authentifier avec notre compte gratuit :
Avec snyk container test, vous pouvez tester n’importe quelle image Docker. Vous pouvez également ajouter le Dockerfile pour obtenir des conseils de correction plus précis.

Snyk a détecté 58 problèmes de sécurité dans cette image de base. La plupart concernent des binaires fournis avec la distribution Linux Debian.
Compte tenu de ces résultats, nous allons remplacer l’image de base par une image openjdk11:JRE basée sur Alpine et fournie par adoptopenjdk, en indiquant le SHA précis.
Le test de cette version avec snyk container ne signale aucune vulnérabilité connue dans cette image de base.
De la même manière, vous pouvez tester votre application Java en exécutant snyk test à la racine de votre projet. Je vous recommande de tester votre application et l’image du conteneur Java créée sur votre machine locale pendant le développement. Automatisez également ces mêmes tests pour l’image et l’application dans votre pipeline CI.
N’oubliez pas non plus que de nouvelles vulnérabilités sont découvertes au fil du temps. Lorsqu’une nouvelle vulnérabilité est identifiée, vous voudrez probablement en être informé.
Utilisez snyk monitor pour votre application et snyk container monitor pour vos images Docker. En surveillant la version actuellement en production, vous pourrez agir comme il convient lorsque de nouveaux problèmes de sécurité seront détectés.

Vous pouvez également connecter votre dépôt Git à Snyk afin que nous vous aidions à détecter et à corriger les vulnérabilités à cette étape de votre cycle de développement logiciel.
Mettons à jour notre Dockerfile actuel :
Consultez notre guide pour en savoir plus sur l’analyse des vulnérabilités dans les conteneurs Docker.
4. Utilisez des builds multi-étapes pour votre conteneur Java
Plus tôt dans cet article, nous avons expliqué qu’il n’est pas nécessaire de créer notre application Java dans un conteneur : seul le résultat de ce build nous intéresse. Dans certains cas, il peut toutefois être pratique de créer notre application dans le cadre du processus de build de l’image Docker.
Heureusement, nous pouvons répartir la création de votre image Docker en plusieurs étapes. Nous pouvons créer une image de build à l’aide de tous les outils nécessaires à la création de notre application, puis créer une deuxième étape pour produire l’image de production proprement dite, qui ne contient que les éléments nécessaires à son exécution en production.
Évitez les fuites d’informations sensibles
Lorsque vous créez des applications Java et des images Docker, vous devez probablement vous connecter à un dépôt Maven privé. En général, les paramètres sont enregistrés dans votre fichier settings.xml, sur votre machine locale ou dans votre environnement de build. Avec les builds multi-étapes, vous pouvez copier en toute sécurité le fichier settings.xml dans votre conteneur de build. Les paramètres contenant les identifiants ne seront pas inclus dans votre image de production. De même, si vous devez utiliser des identifiants comme arguments de ligne de commande, vous pouvez le faire sans risque dans l’image de build : ils ne seront pas inclus dans l’image de production.
Avec les builds multi-étapes, vous pouvez créer plusieurs étapes et ne copier que le résultat dans vos images de production finales. En séparant la compilation du code de la création de l’image, vous pouvez vous concentrer sur le déploiement des seuls éléments nécessaires à l’exécution de l’application dans l’image finale, sans risquer d’inclure du code source, des compilateurs ou d’autres contenus dans vos environnements de déploiement.
Au fait, examinez la sortie de docker history pour l’image de conteneur Java créée :
La sortie ne montre que les informations de l’image de conteneur de production, et non celles de l’image de build.

5. N’exécutez pas les conteneurs en tant que root
Lors de la création d’un conteneur Docker, vous devez appliquer le principe du moindre privilège dans le cadre de la sécurité de votre conteneur Java (c’est-à-dire n’accorder que les privilèges réellement nécessaires). Si un attaquant parvient à infiltrer votre application, vous ne voulez pas qu’il puisse tout contrôler.
Plusieurs niveaux de sécurité permettent de limiter les dégâts qu’un attaquant peut causer en cas de compromission de votre système. Vous devez donc vous assurer que votre application ne s’exécute pas en tant qu’utilisateur root.
Il y a toutefois un problème : par défaut, un conteneur Docker s’exécute en tant que root. C’est pratique en développement, mais vous ne voulez pas que ce soit le cas dans vos images de production. Supposons qu’un attaquant accède à un terminal ou puisse exécuter du code, pour quelque raison que ce soit. Il disposerait alors de privilèges importants sur le conteneur en cours d’exécution et pourrait potentiellement accéder aux systèmes de fichiers de l’hôte via des montages liés disposant de droits d’accès trop élevés.
La solution est assez simple. Consultez la documentation de votre image de base. Si elle comprend déjà un utilisateur aux privilèges réduits, utilisez-le en ajoutant une ligne USER avec son nom dans votre Dockerfile. Sinon, créez un utilisateur spécifique aux privilèges limités pour exécuter votre application et utilisez-le. Veillez simplement à vérifier que cet utilisateur peut exécuter l’application.
Mettons notre Dockerfile à jour en conséquence. Remarquez que nous ne nous préoccupons de l’utilisateur que dans la deuxième partie du build de conteneur multi-étapes.
Remarque : vous pouvez également exécuter le conteneur avec un autre utilisateur en le spécifiant sur la ligne de commande ou dans la configuration de votre orchestrateur. Par exemple, la commande Docker CLI run possède un paramètre -u prévu à cet effet, et les spécifications des pods Kubernetes proposent le même réglage via le champ SecurityContext:runAsUser. Toutefois, le définir dans la spécification de build permet de reproduire le processus.
6. Gérez correctement les événements pour arrêter en toute sécurité une application Web Java dans Docker
Dans de nombreux exemples, on constate l’erreur courante qui consiste à utiliser l’environnement de build pour démarrer l’application Java conteneurisée.
Nous avons déjà expliqué pourquoi inclure Maven ou Gradle dans notre conteneur Docker Java, mais il existe d’autres raisons d’éviter ce type d’outils :
CMD “mvn” “exec:java”CMD [“mvn”, “spring-boot run”]CMD “gradle” “bootRun”CMD “run-app.sh”
Lorsqu’une application s’exécute dans Docker, la première application est exécutée avec l’ID de processus 1 (PID 1). Le noyau Linux traite le PID 1 de manière particulière. En règle générale, le processus ayant le PID 1 est le processus init. Si nous exécutons notre application Java avec Maven, comment être certains que Maven transmet les signaux tels que SIGTERM au processus Java ? Nous ne pouvons pas en être certains !
En exécutant plutôt votre conteneur Docker comme dans l’exemple ci-dessous, votre application Java aura le PID 1, ce qui garantit que les signaux sont correctement transmis.
Notez que les commandes docker kill ... et docker stop ... envoient uniquement des signaux au processus du conteneur ayant le PID 1. Si vous exécutez un script shell qui lance votre application Java, sachez qu’une instance de shell, comme /bin/sh, ne transmet pas les signaux aux processus enfants. Votre application ne recevra donc jamais le signal SIGTERM.
Il est important de savoir que, sous Linux, le PID 1 a également d’autres responsabilités. L’article Docker et le problème du nettoyage des processus zombies du PID 1 les décrit très bien. Dans certains cas, vous ne voudrez pas avoir le PID 1, car vous ne saurez pas comment gérer ces problèmes. Une excellente solution consiste à utiliser dumb-init.
Lorsque vous exécutez votre conteneur Docker de cette façon, dumb-init occupe le PID 1 et se charge de toutes ces responsabilités. Votre processus Java n’a plus à s’en occuper.
Notre Dockerfile mis à jour ressemble maintenant à ceci :
7. Arrêter proprement vos applications Web Java
Lorsque votre application reçoit un signal d’arrêt, l’idéal est que tous les éléments s’arrêtent proprement. Selon la manière dont vous développez votre application, un signal d’interruption (SIGINT) ou CTRL + C peut entraîner l’arrêt immédiat du processus.
Ce n’est peut-être pas ce que vous souhaitez, car cela peut provoquer un comportement inattendu, voire une perte de données.
Lorsque votre application s’exécute sur un serveur Web tel que Payara ou Apache Tomcat, celui-ci se charge très probablement de l’arrêt propre. Il en va peut-être de même pour certains frameworks permettant de créer une application exécutable. Spring Boot, par exemple, intègre une version de Tomcat qui gère efficacement l’arrêt.
Lorsque vous créez une application Java autonome ou que vous générez manuellement un JAR exécutable, vous devez gérer vous-même ces signaux d’interruption.
La solution est assez simple. Vous pouvez ajouter un hook d’arrêt au runtime, comme dans l’exemple ci-dessous. Lorsqu’un signal tel que SIGINT est reçu, un nouveau Thread est lancé pour gérer un arrêt propre.
Certes, il s’agit davantage d’une préoccupation générale liée aux applications Web que d’un sujet propre aux Dockerfile, mais c’est d’autant plus important dans les environnements orchestrés. Pour en savoir plus, consultez notre guide sur l’orchestration des conteneurs.
8. Excluez les fichiers inutiles de vos images de conteneurs Java avec .dockerignore
Pour exclure certains fichiers de votre dépôt Git (public), vous pouvez utiliser le fichier .gitignore. Il évite d’encombrer votre dépôt Git de fichiers inutiles. Il contribue également à empêcher la divulgation de fichiers sensibles dans un dépôt public.
Pour les images Docker, nous disposons d’un fichier similaire : .dockerignore. Comme le fichier d’exclusion de Git, il ignore les fichiers correspondant aux modèles qui y sont définis, empêchant ainsi la copie de fichiers ou de répertoires indésirables dans votre image Docker.
Concrètement, lorsqu’une commande docker build est lancée, l’outil CLI envoie le contenu du répertoire de contexte de build (par défaut, le répertoire contenant le Dockerfile) au runtime du conteneur. Cette copie est ensuite utilisée lors des étapes définies dans le Dockerfile. Tout fichier correspondant à un modèle défini dans .dockerignore ne sera pas copié et ne pourra donc pas être inclus dans le build par une commande COPY ou ADD. Cela accélère également les builds : l’envoi du contexte est beaucoup plus rapide si vous excluez de grandes arborescences de répertoires, comme .git. Cet avantage est particulièrement marqué lorsque vous effectuez un build sur un moteur Docker exécuté sur un autre serveur, via une connexion réseau.
L’exemple simplifié de cet article suppose que nous travaillons avec un seul JAR exécutable. Ce n’est pas la seule façon de distribuer une application Java. Dans le cas d’applications Java autonomes, il est également possible de ne pas créer de JAR « fat » intégrant toutes les dépendances dans l’artefact. Selon le contexte, il peut être nécessaire de copier les répertoires complets contenant les JAR dont dépend notre application, ou d’autres fichiers. Nous voulons éviter que des informations sensibles se retrouvent accidentellement dans nos images Docker, surtout si nous les publions publiquement.
Voici un exemple de fichier .dockerignore :
Le fichier .dockerignore présente les avantages suivants :
Il exclut les dépendances utilisées uniquement à des fins de test.
Il vous protège contre l’exposition de secrets, comme des identifiants contenus dans des fichiers
.envouaws.jsonqui pourraient se retrouver dans l’image Docker Java. Notez que les fichiers journaux de débogage peuvent également contenir des secrets ou des informations sensibles que vous ne souhaitez pas exposer.Il permet de garder votre image Docker propre et, par conséquent, de réduire sa taille. Il contribue également à prévenir les comportements inattendus.
Il accélère les opérations docker build en réduisant la taille du répertoire de contexte de build à copier.
9. Vérifiez que Java tient compte des conteneurs
La machine virtuelle Java (JVM) est remarquable. Elle s’adapte au système sur lequel elle s’exécute. Son optimisation fondée sur le comportement ajuste dynamiquement la taille du tas. Cependant, dans les anciennes versions, comme Java 8 et Java 9, la JVM ne tenait pas compte des limites de processeur ou de mémoire définies par le conteneur. La JVM de ces anciennes versions voyait toute la mémoire et tous les processeurs disponibles sur le système hôte. En pratique, les paramètres des groupes Docker étaient ignorés.
Depuis la sortie de Java 10, la JVM tient compte des conteneurs et reconnaît les contraintes qu’ils définissent. La fonctionnalité UseContainerSupport est une option de la JVM, activée par défaut. Cette prise en charge des conteneurs, introduite dans Java 10, a été rétroportée vers Java-8u191.
Pour les versions antérieures à Java 8, vous pouvez essayer de limiter manuellement la taille du tas à l’aide de l’option -Xmx, mais l’exercice est fastidieux. De plus, la taille du tas ne correspond pas à la quantité de mémoire utilisée par Java. Pour Java-8u131 et Java 9, la prise en charge des conteneurs est expérimentale : vous devez activer les options expérimentales de la JVM ainsi que l’option de limite de mémoire.
Le mieux est de passer à une version plus récente de Java — au-delà de la version 10 — afin que la prise en charge des conteneurs soit activée par défaut. Malheureusement, de nombreuses entreprises dépendent encore fortement de Java 8. Vous devez donc soit utiliser une version plus récente de Java dans vos images Docker, de préférence la dernière version LTS, soit vous assurer d’utiliser au minimum Java 8u191.
Tout cela ne semble pas particulièrement lié à la sécurité des conteneurs Java, n’est-ce pas ? Pourtant, si l’on y réfléchit, la disponibilité est l’un des trois attributs de la triade CIA de la sécurité de l’information.
10. Méfiez-vous des outils de génération automatique de conteneurs Docker
En parcourant Internet, vous trouverez probablement d’excellents outils et plug-ins pour votre système de build. Certains d’entre eux permettent de créer des conteneurs Docker Java et même de les publier automatiquement, si vous le souhaitez.
Du point de vue du développeur, cela semble idéal : vous n’avez pas à vous préoccuper de la maintenance des Dockerfile en plus de la création de l’application.
JIB est un exemple de ce type de plug-in. Il suffit de configurer le plug-in de build comme indiqué ci-dessous, puis d’exécuter mvn jib:dockerBuild :
L’outil crée alors sans difficulté une image Docker portant le nom indiqué.
Vous pouvez faire quelque chose de similaire avec Spring Boot, à partir de la version 2.3, en exécutant la cible mvn :
Dans les deux cas, le système crée automatiquement une image de conteneur Docker Java. Je reconnais que ces conteneurs sont également relativement légers, car ils utilisent eclipse-temurin ou des buildpacks comme base pour les images. Mais quelle que soit la taille de votre image, comment savoir si ces conteneurs sont sécurisés ? Vous devez mener une analyse approfondie et, même dans ce cas, rien ne garantit que leur état de sécurité sera maintenu à l’avenir.
L’analyse des deux images avec snyk container révèle plusieurs vulnérabilités liées aux images de base utilisées par ces deux systèmes. De plus, nous ignorons si les privilèges utilisateur sont correctement définis, parmi tous les autres aspects abordés ici.
Je ne dis pas qu’il ne faut pas utiliser ces outils pour créer des images Docker Java. Toutefois, si vous prévoyez de publier ces images, vous devez examiner attentivement tous les aspects de la sécurité des conteneurs Java. L’analyse du conteneur est un bon point de départ. Si vous choisissez un autre outil, examinez ses paramètres par défaut, comme les images de base et l’utilisateur par défaut, puis remplacez-les par des valeurs adaptées à votre application. Comme dans un Dockerfile, expliciter ces paramètres est toujours la solution la plus sûre.
Résumé
Cette dernière étape conclut notre guide sur la conteneurisation sécurisée des applications Java avec Docker. Nous avons pris en compte les optimisations liées aux performances et à la sécurité afin de créer des images Docker Java prêtes pour la production !
Je vous recommande vivement de consulter les ressources suivantes :
Docker pour les développeurs Java : 5 points essentiels pour ne pas compromettre votre sécurité
Créer des images de conteneurs Java avec Jib

La sécurité des conteneurs, pensée pour les développeurs
Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.
FAQ
Comment vérifier la version de Java dans un conteneur Docker ?
Pour vérifier avec certitude la version de Java installée dans une image de conteneur Docker, exécutez la commande java -version dans le conteneur :
docker run --rm -it eclipse-temurin:11 java -version
openjdk version "11.0.16" 2022-07-19
OpenJDK Runtime Environment Temurin-11.0.16+8 (build 11.0.16+8)
OpenJDK 64-Bit Server VM Temurin-11.0.16+8 (build 11.0.16+8, mixed mode)
Si l’image comporte un entrypoint qui empêche cette opération, vous pouvez le remplacer à l’aide du paramètre --entrypoint, comme dans cet exemple :
docker run --rm -it --entrypoint java tomcat:8 -version
opnjdk version "17.0.4" 2022-07-19
OpenJDK Runtime Environment Temurin-17.0.4+8 (build 17.0.4+8)
OpenJDK 64-Bit Server VM Temurin-17.0.4+8 (build 17.0.4+8, mixed mode, shareing)
Comment installer Java dans un conteneur Docker ?
La méthode recommandée pour installer Java dans vos images de conteneur consiste à utiliser une image de base officielle qui contient la version dont vous avez besoin. Par exemple :
FROM eclipse-temurin:11.0.16_8-jdk
COPY myapp.jar /myapp.jar
...
Comment changer de version de Java dans un conteneur Docker ?
Pour modifier la version de Java utilisée dans votre image de conteneur, le mieux est de mettre à jour l’image de base vers la version souhaitée. Il vous suffit de remplacer la ligne FROM de votre Dockerfile par le tag correspondant à la version voulue, puis de reconstruire l’image avec un tag approprié et de l’envoyer à votre registre d’images. Enfin, mettez à jour votre déploiement pour qu’il utilise ce nouveau tag.



