Cartouche

Champ Valeur
Auteur·e Élise
Édition 2025-02-08
Taille des équipes 1 personnes
Rendu via git, dans vos forks, droits en lecture à delivery_collector

Introduction

Les images que vous avez produite lors du chapitre dernier peuvent peser assez lourd. Une image lourde a plusieurs inconvénient :

  • elle peut prendre longtemps à build ;
  • elle pèse lourd sur votre disque ;
  • elle met du temps à se mettre en ligne ;
  • elle met du temps à se télécharger.

On a plusieurs outils avec Docker qui nous permettent de gagner de la place lorsqu’on fabrique des images :

  • les images de base allégées, comme les :
    • Debian -slim,
    • Alpine Linux ;
  • la construction mutli-stage ;
  • le nettoyage immédiat du cache.

Images allégées

Beaucoup d’image officielles proposent une option alpine. Sur l’image postgres par exemple il y a les tags :

  • 18, lastest, trixie: qui portent sur l’image normale de la version actuelle de postgres, emballée sur Debian Trixie (la version actuelle de Debian) ;
  • 18-alpine, alpine: qui portent sur le même programme mais compilé et emballé avec une base Alpine Linux, plus légère.

L’image Debian pèse 150 MO. Celle avec Alpine pèse 100 MO. Ce n’est pas une énorme différence, mais ça reste tout de même une différence.

Si on est pas comblés par Alpine Linux, il nous reste l’option d’une image debian-slim. C’est en réalité l’option qu’à choisis PostgreSQL. Il s’agit d’images Debian allégées, avec moins de programmes et librairies pré-installés.

La documentation de debian sur le dockerhub nous dis :

debian:<suite>-slim

These tags are an experiment in providing a slimmer base (removing some extra files that are normally not necessary within containers, such as man pages and documentation), and are definitely subject to change.

Installations de paquets

Installer des paquets avec apk ou apt provoque souvent la création (ou la mise à jour) d’un cache sur le système de fichier. Ce cache à pour intérêt d’accélérer les opérations futures d’installation de paquet ou de mises à jours.

Sauf que pour nous, intégrateurs docker, ce cache c’est du poids en plus sur nos images. Là où Alpine est pratique c’est qu’il nous propose d’outrepasser le cache avec :

apk add --no-cache <package>

Sur Debian/Ubuntu c’est un peu plus difficile, il faut nettoyer le cache derrière nous :

apt-get update
apt-get install -y --no-install-recommends <package>
rm -rf /var/lib/apt/lists/*
apt-get clean

Dans un Dockerfile on pourrait être tentés de faire :

RUN apt-get update
RUN apt-get install -y --no-install-recommends <package>
RUN rm -rf /var/lib/apt/lists/*
RUN apt-get clean

Le problème c’est qu’une instruction de Dockerfile crée une couche par dessus une image. Chaque couche ajoute de la donnée et si elle en supprime, elle ne le supprime pas de l’historique. Ainsi ce qu’à fait chaque couche pèsera toujours sur l’image finale.

Bonne pratique : pour chaque commande que l’on fait, on peut se demander si elle produit des fichiers et si ses fichiers sont utile pour l’image finale, s’ils ne le sont pas, il faut les supprimer dans le même RUN.

Exemple d’un projet ruby

Forkez et clonez le dépôt ruby_todo en conservant son nom.

FROM ruby:3.3.0

RUN apt-get update -qq
RUN apt-get install -y build-essential libpq-dev

WORKDIR /app

COPY . .
RUN bundle install

EXPOSE 3000

CMD ["bin/rails", "server", "-b", "0.0.0.0"]

Nous avons pour exemple un projet ruby on rails, un framework pour écrire des API. Pour faire fonctionner un projet Ruby on installe ses dépendances avec bundle install.

Le Dockerfile fonctionne et permet la création de l’image. Cependant il a quelques défauts.

Le premier que je vais vous montrer comment corriger c’est :

COPY . .
RUN bundle install

Le problème de ces deux lignes c’est que si le moindre fichier change dans le projet, la commande bundle install doit être refaite. Ce qui vous fait perdre du temps si vous travaillez sur le projet et n’avez pas changé ses dépendances.

bundle install est analogue à un npm install, de la même façon qu’NPM et ses package.lock et package.json, bundle n’a besoin que de Gemfile et Gemfile.lock pour installer les dépendances.

Ainsi, on peut copier seulement ces deux fichiers avant une installation des dépendances, de sorte que docker build ne refasse pas l’installation s’ils n’ont pas changé :

COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY . .

Testez la différence !

Exercice A : run, env, volume, net

git config --global core.autocrlf input

Écrivez dans un fichier docker.sh la commande pour construire l’image ruby_todo et les commandes pour démarrer sur un réseau todo :

  • une base de donnée postgres, avec stockage persistent ;
  • l’API elle même qui écoute sur le port 3000 de l’hôte.

Vous devez aussi faire les commandes db:create, db:migrate et db:seed sur le conteneur de l’API AVANT de pouvoir faire des requêtes dessus. Mettez ces commandes dans votre script.

# Create and migrate the database
docker exec ruby_todo bin/rails db:create db:migrate

# (Optional) Seed sample data
docker exec ruby_todo bin/rails db:seed

L’API attend une variable d’environnement :

  • DATABASE_URL=postgres://user:password@host:port/db

La base de donnée postgres ce configure également avec des variable d’environnement.

Exercice B : optimiser le Dockerfile

Appliquez sur le Dockerfile du projet ruby_todo les optimisations décrites plus tôt dans ce sujet.

La compilation multi-étape

En plus des optimisations pour limiter la production de cache, l’utilisation d’images de base plus légères, nous avons aussi la possibilité de fragmenter la construction d’une image docker en plusieurs étapes.

L’intérêt ? Une étape de compilation peut contenir toutes les dépendances à la compilation (qui pèsent lourd) et une étape d’exécution peut ne contenir que les dépendances à l’exécution, qui sont plus légères.

Voici un exemple de Dockerfile “multi-stage”. Remarquez qu’il y a deux instructions FROM et l’une d’entre elle a un nom “builder”.

FROM rust:1.92 as builder

WORKDIR /usr/src/myapp
COPY ./Cargo.toml ./Cargo.lock ./
COPY ./src ./src
COPY ./.sqlx ./.sqlx
COPY ./migrations ./migrations
RUN cargo install --path .

FROM debian:trixie-slim
RUN apt-get update && apt-get install -y libc6 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/cargo/bin/ticket_please_be /usr/local/bin/ticket_please_be

EXPOSE 3000

CMD ["ticket_please_be"]
  • Étape 1 : builder
    • on prend pour base l’image rust, faite pour compiler du rust ;
    • on copie les différents fichiers et dossiers dont on a besoin pour compiler ;
    • on compile.
  • Étape 2 :
    • on prend pour base une debian:slim, qui ne contient pas de quoi compiler quoi que ce soit, elle est neutre ;
    • on installe une dépendance dont le projet à besoin à l’exécution ;
    • on copie le fichier compilé lors de l’étape build grâce à --from=builder ;
    • on indique le port sur lequel le programme écoute ;
    • on indique que la commande à exécuter c’est ticket_please_be.

L’avantage des étapes c’est que dans l’image finale produite il y aura seulement le contenu de la dernière étape. Autrement dit l’étape 1 ne pèse pas du tout sur la taille de l’image finale.

Node.js (API / backend)

Cas typique : API Node avec TypeScript (ou bundling), où on veut éviter d’embarquer toute la toolchain dans l’image finale.

  • Étape 1 : builder
    • on part d’une image Node “complète” (qui contient npm, node-gyp, etc.) ;
    • on copie d’abord les fichiers qui décrivent les dépendances (package.json, package-lock.json / pnpm-lock.yaml / yarn.lock) ;
    • on installe les dépendances avec une commande reproductible (ex : npm ci) ;
    • on copie le reste du code source ;
    • on lance la compilation/transpilation (ex : npm run build → génération d’un dist/) ;
  • Étape 2 : runtime
    • on part d’une image Node plus légère (ou une image runtime-only) ;
    • on copie package.json et verrou
    • on installe seulement les dépendances d’exécution : npm ci --omit=dev
    • de l’étape build, on copie uniquement les fichiers compilés présents dans dist/ ;
    • on configure la commande d’exécution (ex : node dist/index.js) ;
    • on expose le port de l’API.

Résultat : l’image finale ne contient ni TypeScript, ni les outils de build, ni les dépendances de dev.

Frontend compilé via NPM

La situation d’un frontend est particulière, car le serveur n’a pas besoin d’exécuter un runtime propre à notre application, il doit se charger seulement de servir des fichiers statiques. Ce que l’on peut faire avec un serveur web comme nginx, apache, caddy et bien d’autres.

  • Étape 1 : builder
    • on part d’une image Node (avec npm) ;
    • on copie package.json + verrou ;
    • on installe les dépendances (ex : npm ci) ;
    • on copie le code srouce du frontend ;
    • on lance le build (ex : npm run build) :
      • production build (minification, tree-shaking, hash des assets, etc.) ;
      • sortie dans un dossier du type dist/ ou build/ selon le framework.
  • Étape 2 : runtime
    • on part d’une image serveur web minimaliste (ex : nginx, caddy, …) ;
    • on copie uniquement les fichiers statiques générés (dist/ ou build/) ;
    • on configure le serveur pour servir ces fichiers (et éventuellement la gestion SPA : fallback sur index.html) ;
    • on expose le port HTTP.

Résultat : image finale très petite, sans Node, juste des fichiers statiques + un serveur. La difficulté de l’étape 2 réside en général dans la configuration du serveur web.

Exercice C : ruby_todo multi-étape

Si nous revenons à notre ruby. À l’exécution les deux choses dont nous avons besoin c’est :

  • tout le code du dépôt (grâce à COPY . .)
  • les fichiers créés par bundle install (le dossier /usr/local/bundle)

Cependant nous n’avons pas besoin du cache créé par bundle install dans ~/.bundle/cache et /usr/local/bundle/cache.

Pour notre première étape nous avons donc besoin de :

  • build-essentials et libpq-dev en provenance des dépôts APT ;
  • copier le Gemfile et le Gemfile.lock dans l’image ;
  • lancer bundle install et nettoyer le cache qu’il crée.

Pour la deuxième étape :

  • libpq5 depuis les dépôts APT (la variante -dev, plus lourde n’est nécessaire qu’à l’étape de build)
  • depuis l’étape 1, copier le bundle créé par bundle install
  • copier tous le code source du projet ;
  • exposer le port d’écoute ;
  • renseigner la commande à exécuter au moment du docker run.

Exercice D : frontend HTML/JS

Dans le repertoire fe/ de ruby_todo il y a un fichier index.html.

D.1. Base de l’URL API

Ce fichier contient une chaîne BASE_API_URL_TEMPLATE qu’il faut remplacer avec l’URL à laquelle se trouve l’API.

Si l’API et ce fichier HTML sont derrière le même serveur HTTP, il faut remplacer BASE_API_URL_TEMPLATE par une chaîne vide.

sed -i "s|BASE_API_URL_TEMPLATE||g" index.html

Si l’API est sur quelque chose comme http://localhost:3000, et le frontend sur http://localhost:4567 il va falloir remplacer BASE_API_URL_TEPLATE par http://localhost:3000 (sans / à la fin)

sed -i "s|BASE_API_URL_TEMPLATE|http://localhost:3000|g" index.html

D.2. Serveur et reverse proxy

Lorsque dans un conteneur on doit servir un frontend composé de fichiers HTML, CSS, JS statique, on utilise un service tel :

  • nginx ;
  • apache/httpd ;
  • caddy ;
  • etc.

Ces services écoutent sur un port et communiquent en HTTP (le protocole utilisé par les navigateurs web.) Un navigateur demande un fichier → le serveur web lui envoie.

Ces services sont aussi capables de faire office de reverse proxy. Ils reçoivent une requête HTTP et décident de la transférer à un autre service – comme une API – puis ils relaient la réponse.

En général on configure les choses ainsi :

  • par défaut toutes les requêtes permettent d’obtenir un fichier statique servi ;
  • les requêtes sur /api elles sont transférées vers un autre service (autre IP et/ou port).

Regardez ce que Claude Sonnet 4.5 propose comme configurations : https://claude.ai/share/faeecee2-eff6-4e17-a17f-04f8b2ccc808.

D.3. Consigne

Écrivez le fichier fe/Dockerfile qui permet de faire un conteneur qui sert le fichier index.html sur le port 80. Il doit être possible de construire le frontend ainsi :

docker build -t todo_fe . --build-arg "BASE_API_URL=http://localhost:3000"

Exercice E : reverse proxy

Modifiez votre fe/Dockerfile et votre éventuelle configuration de serveur HTTP pour que le trafic reçu sur le chemin /api soit redirigé vers http://api:3000/.

Ajoutez au fichier docker.sh, créé à l’exercice A, la commande pour démarrer le conteneur de serveur HTTP de sorte à ce qu’il puisse faire des requêtes sur l’api. Vous pouvez adapter la commande docker run de l’API de sorte qu’elle soit disponible sur le réseau avec le nom api.