🔝 Retour au Sommaire
Sous-section : 37.2.1
Section parente : 37.2 — Multi-stage builds : Optimisation de la taille
Prérequis : Section 37.1 (Images Docker Ubuntu vs Alpine), Chapitre 26 (CMake), Section 2.3 (ccache)
Le stage de build est l'usine jetable de votre pipeline Docker. Son unique mission est de produire un binaire exécutable (et éventuellement des fichiers de configuration associés) à partir de vos sources C++. Une fois le binaire extrait par le stage runtime via COPY --from, l'intégralité du stage de build est abandonnée — il ne contribue pas à l'image finale.
Cette nature éphémère libère des contraintes habituelles : la taille du stage de build n'a aucune importance pour la production. En revanche, sa vitesse et la fiabilité du cache Docker sont critiques, car ce stage est exécuté à chaque build CI/CD.
Voici le stage de compilation complet que nous allons disséquer :
# ============================================================
# Stage BUILD
# ============================================================
FROM ubuntu:24.04 AS build
ENV DEBIAN_FRONTEND=noninteractive
ENV CXX=g++
ENV CC=gcc
# ── 1. Toolchain système ──────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
cmake \
ninja-build \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# ── 2. Dépendances du projet (change rarement) ────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \
libcurl4-openssl-dev \
libspdlog-dev \
&& rm -rf /var/lib/apt/lists/*
# ── 3. Fichiers de configuration CMake (change parfois) ───
WORKDIR /src
COPY CMakeLists.txt .
COPY cmake/ cmake/
# ── 4. Sources (change souvent) ───────────────────────────
COPY src/ src/
COPY include/ include/
# ── 5. Configuration CMake ────────────────────────────────
RUN cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/install
# ── 6. Compilation ────────────────────────────────────────
RUN cmake --build build --parallel $(nproc)
# ── 7. Installation dans un répertoire dédié ──────────────
RUN cmake --install buildChaque étape numérotée répond à une logique précise. Détaillons-les.
FROM ubuntu:24.04 AS build
ENV DEBIAN_FRONTEND=noninteractive
ENV CXX=g++
ENV CC=gcc
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
cmake \
ninja-build \
pkg-config \
&& rm -rf /var/lib/apt/lists/*L'image ubuntu:24.04 est un point de départ solide pour le build. Si vous préférez Debian, debian:bookworm fonctionne de manière identique. L'important est d'utiliser une distribution à base de glibc (voir section 37.1).
Il n'est pas nécessaire d'utiliser une image slim pour le stage de build — la différence de taille est négligeable puisque la toolchain C++ pèse bien plus que les quelques mégaoctets économisés par une image slim.
Les variables CXX et CC sont lues par CMake pour déterminer le compilateur à utiliser. Les définir au niveau de l'ENV Docker garantit qu'elles sont disponibles pour toutes les instructions RUN suivantes, sans avoir à les passer en argument CMake à chaque fois. Cela évite aussi les surprises si plusieurs versions de GCC coexistent dans l'image.
Le générateur Ninja (-G Ninja) est le choix recommandé pour les builds Docker, pour deux raisons. D'abord, Ninja est significativement plus rapide que Make sur les builds parallèles — la différence est mesurable dès que le projet dépasse quelques dizaines de fichiers source (voir section 28.3). Ensuite, Ninja produit une sortie plus lisible dans les logs Docker, sans les messages make[1]: Entering directory... qui polluent la sortie.
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \
libcurl4-openssl-dev \
libspdlog-dev \
&& rm -rf /var/lib/apt/lists/*La toolchain (étape 1) et les dépendances du projet (étape 2) sont volontairement dans des instructions RUN séparées. La raison est le cache Docker : la toolchain ne change quasiment jamais (vous ne changez pas de version de GCC tous les jours), tandis que les dépendances du projet évoluent au fil du temps. En les séparant, un ajout de dépendance (par exemple libprotobuf-dev) n'invalide que le layer des dépendances — le layer de la toolchain reste en cache.
C'est un compromis : fusionner les deux RUN en un seul produirait un layer unique légèrement plus petit, mais invaliderait le cache de la toolchain à chaque modification de dépendance. Pour un build CI/CD exécuté des dizaines de fois par jour, le gain de cache l'emporte largement.
Pour les dépendances disponibles dans les dépôts APT de la distribution, l'installation directe via apt-get est le chemin le plus simple et le plus rapide dans Docker. Les paquets -dev contiennent les headers et les fichiers .so de linkage nécessaires à la compilation, tandis que les paquets runtime (sans le suffixe -dev) ne contiennent que les librairies partagées nécessaires à l'exécution.
Pour des dépendances non disponibles dans APT, ou lorsque vous avez besoin d'une version spécifique non fournie par la distribution, Conan ou vcpkg sont la solution. L'intégration de Conan dans un stage Docker suit ce schéma :
FROM ubuntu:24.04 AS build
ENV DEBIAN_FRONTEND=noninteractive
# Toolchain + Python pour Conan
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build pkg-config \
python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --break-system-packages conan \
&& conan profile detect
# Résolution des dépendances Conan (layer dédié)
WORKDIR /src
COPY conanfile.py conan/ CMakeLists.txt ./
RUN conan install . --build=missing \
-s build_type=Release \
-of build/conan
# Copie des sources et compilation
COPY src/ src/
COPY include/ include/
RUN cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_TOOLCHAIN_FILE=build/conan/conan_toolchain.cmake \
&& cmake --build build --parallel $(nproc)Le point crucial est la position du COPY conanfile.py : il est copié avant les sources. Ainsi, tant que conanfile.py ne change pas, l'instruction conan install reste en cache — même si les fichiers .cpp changent à chaque commit. La résolution de dépendances Conan pouvant prendre plusieurs minutes (voire bien plus si des librairies doivent être compilées avec --build=missing), ce gain de cache est considérable.
WORKDIR /src
COPY CMakeLists.txt .
COPY cmake/ cmake/ Cette étape illustre un principe fondamental de l'optimisation Docker : copier les fichiers dans l'ordre inverse de leur fréquence de modification.
Le CMakeLists.txt racine et les fichiers du répertoire cmake/ (modules, toolchains, configuration Find) changent moins souvent que les fichiers source. En les copiant dans un layer séparé, une modification dans src/main.cpp n'invalidera pas le cache de configuration CMake.
En pratique, la séquence de copie optimale pour un projet C++ est :
- Fichiers de dépendances (
conanfile.py,vcpkg.json) → change rarement - Configuration CMake (
CMakeLists.txt,cmake/) → change occasionnellement - Headers publics (
include/) → change modérément - Sources (
src/) → change à chaque commit
L'instruction COPY . . copie l'intégralité du contexte de build en un seul layer. C'est pratique mais désastreux pour le cache : le moindre changement dans n'importe quel fichier du projet (y compris un commentaire dans le README) invalide ce layer et tous ceux qui suivent. Réservez le COPY . . uniquement si votre .dockerignore est très strict et que vous n'avez pas besoin de stratégie de cache fine.
COPY src/ src/
COPY include/ include/ Les sources arrivent en dernier. C'est le layer le plus fréquemment invalidé, et il est normal qu'il le soit — c'est le code que vous développez. Toutes les instructions qui suivent (cmake -B, cmake --build) seront réexécutées à chaque changement de source, mais les layers précédents (toolchain, dépendances, configuration) seront servis depuis le cache.
Même avec une copie sélective, un .dockerignore bien configuré est indispensable. Il empêche Docker d'envoyer des fichiers inutiles au daemon lors du docker build :
# Artefacts de build locaux
build/
cmake-build-*/
out/
# Contrôle de version
.git/
.gitignore
# IDE et éditeurs
.vscode/
.idea/
*.swp
*~
# Cache Conan local
.conan2/
# Docker (éviter la récursion)
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation et CI (inutiles pour le build)
docs/
README.md
LICENSE
.github/
.gitlab-ci.yml
Sur un projet typique avec un historique Git conséquent, le .dockerignore peut réduire la taille du contexte de build de plusieurs centaines de mégaoctets, accélérant significativement le démarrage de chaque docker build.
RUN cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/install-DCMAKE_BUILD_TYPE=Release — En production, on compile toujours en Release. Les optimisations du compilateur (-O2 ou -O3) réduisent la taille du binaire et améliorent les performances. Les symboles de debug (-g) ne sont pas inclus par défaut, ce qui allège encore le binaire.
-DCMAKE_INSTALL_PREFIX=/install — Ce répertoire servira de destination pour cmake --install. Plutôt que de chercher le binaire dans l'arborescence du build, on l'installe proprement dans /install/bin/, ce qui simplifie le COPY --from dans le stage runtime. C'est une pratique qui devient indispensable dès que le projet produit plusieurs binaires ou fichiers de configuration.
-G Ninja — Générateur Ninja, comme discuté à l'étape 1.
Selon le projet, vous pourrez ajouter :
RUN cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/install \
-DBUILD_TESTING=OFF \
-DBUILD_SHARED_LIBS=OFF \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON-DBUILD_TESTING=OFF — Si les tests sont exécutés dans un stage séparé (voir section 37.2), il est inutile de compiler les exécutables de test dans le stage de build principal. Cela réduit le temps de compilation.
-DBUILD_SHARED_LIBS=OFF — Force le linkage statique des librairies internes au projet. Cela ne concerne pas les dépendances système (glibc, OpenSSL), mais les librairies que votre projet produit lui-même. Le binaire résultant est plus autonome et simplifie le stage runtime.
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON — Active le Link-Time Optimization (LTO, voir section 41.5). Le compilateur optimise à travers les frontières des unités de compilation, produisant un binaire plus petit et plus rapide. Le coût est un temps de linkage plus long, ce qui est un compromis acceptable en CI.
RUN cmake --build build --parallel $(nproc)L'option --parallel $(nproc) utilise tous les cœurs CPU disponibles dans le conteneur. Par défaut, Docker alloue tous les cœurs de la machine hôte au conteneur pendant le build. Sur un serveur CI à 8 cœurs, cela divise le temps de compilation par un facteur proche de 8 pour les projets suffisamment grands.
Si vous devez limiter le parallélisme (par exemple pour éviter de saturer la mémoire sur un gros projet), remplacez $(nproc) par une valeur fixe :
RUN cmake --build build --parallel 4Les projets C++ lourds en templates (Boost, Eigen, code utilisant massivement la métaprogrammation) peuvent consommer plusieurs gigaoctets de RAM par processus de compilation. Avec --parallel $(nproc) sur une machine à 16 cœurs, 16 compilations simultanées peuvent facilement nécessiter 32 GB de RAM ou plus.
Si le build échoue avec des erreurs de type internal compiler error ou si le processus est tué par l'OOM killer, réduisez le parallélisme ou augmentez la mémoire disponible pour Docker :
# Limiter le parallélisme à la compilation
docker build --build-arg JOBS=4 .
# Dans le Dockerfile
ARG JOBS=0
RUN cmake --build build --parallel ${JOBS:-$(nproc)} Avec JOBS=0 par défaut, la valeur de $(nproc) est utilisée. En passant --build-arg JOBS=4, on force 4 compilations parallèles.
RUN cmake --install buildCette commande copie les artefacts produits (binaires, librairies, headers publics, fichiers de configuration) dans le répertoire spécifié par CMAKE_INSTALL_PREFIX — ici /install.
Après cette étape, le contenu de /install est typiquement :
/install/
├── bin/
│ └── myapp # Binaire principal
├── lib/
│ └── libmylib.so # Librairies partagées du projet (si applicable)
└── etc/
└── myapp/
└── config.yaml # Fichiers de configuration (si installés par CMake)
Cette structure propre est ce que le stage runtime va copier. C'est beaucoup plus fiable que de copier manuellement des fichiers depuis l'arborescence de build, dont la structure interne peut varier selon le générateur CMake utilisé.
Pour que cmake --install fonctionne correctement, votre CMakeLists.txt doit contenir les directives install() appropriées :
# Dans CMakeLists.txt
install(TARGETS myapp RUNTIME DESTINATION bin)
install(FILES config/config.yaml DESTINATION etc/myapp) Si votre projet n'utilise pas install(), vous pouvez copier le binaire directement :
# Alternative sans cmake --install
# Le binaire se trouve où Ninja l'a produit
RUN cp build/myapp /install/bin/myappLa section 2.3 a présenté ccache comme outil d'accélération de la compilation locale. Son intégration dans Docker est particulièrement précieuse car elle permet de conserver un cache de compilation entre les builds Docker successifs, contournant ainsi la nature éphémère des containers.
Le principe repose sur les volumes Docker ou les caches BuildKit :
FROM ubuntu:24.04 AS build
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build ccache \
&& rm -rf /var/lib/apt/lists/*
# Configurer ccache comme wrapper du compilateur
ENV CCACHE_DIR=/ccache
ENV CMAKE_C_COMPILER_LAUNCHER=ccache
ENV CMAKE_CXX_COMPILER_LAUNCHER=ccache
WORKDIR /src
COPY . .
# Le cache est monté depuis l'hôte via BuildKit
RUN --mount=type=cache,target=/ccache \
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build --parallel $(nproc)L'instruction RUN --mount=type=cache,target=/ccache est une fonctionnalité de BuildKit (activé par défaut depuis Docker 23.0). Elle monte un volume persistant dans le conteneur pendant l'exécution de la commande RUN. Ce volume survit entre les builds successifs : le cache ccache est préservé même si le layer Docker est invalidé.
Le résultat est spectaculaire sur les rebuilds : une modification mineure dans un seul fichier .cpp ne recompile que ce fichier, même si le layer Docker a été entièrement invalidé. Le chapitre 38 (section 38.3) détaille cette technique dans le contexte de la CI/CD.
Pour déclencher un build avec BuildKit :
DOCKER_BUILDKIT=1 docker build -t myapp .
# Ou avec docker buildx (recommandé en 2026)
docker buildx build -t myapp .Avant de passer au stage runtime, il est prudent de vérifier que le binaire est fonctionnel et d'identifier ses dépendances dynamiques. Ces informations seront essentielles pour le stage runtime (section 37.2.2) et la gestion des librairies partagées (section 37.3).
# Vérification : le binaire s'exécute
RUN /install/bin/myapp --version
# Inspection : quelles librairies dynamiques sont nécessaires ?
RUN ldd /install/bin/myappLa sortie de ldd ressemble typiquement à :
linux-vdso.so.1 (0x00007ffc...)
libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3 (0x00007f...)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f...)
libcurl.so.4 => /lib/x86_64-linux-gnu/libcurl.so.4 (0x00007f...)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f...)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f...)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
Chacune de ces librairies doit être présente dans l'image runtime. Les librairies fournies par la glibc elle-même (libc.so.6, libm.so.6, libpthread.so.0, ld-linux-x86-64.so.2) sont incluses dans l'image de base Debian/Ubuntu. Les autres (libssl.so.3, libcurl.so.4) doivent être installées explicitement dans le stage runtime, soit via APT (les paquets sans le suffixe -dev), soit en les copiant manuellement depuis le stage de build.
Cette sortie ldd est le contrat entre le stage de build et le stage de runtime — tout ce qui y apparaît doit exister dans l'image de production.
Ordonner les layers par fréquence de changement — Toolchain en premier, sources en dernier. Le cache Docker récompense cet ordonnancement.
Séparer la résolution de dépendances de la compilation — Les fichiers conanfile.py ou vcpkg.json sont copiés et résolus dans un layer dédié, avant la copie des sources.
Utiliser Ninja — Plus rapide que Make, sortie plus propre, recommandé par la communauté CMake.
Installer dans un PREFIX dédié — cmake --install vers /install produit une arborescence propre, facile à copier dans le stage runtime.
Intégrer ccache via BuildKit — Le --mount=type=cache préserve le cache de compilation entre les builds, réduisant drastiquement les temps de rebuild.
Inspecter le binaire avec ldd — Connaître les dépendances dynamiques du binaire est un prérequis pour construire un stage runtime correct.