Maîtriser Docker et Kubernetes : Déploiement et Scalabilité d'Applications Modernes
Maîtriser Docker et Kubernetes : Déploiement et Scalabilité d'Applications Modernes

Dockerfile et construction d'images

Bienvenue à cette leçon cruciale de notre cours "Maîtriser Docker et Kubernetes : Déploiement et Scalabilité d'Applications Modernes". Aujourd'hui, nous allons plonger au cœur de la création de conteneurs : le Dockerfile et le processus de construction d'images Docker. Les images sont les briques fondamentales de Docker, des modèles immuables qui encapsulent une application et toutes ses dépendances. Comprendre comment les construire efficacement est essentiel pour garantir la reproductibilité, la portabilité et la performance de vos applications conteneurisées.

1. Introduction : Qu'est-ce qu'une Image Docker et un Dockerfile ?

Une image Docker est un template léger, autonome et exécutable qui inclut tout le nécessaire pour exécuter une application : le code, un environnement d'exécution, des bibliothèques système, des outils, des dépendances et des fichiers de configuration. Les images sont construites à partir d'une série de couches superposées, ce qui les rend efficaces en termes de stockage et rapides à distribuer.

Le Dockerfile est le fichier texte qui contient toutes les instructions nécessaires à Docker pour construire une image. C'est la "recette" pour créer votre image. En définissant explicitement chaque étape du processus de construction, le Dockerfile garantit que votre environnement d'application est toujours le même, quel que soit l'endroit où l'image est construite ou exécutée. Il s'agit d'un composant clé pour l'intégration continue et le déploiement continu (CI/CD) dans un pipeline DevOps.

2. Les Instructions Essentielles d'un Dockerfile

Un Dockerfile est composé d'une série d'instructions, chacune créant une nouvelle couche dans l'image. Voici les instructions les plus courantes et leur utilité :

2.1. FROM : Choisir l'Image de Base

La première instruction dans presque tous les Dockerfiles est FROM. Elle spécifie l'image de base à partir de laquelle votre image sera construite. C'est le point de départ de votre environnement.

FROM ubuntu:22.04

Explication : Cette ligne indique que notre image sera basée sur l'image officielle d'Ubuntu version 22.04. Il est recommandé d'utiliser des images de base minimalistes (comme alpine ou des images spécifiques à l'application comme node:18-alpine) pour réduire la taille finale de votre image et les vecteurs d'attaque potentiels.

2.2. LABEL : Ajouter des Métadonnées

L'instruction LABEL permet d'ajouter des métadonnées à l'image. Cela peut inclure des informations sur le mainteneur, la version, ou tout autre détail pertinent.

LABEL maintainer="Votre Nom <votre.email@example.com>"
LABEL version="1.0.0"
LABEL description="Application web simple"

Explication : Ces étiquettes ne changent pas le comportement de l'image, mais fournissent des informations utiles pour la gestion et l'organisation.

2.3. RUN : Exécuter des Commandes pendant la Construction

L'instruction RUN exécute des commandes dans le contexte de l'image en cours de construction. Chaque instruction RUN crée une nouvelle couche.

RUN apt-get update && apt-get install -y \
    nginx \
    curl \
    && rm -rf /var/lib/apt/lists/*

Explication : Cette commande met à jour les paquets et installe nginx et curl. Notez l'utilisation de && pour combiner plusieurs commandes en une seule instruction RUN. Cela est une bonne pratique pour réduire le nombre de couches de l'image finale et optimiser la mise en cache. Le rm -rf /var/lib/apt/lists/* est crucial pour nettoyer les caches APT et réduire la taille de l'image.

2.4. COPY et ADD : Copier des Fichiers

Ces instructions servent à copier des fichiers ou des répertoires depuis l'hôte de construction (le "contexte de build") vers l'image.

  • COPY <source> <destination>: C'est l'instruction préférée. Elle copie des fichiers et des répertoires exactement tels quels.
  • ADD <source> <destination>: Fait la même chose que COPY, mais a des fonctionnalités supplémentaires :
    • Si <source> est une URL, ADD télécharge le fichier.
    • Si <source> est un fichier .tar, ADD le décompresse automatiquement.
COPY ./app /usr/src/app
ADD https://example.com/latest.tar.gz /tmp/

Explication : La première ligne copie le contenu du répertoire ./app de votre machine locale (là où vous exécutez docker build) vers /usr/src/app dans l'image. La deuxième télécharge un fichier tarball et le décompresse dans /tmp. Sauf si vous avez besoin des fonctionnalités spécifiques de ADD, privilégiez COPY car il est plus transparent et prédictible.

2.5. WORKDIR : Définir le Répertoire de Travail

L'instruction WORKDIR définit le répertoire de travail pour toutes les instructions RUN, CMD, ENTRYPOINT, COPY et ADD qui suivent.

WORKDIR /usr/src/app

Explication : Après cette ligne, toute instruction comme RUN npm install ou COPY package.json . sera exécutée à partir de /usr/src/app.

2.6. EXPOSE : Documenter les Ports

EXPOSE informe Docker que le conteneur écoutera sur les ports réseau spécifiés au moment de l'exécution. Cela n'expose pas réellement le port, mais sert de documentation et permet de lier des ports dynamiques si l'utilisateur utilise l'option -P de docker run.

EXPOSE 80
EXPOSE 443/tcp 443/udp

Explication : Indique que l'application dans le conteneur utilisera le port 80 (par défaut TCP) et les ports 443 en TCP et UDP. Pour mapper ce port à un port de l'hôte, vous devrez utiliser l'option -p avec docker run (ex: docker run -p 8080:80 my-image).

2.7. ENV et ARG : Variables d'Environnement

  • ENV <key>=<value>: Définit des variables d'environnement qui seront disponibles à l'intérieur du conteneur pendant l'exécution et pendant la construction pour les instructions suivantes.
  • ARG <name>[=<default value>]: Définit des variables qui sont disponibles uniquement pendant le processus de construction. Elles ne persistent pas dans l'image finale.
# ENV: disponible à la construction ET à l'exécution
ENV NODE_ENV production
ENV PORT 3000

# ARG: disponible UNIQUEMENT à la construction
ARG BUILD_VERSION
RUN echo "Construisant la version: $BUILD_VERSION"

Explication : NODE_ENV et PORT seront accessibles par votre application une fois le conteneur démarré. BUILD_VERSION peut être passée via docker build --build-arg BUILD_VERSION=1.2.3 et utilisée pour des scripts de construction.

2.8. VOLUME : Points de Montage

L'instruction VOLUME crée un point de montage pour un volume externe, ou des points de montage qui serviront à persister des données générées par le conteneur.

VOLUME /var/log/my_app

Explication : Indique que le répertoire /var/log/my_app de l'image est destiné à être un volume. Lorsque le conteneur est démarré, Docker peut y monter un volume de l'hôte ou un volume géré par Docker pour stocker des données de manière persistante.

2.9. USER : Définir l'Utilisateur du Conteneur

L'instruction USER définit l'utilisateur ou l'UID à utiliser pour les RUN, CMD et ENTRYPOINT suivants. Par défaut, les conteneurs s'exécutent en tant que root. Il est fortement recommandé de changer d'utilisateur pour un non-root pour des raisons de sécurité.

RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser

Explication : Crée un groupe et un utilisateur appuser sans privilèges, puis bascule l'exécution des commandes suivantes sous cet utilisateur.

2.10. CMD et ENTRYPOINT : Commandes par Défaut du Conteneur

Ces deux instructions définissent la commande qui sera exécutée lorsque le conteneur démarre.

  • CMD ["executable", "param1", "param2"]: Définit la commande par défaut à exécuter quand le conteneur démarre. S'il y a plusieurs CMD, seule la dernière est prise en compte. CMD peut être surchargée par des arguments passés à docker run.
  • ENTRYPOINT ["executable", "param1"]: Définit l'exécutable qui sera appelé au démarrage du conteneur. Les arguments passés à docker run sont ajoutés à cet ENTRYPOINT. L'instruction CMD est alors utilisée pour fournir des arguments par défaut à ENTRYPOINT.

Comparaison de CMD et ENTRYPOINT :

| Caractéristique | CMD | ENTRYPOINT | | :-------------- | :-------------------------------------- | :--------------------------------------------------- | | Usage principal | Définir les arguments par défaut | Configurer le conteneur comme un exécutable | | Surcharge | Peut être surchargée par docker run | Les arguments de docker run sont ajoutés à l'ENTRYPOINT | | Forme | Shell form (CMD command param1) ou Exec form (CMD ["cmd", "p1"]) | Exec form (ENTRYPOINT ["cmd", "p1"]) recommandée |

Exemples :

# Utilisation simple de CMD (surchargable)
CMD ["nginx", "-g", "daemon off;"]

# Utilisation simple de ENTRYPOINT (non surchargable, agit comme l'exécutable principal)
ENTRYPOINT ["node", "app.js"]

# Combinaison de ENTRYPOINT et CMD (CMD fournit les arguments par défaut à ENTRYPOINT)
ENTRYPOINT ["apachectl"]
CMD ["-D", "FOREGROUND"]
# docker run mon_image -> exécute "apachectl -D FOREGROUND"
# docker run mon_image -k start -> exécute "apachectl -k start"

Explication : La forme CMD ["executable", "param1"] (forme "exec") est préférable car elle exécute le processus directement sans passer par un shell, ce qui est plus efficace et permet de mieux gérer les signaux (comme Ctrl+C). Pour ENTRYPOINT, c'est la seule forme recommandée.

3. Construire une Image Docker : L'exemple Node.js

Maintenant, mettons tout cela en pratique en construisant une image pour une application Node.js simple.

Considérons une application Node.js avec les fichiers suivants :

  • package.json:

    {
      "name": "my-node-app",
      "version": "1.0.0",
      "description": "A simple Node.js web app",
      "main": "app.js",
      "scripts": {
        "start": "node app.js"
      },
      "dependencies": {
        "express": "^4.18.2"
      }
    }
    
  • app.js:

    const express = require('express');
    const app = express();
    const port = process.env.PORT || 3000;
    
    app.get('/', (req, res) => {
      res.send('Hello Docker from Node.js App!');
    });
    
    app.listen(port, () => {
      console.log(`App listening at http://localhost:${port}`);
    });
    
  • .dockerignore:

    node_modules
    npm-debug.log
    .git
    .gitignore
    

    Explication : Similaire à .gitignore, ce fichier indique à Docker les fichiers et répertoires à ignorer lors de la copie du contexte de build vers le démon Docker. Cela permet de réduire la taille du contexte de build et d'éviter de copier des fichiers inutiles (comme node_modules qui seront installés dans l'image).

  • Dockerfile:

    # Étape 1: Utiliser une image de base Node.js légère
    FROM node:18-alpine
    
    # Étape 2: Définir le répertoire de travail dans le conteneur
    WORKDIR /app
    
    # Étape 3: Copier les fichiers de dépendances et installer
    # Nous copions package.json et package-lock.json (si existant) en premier.
    # Cela permet à Docker de mettre en cache cette couche si seulement le code change,
    # mais pas les dépendances.
    COPY package*.json ./
    
    # Exécuter l'installation des dépendances
    RUN npm install --production
    
    # Étape 4: Copier le reste du code source de l'application
    # Une fois les dépendances installées, nous copions le reste du code.
    COPY . .
    
    # Étape 5: Documenter le port sur lequel l'application écoute
    EXPOSE 3000
    
    # Étape 6: Définir la commande par défaut pour exécuter l'application
    CMD ["npm", "start"]
    

3.1. Le Processus de Construction

Pour construire l'image, ouvrez un terminal dans le répertoire contenant votre Dockerfile et les fichiers de l'application, puis exécutez la commande docker build :

docker build -t my-node-app:1.0 .
  • docker build: La commande pour construire une image.
  • -t my-node-app:1.0: Attribue un nom (my-node-app) et une étiquette/tag (1.0) à l'image. Le tag est utile pour la gestion des versions.
  • .: Indique le "contexte de build". Dans ce cas, c'est le répertoire courant, ce qui signifie que Docker enverra tous les fichiers de ce répertoire (sauf ceux ignorés par .dockerignore) au démon Docker pour la construction.

Après la construction, vous pouvez voir votre image en exécutant :

docker images

Pour exécuter votre application conteneurisée :

docker run -p 8080:3000 my-node-app:1.0
  • -p 8080:3000: Mappe le port 8080 de votre machine hôte au port 3000 du conteneur (celui exposé par l'application Node.js). Vous devriez pouvoir accéder à l'application via http://localhost:8080 dans votre navigateur.

4. Bonnes Pratiques de Construction d'Images Docker

Pour des images efficaces, sécurisées et maintenables, il est crucial de suivre certaines bonnes pratiques :

4.1. Optimisation de la Taille des Images (Multi-stage Builds)

Des images plus petites sont plus rapides à construire, à pousser/tirer et plus sécurisées. Les constructions multi-étapes (multi-stage builds) sont une technique puissante pour réduire drastiquement la taille des images finales. Elles permettent de n'inclure que l'artefact final (un binaire compilé, un paquet d'application léger) et non les outils de compilation ou les dépendances de développement.

# --- Étape de construction (builder stage) ---
FROM node:18-slim AS builder

WORKDIR /app
COPY package*.json ./
RUN npm install

COPY . .
# Si vous avez besoin de compiler/transpiler votre code (ex: React, Angular, TypeScript)
# RUN npm run build

# --- Étape finale (production stage) ---
FROM node:18-alpine

WORKDIR /app

# Copier seulement les dépendances de production et le code de l'application compilé
# depuis l'étape de construction précédente
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/app.js .
COPY --from=builder /app/package.json . # Gardez package.json pour npm start ou inspection

# Exposer le port et définir la commande de démarrage
EXPOSE 3000
CMD ["npm", "start"]

Explication : Dans cet exemple, la première étape (builder) contient tout le nécessaire pour construire l'application (incluant npm install). La deuxième étape (production) est basée sur une image plus légère (node:18-alpine) et ne copie que les artefacts nécessaires de l'étape builder (le dossier node_modules et le code applicatif). Cela garantit que l'image finale ne contient pas les outils de développement ni les caches de npm, réduisant considérablement sa taille.

4.2. Sécurité

  • Utiliser des images de base minimales : Moins il y a de composants, moins il y a de vulnérabilités potentielles.
  • Ne pas exécuter en tant que root : Toujours basculer vers un utilisateur non-privilégié (USER) dès que possible.
  • Mettre à jour régulièrement : Assurez-vous que vos images de base et les paquets installés sont à jour.
  • Nettoyer les caches : Supprimez les fichiers inutiles après l'installation (rm -rf /var/lib/apt/lists/*, etc.).

4.3. Mise en Cache des Couches

Docker met en cache chaque couche d'une image. Si une instruction change, Docker invalide le cache à partir de cette instruction et de toutes les suivantes. Pour tirer parti de ce mécanisme :

  • Placez les instructions qui changent rarement (comme l'installation des dépendances) avant celles qui changent fréquemment (comme le code de votre application).
  • Utilisez .dockerignore pour éviter d'invalider le cache inutilement en copiant des fichiers non pertinents.

4.4. Utiliser un .dockerignore

Comme démontré dans l'exemple Node.js, un fichier .dockerignore permet d'exclure des fichiers et des répertoires du contexte de build, réduisant ainsi le temps de copie et la taille de l'image.

5. Conclusion et Prochaines Étapes

Vous avez maintenant une compréhension solide des Dockerfiles et du processus de construction d'images Docker. Vous savez comment définir les instructions clés, optimiser la taille de vos images avec les constructions multi-étapes, et appliquer les bonnes pratiques pour des images robustes et sécurisées.

Le Dockerfile est la pierre angulaire de la conteneurisation. Il permet de :

  • Reproduire : Garantir que l'environnement de l'application est toujours le même.
  • Automatiser : Intégrer la construction d'images dans les pipelines CI/CD.
  • Optimiser : Créer des images légères et efficaces.

Dans les prochaines leçons, nous explorerons comment utiliser ces images pour créer et gérer des conteneurs, comment les orchestrer avec Docker Compose, et comment les déployer à l'échelle avec Kubernetes. Maîtriser le Dockerfile est le premier pas essentiel vers un déploiement et une scalabilité efficaces de vos applications modernes.