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 :
- Découverte des bases de Docker ;
- Conteneuriser son application Node.js ;
- Déploiement avec Docker compose
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
:
1 2 3 | FROM parent_image # Commentaire INSTRUCTION arguments |
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 :
1 2 3 | FROM busybox ENTRYPOINT ["echo"] CMD ["hello world"] |
Créons ensuite l’image à partir de ce Dockerfile
via la commande suivante :
1 | docker image build -t my-hello-world . |
Puis lançons un conteneur basé sur cette image :
1 | docker container run --rm my-hello-world |
Vous devriez voir s’afficher “Hello World
” à l’écran. Bravo vous venez de créer votre première image !
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 :
1 | FROM <image>[:<tag | @Digest>] [AS <name>] |
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 :
1 | FROM node:17.4.0-alpine3.14 |
À noter qu’il est également possible de créer une image en ne se basant sur aucune autre :
1 | FROM scratch |
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 :
1 | ARG <name>[=<default value>] |
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 :
1 2 3 | ARG IMAGE_VERSION # Variable avec une valeur par défaut ARG NODE_ENV=development |
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 :
1 | ENV <key1>=<value1> <key2>=<value2> ... |
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 :
1 2 | ENV LOGGING=true ENV APP_NAME=my_app APP_DESCRIPTION=my_description |
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.
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
:
1 2 | ARG NODE_ENV=development ENV NODE_ENV=$NODE_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 :
1 2 | COPY [--chown=<user>:<group>] <src>... <dest> COPY [--chown=<user>:<group>] ["<src>",... "<dest>"] |
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 :
1 2 | COPY --chown=node:node package*.json ./ COPY . . |
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 :
1 2 | ADD [--chown=<user>:<group>] <src>... <dest> ADD [--chown=<user>:<group>] ["<src>",... "<dest>"] |
Exemple :
1 2 3 4 5 | # myApp.tar.gz sera décompressé dans /app/ ADD myApp.tar.gz /app/ # Le fichier sera téléchargé et décompressé dans /tmp/ ADD http://example.com/data.tar.gz /tmp/ |
RUN
Cette instruction permet d’exécuter une commande.
Son utilisation est la suivante :
1 2 3 4 5 | # Forme shell RUN <command> # Forme exec RUN ["executable", "param1", "param2", "..."] |
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 oucmd /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 :
1 2 3 | RUN apt-get update && apt-get install git RUN npm ci RUN ["npm", "run", "build"]<code> |
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 :
1 2 3 4 5 6 7 8 | # format shell CMD command param1 param2 # format exec CMD ["executable","param1","param2"] # Ce format sert pour passer des arguments par défaut à l'instruction ENTRYPOINT CMD ["param1","param2"] |
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 :
1 2 3 4 5 | # format shell CMD echo "Hello, World" # format exec CMD ["echo", "Hello, World"] |
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 :
1 2 3 4 5 | # forme shell ENTRYPOINT command param1 param2 # forme exec ENTRYPOINT ["executable", "param1", "param2"] |
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 :
1 | ENTRYPOINT ["npm", "start"] |
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) :
1 2 3 | FROM busybox ENTRYPOINT ["echo"] CMD ["hello world"] |
Créons donc l’image à partir de ce Dockerfile
via la commande suivante :
1 | docker image build -t my-hello-world . |
Puis lançons un conteneur basé sur cette image sans ajouter d’arguments au conteneur :
1 | docker container run --rm my-hello-world |
Vous devriez voir s’afficher “Hello World
” à l’écran. Ressayons cette fois-ci en passant une chaîne de caractères au conteneur :
1 | docker container run --rm my-hello-world Hello code heroes ! |
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 :
1 | WORKDIR /path/to/workdir |
Exemple :
1 2 3 4 5 | # On se déplace dans le répertoire /opt WORKDIR /opt # Créer le dossier my-app dans /opt et se déplace dans celui-ci WORKDIR my-app |
LABEL
Cette instruction permet d’ajouter des métadonnées à une image.
Son utilisation est la suivante :
1 | LABEL <key1>=<value1> <key2>=<value2> <key2>=<value2> ... |
Exemple :
1 2 3 4 5 6 | # Ajoute la référence du commit à partir duquel l'image est créée ARG vcs-ref=unknown LABEL vcs-ref=$vcs-ref # Ajoute l'auteur de l'image LABEL maintainer="John Doe <john@doe.com>" |
Il est possible ensuite d’afficher les labels d’une image via la commande suivante :
1 | docker inspect --format='{{json .Config.Labels}}' my-image |
Ainsi que de filtrer les images par label :
1 | docker images --filter "label=maintainer=John Doe <john@doe.com>" |
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 :
1 | EXPOSE <port> [<port>/<protocol>...] |
Exemple :
1 2 3 4 5 6 | # Si aucun protocole n'est spécifié c'est le protocole TCP qui est utilisé EXPOSE 8080 #, Mais il est possible de spécifier le protocole (TCP ou UDP) EXPOSE 80/tcp EXPOSE 80/udp |
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 :
1 2 3 4 5 | # Vous devez spécifier le port côté hôte et le port exposé du conteneur docker run -p <HOST_PORT>:<CONTAINER:PORT> IMAGE_NAME # Ou publier l'ensemble des ports exposés vers des ports aléatoires côté hôte docker run -P IMAGE_NAME |
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 :
1 2 | USER <user>[:<group>] USER <UID>[:<GID>] |
Exemple :
1 2 3 4 5 6 7 8 9 10 11 12 | # Pensez à vérifier la documentation, la plupart des images ont déjà un utilisateur de créé c'est le cas des images Node.js USER node # Ou vous pouvez créer un nouvel utilisateur ARG USERNAME=newuser ARG USER_UID=1000 ARG USER_GID=$USER_UID RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME USER $USERNAME |
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 :
1 2 | VOLUME ["<path>", ...] VOLUME <path> [<path> ... |
Exemple :
1 2 | VOLUME ["/data"] VOLUME /var/log /var/db /moreData |
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 :
1 | VOLUME [/var/lib/mysql] |
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 :
1 | ONBUILD <INSTRUCTION> |
Exemple :
1 | ONBUILD ADD . /usr/src/app |
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 :
1 | STOPSIGNAL signal |
Exemple :
1 2 3 | STOPSIGNAL SIGKILL # On peut également utiliser la valeur numérique du signal STOPSIGNAL 9 |
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 :
1 2 3 | HEALTHCHECK [OPTIONS] CMD command # Désactiver les vérifications contenues dans l'image de base HEALTHCHECK NONE |
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 étantUnhealthy
(par défaut 3)
Exemple :
1 2 3 | # Un appel HTTP est effectué sur la route /api/healthcheck HEALTHCHECK --interval=5m --timeout=3s \ CMD curl -f http://localhost:9000/api/healthcheck || exit 1 |
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 :
1 | SHELL ["executable", "parameters"] |
Exemple :
1 2 3 4 | FROM ubuntu RUN echo "J'utilise /bin/sh par défaut" SHELL ["/bin/bash", "-c"] RUN echo "J'utilise maintenant /bin/bash par défaut" |
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.
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“.
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.
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 :
1 | npm install fastify |
Créons ensuite un fichier server.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | const Fastify = require('fastify') const fastify = Fastify({ logger: true }) fastify.get('/', (request, reply) => { reply.send({ hello: 'world' }) }) fastify.listen(3000, '0.0.0.0', (err) => { if (err) { fastify.log.error(err) process.exit(1) } }) |
Modifions le fichier package.json
pour ajouter un script start
:
1 2 3 4 5 | { "scripts": { "start": "node server.js" } } |
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 :
1 | FROM node:latest |
ou encore :
1 | FROM node:lts |
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 :
1 | FROM node:16.14.0-alpine3.14 |
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
:
1 | ENV NODE_ENV production |
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
:
1 | WORKDIR /usr/src/app |
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
:
1 2 | WORKDIR /usr/src/app RUN chown node:node ./ |
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
:
1 2 | USER node:node WORKDIR /usr/src/app |
Contrairement à la solution précédente, ici aucune nouvelle couche n’est créée. Néanmoins comme l’instruction USER
dé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.
1 | RUN apk add --no-cache tini |
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
) :
1 | COPY package*.json ./ |
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 :
1 | RUN npm ci --only=production && npm cache clean --force |
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 :
1 | COPY . . |
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
:
1 | COPY --chown=node:node . . |
Afin d’ignorer certains fichiers et dossiers lors de la copie, une bonne pratique est d’utiliser un fichier .dockerignore
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # Logs logs/*.log *.log npm-debug.log* # Le dossier des dépendances node_modules/ # Fichier contenant les variables d'environnements .env # La configuration d'IntelliJ .idea/ # La configuration de VScode .vscode/* # Git ignore .gitignore |
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
:
1 | USER node |
Exposer les ports
Nous informons ensuite Docker que notre application écoute sur le port 3000 :
1 | EXPOSE 3000 |
Démarrer l’application
Enfin nous définissons l’exécutable de notre conteneur :
1 | ENTRYPOINT ["/sbin/tini", "--" , "node", "server.js"] |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | FROM node:16.14.0-alpine3.14 ENV NODE_ENV production WORKDIR /usr/src/app RUN apk add --no-cache tini COPY package*.json ./ RUN npm ci --only=production && npm cache clean --force COPY . . USER node EXPOSE 3000 ENTRYPOINT ["/sbin/tini", "--" , "node", "server.js"] |
Pour créer l’image à l’aide de celui-ci, nous utilisons la commande suivante :
1 | docker image build -t fastify_example . |
L’image fastify_example
est ainsi créée :
1 2 3 | docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE fastify_example latest 6cb826b00143 22 hours ago 117MB <br> |
Création d’un conteneur
Nous pouvons pour terminer, créer un conteneur basé sur cette image nouvellement créée :
1 | docker container run --rm -p 3000:3000 fastify_example |
Effectuons ensuite une requête GET sur l’URL suivante http://localhost:3000, la réponse suivante s’affiche :
1 | {"hello":"world"} |
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 :
1 | npx create-react-app react_docker |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | # Stage 1 : Build FROM node:16.14.0-alpine3.14 AS builder WORKDIR /usr/src/app COPY package*.json ./ RUN npm ci && npm cache clean --force COPY . . # On lance la commande de build RUN npm run build # Stage 2 : Notre image finale utilisant le contenu du build du stage 1 FROM nginx:1.21.6-alpine WORKDIR /usr/share/nginx/html # On supprime les données statiques de nginx RUN rm -rf ./* # On copie l'application React construite dans le stage 1 dans le répertoire de nginx COPY --from=builder /usr/src/app/build . # On lance nginx ENTRYPOINT ["nginx", "-g", "daemon off;"] |
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
:
1 | FROM node:16.14.0-alpine3.14 as builder |
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 :
1 | COPY --from=builder /usr/src/app/build . |
Rien de plus simple ! Créons maintenant notre image :
1 | docker image build -t react_multistage . |
Ainsi qu’un conteneur basé sur celle-ci :
1 | docker container run --rm -p 8080:80 react_multistage |
Rendons-nous sur l’URL suivante http://localhost:8080 pour s’assurer que tout fonctionne. La page suivante devrait s’afficher :
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.
Une fois le compte crée, nous allons nous connecter au registre sur notre machine via la commande suivante :
1 | docker login |
Ensuite nous allons créer un tag pour notre image :
1 | docker tag fastify_example arkerone/fastify_example:1.0.0 |
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 :
1 | docker push arkerone/fastify_example:1.0.0 |
Et… c’est tout ! Notre image est maintenant 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.
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.
Très bel article; fort complet et très accessible. Bravo et encore ! 😉
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 !
Très sympa et très complet l’article 🙂
Au début Docker c’est un peu galère à prendre en main, mais une fois cette étape passée, plus possible de s’en passer !