Chapitre 2 • Optimisations et serveurs HTTP
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 ;
- Debian
- 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>-slimThese 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.
- on prend pour base une
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’undist/) ;
- on part d’une image Node “complète” (qui contient
- Étape 2 : runtime
- on part d’une image Node plus légère (ou une image runtime-only) ;
- on copie
package.jsonet 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/oubuild/selon le framework.
- on part d’une image Node (avec
- É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/oubuild/) ; - on configure le serveur pour servir ces fichiers (et éventuellement la gestion SPA : fallback sur index.html) ;
- on expose le port HTTP.
- on part d’une image serveur web minimaliste (ex :
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-essentialsetlibpq-deven provenance des dépôts APT ;- copier le
Gemfileet leGemfile.lockdans l’image ; - lancer
bundle installet nettoyer le cache qu’il crée.
Pour la deuxième étape :
libpq5depuis les dépôts APT (la variante-dev, plus lourde n’est nécessaire qu’à l’étape debuild)- 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
/apielles 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.