La renommée de docker croît tous les jours un peu plus alors que les patterns standard d’utilisation ne sont pas encore bien définis.
Il existe ainsi pour chaque problématique une multitude de réponses possibles. Qu’en est-il de la construction de projets maven?
Voici deux stratégies possibles pour ces projets.
Toutes les sources illustrant l’article sont disponibles sous ce projet github. Il existe une branche pour chacune des statégies.
Pour présenter les différentes stratégies nous utiliserons l’application exemple de fluent-http. Ce projet très simple est représentatif des applications web.
Une image docker, un container docker
Cette version est directement inspirée par l’article de Cédric Exbrayat Développer pour Google Glass.
Elle s’appuie sur un unique container contenant tout ce qui est nécessaire: java, maven et les sources compilées.
Voici le Dockerfile
FROM java:8
RUN apt-get update
RUN apt-get install -y maven
ADD . /project
WORKDIR /project
RUN mvn install -DskipTests
EXPOSE 8080
CMD mvn exec:java
Phase de construction de l’image
On part de la version officielle de java. Les 2 lignes suivantes s’occupent de l’installation de maven.
Toutes les sources du projet sont ajoutées dans le répertoire ./project. On se positionne dans ce répertoire avant de lancer la compilation du projet.
Phase de lancement de démarrage de l’image
La dernière ligne, exécuté lors du lancement du container, s’appuie sur le plugin maven exec pour démarrer notre serveur.
On utilise un seul script pour construire et démarrer notre container.
docker build -t helloworld .
docker rm -f helloworld
docker run -d \
--name helloworld \
-p 8080:8080 \
helloworld
- La première ligne reconstruit une nouvelle image helloworld.
- La deuxième tue et supprime un éventuel container ayant le nom helloworld.
- Enfin, la dernière ligne démarre un container utilisant l’image qui vient d’être crée. Le container est également nommé helloworld.
L’avantage de cette stratégie est sa simplicité. Une seule image, un seul script: difficile de faire plus simple.
Son inconvénient majeur: sa lenteur. Lors de la phase de construction, maven télécharge toutes les dépendances nécessaires à la construction du projet (elles sont nombreuses). Lors de la phase de démarrage d’autres dépendances sont téléchargées.
Avec ma connexion internet, la construction dure environ 70 secondes et le démarrage 20 secondes. Ce temps est stable pour chaque redéploiement.
Pour mon architecture, c’est trop long. Je souhaite mettre à jour un composant le plus rapidement possible.
Voyons une autre stratégie.
Deux images dockers et deux containers
Cette stratégie est un petit peu plus complexe mais nettement plus performante.
L’image mvn est une image standard, non dépendante du code. Elle n’est quasiment jamais reconstruite.
FROM java:8
VOLUME /project
WORKDIR /project
RUN apt-get update
RUN apt-get install -y maven
ENTRYPOINT ["mvn"]
On précise le point d’entrée comme étant la commande mvn. On déclare aussi /project comme étant un volume. C’est important pour la suite.
La construction de cette image est très simple:
#!/bin/sh
docker build -t mvn .
On crée ensuite une image spécifique au projet hello-mvn et qui dérive de l’image mvn:
FROM mvn
ADD ./ /project
EXPOSE 8080
Par rapport à l’image mvn de base, on ajoute dans le volume /project toutes nos sources et on expose le port de notre serveur.
Grosse différence par rapport à la première stratégie: les sources ne sont pas compilées durant la construction de l’image.
La construction du l’application est réalisée par le container hello-mvn. Ce container execute la compilation et s’arrête. L’instance arrêtée dispose toujours de l’ensemble des sources et du résultat de sa compilation dans le volume /projet.
docker rm -f hello-mvn
docker run \
--name hello-mvn \
hello-mvn compile -DskipTests
Le container qui porte le serveur web démarre en reférençant le premier container et accède au volume /project :
docker rm -f helloworld
docker run -d \
--name helloworld \
--volumes-from hello-mvn \
-p 8080:8080 \
hello-mvn exec:java
Les goals maven sont désormais tous executés durant les phases de run des containers et non de build des images. On peut optimiser leur fonctionnement en partageant un volume portant le répertoire maven .m2. Chaque téléchargement sera stocké dans ce répertoire garantissant une execution de maven plus rapide.
On modifie les scripts de lancement des containers hello-mvn et helloworld pour bénéficier de ce volume.
Le script final pour redéployer l’application est le suivant:
#!/bin/bash
docker build -t hello-mvn .
docker rm -f hello-mvn
docker run \
--name hello-mvn \
-v ~/.m2/:/root/.m2 \
hello-mvn compile -DskipTests
docker rm -f helloworld
docker run -d \
--name helloworld \
--volumes-from hello-mvn \
-v ~/.m2/:/root/.m2 \
-p 8080:8080 \
hello-mvn exec:java
L’exécution complète du script passe à 9s en cohérence avec une exécution de la compilation et du démarrage dans un environnement de développement.
Et vous, quelles stratégies de construction et démarrage utilisez-vous pour vos projets maven?
Pour mes images docker, j’essaie de respecter quelques principes qui facilitent la vie pour le passage en prod :
– images les plus petites possibles (incorporer le moins de choses qui ne sert pas au runtime). Je ne vais pas jusquà essayer de tout reconstruire from scratch, j’utilise le coeur ubuntu14.04 (j’essaierai l’image officielle java, pas vue sortir celle-là).
– images les plus indépendantes possibles.
Ces 2 aspects me semblent violés par la proposition n°2 :
– l’image de runtime est dépendante de beaucoup de choses : l’image de build, le container de build, son volume.
– je n’ai pas compris pourquoi le volume .m2/ ne peut pas être directement récupéré de l’image de build, tant qu’à faire. Pourquoi passer par un volume sur l’hôte ? Peut-être pour pouvoir récupérer le .m2/ entre des suppressions de containeurs ? Je trouve qu’il est utile, lorsqu’on décide de supprimer un container, qu’on supprime également, tant qu’à faire, toute donnée qui peut être récupérée. Ca fait le ménage. Ca ralentira le premier build, mais basta. Un container peut très bien être démarré et arrêté sans avoir à être supprimé à chaque fois, surtout un container de build.
– le répertoire .m2/ contient trop de choses. En plus de contenir les dépendances nécessaires au runtime, il va également contenir toutes les dépendances nécessaires à la compilation (tous les plugins maven, par ex., junit éventuellement et d’autres choses).
Au final, on est dans un mode où la construction doit nécessairement se faire sur le serveur hôte docker. Il est impossible de déployer simplement l’application produite sur un autre hôte. Sur l’hôte il faut :
– déployer 1 image générique (normal)
– démarrer 2 containers dont 1 de build
– installer un script non standard pour enchaîner les démarrages / arrêts, et partager des choses entre l’environnement de build et l’environnement de runtime.
Je pense que le tout peut être simplifié, selon 2 axes opposés, mais qui chacun peuvent faire sens en fonction de la philosophie qu’on adopte :
a) tant qu’à mélanger environnement de compilation et de runtime, autant aller jusqu’au bout et n’avoir qu’un seul container.
ou
b) mieux séparer la phase de compilation / packaging, dont l’output sera la fabrication d’une image ; et la phase d’exécution, qui lance l’image auto-suffisante créée par la phase de compilation.
La phase b) nécessite d’ajouter un volume sur la socket docker pour pouvoir lancer docker sur l’hôte à partir du docker de build. Elle ne peut se faire qu’en environnement maîtrisé.
La phase a) assume bien le mélange compilation/run, et en cela est plus fidèle aux exemple que l’on voit dans le monde ruby/python.
Actuellement, au travail, nous faisons la philosophie de la phase b), mais l’étape de compilation n’est pas encore faite sous docker (c’est aussi l’avantage de séparer mieux les choses, il y a plus d’indépendance, et donc plus de choix).
En fait, la première version, en prenant juste une petite idée de la deuxième version, pourrait tourner en réglant le problème de la lenteur :
– en déclarant ~/.m2/ comme un volume
selon que tu veux que ~/.m2 soit perdu ou non si tu supprimes le container, alors soit tu le laisses en volume anonyme, soit tu lui fixes un emplacement sur ton docker hôte.
Idée encore meilleure (?) : ne pas déclarer de volume pour ~/.m2 dans le dockerfile, mais quand meme monter un volume sur ~/.m2 sur le poste de développement => c’est rapide en développement, mais lorsque tu veux créer l’image finale, tu ne mets pas le ~/.m2 en volume et zou, le .m2 se retrouvera dans l’image qui partira en prod.
Je suis absolument désolé de violer tes principes. Ce n’est pas dans mes habitudes ;-).
L’utilisation du volume ~/.m2 n’est pas obligatoire.
Elle est là pour profiter du cache offert par maven. Mon objectif est de déployer le plus rapidement possible.
Si je n’utilise pas le cache maven, je me retrouve à télécharger des dizaines de dépendances que j’avais déjà téléchargées la fois précédente : bof, bof,… Le repository maven local est justement là pour ça. Dans mon cas, j’ai partagé le répertoire maven de l’utilisateur ce qui permet aux différents container de bénéficier des téléchargements réalisé par les autres containers. Si tu veux restreindre le périmètre de partage, il est possible d’utiliser un répertoire maven pour le projet :
docker run -v $(pwd)/.m2/:/root/.m2 …
Dans les faits je n’arrête (presque) jamais un container pour le redémarrer ensuite. Si je touche un container c’est pour une nouvelle livraison qui implique donc une reconstruction de l’image. Pour le moment, je n’ai pas de registry docker interne. Mes scripts de déploiement utilisent git pour obtenir la dernière version du projet, créent l’image et démarrent le container.
C’est l’objectif de vitesse qui m’a poussé à utiliser exactement le même environnement de démarrage pour le dev et la prod. Je ne package pas l’application fluent-http sous la forme de jar. C’est pourquoi j’ai besoin de cette dépendance entre les deux containers. Supprimer cette dépendance impliquerait de passer l’artefact issu de la phase de construction au container en charge de l’exécution. C’est une autre forme de dépendance.
Pour terminer, l’image «maven» est pratique dans beaucoup de scénarios. Je la mets en oeuvre par exemple pour gérer le versioning de mes bases de données. Un mini dockerfile :
FROM mvn
ADD ./pom.xml /project/pom.xml
ADD ./src /project/src
avec un script pas beaucoup plus grand :
#!/bin/sh
docker build -t db-migration-mvn .
docker run \
–rm \
-v ~/.m2:/root/.m2 \
–link dbcrm:dbcrm \
db-migration-mvn \
compile flyway:migrate
et on a un processus propre et indépendant. Le projet principal n’a pas à importer les dépendances de flyway.
Ce qui ressort de tout ça est une grande variété dans les possibilités de mise en oeuvre de docker. Chacun peut définir les mécanismes les plus adaptés à son projet.