◀ Retour au blog
Docker

Builds Docker multi-stage pour PHP

Publié le 15 Jan 2024· 7 min de lecture
#Docker#PHP#Optimisation

Pourquoi utiliser les builds multi-stage ?

Les builds multi-stage permettent de séparer l'environnement de compilation de l'environnement d'exécution. Résultat : des images plus légères et plus sécurisées.

Le principe est simple : un même Dockerfile contient plusieurs instructions FROM. Chacune démarre une nouvelle étape (stage), avec sa propre image de base. Les étapes intermédiaires installent les outils de build, téléchargent les dépendances ou compilent les assets ; l'étape finale ne récupère, avec COPY --from, que le résultat. Tout ce qui n'est pas explicitement copié (compilateurs, caches de paquets, sources inutiles) disparaît de l'image livrée.

Le problème des images monolithiques

Une image PHP classique avec toutes les dépendances de développement peut facilement dépasser 1 Go. En production, vous n'avez besoin ni de Composer, ni des outils de test, ni des fichiers source non compilés.

Ce poids a des conséquences concrètes : des déploiements plus lents car chaque serveur doit télécharger l'image, un registre qui se remplit plus vite, et surtout une surface d'attaque plus large. Chaque binaire présent dans l'image (compilateur, client Git, gestionnaire de paquets) est un outil de plus à la disposition d'un attaquant, et une source supplémentaire d'alertes dans les scanners de vulnérabilités.

Exemple de Dockerfile multi-stage

# Stage 1 : Build
FROM composer:2 AS builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --optimize

# Stage 2 : Production
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=builder /app /var/www/html
EXPOSE 9000
CMD ["php-fpm"]

La première étape, nommée builder, part de l'image officielle Composer. Elle copie d'abord uniquement composer.json et composer.lock, puis installe les dépendances : tant que ces deux fichiers ne changent pas, Docker réutilise la couche en cache et l'installation n'est pas relancée, même si le code applicatif a changé. Les scripts et la génération de l'autoloader sont différés (--no-scripts, --no-autoloader), car le code n'est pas encore là : une fois celui-ci copié, composer dump-autoload --optimize génère un autoloader qui inclut les classes de l'application. La seconde étape part d'une image PHP-FPM Alpine, installe les extensions nécessaires avant de copier le code, pour que cette couche coûteuse reste en cache, puis récupère le répertoire /app complet.

Une version plus aboutie

Dans un vrai projet, on va généralement plus loin : le processus ne doit pas tourner en root, le cache de Composer peut être conservé entre deux builds, les dépendances de compilation des extensions ne doivent pas rester dans l'image, et une application Symfony a souvent des assets front à compiler. Voici une version plus aboutie :

# syntax=docker/dockerfile:1

# Stage 1 : dépendances PHP
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative

# Stage 2 : assets front
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY assets/ assets/
COPY webpack.config.js ./
RUN npm run build

# Stage 3 : production
FROM php:8.3-fpm-alpine AS production
RUN apk add --no-cache icu-libs \
    && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \
    && docker-php-ext-install intl pdo_mysql opcache \
    && apk del .build-deps
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app ./
COPY --from=assets /app/public/build ./public/build
USER www-data

Quelques explications :

  • --mount=type=cache conserve le cache de Composer entre deux builds sur la même machine, sans jamais l'écrire dans l'image.
  • --ignore-platform-reqs est nécessaire car l'image Composer n'a ni la même version de PHP ni les mêmes extensions que l'image finale. Composer génère un fichier platform_check.php qui vérifie la version de PHP au démarrage : une incompatibilité sera donc détectée immédiatement.
  • --classmap-authoritative produit une classmap complète, générée après la copie du code, et évite toute recherche de fichier sur le disque au chargement d'une classe.
  • Les dépendances de compilation (icu-dev, $PHPIZE_DEPS) sont installées et supprimées dans la même instruction RUN : seules les bibliothèques d'exécution comme icu-libs restent dans l'image.
  • L'étape assets apporte Node.js, qui n'a rien à faire en production : seul le dossier public/build est copié.

Pensez aussi au fichier .dockerignore. Sans lui, COPY . . envoie au build le dossier vendor/ local, qui écraserait celui que l'étape vient d'installer, ainsi que .git et vos fichiers d'environnement locaux :

.git
vendor/
node_modules/
var/
.env.local
docker-compose*.yml

Construire une étape précise

L'option --target arrête le build à l'étape indiquée. C'est utile pour déboguer une étape intermédiaire, ou pour réutiliser le même Dockerfile avec une étape de développement :

docker build --target production -t myapp:1.4.2 .
docker build --target vendor -t myapp:vendor .

# Comparer la taille et inspecter les couches
docker image ls myapp
docker history myapp:1.4.2

Avec BuildKit, activé par défaut dans les versions récentes de Docker, seules les étapes nécessaires à la cible sont construites, et les étapes indépendantes (ici vendor et assets) sont construites en parallèle.

Le même mécanisme permet de décrire une étape de développement qui part de l'image de production et n'y ajoute que Xdebug et les outils de test. Développement et production partagent alors la même base, et Docker Compose choisit l'étape à construire grâce à la clé build.target.

Avantages concrets

  • Taille réduite : passage de 800 Mo à moins de 150 Mo
  • Sécurité : pas d'outils de développement en production
  • Cache Docker : chaque stage est mis en cache indépendamment
  • Reproductibilité : même résultat sur tous les environnements

Bonnes pratiques

Utilisez Alpine comme image de base pour minimiser la surface d'attaque. Installez uniquement les extensions PHP nécessaires. Configurez OPcache pour la production avec les paramètres optimaux.

RUN echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

Plutôt qu'une succession de echo, un fichier docker/php/opcache.ini versionné et copié dans l'image (comme dans la version aboutie ci-dessus) est plus lisible. Il peut aussi activer le préchargement proposé par Symfony et régler le cache realpath :

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/var/www/html/config/preload.php
opcache.preload_user=www-data
realpath_cache_size=4096K
realpath_cache_ttl=600

Avec validate_timestamps=0, PHP ne vérifie plus si les fichiers ont changé : c'est exactement ce que l'on veut dans une image immuable, mais cela signifie qu'il ne faut jamais modifier le code d'un conteneur en cours d'exécution. Un déploiement se fait en remplaçant le conteneur.

Enfin, ordonnez les instructions du Dockerfile de la moins souvent modifiée à la plus souvent modifiée (paquets système, extensions, dépendances, puis code) et épinglez les versions des images de base pour que deux builds du même commit produisent le même résultat.

Cette approche est utilisée avec succès sur des plateformes comme Keytchens pour déployer des applications Symfony avec des temps de build réduits de 60%.