Construire une image Docker avec un Dockerfile

Mon infrastructure reposant sur les conteneurs, je crée et maintiens régulièrement des images Docker pour mes projets. Nous allons voir comment faire cela du mieux possible.

Docker, conteneurs et images, c'est quoi déjà ?

Pour faire simple, Docker est un outil qui permet de gérer et d'exécuter des applications grâce à des conteneurs.

Les conteneurs, ce sont des boîtes virtuelles qui contiennent une application avec toutes ses dépendances, tout en l'isolant et en contrôlant ses interactions avec les autres conteneurs et le système d'exploitation (OS) de la machine hôte.

Enfin les images Docker contiennent les instructions et les fichiers qui permettent la création de conteneurs d'un type spécifique.

La conteneurisation offre plusieurs avantages :

  • Uniformisation des environnements : les différents environnements (développement, test, production...) peuvent utiliser les mêmes images (ou très similaires) permettant une cohérence ;

  • Amélioration de la scalabilité : la possibilité d’exécuter en parallèle la même application sur une machine en la lançant dans des conteneurs différents permet par exemple une meilleure gestion des montées en charge ;

  • Sécurité renforcée : l'isolation des conteneurs et la gestion précise de leurs flux peuvent limiter certaines attaques et vulnérabilités.

La structure du fichier

Un fichier Dockerfile se présente sous la forme d'un fichier texte contenant une suite d'instructions et de paramètres. Voici un exemple :

Fichier : Dockerfile (dockerfile)
1# Exemple de fichier Dockerfile
2ARG nodeversion=23-alpine # Création d'un argument global
3
4# Définition d'une nouvelle image avec l'image de base spécifiée
5FROM node:${nodeversion}
6# Création d'une variable d'environnement
7ENV APP_NAME=App3000
8# Copie de fichiers de l'hôte vers l'image
9COPY package*.json ./
10# Exécution d'une commande dans l'image
11RUN npm ci --only=production
12# Utilisation de l'utilisateur node pour les prochaines instructions
13USER node
14# Copie du reste des fichiers dans l'image avec le propriétaire `node`
15COPY --chown=node . .
16# Indique que le port 3000 est exposé par l'application
17EXPOSE 3000
18# Spécifie le fichier à exécuter au lancement
19CMD ["node", "server.js"]

Les principales instructions

CommandeDescription
FROM
  • Définit l'image de base à utiliser pour construire notre image.
  • Il s'agit de la seule instruction obligatoire d'un Dockerfile et est généralement la première (hors ARG).
  • Si plusieurs FROM sont déclarés, les instructions précédentes (hors ARG globaux) n'ont plus d'effet.
  • Il est possible de donner un nom pour identifier le FROM en utilisant FROM <image> as <fromName>.
  • Des images de base peuvent être trouvées sur le Docker Hub
ARG
  • Définit une variable (et sa valeur par défaut) utilisable pendant la construction de l'image.
  • Il s'agit de la seule instruction utilisable devant un FROM pour définir des arguments globaux (pour toutes les images du fichier). Si ARG est déclaré après un FROM, l'argument sera spécifique à cette image.
  • La valeur des arguments peut être modifiée au moment de la construction de l'image afin de la faire varier (inclure un utilitaire ou des fichiers en développement mais pas en production par exemple...).
ENV
  • Définit une variable d'environnement (et sa valeur par défaut) utilisable pendant le build et dans le conteneur par la suite.
  • Si la variable ne sert que le temps de la construction de l'image et n'est pas utile aux containers, il est préférable d'utiliser ARG.
  • La valeur des variables d'environnement peut être modifiée au moment d’exécuter les containers.
RUN
  • Exécute une commande pendant la construction de l'image.
  • Afin d'optimiser le cache, l'ordre des instructions a son importance.
COPY
  • Copie des fichiers et dossiers de l'hôte (ou d'une image intermédiaire) vers l'image en cours.
  • COPY accepte différents paramètres tels que --chown=<group>:<user> ou encore -from=<fromName>.
  • Les éléments copiés peuvent être restreints par le fichier .dockerignore.
ADD
  • Copie également du contenu dans l'image mais supporte les URLs, les répertoires GIT ou encore les archives compressées.
  • COPY doit être privilégié lorsque les fonctionnalités spécifiques de ADD ne sont pas utilisées.
  • ADD est par exemple plus intéressant pour ajouter un dépôt GIT à votre image car, il va bénéficier du cache Docker (pour les prochaines constructions), ne nécessitera pas d'avoir Git installé dans l'image et ne copiera pas le dossier .git par défaut.
EXPOSE
  • Indique un port utilisé par le conteneur.
  • Cette fonction est seulement indicative et le port ne sera pas publié (utilisable) pour autant.
WORKDIR
  • Définit le répertoire de travail courant.
  • Vous pouvez changer plusieurs fois le répertoire par image.
  • S'il n'est pas défini, le répertoire sera hérité de l'image de base.
  • Les images qui seront issues de la vôtre hériteront donc de cette valeur (la dernière déclarée).
ENTRYPOINT & CMD
  • Définissent la commande lancée à l’exécution du conteneur.
  • La commande est le résultat de la concaténation des deux (ENTRYPOINT + CMD).
  • Les deux instructions sont normalement définies comme des listes.
  • Si elles ne sont pas redéfinies, nous héritons des paramètres de l'image de base.
  • Les instructions sont modifiables lors de l’exécution du conteneur avec docker run --entrypoint <ENTRYPOINT> <service> <CMD>.
USER
  • Définit l'utilisateur et éventuellement le groupe pour les prochaines instructions RUN, ENTRYPOINT et CMD).
  • Si l'utilisateur n'existe pas, il faudra le créer avec un RUN en fonction de votre image.
  • Si la valeur n'est pas définie, nous héritons de celle de l'image de base (normalement root).
  • Utiliser un utilisateur sans droit d'administration est une bonne pratique.

La liste complète des instructions est disponible sur la documentation dédiée au Dockerfile.

Fichier .dockerignore

Un fichier .dockerignore peut être créé afin d'empêcher la copie de certains éléments dans l'image finale.

Fichier : .dockerignore (dockerignore)
1# Ignore les fichiers et dossiers suivants
2.dockerignore
3.gitignore
4.git
5
6*.env # Empêche la copie des fichiers .env
7?.txt # Empêche la copie des fichiers a.txt, b.txt... mais pas aa.txt
8!.git/keep # Conserve le fichier keep

Le fichier .dockerignore n'agit que sur les commandes COPY et ADD du répertoire local vers l'image. La copie de fichiers d'une image à une autre ne sera pas impactée.

Variante des images de base (clause FROM)

Un bon choix de la variante de l'image de base peut améliorer le poids, les performances et réduire les vulnérabilités de votre projet.

Les informations sont généralement disponibles sur la page dédiée à l'image lorsque vous utilisez une image populaire. Voici néanmoins trois variantes populaires utilisées par Node et Nginx :

XXX:<version> : Image par défaut. Basée sur Debian et intégrant les paquets courants de la distribution, elle répond donc à la plupart des cas d'utilisation. Cela simplifie les évolutions des projets car il n'y aura pas forcément besoin de modifier le Dockerfile régulièrement lors de l'ajout d'une fonctionnalité par exemple.

XXX:<version>-alpine : Basée sur Alpine Linux, elle vise à créer des images légères. Elle n'embarque pas les paquets courants (comme git ou bash) et peut donc nécessiter des installations de bibliothèques logicielles ou des adaptations.

XXX:<version>-slim : Basée sur Debian, elle vise également à réduire la taille de l'image finale. Elle n'embarque pas les paquets courants et peut donc également nécessiter des adaptations.

Voici un comparatif des images obtenues pour un petit projet Node sans avoir cherché à adapter mon projet :

VariantePoidsNote
node:20897,64 MioPrésence d'utilitaires dont je n'ai pas besoin.
node:20-alpine563,39 Mio (-37%)La bibliothèque npm bcrypt plantait.
node:20-slim586,43 Mio (-34,5%)-

D'ailleurs, si vous voulez savoir s'il vaut mieux utiliser les empreintes (hash) ou les tags des images, j'en ai fait un article.

Création d'images intermédiaires

Des images intermédiaires peuvent être créées en plaçant plusieurs instructions FROM dans le fichier. Elles peuvent permettre d'optimiser l'utilisation du cache Docker, de réaliser une image finale optimisée ou encore d'empêcher des informations sensibles (mots de passe, secrets...) d'être accessibles sur l'image finale.

Voici un exemple permettant de réduire la taille de l'image finale :

Fichier : Dockerfile (dockerfile)
1# Variable globale disponible pendant le build
2ARG nodeversion=23
3
4# Étape 1 : Construction du projet
5FROM node:${nodeversion} as builder
6# Définition du répertoire de travail
7WORKDIR /usr/src/app
8# Copie des fichiers de dépendances avant l'installation
9COPY package*.json ./
10# Installation des dépendances en mode production
11RUN npm ci --only=production
12# Copie du code source après l'installation des dépendances
13COPY . .
14# Construction du projet
15RUN npm run build --progress --display-error-details
16
17# Étape 2 : Création de l'image finale plus légère
18FROM node:${nodeversion}
19# Définition du répertoire de travail
20WORKDIR /usr/src/app
21# Copie uniquement les fichiers nécessaires depuis l'étape précédente
22COPY --from=builder /usr/src/app .
23# Port utilisé par l'application
24EXPOSE 3000
25# Commande de démarrage
26CMD ["node", "server.js"]
TypePoids
Sans image intermédiaire897,64 Mio
Avec image intermédiaire730,36 Mio (-18,6%)

Utiliser le cache Docker

La construction des images docker fonctionne grâce à un système de couches (layers). Lorsqu'une image est reconstruite, Docker peut gagner du temps et passer certaines étapes en repartant d'une couche antérieure des images précédentes

Par exemple :

Fichier : Dockerfile (dockerfile)
1FROM node
2COPY package*.json ./
3RUN npm ci
4COPY . .

pourrait être simplifié en :

Fichier : Dockerfile (dockerfile)
1FROM node
2COPY . .
3RUN npm ci

mais cela ferait perdre les bénéfices du cache Docker.

En effet, dans le premier, si je reconstruis mon image et que les fichiers package*.json n'ont pas changé, Docker reprendra la construction directement à la ligne 4 sans avoir besoin d'exécuter de nouveau la commande npm ci. Dans le deuxième cas, même si j'ajoute un espace dans un fichier quelconque, Docker devra tout réexécuter.

Il en est de même avec l'instruction ADD. Si vous ajoutez par exemple un répertoire Git avant de lancer un calcul dessus. Si le dépôt Git n'a pas évolué, Docker pourrait passer le calcul.

L'ordre des différentes instructions a donc son importance dans le fichier final. Si toutefois, vous aviez plusieurs étapes consommatrices en ressources et qui pourraient être mise en cache, il est alors possible d'effectuer plusieurs images intermédiaires pour copier les résultats dans votre image finale.

Génération de l'image

En ligne de commande

L'image peut être construite avec la commande suivante :

(bash)
1docker build -t <AppName> .
2
3# Pour spécifier le tag (latest par défaut), il faut écrire <AppName>:<Tag>
4docker build -t <AppName>:<Tag> .
5
6# Pour réécrire des arguments, il faut utiliser `--build-arg` :
7docker build --build-arg MON_ARG=VALUE -t <AppName> .

Depuis un docker-compose

L'image peut aussi être créée directement depuis un fichier docker-compose :

Fichier : docker-compose.yml (yaml)
1services:
2  mon-service:
3    build:
4      context: . # Dossier contenant le Dockerfile
5      dockerfile: Dockerfile  # Nom du fichier (optionnel si le fichier s'appelle "Dockerfile")
6      args:
7        NODE_VERSION: "18"
8    environment:
9      NODE_ENV: "development"
10    image: mon-application:latest  # Nom de l'image générée
11    ports:
12      - "3000:3000"

Il faudra alors lancer la commande suivante :

(bash)
1docker-compose up --build

Documentation

En suivant ce lien, vous trouverez la documentation officielle sur le Dockerfile.


Valentin LORTET

Cet article vous a plu ? N'hésitez pas à le partager :

Découvrir d'autres articles