Docker et l'intégration continue
Docker est devenu incontournable dans les pipelines CI/CD modernes. Il garantit la cohérence entre les environnements de test et de production.
Le principe est simple : au lieu de reconstruire l'environnement sur chaque machine de CI (version de PHP, extensions, dépendances système), on construit une image une seule fois, on la teste, puis on déploie exactement cette image. Le fameux « ça marche sur ma machine » disparaît, car la machine fait désormais partie de l'artefact livré. Le pipeline devient aussi indépendant du fournisseur de CI : les mêmes commandes docker build et docker run fonctionnent sur GitHub Actions, GitLab CI ou Jenkins.
Un Dockerfile multi-étapes
Tout repose sur un Dockerfile multi-étapes (multi-stage) : une étape de base commune, une cible test qui contient les dépendances de développement, et une cible production allégée. Le pipeline choisit la cible avec l'option --target.
# syntax=docker/dockerfile:1
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache icu-libs libzip \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
&& docker-php-ext-install intl zip pdo_mysql opcache \
&& apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app
FROM base AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress
FROM base AS test
ENV APP_ENV=test
COPY composer.json composer.lock ./
RUN composer install --no-scripts --no-autoloader --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload
FROM base AS production
ENV APP_ENV=prod
COPY --from=vendor /var/www/app/vendor ./vendor
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
&& php bin/console cache:warmup \
&& chown -R www-data:www-data var
USER www-data
L'ordre des instructions n'est pas anodin : composer.json et composer.lock sont copiés avant le reste du code. Tant que les dépendances ne changent pas, Docker réutilise la couche qui contient vendor/, et seule la copie du code source est refaite. C'est ce qui rend les builds suivants rapides.
Pensez aussi au fichier .dockerignore, qui évite d'envoyer au build des fichiers inutiles ou sensibles, et d'invalider le cache à chaque modification locale :
.git
vendor
var
node_modules
.env.local
.env.*.local
Pipeline GitHub Actions avec Docker
Voici une version minimale du pipeline : un job construit l'image de test et exécute PHPUnit dedans, puis, si les tests passent, un second job construit l'image de production et la pousse vers GitHub Container Registry.
name: CI/CD Pipeline
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build test image
run: docker build --target test -t app:test .
- name: Run tests
run: docker run --rm app:test php bin/phpunit
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build and push production image
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
docker build --target production -t "$IMAGE:$GITHUB_SHA" -t "$IMAGE:latest" .
docker push --all-tags "$IMAGE"
Le nom de l'image est dérivé du dépôt (ghcr.io/proprietaire/depot), converti en minuscules car les registres refusent les majuscules. L'authentification utilise le GITHUB_TOKEN du workflow, qui a seulement besoin de la permission packages: write. Chaque image reçoit deux tags : le SHA du commit, qui indique précisément ce qui tourne en production et facilite le retour arrière, et latest. En revanche, chaque job reconstruit tout depuis zéro, car les runners ne conservent pas le cache Docker entre deux exécutions.
Optimisation du cache
Le cache des couches Docker est crucial pour la vitesse du pipeline :
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build with cache
uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha stocke les couches dans le cache de GitHub Actions. Avec mode=max, les couches des étapes intermédiaires (comme vendor) sont aussi exportées, et pas seulement celles de l'image finale. Quand plusieurs builds partagent le même cache, donnez à chacun son propre scope pour qu'ils ne s'écrasent pas mutuellement.
Un pipeline complet et traçable
La version suivante reprend le même principe avec les actions Docker officielles : tags générés par docker/metadata-action, cache séparé par cible, tests exécutés avec leurs services grâce à Docker Compose, et pull requests testées sans rien publier.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
target: test
tags: app:test
load: true
cache-from: type=gha,scope=test
cache-to: type=gha,scope=test,mode=max
- name: Run tests
run: docker compose -f compose.ci.yaml run --rm app php bin/phpunit
- name: Clean up
if: always()
run: docker compose -f compose.ci.yaml down -v
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
target: production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=production
cache-to: type=gha,scope=production,mode=max
Les points clés :
load: truecharge l'image de test dans le démon Docker du runner pour pouvoir la lancer ensuite ; sans cette option, Buildx garde le résultat dans son propre cache.docker/login-actions'authentifie sur GHCR avec leGITHUB_TOKENdu workflow : aucun jeton personnel à créer, il suffit d'accorderpackages: writeau job.docker/metadata-actiongénère les tags :type=shaproduit un tag du typesha-1a2b3c4, etlatestn'est ajouté que sur la branche par défaut. Chaque image en production est ainsi reliée à un commit précis, et un retour arrière consiste à redéployer le tag précédent.- Le job de publication ne s'exécute que sur
main: les pull requests sont testées, mais ne publient rien.
Tests en parallèle
- Utilisez Docker Compose pour lancer les services de test (DB, Redis)
- Parallélisez les suites de tests dans des conteneurs séparés
- Nettoyez les ressources après chaque exécution
Le fichier compose.ci.yaml utilisé plus haut démarre la base de données à côté de l'image de test, et n'exécute les tests qu'une fois MySQL réellement prêt :
services:
app:
image: app:test
depends_on:
mysql:
condition: service_healthy
environment:
DATABASE_URL: mysql://root:root@mysql:3306/test
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 5s
timeout: 5s
retries: 10
La condition service_healthy est essentielle : un simple depends_on attend seulement que le conteneur démarre, pas que MySQL accepte les connexions. Pour paralléliser, une matrice GitHub Actions peut lancer plusieurs jobs avec la même image, chacun exécutant une suite PHPUnit différente (--testsuite unit, --testsuite integration). Le down -v final, exécuté même en cas d'échec grâce à if: always(), supprime conteneurs et volumes.
Pièges fréquents
- Secrets dans l'image : ne copiez jamais un fichier
.env.localni une clé dans l'image, et ne passez pas de secret viaARG, qui reste visible dans l'historique. Les secrets de production sont injectés au démarrage du conteneur. - Image de test déployée : sans
--target production, Docker construit la dernière étape du Dockerfile. Vérifiez que c'est bien celle que vous attendez. - Nom d'image en majuscules : les registres exigent des noms en minuscules, ce qui pose problème si le nom de l'organisation GitHub contient des majuscules.
- Cache invalidé en permanence : un
COPY . .placé trop tôt, ou l'absence de.dockerignore, fait reconstruire les dépendances à chaque commit.
En résumé
Un Dockerfile multi-étapes, un cache Buildx correctement configuré, des images taguées par commit et des services de test orchestrés par Compose : avec ces quatre éléments, le pipeline construit une seule fois ce qu'il teste et déploie.
Cette stratégie permet de réduire le temps de déploiement de 30 minutes à moins de 5 minutes.