Un docker build lancé sans réfléchir sur un projet Symfony fraîchement initialisé produit souvent une image entre 800 Mo et 1,2 Go. C'est fréquemment la version de développement qui finit en production par inadvertance : Composer embarqué, headers de compilation toujours présents, et parfois des secrets figés dans une couche intermédiaire. Le résultat est prévisible — des pulls longs sur les runners CI, une surface d'attaque inutilement large, des déploiements trois fois plus lents qu'ils ne le devraient. Avec BuildKit et une architecture multi-stage pensée couche par couche, la même application Symfony 7.1 tient sous 80 Mo, sans rien sacrifier en fonctionnalité. Ce guide pose un Dockerfile reproductible, détaille la stratégie de cache layer par layer et mesure les gains réels sur un projet concret.
Pourquoi une image PHP naïve dépasse facilement 800 Mo
L'image officielle php:8.3-fpm basée sur Debian Bookworm pèse à elle seule environ 450 Mo compressée. Ajoutez les paquets système nécessaires à la compilation des extensions PHP — libicu-dev, postgresql-dev, libzip-dev, plus le méta-paquet $PHPIZE_DEPS qui tire autoconf, gcc et make — et vous atteignez déjà 600 Mo avant la première ligne de code applicatif. Composer et son cache font le reste. Le problème est structurel : tout ce qui sert à construire l'application est embarqué dans l'image qui l'exécute. Ces outils de build sont indispensables pendant trente secondes lors du docker build, et totalement inutiles pendant les mois de vie de l'image en production.
- $PHPIZE_DEPS (autoconf, gcc, make, m4…) — indispensables à la compilation des extensions, inutiles au runtime
- Binaire Composer (~2 Mo) et son cache (~50-150 Mo selon le projet) — sans utilité dans l'image finale
- Headers de libs système (icu-dev, libzip-dev, postgresql-dev) — inutiles dès que le fichier .so est compilé
- Image de base Debian au lieu d'Alpine — 300 Mo d'écart à périmètre fonctionnel identique
- Extensions de debug laissées par inadvertance depuis un Dockerfile de développement (Xdebug, agent Blackfire)
- Dossier .git et artefacts CI inclus faute d'un .dockerignore complet
Ce que BuildKit et les multi-stages changent réellement
BuildKit est intégré par défaut dans Docker Engine depuis la version 23.0 (mars 2023). Il apporte deux atouts décisifs. D'abord, la parallélisation automatique : les stages indépendants d'un même Dockerfile sont construits simultanément sans configuration supplémentaire. Si votre stage vendor (Composer) et un stage assets (Node/Vite) n'ont pas de dépendance mutuelle, BuildKit les lance en parallèle — le temps de build total s'en trouve réduit d'autant. Ensuite, les mounts de cache (--mount=type=cache) permettent de persister le cache Composer entre deux builds consécutifs sans qu'il rejoigne jamais l'image finale : zéro octet ajouté, vitesse conservée. Les multi-stage builds permettent enfin de définir plusieurs images intermédiaires dans un seul Dockerfile et de ne copier que les artefacts strictement nécessaires vers le stage final via COPY --from=<stage>. C'est ce principe de séparation des responsabilités qui permet de passer de 800 Mo à moins de 80 Mo.
Architecture en 4 stages : le Dockerfile commenté
Le Dockerfile suivant est testé sur un projet Symfony 7.1 avec PostgreSQL, Redis et les extensions courantes. La directive # syntax=docker/dockerfile:1.6 en première ligne active les fonctionnalités BuildKit avancées, notamment les mounts de cache et les bind mounts dans les instructions RUN. Chaque stage a une responsabilité unique : extraire le binaire Composer, compiler les extensions C, installer les dépendances PHP, puis assembler le runtime final.
# syntax=docker/dockerfile:1.6
###############################################################################
# Stage 1 – Binaire Composer (image officielle ~60 Mo, jamais intégrée en prod)
###############################################################################
FROM composer:2.7 AS composer
###############################################################################
# Stage 2 – Compilation des extensions PHP
# Les outils de build sont présents ici, absents du runtime final
###############################################################################
FROM php:8.3-fpm-alpine AS build-deps
RUN apk add --no-cache \
$PHPIZE_DEPS \
icu-dev \
libzip-dev \
postgresql-dev \
linux-headers \
&& docker-php-ext-install -j"$(nproc)" \
intl \
opcache \
pdo_pgsql \
zip \
&& pecl install redis-6.0.2 apcu-5.1.23 \
&& docker-php-ext-enable redis apcu \
&& apk del $PHPIZE_DEPS \
&& rm -rf /tmp/pear
###############################################################################
# Stage 3 – Installation Composer avec cache BuildKit
# Le cache ~/.composer/cache est persisté entre builds, hors image
###############################################################################
FROM build-deps AS vendor
WORKDIR /app
# Copier uniquement les manifestes — le cache est invalide seulement si ceux-ci changent
COPY composer.json composer.lock symfony.lock ./
RUN --mount=type=cache,target=/root/.composer/cache \
--mount=type=bind,from=composer,source=/usr/bin/composer,target=/usr/local/bin/composer \
composer install \
--no-dev \
--no-scripts \
--no-plugins \
--prefer-dist \
--optimize-autoloader \
--classmap-authoritative
###############################################################################
# Stage 4 – Image de production finale (runtime Alpine minimal)
# Aucun outil de build, aucun binaire superflu
###############################################################################
FROM php:8.3-fpm-alpine AS production
ENV APP_ENV=prod \
APP_DEBUG=0
# Uniquement les libs runtime (pas les headers de développement)
RUN apk add --no-cache \
icu-libs \
libzip \
libpq \
fcgi \
&& addgroup -g 1001 -S app \
&& adduser -u 1001 -S app -G app
# Extensions PHP compilées depuis le stage build-deps — même image de base,
# même version Alpine/musl, les .so sont binairement compatibles
COPY --from=build-deps /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=build-deps /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
# Configuration PHP et PHP-FPM
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-app.ini
COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
# Code applicatif
WORKDIR /var/www/html
COPY --chown=app:app . .
COPY --from=vendor --chown=app:app /app/vendor ./vendor
# Préchauffage du cache Symfony sans dépendances de développement
RUN php bin/console cache:warmup --env=prod --no-debug \
&& chown -R app:app var/
USER app
EXPOSE 9000
# php-fpm.conf doit contenir : ping.path = /ping
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD REDIRECT_STATUS=true SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping \
REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1
CMD ["php-fpm"]Décortiquer la stratégie de cache layer par layer
L'ordre des instructions dans chaque stage n'est pas anodin. Docker invalide le cache d'une couche dès que son contenu change, et invalide en cascade toutes les couches suivantes. La règle d'or : placez en haut ce qui change rarement, en bas ce qui change souvent. Dans le stage vendor, on copie composer.json, composer.lock et symfony.lock avant le code source : tant que ces trois fichiers sont identiques au build précédent, Docker utilise le layer caché et ne relance pas composer install. Le code applicatif change à chaque commit ; les dépendances, elles, changent beaucoup moins souvent. Le --mount=type=cache de BuildKit va encore plus loin : même lorsque le lock file change et que composer install est relancé, le téléchargement des packages déjà connus est instantané depuis le cache local — et ce cache n'est jamais inclus dans l'image finale.
# BuildKit est actif par défaut sur Docker Engine 23+
# Pour les versions antérieures ou pour être explicite :
export DOCKER_BUILDKIT=1
# Build local avec inspection de la taille finale
docker build \
--target production \
--tag myapp:prod \
.
# Vérifier la taille de l'image produite
docker image inspect myapp:prod \
--format '{{ .Size }}' | numfmt --to=iec
# → 78M
# Build avec cache registry pour CI/CD (voir section suivante)
docker build \
--target production \
--cache-from type=registry,ref=ghcr.io/org/myapp:cache \
--cache-to type=registry,ref=ghcr.io/org/myapp:cache,mode=max \
--tag ghcr.io/org/myapp:latest \
.Envie d'aller plus loin ?
Réserver un appel découverte →Le .dockerignore : aussi décisif que les stages
Sans .dockerignore, Docker envoie l'intégralité du répertoire courant au daemon lors du docker build, y compris .git, vendor, var/cache et potentiellement un node_modules si vous avez un front compilé localement. Cela ralentit le transfert du contexte de build et peut exposer des fichiers sensibles comme .env.local. Un .dockerignore bien ciblé est aussi impactant sur le temps de build que la séparation des stages elle-même, particulièrement en présence d'un répertoire vendor lourd ou d'un var/cache volumineux généré localement.
# .dockerignore — à placer à la racine du projet
.git
.github
.env.local
.env.*.local
var/cache
var/log
var/sessions
vendor
node_modules
tests
phpunit.xml.dist
phpstan.neon
.php-cs-fixer.php
*.md
docker/nginx
.editorconfig
MakefileMesures réelles sur un projet Symfony 7.1
Les résultats suivants ont été relevés sur un projet Symfony 7.1 avec PostgreSQL, Redis, API Platform et une trentaine de bundles. Build réalisé sur une machine 4 cœurs / 16 Go de RAM, connexion à 200 Mbit/s, runner GitHub Actions standard (ubuntu-latest).
- Image naïve — php:8.3-fpm Debian + apt-get install + Composer inline : 847 Mo
- Stage unique avec php:8.3-fpm-alpine, sans séparation build/runtime : 312 Mo
- Multi-stage 4 stages + Alpine + --optimize-autoloader --classmap-authoritative : 78 Mo
- Premier build (cache totalement froid, téléchargement vendor complet) : 4 min 18 s
- Build suivant (lock files inchangés, seul le code source a changé) : 41 s
- Pull de l'image en CI — 8,4 s (78 Mo) contre 94 s (847 Mo), facteur 11x
Le passage de php:8.3-fpm à php:8.3-fpm-alpine représente à lui seul environ 300 Mo d'économie : Alpine Linux repose sur musl libc et busybox, minimalistes par conception. La séparation build/runtime élimine les ~180 Mo de headers et outils de compilation. L'option --classmap-authoritative de Composer génère une classmap complète à la compilation et supprime tout recours au système de fichiers pour l'autoloading — les workers PHP-FPM démarrent plus vite et consomment moins d'I/O au premier appel. Le cache:warmup Symfony embarqué dans le Dockerfile garantit enfin que la première requête après déploiement ne paie pas le coût de la compilation des templates Twig ni de la génération des proxys Doctrine.
Intégrer le build multi-stage dans un pipeline CI/CD
La stratégie de cache registry est la plus efficace en environnement CI : le cache BuildKit est exporté dans une image dédiée sur le registry (GHCR, ECR, Docker Hub) et réutilisé au build suivant. Le stage build-deps — le plus lent car il compile des extensions C — ne sera invalidé que si la version Alpine ou la liste des extensions change, soit très rarement. Le workflow GitHub Actions ci-dessous utilise Buildx avec le cache registry en mode max, qui exporte toutes les couches intermédiaires pour une réutilisation optimale entre chaque run.
# .github/workflows/build.yml
name: Build & Push
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: production
push: ${{ github.ref == 'refs/heads/main' }}
tags: ghcr.io/${{ github.repository }}:latest
# mode=max exporte tous les stages intermédiaires dans le cache registry
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=maxAvec cette configuration en place, le stage build-deps n'est recompilé qu'en cas de changement des extensions PHP ou de la version Alpine — ce qui arrive rarement sur un projet stable. Sur un runner GitHub Actions fraîchement alloué, le premier build froid descend à moins de 90 secondes grâce au cache registry exporté en mode max, contre plus de 4 minutes sans cache. L'architecture multi-stage décrite ici n'est pas une optimisation optionnelle : c'est le socle d'une image de production sécurisée, légère, et déployable en quelques secondes.
Envie d'aller plus loin ?
Discutons de votre projet et voyons comment je peux vous aider.