Docker : conteneuriser son application

Docker : conteneuriser son application

Après avoir découvert les bases de Docker dans le précédent article, il est temps de passer à la vitesse supérieure et de voir comment créer nos propres images, conteneuriser nos applications et publier nos images sur un registre.

Pour rappel, cette série se compose de trois articles :

La création d’images

Comme nous l’avons vu dans le premier article, une image contient tout ce qui est nécessaire au fonctionnement d’une application c’est-à-dire le code de celle-ci, les dépendances, la configuration, les variables d’environnement, etc. Les images sont créées à l’aide d’un fichier texte contenant toutes les instructions nécessaires à leurs créations, un peu comme une recette de cuisine. Ce fichier texte porte un nom, le Dockerfile.

Voici le format d’un Dockerfile :

Par convention les instructions sont en majuscules, bien que cela ne soit pas nécessaire. Un Dockerfile doit par contre commencer par une instruction FROM qui indique l’image de base servant à la création de notre image.

Prenons un exemple de Dockerfile permettant de créer l’image d’une application affichant « Hello World » sur la sortie standard :

Créons ensuite l’image à partir de ce Dockerfile via la commande suivante :

Puis lançons un conteneur basé sur cette image :

Vous devriez voir s’afficher « Hello World » à l’écran. Bravo vous venez de créer votre première image !

Le fichier Dockerfile permet la création d'une image à partir de laquelle nous pouvons créer un conteneur
Le fichier Dockerfile permet la création d’une image à partir de laquelle nous pouvons créer un conteneur

Si vous n’avez pas tout compris, ce n’est pas grave je vais tout vous expliquez dans la suite de cet article.

Tout savoir sur le Dockerfile

Bon notre exemple était vraiment simple, vous avez sûrement envie de voir comment conteneuriser votre application. Mais un peu de patience, nous allons tout d’abord nous intéresser au Dockerfile et découvrir les différentes instructions qui le composent.

FROM

Cette instruction permet d’utiliser une image servant de base à la création de notre nouvelle image.

Son utilisation est la suivante :

Elle permet également de créer une étape de construction et donner un nom à celle-ci via l’instruction AS, mais nous en reparlerons lorsque nous aborderons le multi-stage.

Exemple :

À noter qu’il est également possible de créer une image en ne se basant sur aucune autre :

ARG

Cette instruction permet de définir une variable que l’on peut passer au moment de la construction de l’image, via la commande docker image build, en utilisant l’option --build-arg <varname>=<value>.

Son utilisation est la suivante :

Il est également possible de définir une valeur par défaut.

Attention, les variables définies par ARG ne sont accessibles que lors de la construction de l’image.

Exemple :

ENV

Cette instruction permet de définir des variables accessibles lors de la construction de l’image et comme variables d’environnements dans un conteneur.

Son utilisation est la suivante :

Il est possible d’écraser les valeurs de ces variables via l’option --env <key>=<value> ou lors de l’utilisation d’un fichier .env via l’option --env-file=<path> passée à la commande docker run.

Exemple :

Différences entre ARG et ENV

Nous venons de le voir, les variables créées via ARG sont uniquement accessibles lors de la création de l’image contrairement à celles créées par ENV qui sont à la fois accessibles à la création de l’image, mais également lors de l’exécution du conteneur.

ENV fournit donc des valeurs par défaut pour nos futures variables d’environnement et ne devrait être utilisé que pour cela.

Différence entre ARG et ENV
Différence entre ARG et ENV

Il n’est pas possible de modifier la valeur d’une variable définie par ENV lors de la construction d’une image contrairement à une variable définie par ARG via l’option --build-arg. Néanmoins il est possible d’utiliser une variable définit par ARG comme valeur par défaut d’une variable définir par ENV :

COPY

Cette instruction comme son nom l’indique permet de copier des fichiers ou répertoires d’une source vers une destination.

Son utilisation est la suivante :

Par défaut c’est l’utilisateur root qui sera propriétaire des fichiers ou répertoire copiés, mais il est possible de modifier celui-ci via l’option --chown=<user>:<group>.

Il existe également l’option --from=<name> permettant de définir la source comme appartenant à une étape de construction précédente (créée via FROM <image> AS <name>), mais on en reparlera lorsque nous aborderons le multi-stage.

Exemple :

ADD

Cette instruction est similaire à COPY à la différence que la source peut être une URL ou une archive (tar) que la commande décompresse.

Son utilisation est la suivante :

Exemple :

RUN

Cette instruction permet d’exécuter une commande.

Son utilisation est la suivante :

Il existe deux formes pour cette commande :

  • La forme « shell » : La commande est exécutée dans un shell (/bin/sh -c par défaut sous Linux ou cmd /S /C sous Windows). Cela permet d’utiliser toutes les fonctionnalités du shell : pipe, redirection, chaînage de commandes, substitution de variables, etc.
  • La forme « exec » : Elle exécute simplement le binaire que vous fournissez avec les arguments que vous incluez, mais sans aucune fonctionnalité d’analyse du shell.

Exemple :

CMD

Cette instruction permet de définir une commande par défaut, qui sera exécutée lors du démarrage du conteneur si aucune commande n’est passée.

Son utilisation est la suivante :

Il existe trois formes pour cette commande :

  • Les formes « shell » et « exec » qui s’utilisent de la même façon que l’instruction RUN ;
  • La troisième forme permet quant à elle de passer des arguments par défaut à l’instruction ENTRYPOINT comme nous le verrons juste après.

Exemple :

ENTRYPOINT

Cette instruction est utilisée pour définir des exécutables qui s’exécuteront toujours lors du démarrage du conteneur. Contrairement à l’instruction CMD, l’instruction ENTRYPOINT ne peut pas être ignorée ou remplacée.

Son utilisation est la suivante :

Il existe deux formes pour cette commande, la forme « shell » et la forme « exec » qui s’utilisent de la même façon que pour les instructions RUN et CMD ;

Attention la forme « shell » créait un processus (PID 1) qui est le shell (/bin/bash sous Linux), votre application conteneurisée ne recevra donc pas les signaux comme c’est le cas lors de l’arrêt du conteneur via la commande docker container stop mais on en reparlera un peu plus tard dans la suite de cet article.

Exemple :

Utiliser CMD et ENTRYPOINT ensemble

Comme évoqué plutôt, l’instruction CMD permet de passer des arguments par défaut à l’instruction ENTRYPOINT. Il suffit d’utiliser l’instruction CMD après l’instruction ENTRYPOINT.

Prenons un exemple (que vous avez déjà vu au début de l’article) :

Créons donc l’image à partir de ce Dockerfile via la commande suivante :

Puis lançons un conteneur basé sur cette image sans ajouter d’arguments au conteneur :

Vous devriez voir s’afficher « Hello World » à l’écran. Ressayons cette fois-ci en passant une chaîne de caractères au conteneur :

Vous devriez cette fois-ci voir s’afficher « Hello code heroes ! » à l’écran. Le fait de passer des arguments lors de l’exécution du conteneur écrase bien les arguments par défaut présent dans l’instruction CMD.

WORKDIR

Cette instruction définit le répertoire de travail pour toutes les instructions RUN, CMD, ENTRYPOINT, COPY et ADD qui le suivent dans le Dockerfile. Si le répertoire n’existe pas, celui-ci sera créé.

Son utilisation est la suivante :

Exemple :

LABEL

Cette instruction permet d’ajouter des métadonnées à une image.

Son utilisation est la suivante :

Exemple :

Il est possible ensuite d’afficher les labels d’une image via la commande suivante :

Ainsi que de filtrer les images par label :

Il existe une spécification concernant les labels que vous pouvez retrouver ici.

EXPOSE

Cette instruction permet d’informer Docker que le conteneur écoute sur un port.

Son utilisation est la suivante :

Exemple :

Il y a souvent une confusion avec cette instruction. Celle-ci informe Docker que le conteneur écoute sur les ports réseau spécifiés lors de l’exécution, elle ne rend pas les ports du conteneur accessibles à l’hôte. Le fait de rendre les ports accessibles à l’hôte s’appelle la publication de ports et se réalise lors du lancement du conteneur via la commande suivante :

USER

Cette instruction définit l’utilisateur (UID) et éventuellement le groupe d’utilisateurs (GID) à utiliser lors de l’exécution de l’image, mais également pour toutes les instructions RUN, CMD et ENTRYPOINT qui la suivent dans le Dockerfile.

Son utilisation est la suivante :

Exemple :

VOLUME

Cette instruction permet de choisir un ou plusieurs répertoires côté conteneur qui seront montés comme volume côté hôte. Les volumes ont un nom qui est généré automatiquement (un long ID, que vous pouvez retrouver via la commande docker volume ls) et sont appelés volumes anonymes. Par défaut, ils se trouvent dans le répertoire /var/lib/docker/volumes sous Linux.

L’instruction s’utilise comme suit :

Exemple :

L’instruction sert généralement de métadonnées (que l’on peut retrouver via la commande docker inspect image <image_name>) pour savoir quels répertoires seront montés comme volumes. Il est possible de « binder » ces répertoires côté conteneurs avec des répertoires côté hôte comme nous avons pu le voir dans le premier article. Dans ce cas le volume anonyme n’est pas créé.

Il est souvent utile pour des raisons de performance d’avoir par défaut un volume de créé, puisque la couche en lecture/écriture contenue dans chaque conteneur est moins performante qu’un volume. C’est le cas de l’image MySQL qui créait un volume pour le répertoire /var/lib/mysql du conteneur :

 

Chaque conteneur dispose d'une couche en lecture/écriture
Chaque conteneur dispose d’une couche en lecture/écriture

 

ONBUILD

Cette instruction ajoute à l’image une instruction à exécuter ultérieurement, lorsque l’image sera utilisée comme base pour la construction d’une autre image. L’instruction sera exécutée dans le contexte de la construction en cours, comme si elle avait été insérée immédiatement après l’instruction FROM dans le Dockerfile.

Son utilisation est la suivante :

Exemple :

STOPSIGNAL

Cette instruction permet de définir le signal POSIX qui sera envoyé au conteneur pour le quitter, par défaut il s’agit de SIGTERM.

Son utilisation est la suivante :

Exemple :

HEALTHCHECK

Cette instruction permet d’indiquer à Docker comment tester le conteneur afin de vérifier s’il fonctionne toujours correctement.

Elle renvoie un code de retour :

  • 0 (success) : Le conteneur est fonctionnel ;
  • 1 (unhealthy) : Le conteneur est non fonctionnel.

Et ajoute une information concernant l’état du conteneur en plus de son statut. Cette information peut avoir trois valeurs possibles : 

  • Starting : Le conteneur est en cours de démarrage ;
  • Healthy : Le conteneur est fonctionnel ;
  • Unhealthy : Le conteneur est non fonctionnel.

Son utilisation est la suivante :

Voici les options :

  • --interval=DURATION : Le délai entre chaque vérification (par défaut  30 secondes)
  • --timeout=DURATION : Le délai maximum d’exécution de la vérification (par défaut 30 secondes)
  • --start-period=DURATION : Le délai avant de commencer la vérification (par défaut 0 seconde)
  • --retries=N : Le nombre d’essais consécutifs maximum avant de considérer le conteneur comme étant Unhealthy (par défaut 3)

Exemple :

Malheureusement, il n’y a pas de mécanisme pour redémarrer un conteneur Unhealthy (du moins pas pour un conteneur « standalone »), mais des projets existent en attendant que cela soit géré nativement.

SHELL

Cette instruction permet de changer le shell utilisé pour toutes les instructions utilisant le format shell (CMD, ENTRYPOINT et RUN).

Son utilisation est la suivante :

Exemple :

Rappel sur le concept de couche

Nous avons déjà parlé du concept de couche (layer) pour une image dans le premier article. Pour rappel, une couche correspond à une étape de création de l’image et contient un ensemble de fichiers créés lors de cette étape.

Chaque couche contient des fichiers
Chaque couche contient des fichiers

Ces étapes de création correspondent aux instructions que l’on vient de voir à l’instant. Mais toutes les instructions ne créaient pas de couche, seul quatre d’entre elles le font : FROM, COPY, RUN et CMD. Donc à chaque fois que l’une de ses instructions apparaît dans un Dockerfile, une couche est créée.

Docker utilise un système de fichier permettant de fusionner ces différentes couches et de présenter une vue unifiée, c’est ce qu’on appelle un « Union File System« .

Docker fusionne les différentes couches afin de présenter un système de fichier unifié
Docker fusionne les différentes couches afin de présenter un système de fichier unifié

La mise en cache

Mais la construction d’une image peut-être lente, si l’on doit à chaque fois reconstruire les différentes couches, c’est pourquoi Docker implémente la mise cache afin d’accélérer celle-ci. Si pour chaque instruction COPY, RUN et CMD (attention FROM n’utilise pas le cache) dans le Dockerfile, celle-ci ainsi que leurs fichiers associés n’ont pas changé depuis la dernière construction, alors Docker va utiliser la couche correspondante se trouvant dans le cache. Si une couche liée à une instruction est amenée à être reconstruite, toutes les couches suivantes le seront également. C’est pourquoi l’ordre des instructions est important comme nous le verrons par la suite.

Si une couche liée à une instruction est amenée à être reconstruite, toutes les couches suivantes le seront également.
Si une couche liée à une instruction est amenée à être reconstruite, toutes les couches suivantes le seront également.

 

Conteneuriser une application Node.js

Nous allons créer une application Node.js toute simple utilisant Fastify afin de nous concentrer sur la création du Dockerfile et découvrir au fur et à mesure quelques bonnes pratiques.

Commençons par installer Fastify :

Créons ensuite un fichier server.js :

Modifions le fichier package.json pour ajouter un script start :

Création du Dockerfile

Nous allons maintenant créer le fichier Dockerfile de notre application étape par étape.

L’image de base

Comme nous l’avons vu, la première instruction d’un Dockerfile est l’instruction FROM qui permet de choisir une image qui servira de base à la création de notre nouvelle image. Naïvement on pourrait donc écrire notre instruction comme ceci :

ou encore :

Le souci c’est que les tags latest ou lts ne sont pas déterministes, ceux-ci peuvent changer au cours du temps. Imaginons que nous utilisons le tag latest, celui-ci pointe, au moment de l’écriture de l’article, sur la version 17.7.1 de Node.js. La version 18 de Node.js est prévue pour le mois d’avril 2022, à ce moment-là, le tag latest pointera vers cette version. Donc si nous utilisons ce tag, il est probable que l’application ne soit plus compatible avec la version pointer par celui-ci.

Pour cela, il est nécessaire d’utiliser une version déterministe en utilisant le tag d’une version spécifique. On va donc partir sur la version 16.14.0 de Node.js basé sur l’image d’alpine :

Optimiser notre application pour la production

Certains frameworks et bibliothèques ont une configuration destinée à la production qui est activée uniquement si la variable d’environnement NODE_ENV est définie sur production. C’est notamment le cas d’Express.js, pour Fastify ce n’est pas le cas, mais cela ne coûte rien de modifier cette variable d’environnement dans notre fichier Dockerfile. Pour cela on utilise l’instruction ENV :

Création du répertoire de l’application

Ensuite, nous allons créer le répertoire de travail de notre application au sein de l’image via la commande WORKDIR :

Par défaut, Docker va utiliser l’utilisateur root pour créer ce répertoire donc celui-ci en sera le propriétaire. Si jamais votre application a besoin de créer des fichiers et donc d’avoir un droit en écriture dans ce répertoire, plutôt que de changer les permissions (par défaut 755) vous pouvez changer le propriétaire.

Soit via la commande Linux chown :

Cela aura pour effet de créer une nouvelle couche dans notre image finale.

Soit via l’instruction USER permettant de changer l’utilisateur avant l’instruction WORKDIR :

Contrairement à la solution précédente, ici aucune nouvelle couche n’est créée. Néanmoins comme l’instruction USERdéfinit l’utilisateur pour toutes les instructions RUN, CMD et ENTRYPOINT qui la suivent dans le Dockerfile si une instruction nécessite l’utilisateur root vous devrez faire appel de nouveau à l’instruction USER pour utiliser celui-ci.

Installation d’outils ou programmes

La prochaine étape consiste à installer des outils ou programmes externes. Mais installez uniquement ce dont vous avez besoin pour ne pas alourdir inutilement l’image. Pour notre exemple, nous allons installer un petit programme qui s’appelle tini, je ne vous en dis pas plus on en reparle après pour voir à quoi celui-ci va nous servir.

Copie des fichiers package.json et package-lock.json

Nous copions ensuite les fichiers package.json et package-lock.json dans le répertoire courant de l’image (/usr/src/app) :

Pourquoi copier ses deux fichiers plutôt que l’intégralité des fichiers et répertoire de notre application ? C’est pour une histoire de cache ! Vous vous souvenez si une instruction dans le DockerFile ainsi que les fichiers associés n’ont pas changé depuis la dernière construction, Docker va utiliser la couche correspondante se trouvant dans le cache et dans le cas contraire, cette couche et celles qui le succèdent devront être reconstruites.

Le code source d’un projet est amené à être modifié beaucoup plus régulièrement que ses dépendances. Si l’on copiait les fichiers package.json et package-lock.json avec les autres fichiers de l’application, à chaque changement du code source nous devrions invalider le cache des couches suivantes qui comprennent notamment la partie installation des dépendances. Nous devrions donc récréer la couche correspondant à l’installation des dépendances même si aucune d’entre elles n’a été modifiée ou ajoutée.

Installation des dépendances

Nous installons ensuite les dépendances :

La commande npm ci (pour clean install) permet d’avoir une installation déterministe contrairement à la commande npm install en se basant sur le fichier package-lock.json. Je vous invite à aller lire la documentation de cette commande.

Vu que Docker utilise déjà un système de cache, nous supprimons celui de npm qui est inutile afin de réduire la taille de l’image.

Nous combinons les deux commandes (via &&) afin de ne produire qu’une seule couche et donc réduire la taille de l’image.

Copier le code source

Ensuite nous copions le reste du code source :

Par défaut c’est l’utilisateur root qui sera le propriétaire des fichiers et répertoires copiés, vous pouvez si besoin changer ce comportement via l’option --chown :

Afin d’ignorer certains fichiers et dossiers lors de la copie, une bonne pratique est d’utiliser un fichier .dockerignore :

Changer d’utilisateur

Afin d’appliquer le principe de moindre privilège et ainsi renforcer la sécurité de notre conteneur, nous devons exécuter celui-ci avec un utilisateur non-root à l’aide de l’instruction USER. Par défaut, les images Node.js proposent un utilisateur nommé node :

Exposer les ports

Nous informons ensuite Docker que notre application écoute sur le port 3000 :

Démarrer l’application

Enfin nous définissons l’exécutable de notre conteneur :

Dans notre cas nous devons exécuter notre script server.js, néanmoins nous passons par l’utilisation du programme tini. L’exécutable passé dans l’instruction ENTRYPOINT aura comme identifiant de processus (PID) 1 qui correspond au processus init qui est entre autre responsable du démarrage et de l’arrêt du système, mais également de gérer les processus zombies et de propager les signaux aux processus enfants. Le programme node n’est pas forcément adapté pour tenir ce rôle c’est pourquoi nous utilisons tini.

Notez que depuis la version 1.13 de Docker, tini est livré avec celui-ci et peut être utilisé en ajoutant l’option --init lors de l’utilisation de la commande docker run.

Création de l’image via le Dockerfile

Notre fichier Dockerfile ressemble donc à ceci :

Pour créer l’image à l’aide de celui-ci, nous utilisons la commande suivante :

L’image fastify_example est ainsi créée :

Création d’un conteneur

Nous pouvons pour terminer, créer un conteneur basé sur cette image nouvellement créée :

Effectuons ensuite une requête GET sur l’URL suivante http://localhost:3000, la réponse suivante s’affiche :

Nous venons de conteneuriser notre première application !

Utilisation du multi-stage

La façon dont nous écrivons nos Dockerfiles a un impact sur la taille de nos images, une bonne pratique est d’avoir les images les plus petites possible. Comme chaque instruction COPY, RUN et CMD créaient une nouvelle couche augmentant la taille de nos images, il faut veiller à limiter leurs utilisations, mais également les utiliser intelligemment pour profiter du cache de Docker.

Parfois nous devons installer des outils pour construire, compiler ou tester notre application malheureusement ceux-ci vont faire partie de l’image finale et donc augmenter sa taille. Ces outils ne sont généralement d’aucune utilité pour l’exécution de notre application, nous pourrions bien entendu supprimer ceux-ci via une instruction RUN, mais cela rajouterait une couche et complexifierait notre image.

C’est là que le multi-stage entre en jeu. Le multi-stage permet d’avoir un seul fichier Dockerfile contenant plusieurs instructions FROM. Chaque instruction FROM est une étape de construction pouvant facilement copier via l’instruction COPY des données des étapes précédentes ou bien servir de base aux étapes suivantes.

L’une des utilisations les plus fréquentes du multi-stage est l’application du pattern builder.

Pour illustrer son utilisation, nous allons quitter Node.js et créer une application React :

Notre application React n’a pas réellement besoin de Node.js, celui-ci est seulement utile pour la construction de celle-ci (via la commande npm run build). Notre image finale ne devrait donc contenir que le résultat de la construction de notre application ainsi qu’un serveur web comme nginx par exemple.

Nous allons donc créer un Dockerfile utilisant le multi-stage comme suit :

Nous créons donc une première étape qui va servir à construire notre application. Nous donnons un nom celle-ci via le mot-clé AS dans notre instruction FROM :

Nous utilisons ensuite les données créées dans cette première étape dans notre seconde étape via la commande COPY et de l’option --from permettant d’indiquer la source de la copie :

Rien de plus simple ! Créons maintenant notre image :

Ainsi qu’un conteneur basé sur celle-ci :

Rendons-nous sur l’URL suivante http://localhost:8080 pour s’assurer que tout fonctionne. La page suivante devrait s’afficher :

 

Notre application React conteneurisée via le multi-stage
Notre application React conteneurisée via le multi-stage

Note : N’essayez pas de modifier le fichier src/App.js comme indiqué cela ne fonctionnera pas puisque notre conteneur contient les données de la construction, nous ne pouvons donc pas directement modifier ceux-ci.

Publier son image

Avant de terminer cet article, nous allons voir comment publier l’image de notre application Fastify sur Docker Hub.

Dans un premier temps, nous devons créer un compte sur Docker Hub via le lien suivant.

La page d'inscription sur Docker Hub est très simple
La page d’inscription sur Docker Hub est très simple

Une fois le compte crée, nous allons nous connecter au registre sur notre machine via la commande suivante :

Ensuite nous allons créer un tag pour notre image :

L’image doit être nommée comme suit <username>/<image_name>:<tag_name> afin de la publier sur notre registre personnel de Docker Hub. Utilisez bien entendu votre nom d’utilisateur.

Enfin publions notre image sur le registre :

Et… c’est tout ! Notre image est maintenant publiée sur Docker Hub.

Notre image est bien publiée sur Docker Hub
Notre image est bien publiée sur Docker Hub

Pour finir…

Nous venons de voir comment conteneuriser une application et découvert quelques bonnes pratiques. Je vous invite grandement à faire vos propres tests et conteneuriser vos propres applications pour mieux comprendre comment tout cela fonctionne. Dans le prochain article nous nous attaquerons à docker-compose qui est notamment utilisé pour mettre en place de façon très simple un environnement de développement, mais également déployer nos applications.


Annonces partenaire

Je suis lead developer dans une boîte spécialisée dans l'univers du streaming/gaming, et en parallèle, je m'éclate en tant que freelance. Passionné par l'écosystème JavaScript, je suis un inconditionnel de Node.js depuis 2011. J'adore échanger sur les nouvelles tendances et partager mon expérience avec les autres développeurs. Si vous avez envie de papoter, n'hésitez pas à me retrouver sur Twitter, m'envoyer un petit email ou même laisser un commentaire.

3 commentaires

  1. Je suis en train de rassembler des connaissances pour me monter une stack de développement cohérente, et tes articles m’aident beaucoup ! J’ai hâte de lire la suite sur docker-compose. Bonne continuation à toi !

Laisser un commentaire

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

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